diff --git a/.bumpversion.toml b/.bumpversion.toml index a6912bed5..f14c9b1cb 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.40.0-beta.12" +current_version = "0.41.0-beta.1" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/.github/release_notes.json b/.github/release_notes.json index a97860698..6593ef66a 100644 --- a/.github/release_notes.json +++ b/.github/release_notes.json @@ -6,6 +6,10 @@ "title": "## 🏆 Highlights", "labels": ["highlight"] }, + { + "title": "## 🚨 Critical Fixes", + "labels": ["critical-fix"] + }, { "title": "## 🛠 Breaking Changes", "labels": ["breaking-change"] @@ -38,4 +42,4 @@ "labels": ["ci"] } ] -} \ No newline at end of file +} diff --git a/.github/workflows/license-header-check.yml b/.github/workflows/license-header-check.yml index 6a9bdabc3..8b8510b58 100644 --- a/.github/workflows/license-header-check.yml +++ b/.github/workflows/license-header-check.yml @@ -9,6 +9,7 @@ on: - python/** - nodejs/** - java/** + - docs/web-tests/** - .github/workflows/license-header-check.yml permissions: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 585abf5ee..cbfefc83d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -285,7 +285,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - msrv: ["1.91.0"] # This should match up with rust-version in Cargo.toml + msrv: ["1.94.0"] # This should match up with rust-version in Cargo.toml env: # Need up-to-date compilers for kernels CC: clang-18 @@ -308,40 +308,9 @@ jobs: # unreadable outside their own branch anyway, since GitHub scopes # caches to the creating ref. save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Downgrade dependencies - # These packages have newer requirements for MSRV - run: | - cargo update -p aws-sdk-bedrockruntime --precise 1.77.0 - cargo update -p aws-sdk-dynamodb --precise 1.68.0 - cargo update -p aws-config --precise 1.6.0 - cargo update -p aws-sdk-kms --precise 1.63.0 - cargo update -p aws-sdk-s3 --precise 1.79.0 - cargo update -p aws-sdk-sso --precise 1.62.0 - cargo update -p aws-sdk-ssooidc --precise 1.63.0 - cargo update -p aws-sdk-sts --precise 1.63.0 - # aws-runtime/sigv4/credential-types/types and the aws-smithy-* - # crates bumped their MSRV to 1.91.1 in late 2026; pin to the last - # 1.91.0-compatible versions. The order matters — each downgrade - # only succeeds once everything that still pins it at a higher - # version has itself been downgraded. - cargo update -p aws-runtime --precise 1.5.12 - cargo update -p aws-types --precise 1.3.9 - cargo update -p aws-sigv4 --precise 1.3.5 - cargo update -p aws-credential-types --precise 1.2.8 - # aws-smithy-checksums must stay at or above 0.63.13: OpenDAL's S3 - # service needs crc-fast ~1.9, and older releases pin it to ~1.3. - cargo update -p aws-smithy-checksums --precise 0.63.13 - cargo update -p aws-smithy-runtime --precise 1.9.3 - cargo update -p aws-smithy-http --precise 0.62.6 - cargo update -p aws-smithy-eventstream --precise 0.60.14 - cargo update -p aws-smithy-http-client --precise 1.1.3 - cargo update -p aws-smithy-observability --precise 0.1.4 - cargo update -p aws-smithy-query --precise 0.60.8 - cargo update -p aws-smithy-runtime-api --precise 1.9.3 - cargo update -p aws-smithy-async --precise 1.2.7 - cargo update -p aws-smithy-types --precise 1.3.6 - cargo update -p aws-smithy-xml --precise 0.60.11 - cargo update -p home --precise 0.5.9 + - name: Downgrade dependencies + # aws-smithy-types 1.6.1+ requires rustc 1.94.1 + run: cargo update -p aws-smithy-types --precise 1.5.0 - name: cargo +${{ matrix.msrv }} check env: RUSTUP_TOOLCHAIN: ${{ matrix.msrv }} diff --git a/.typos.toml b/.typos.toml index 7d1e4b14b..3083112e3 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,5 +1,15 @@ [default] -extend-ignore-re = ["(?Rm)^.*(#|//)\\s*spellchecker:disable-line$"] +extend-ignore-re = [ + "(?Rm)^.*(#|//)\\s*spellchecker:disable-line$", + # Conventional import aliases in the docs examples: `import fiftyone as fo` + # and `import torchvision.io as tio`. + "\\bas (fo|tio)\\b", + "\\btio\\.", + # The substring query in the n-gram full-text search example. + 'search\("nce"', + # Garbled OCR output quoted verbatim in the VLM fine-tuning tutorial. + "permission nstallation", +] [default.extend-words] # Azure Kubernetes Service, mentioned in rust/lancedb/src/remote/oauth.rs. @@ -12,6 +22,10 @@ mmaped = "mmaped" # `WriteableBuffer` is the real name of a type from Python's `_typeshed` stubs, # used in python/python/lancedb/_blob.py. Writeable = "Writeable" +# Superlinked's self-hosted inference engine (SIE), its `sie-lancedb` package, +# and the `sie` embedding function it registers, in +# docs/web/integrations/embedding/superlinked.mdx. +SIE = "SIE" [files] extend-exclude = [ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64337723b..a13086061 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,50 @@ are wrappers around the Rust library. * `nodejs`: Typescript package source code * `node`: **Deprecated** Typescript package source code * `java`: Java package source code -* `docs`: Documentation source code +* `docs/src`: SDK reference, built with mkdocs +* `docs/web`: the open-source pages of [docs.lancedb.com](https://docs.lancedb.com) +* `docs/web-tests`: the tests the documentation's code examples are extracted from + +## Documentation + +The open-source pages of [docs.lancedb.com](https://docs.lancedb.com) live in +`docs/web`, so a change to behaviour and the change to the page describing it +belong in the same pull request. + +Preview them locally — this serves `docs/web` on its own, with no other checkout: + +```bash +npm i -g mint # https://mintlify.com/docs/installation +cd docs/web && mint dev +``` + +This repository holds a page at every path the site publishes, so the whole +navigation resolves locally. Enterprise pages exist here as open-source pages -- +what the capability is, and what the embedded form does instead. A separate +private repository supplies the fuller version of each, which replaces the page +at the same path when the published site is assembled. + +Two things to know before editing: + +**Code examples are not written into the pages.** They live in real tests under +`docs/web-tests/{py,ts,rs}`, are extracted into `docs/web/snippets/`, and are +imported by the pages. Edit the test, then regenerate from the repository root: + +```bash +uv run docs/web-tests/mdx_snippets_gen.py -s docs/web-tests/py -s docs/web-tests/ts -s docs/web-tests/rs -o docs/web/snippets +``` + +Review the diff before committing: a few modules currently regenerate with +unrelated changes, so commit only the snippets for the examples you changed. + +Run the Python, TypeScript and Rust examples in `docs/web-tests/{py,ts,rs}` +locally before submitting the change. These examples are not yet wired into +pull-request CI; that integration remains follow-up work. + +**Every heading carries an explicit `{#anchor}`.** Those anchors are how the +Enterprise pages attach their additions to the right section, so they are +assigned once and never regenerated. Leave an existing anchor alone even when +you reword the heading above it; only new headings need a new one. ## Release process diff --git a/Cargo.lock b/Cargo.lock index 77c7412f4..a3d865be8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1819,7 +1819,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3087,7 +3087,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3315,7 +3315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3588,8 +3588,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-array", "rand 0.9.5", @@ -4880,7 +4880,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5143,8 +5143,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" [[package]] name = "lance" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arc-swap", "arrow", @@ -5217,8 +5217,8 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-array", "arrow-buffer", @@ -5240,7 +5240,7 @@ dependencies = [ [[package]] name = "lance-arrow-scalar" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-array", "arrow-buffer", @@ -5254,7 +5254,7 @@ dependencies = [ [[package]] name = "lance-arrow-stats" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-array", "arrow-schema", @@ -5263,8 +5263,8 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrayref", "crunchy", @@ -5274,8 +5274,8 @@ dependencies = [ [[package]] name = "lance-core" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-array", "arrow-buffer", @@ -5312,8 +5312,8 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow", "arrow-array", @@ -5344,8 +5344,8 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow", "arrow-array", @@ -5362,8 +5362,8 @@ dependencies = [ [[package]] name = "lance-derive" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "proc-macro2", "quote", @@ -5372,8 +5372,8 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-arith", "arrow-array", @@ -5406,8 +5406,8 @@ dependencies = [ [[package]] name = "lance-file" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-arith", "arrow-array", @@ -5439,8 +5439,8 @@ dependencies = [ [[package]] name = "lance-geo" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "datafusion", "geo-traits", @@ -5454,8 +5454,8 @@ dependencies = [ [[package]] name = "lance-index" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arc-swap", "arrow", @@ -5526,8 +5526,8 @@ dependencies = [ [[package]] name = "lance-index-core" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-array", "arrow-schema", @@ -5550,8 +5550,8 @@ dependencies = [ [[package]] name = "lance-io" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow", "arrow-array", @@ -5593,8 +5593,8 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-array", "arrow-schema", @@ -5608,8 +5608,8 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow", "async-trait", @@ -5623,8 +5623,8 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arc-swap", "arrow", @@ -5678,8 +5678,8 @@ dependencies = [ [[package]] name = "lance-select" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-array", "arrow-buffer", @@ -5693,8 +5693,8 @@ dependencies = [ [[package]] name = "lance-table" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow", "arrow-array", @@ -5734,8 +5734,8 @@ dependencies = [ [[package]] name = "lance-testing" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "arrow-array", "arrow-schema", @@ -5748,8 +5748,8 @@ dependencies = [ [[package]] name = "lance-tokenizer" -version = "13.1.0-beta.1" -source = "git+https://github.com/lance-format/lance.git?tag=v13.1.0-beta.1#e5a3553b1699a7062ff7afe4ee268d596bbf2e0d" +version = "14.0.0-beta.7" +source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21" dependencies = [ "frostem", "icu_segmenter", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "lancedb" -version = "0.40.0-beta.12" +version = "0.41.0-beta.1" dependencies = [ "ahash", "anyhow", @@ -5859,7 +5859,7 @@ dependencies = [ [[package]] name = "lancedb-nodejs" -version = "0.40.0-beta.12" +version = "0.41.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -5884,7 +5884,7 @@ dependencies = [ [[package]] name = "lancedb-python" -version = "0.40.0-beta.12" +version = "0.41.0-beta.1" dependencies = [ "arc-swap", "arrow", @@ -6637,7 +6637,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6766,7 +6766,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.5.0", @@ -8140,8 +8140,8 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", - "itertools 0.14.0", + "heck 0.4.1", + "itertools 0.12.1", "log", "multimap", "petgraph", @@ -8160,7 +8160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.117", @@ -8422,7 +8422,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -9220,7 +9220,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9291,7 +9291,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9838,7 +9838,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.117", @@ -9850,7 +9850,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54254b8531cafa275c5e096f62d48c81435d1015405a91198ddb11e967301d40" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.117", @@ -10284,7 +10284,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11305,7 +11305,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 183e5f4f7..ad7c00477 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,23 +10,23 @@ repository = "https://github.com/lancedb/lancedb" description = "Serverless, low-latency vector database for AI applications" keywords = ["lancedb", "lance", "database", "vector", "search"] categories = ["database-implementations"] -rust-version = "1.91.0" +rust-version = "1.94.0" [workspace.dependencies] -lance = { "version" = "=13.1.0-beta.1", default-features = false, "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-core = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-datagen = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-file = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-io = { "version" = "=13.1.0-beta.1", default-features = false, "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-index = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-linalg = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace-impls = { "version" = "=13.1.0-beta.1", default-features = false, "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-table = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-testing = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-datafusion = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-encoding = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } -lance-arrow = { "version" = "=13.1.0-beta.1", "tag" = "v13.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" } +lance = { "version" = "=14.0.0-beta.7", default-features = false, "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-core = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-datagen = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-file = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-io = { "version" = "=14.0.0-beta.7", default-features = false, "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-index = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-linalg = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace-impls = { "version" = "=14.0.0-beta.7", default-features = false, "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-table = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-testing = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-datafusion = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-encoding = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } +lance-arrow = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" } lancedb = { path = "rust/lancedb", default-features = false } ahash = "0.8" # Note that this one does not include pyarrow diff --git a/README.md b/README.md index c8a70780d..7bf5d39fc 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ - - LanceDB Cloud Public Beta -
+[![Reverie](docs/src/assets/reverie-banner.png)](https://www.reveriesummit.com/?utm_medium=social&utm_source=github&utm_campaign=reverie-2026) [![LanceDB](docs/src/assets/hero-header.png)](https://lancedb.com) [![Website](https://img.shields.io/badge/-Website-100000?style=for-the-badge&labelColor=645cfb&color=645cfb)](https://lancedb.com/) [![Blog](https://img.shields.io/badge/Blog-100000?style=for-the-badge&labelColor=645cfb&color=645cfb)](https://blog.lancedb.com/) @@ -13,7 +11,7 @@ LanceDB -# **The Multimodal AI Lakehouse** +# **The Multimodal Lakehouse for AI** [**How to Install** ](#how-to-install) ✦ [**Detailed Documentation**](https://docs.lancedb.com) ✦ [**Tutorials and Recipes**](https://github.com/lancedb/vectordb-recipes/tree/main) ✦ [**Contributors**](#contributors) diff --git a/docs/src/assets/hero-header.png b/docs/src/assets/hero-header.png index 588053722..aa474b06b 100644 Binary files a/docs/src/assets/hero-header.png and b/docs/src/assets/hero-header.png differ diff --git a/docs/src/assets/reverie-banner.png b/docs/src/assets/reverie-banner.png new file mode 100644 index 000000000..d7d49cb3b Binary files /dev/null and b/docs/src/assets/reverie-banner.png differ diff --git a/docs/src/java/java.md b/docs/src/java/java.md index 0565156dc..20db07d56 100644 --- a/docs/src/java/java.md +++ b/docs/src/java/java.md @@ -14,7 +14,7 @@ Add the following dependency to your `pom.xml`: com.lancedb lancedb-core - 0.40.0-beta.12 + 0.41.0-beta.1 ``` diff --git a/docs/src/js/classes/Catalog.md b/docs/src/js/classes/Catalog.md index deac5e3ce..8351380e2 100644 --- a/docs/src/js/classes/Catalog.md +++ b/docs/src/js/classes/Catalog.md @@ -89,19 +89,30 @@ Drop an empty database. The server rejects nonempty databases. ### listDatabases() ```ts -listDatabases(options): Promise +listDatabases(options): DatabaseNames ``` -List one page of databases; pass pageToken from a response for the next page. +Iterate lazily over all database names using `for await...of`. +pageToken resumes from a saved token; omitted starts at the beginning. +pageLimit limits each REST response, not the total; omitted uses the server default. +Request errors are raised during iteration and terminate the iterator. #### Parameters * **options** = `{}` -* **options.limit?**: `number` +* **options.pageLimit?**: `number` * **options.pageToken?**: `string` #### Returns -`Promise`<[`ListDatabasesResponse`](../interfaces/ListDatabasesResponse.md)> +[`DatabaseNames`](DatabaseNames.md) + +#### Example + +```ts +for await (const name of catalog.listDatabases({ pageLimit: 20 })) { + console.log(name); +} +``` diff --git a/docs/src/js/classes/Connection.md b/docs/src/js/classes/Connection.md index f1aace87b..d5ab8c3d1 100644 --- a/docs/src/js/classes/Connection.md +++ b/docs/src/js/classes/Connection.md @@ -183,10 +183,6 @@ Define a materialized view named `name` over the table `source`. The view is populated before creation returns. Set `withNoData` to create only its definition and empty backing table. The view is a normal table: it can be queried, indexed and searched, and it appears in `tableNames`. -The source table must have stable row ids (create it with -the `newTableEnableStableRowIds` storage option); they keep the view's -provenance valid across source compactions and cannot be enabled after -a table exists. #### Parameters @@ -615,30 +611,42 @@ Return true if the connection has not been closed ### listJobs() ```ts -abstract listJobs(): Promise +abstract listJobs(options?): Listing ``` -List server-side jobs across the database's tables. +Iterate lazily over server-side jobs across the database's tables. +Use `for await...of`. Options control page size and the starting token. +See [Listing](Listing.md) for pagination state and error behavior. + +#### Parameters + +* **options?**: [`ListingOptions`](../interfaces/ListingOptions.md) #### Returns -`Promise`<[`JobInfo`](../interfaces/JobInfo.md)[]> +[`Listing`](Listing.md)<[`JobInfo`](../interfaces/JobInfo.md)> *** ### listMaterializedViews() ```ts -abstract listMaterializedViews(): Promise +abstract listMaterializedViews(options?): Listing ``` The names of the materialized views in this database. -Found by reading every table's schema, so this costs an open per table. +Iterate lazily with `for await...of`. Local listings inspect table schemas +one page at a time. Options control page size and the starting token. +See [Listing](Listing.md) for pagination state and error behavior. + +#### Parameters + +* **options?**: [`ListingOptions`](../interfaces/ListingOptions.md) #### Returns -`Promise`<`string`[]> +[`Listing`](Listing.md)<`string`> *** @@ -739,20 +747,24 @@ A page of table names and an ### listViews() ```ts -abstract listViews(namespacePath?): Promise +abstract listViews(namespacePath?, options?): Listing ``` The names of the views in one namespace. Names only; a definition comes from [describeView](Connection.md#describeview). +Iterate lazily with `for await...of`. See [Listing](Listing.md) for pagination +state and error behavior; options control page size and the starting token. #### Parameters * **namespacePath?**: `string`[] +* **options?**: [`ListingOptions`](../interfaces/ListingOptions.md) + #### Returns -`Promise`<`string`[]> +[`Listing`](Listing.md)<`string`> *** diff --git a/docs/src/js/classes/DatabaseNames.md b/docs/src/js/classes/DatabaseNames.md new file mode 100644 index 000000000..6b78f93b8 --- /dev/null +++ b/docs/src/js/classes/DatabaseNames.md @@ -0,0 +1,79 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / DatabaseNames + +# Class: DatabaseNames + +A lazy async iterator of database names with pagination state. +Returned by [Catalog.listDatabases](Catalog.md#listdatabases). + +## Implements + +- `AsyncIterableIterator`<`string`> + +## Methods + +### \[asyncIterator\]() + +```ts +asyncIterator: AsyncIterableIterator +``` + +#### Returns + +`AsyncIterableIterator`<`string`> + +#### Implementation of + +`AsyncIterableIterator.[asyncIterator]` + +*** + +### next() + +```ts +next(): Promise> +``` + +Fetch the next name, requesting another page only when needed. + +#### Returns + +`Promise`<`IteratorResult`<`string`, `any`>> + +#### Implementation of + +`AsyncIterableIterator.next` + +*** + +### numPageResults() + +```ts +numPageResults(): number +``` + +Number of names available without another REST request. + +#### Returns + +`number` + +*** + +### pageToken() + +```ts +pageToken(): undefined | string +``` + +Token for the next REST request. Initially this is the supplied starting token. +Returns undefined after the final page is fetched, even if names remain cached. +Drain the cache before saving a token to avoid skipping names when resuming. +A failed request terminates iteration and retains its token for a new iterator. + +#### Returns + +`undefined` \| `string` diff --git a/docs/src/js/classes/Listing.md b/docs/src/js/classes/Listing.md new file mode 100644 index 000000000..1a94df2dc --- /dev/null +++ b/docs/src/js/classes/Listing.md @@ -0,0 +1,91 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / Listing + +# Class: Listing<T> + +A lazy async iterator over database resources with pagination state. +Requests occur during iteration; request errors terminate the iterator. + +## Example + +```ts +for await (const name of connection.listViews([], { pageLimit: 20 })) { + console.log(name); +} +``` + +## Type Parameters + +• **T** + +## Implements + +- `AsyncIterableIterator`<`T`> + +## Methods + +### \[asyncIterator\]() + +```ts +asyncIterator: AsyncIterableIterator +``` + +#### Returns + +`AsyncIterableIterator`<`T`> + +#### Implementation of + +`AsyncIterableIterator.[asyncIterator]` + +*** + +### next() + +```ts +next(): Promise> +``` + +Fetch the next item, requesting another page only when needed. + +#### Returns + +`Promise`<`IteratorResult`<`T`, `any`>> + +#### Implementation of + +`AsyncIterableIterator.next` + +*** + +### numPageResults() + +```ts +numPageResults(): number +``` + +Number of cached items available without another request. + +#### Returns + +`number` + +*** + +### pageToken() + +```ts +pageToken(): undefined | string +``` + +Token for the next request. Initially this is the supplied starting token. +Returns undefined after fetching the final page, even with cached items. +Drain the cache before saving a token to avoid skipping items on resumption. +Failed requests terminate iteration and retain their token for a new iterator. + +#### Returns + +`undefined` \| `string` diff --git a/docs/src/js/classes/MaterializedView.md b/docs/src/js/classes/MaterializedView.md index 58eaa3c13..0e2413328 100644 --- a/docs/src/js/classes/MaterializedView.md +++ b/docs/src/js/classes/MaterializedView.md @@ -65,21 +65,14 @@ refresh(options?): Promise Recompute the view from its source. -The refresh is incremental when the source's changes can be reconciled -into the view -- rows added, changed or removed since the last one -- -and otherwise rebuilds. `full` forces a rebuild; `sourceVersion` -refreshes to that source version instead of the latest. - -Concurrent refreshes of one view do not duplicate its rows. Two that -plan the same source rows conflict on commit, and the loser throws -rather than writing them a second time. +Every refresh rebuilds the complete view. For local views, +`sourceVersion` refreshes to that source version instead of the latest; +remote SQL refreshes do not support source-version pinning. #### Parameters * **options?** -* **options.full?**: `boolean` - * **options.sourceVersion?**: `number` #### Returns diff --git a/docs/src/js/classes/Table.md b/docs/src/js/classes/Table.md index ac16b8a81..3ac1dd031 100644 --- a/docs/src/js/classes/Table.md +++ b/docs/src/js/classes/Table.md @@ -662,10 +662,9 @@ Read the [LsmWriteSpec](../interfaces/LsmWriteSpec.md) currently installed on th Resolves to `undefined` when the MemWAL LSM write path is not enabled (no spec has been set, or it was removed with [Table#unsetLsmWriteSpec](Table.md#unsetlsmwritespec)). -The returned spec mirrors what was passed to -[Table#setLsmWriteSpec](Table.md#setlsmwritespec), except that `maintainedIndexes` always -reports the concrete list resolved when the spec was set — `undefined` -never round-trips. +The spec is the one installed, including its maintained-index selection: +an absent `maintainedIndexes` for every index the table has, an empty +array for none. #### Returns @@ -1107,8 +1106,8 @@ All variants require the table to have an unenforced primary key ([Table#setUnenforcedPrimaryKey](Table.md#setunenforcedprimarykey)); bucket sharding additionally requires it to be the single column being bucketed. -Omitting `maintainedIndexes` maintains every index on the table, resolved -here, failing if one cannot be maintained — name them to install anyway. +Omitting `maintainedIndexes` maintains every index the table has, +including ones created later, and skips a kind the MemWAL cannot maintain. Naming them pins an exact set, and a still-building index is rejected rather than quietly omitted. diff --git a/docs/src/js/functions/connectCatalog.md b/docs/src/js/functions/connectCatalog.md index 2bdb3182f..ca51c0ed4 100644 --- a/docs/src/js/functions/connectCatalog.md +++ b/docs/src/js/functions/connectCatalog.md @@ -28,5 +28,7 @@ headers; opened database connections inherit authentication and client options. ```ts const catalog = await connectCatalog("https://my-server.example", { apiKey: "secret" }); const db = await catalog.createDatabase("analytics", { existOk: true }); -const page = await catalog.listDatabases({ limit: 20 }); +for await (const name of catalog.listDatabases({ pageLimit: 20 })) { + console.log(name); +} ``` diff --git a/docs/src/js/globals.md b/docs/src/js/globals.md index edb0bacc3..9cf3136af 100644 --- a/docs/src/js/globals.md +++ b/docs/src/js/globals.md @@ -27,9 +27,11 @@ - [Branches](classes/Branches.md) - [Catalog](classes/Catalog.md) - [Connection](classes/Connection.md) +- [DatabaseNames](classes/DatabaseNames.md) - [HeaderProvider](classes/HeaderProvider.md) - [Index](classes/Index.md) - [Job](classes/Job.md) +- [Listing](classes/Listing.md) - [MakeArrowTableOptions](classes/MakeArrowTableOptions.md) - [MatchQuery](classes/MatchQuery.md) - [MaterializedView](classes/MaterializedView.md) @@ -108,11 +110,11 @@ - [JobEventsOptions](interfaces/JobEventsOptions.md) - [JobFailureInfo](interfaces/JobFailureInfo.md) - [JobInfo](interfaces/JobInfo.md) -- [ListDatabasesResponse](interfaces/ListDatabasesResponse.md) - [ListNamespacesOptions](interfaces/ListNamespacesOptions.md) - [ListNamespacesResponse](interfaces/ListNamespacesResponse.md) - [ListTablesOptions](interfaces/ListTablesOptions.md) - [ListTablesResponse](interfaces/ListTablesResponse.md) +- [ListingOptions](interfaces/ListingOptions.md) - [LsmStats](interfaces/LsmStats.md) - [LsmWriteSpec](interfaces/LsmWriteSpec.md) - [MaterializedViewDefinition](interfaces/MaterializedViewDefinition.md) @@ -157,9 +159,12 @@ - [AnalyzePlanDistributedMetrics](type-aliases/AnalyzePlanDistributedMetrics.md) - [BaseTokenizer](type-aliases/BaseTokenizer.md) +- [BlobData](type-aliases/BlobData.md) +- [BlobInput](type-aliases/BlobInput.md) - [BlobOptions](type-aliases/BlobOptions.md) - [BlobRange](type-aliases/BlobRange.md) - [BlobReadOptions](type-aliases/BlobReadOptions.md) +- [BlobUri](type-aliases/BlobUri.md) - [Data](type-aliases/Data.md) - [DataLike](type-aliases/DataLike.md) - [DataTypeLike](type-aliases/DataTypeLike.md) diff --git a/docs/src/js/interfaces/FtsOptions.md b/docs/src/js/interfaces/FtsOptions.md index 1d3c9ff82..654fa138f 100644 --- a/docs/src/js/interfaces/FtsOptions.md +++ b/docs/src/js/interfaces/FtsOptions.md @@ -37,6 +37,8 @@ The following tokenizers are available: "raw" - Raw tokenizer. This tokenizer does not split the text into tokens and indexes the entire text as a single token. +"code" - Code tokenizer. This tokenizer keeps snake_case and camelCase identifiers as single tokens. + "icu" - ICU dictionary-based word segmentation. "icu/split" - ICU segmentation with simple-style delimiter splitting. diff --git a/docs/src/js/interfaces/ListDatabasesResponse.md b/docs/src/js/interfaces/ListDatabasesResponse.md deleted file mode 100644 index a1a247746..000000000 --- a/docs/src/js/interfaces/ListDatabasesResponse.md +++ /dev/null @@ -1,23 +0,0 @@ -[**@lancedb/lancedb**](../README.md) • **Docs** - -*** - -[@lancedb/lancedb](../globals.md) / ListDatabasesResponse - -# Interface: ListDatabasesResponse - -## Properties - -### databases - -```ts -databases: string[]; -``` - -*** - -### pageToken? - -```ts -optional pageToken: string; -``` diff --git a/docs/src/js/interfaces/ListTablesOptions.md b/docs/src/js/interfaces/ListTablesOptions.md index 52ace47e5..b8dd8b5ef 100644 --- a/docs/src/js/interfaces/ListTablesOptions.md +++ b/docs/src/js/interfaces/ListTablesOptions.md @@ -15,6 +15,7 @@ optional limit: number; ``` An upper bound on how many tables to return. +Defaults to 100. Zero returns an empty page without a continuation token. A page may hold fewer than this and still not be the last one, so keep going while the response carries a page token rather than while pages are diff --git a/docs/src/js/interfaces/ListingOptions.md b/docs/src/js/interfaces/ListingOptions.md new file mode 100644 index 000000000..2070f8e3a --- /dev/null +++ b/docs/src/js/interfaces/ListingOptions.md @@ -0,0 +1,29 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / ListingOptions + +# Interface: ListingOptions + +Pagination controls for lazy resource listings. + +## Properties + +### pageLimit? + +```ts +optional pageLimit: number; +``` + +Maximum results per request, not a limit on the whole listing. + +*** + +### pageToken? + +```ts +optional pageToken: string; +``` + +Starting continuation token; omitted starts at the beginning. diff --git a/docs/src/js/interfaces/LsmWriteSpec.md b/docs/src/js/interfaces/LsmWriteSpec.md index f2ae91186..693542dbd 100644 --- a/docs/src/js/interfaces/LsmWriteSpec.md +++ b/docs/src/js/interfaces/LsmWriteSpec.md @@ -34,9 +34,8 @@ Bucket and identity variants: the sharding column. optional maintainedIndexes: string[]; ``` -Indexes the MemWAL keeps up to date. Omit to maintain every supported -index, resolved on install — a snapshot, so indexes created later are not -maintained. Pass `[]` for none. +Indexes the MemWAL keeps up to date. Omit for every index the table has, +including ones created later. Pass `[]` for none. *** diff --git a/docs/src/js/interfaces/RefreshMaterializedViewResult.md b/docs/src/js/interfaces/RefreshMaterializedViewResult.md index cb7100cd8..1c8b8bc21 100644 --- a/docs/src/js/interfaces/RefreshMaterializedViewResult.md +++ b/docs/src/js/interfaces/RefreshMaterializedViewResult.md @@ -14,7 +14,7 @@ mode: string; ``` -How the view was brought up to date: "rebuild", "incremental" or "no_op". +How the view was brought up to date: "rebuild". *** diff --git a/docs/src/js/interfaces/TableNamesOptions.md b/docs/src/js/interfaces/TableNamesOptions.md index 9254e9fa7..123aa6c9f 100644 --- a/docs/src/js/interfaces/TableNamesOptions.md +++ b/docs/src/js/interfaces/TableNamesOptions.md @@ -19,7 +19,7 @@ instead. optional limit: number; ``` -An optional limit to the number of results to return. +The maximum number of names to return. Omitted returns all names; zero returns none. *** diff --git a/docs/src/js/interfaces/TableStatistics.md b/docs/src/js/interfaces/TableStatistics.md index e2e8ef34d..4061a9f15 100644 --- a/docs/src/js/interfaces/TableStatistics.md +++ b/docs/src/js/interfaces/TableStatistics.md @@ -18,6 +18,23 @@ Statistics on table fragments *** +### numDeletedRows? + +```ts +optional numDeletedRows: number; +``` + +The number of rows marked as deleted across all fragments of the table + +These rows are not included in `numRows`, but still occupy space on disk +until the table is compacted, so a large value here indicates that the +table should be optimized. Fragments in which every row was deleted are +dropped outright, so their rows are not counted here. + +Absent (`undefined`) when the backend does not report deletion counts. + +*** + ### numIndices ```ts diff --git a/docs/src/js/type-aliases/BaseTokenizer.md b/docs/src/js/type-aliases/BaseTokenizer.md index 75b377f27..5fd1b55d4 100644 --- a/docs/src/js/type-aliases/BaseTokenizer.md +++ b/docs/src/js/type-aliases/BaseTokenizer.md @@ -12,6 +12,7 @@ type BaseTokenizer: | "whitespace" | "raw" | "ngram" + | "code" | "icu" | "icu/split" | `jieba/${string}` diff --git a/docs/src/js/type-aliases/BlobData.md b/docs/src/js/type-aliases/BlobData.md new file mode 100644 index 000000000..2af0a3659 --- /dev/null +++ b/docs/src/js/type-aliases/BlobData.md @@ -0,0 +1,16 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / BlobData + +# Type Alias: BlobData + +```ts +type BlobData: Buffer | Uint8Array | ArrayBuffer | Blob; +``` + +Bytes accepted for a blob value. `ArrayBuffer` is wrapped without copying. +`Blob` and `File` are read with `arrayBuffer()`, which only the async write +paths ([Connection.createTable](../classes/Connection.md#createtable), [Table.add](../classes/Table.md#add), +[Table.mergeInsert](../classes/Table.md#mergeinsert)) can do. diff --git a/docs/src/js/type-aliases/BlobInput.md b/docs/src/js/type-aliases/BlobInput.md new file mode 100644 index 000000000..d1738a760 --- /dev/null +++ b/docs/src/js/type-aliases/BlobInput.md @@ -0,0 +1,36 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / BlobInput + +# Type Alias: BlobInput + +```ts +type BlobInput: + | BlobData + | BlobUri + | object + | object + | null + | undefined; +``` + +A value accepted for a `lance.blob.v2` column (see [blob](../functions/blob.md)). + +Either inline bytes, a URI pointing at external bytes, or a struct that sets +exactly one of `data` or `uri`. `null` and `undefined` write a null blob. + +## Example + +```ts +import { pathToFileURL } from "node:url"; +import type { BlobInput } from "@lancedb/lancedb"; + +const rows: { id: bigint; image: BlobInput }[] = [ + { id: 1n, image: await (await fetch(url)).arrayBuffer() }, + { id: 2n, image: pathToFileURL("/data/cat.png") }, + { id: 3n, image: { data: new Blob(["hello"]) } }, +]; +await table.add(rows); +``` diff --git a/docs/src/js/type-aliases/BlobUri.md b/docs/src/js/type-aliases/BlobUri.md new file mode 100644 index 000000000..a90b395fc --- /dev/null +++ b/docs/src/js/type-aliases/BlobUri.md @@ -0,0 +1,13 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / BlobUri + +# Type Alias: BlobUri + +```ts +type BlobUri: string | URL; +``` + +A URI accepted for a blob value. A `URL` is stored as its `href`. diff --git a/docs/src/python/python.md b/docs/src/python/python.md index 66fda4378..3776283dc 100644 --- a/docs/src/python/python.md +++ b/docs/src/python/python.md @@ -26,6 +26,14 @@ is also an [asynchronous API client](#connections-asynchronous). ::: lancedb.db.DBConnection +Resource listings (`list_secrets`, `list_views`, `list_jobs`, `list_functions`, and +`list_materialized_views`) return lazy iterators. Use `list(db.list_views())` to +collect synchronous results, or `[name async for name in db.list_views()]` with +an asynchronous connection. `page_limit` controls each request, and `page_token` +resumes a listing. Drain the iterator's cached results before saving its token. + +::: lancedb.listing.Listing + ::: lancedb.Session ## Catalogs (Synchronous) @@ -37,7 +45,8 @@ are ordinary connections. Dropping a database requires it to be empty. ::: lancedb.catalog.Catalog -::: lancedb.catalog.ListDatabasesResponse +::: lancedb.catalog.DatabaseNames + ## Remote SQL @@ -77,6 +86,15 @@ print(query.describe().status) for batch in query.reader(): print(batch.num_rows) +# Values bind to $1 / $name placeholders and travel as Arrow, so a float32 +# stays a float32 and a vector stays a compact fixed-size list: +import numpy as np + +reader = db.execute_query( + "SELECT id FROM docs ORDER BY distance(vector, $vector) LIMIT $k", + parameters={"vector": np.random.rand(768).astype(np.float32), "k": 10}, +) + # The async connection exposes the same lifecycle without blocking: # async_db = await lancedb.connect_async( # "db://analytics", @@ -192,39 +210,14 @@ listing a storage directory. ::: lancedb.sql.QueryDescription +::: lancedb.sql.QueryParameters + ## Materialized Views (Synchronous) ::: lancedb.materialized_view.MaterializedView ::: lancedb.materialized_view.MaterializedViewDefinition -::: lancedb.vector_dedup - -::: lancedb.materialized_view.VectorDedupSource - -For an existing indexed source, declare and materialize a dedup result without -writing SQL. Creation captures the source version once and waits for the complete -result; remote connections use the service's MV jobs. - -```python -from lancedb import vector_dedup - -view = db.create_materialized_view( - "images_clean", - vector_dedup("images", column="phash", distance_threshold=4), -) -cleaned = view.table -``` - -To submit without waiting, call `db.create_materialized_view_async(...)` with the -same source expression and wait on the returned job. To declare only, pass -`with_no_data=True`, then use `view.refresh_async().wait()` when ready. Pass -`dataset_version=...` to `vector_dedup` for an explicit snapshot. The default -policy retains direct representatives (A-B-C without A-C keeps A and C), preserves -the source, and materializes its original columns. Only within-segment/partition -pairs are covered, using the index's distance representation. - - ## Views ::: lancedb.view.ViewDescription @@ -440,6 +433,8 @@ latency on your workload before opting in. ::: lancedb.catalog.AsyncCatalog +::: lancedb.catalog.AsyncDatabaseNames + Connections represent a connection to a LanceDb database and can be used to create, list, or open tables. @@ -447,6 +442,8 @@ can be used to create, list, or open tables. ::: lancedb.db.AsyncConnection +::: lancedb.listing.AsyncListing + ## Namespaces (Asynchronous) ::: lancedb.connect_namespace_async diff --git a/docs/web-tests/camelot.json b/docs/web-tests/camelot.json new file mode 100644 index 000000000..e41bd459c --- /dev/null +++ b/docs/web-tests/camelot.json @@ -0,0 +1,66 @@ +[ + { + "id": 1, + "name": "King Arthur", + "role": "King of Camelot", + "description": "The legendary ruler of Camelot, wielder of Excalibur, and leader of the Knights of the Round Table.", + "vector": [0.72, -0.28, 0.60, 0.86], + "stats": { "strength": 2, "courage": 5, "magic": 1, "wisdom": 4 } + }, + { + "id": 2, + "name": "Merlin", + "role": "Wizard and Advisor", + "description": "A powerful wizard and prophet who mentors Arthur and shapes the destiny of Camelot through magic and foresight.", + "vector": [0.05, 0.88, 0.62, 0.85], + "stats": { "strength": 2, "courage": 4, "magic": 5, "wisdom": 5 } + }, + { + "id": 3, + "name": "Queen Guinevere", + "role": "Queen of Camelot", + "description": "Arthur's queen, admired for her grace and diplomacy, whose romances and loyalties influence Camelot's fate.", + "vector": [0.22, -0.22, 0.42, 0.82], + "stats": { "strength": 1, "courage": 3, "magic": 1, "wisdom": 4 } + }, + { + "id": 4, + "name": "Sir Lancelot", + "role": "Knight of the Round Table", + "description": "Arthur's most skilled knight, famed for unmatched combat prowess and his tragic love for Queen Guinevere.", + "vector": [0.86, -0.35, 0.38, 0.55], + "stats": { "strength": 5, "courage": 5, "magic": 1, "wisdom": 3 } + }, + { + "id": 5, + "name": "Sir Gawain", + "role": "Knight of the Round Table", + "description": "A noble and honorable knight known for his courtesy and his encounter with the Green Knight.", + "vector": [0.82, -0.32, 0.52, 0.60], + "stats": { "strength": 4, "courage": 5, "magic": 1, "wisdom": 4 } + }, + { + "id": 6, + "name": "Sir Galahad", + "role": "Knight of the Round Table", + "description": "The purest and most virtuous knight, chosen to achieve the Holy Grail due to his unwavering spiritual purity.", + "vector": [0.80, -0.20, 0.70, 0.78], + "stats": { "strength": 4, "courage": 5, "magic": 2, "wisdom": 5 } + }, + { + "id": 7, + "name": "Sir Percival", + "role": "Knight of the Round Table", + "description": "A loyal and innocent knight whose bravery and sincerity make him one of the key seekers of the Holy Grail.", + "vector": [0.78, -0.36, 0.48, 0.52], + "stats": { "strength": 4, "courage": 4, "magic": 1, "wisdom": 3 } + }, + { + "id": 8, + "name": "Mordred", + "role": "Traitor Knight", + "description": "Arthur's treacherous son or nephew who ultimately rebels against him, leading to Camelot's downfall.", + "vector": [0.68, -0.30, -0.65, 0.20], + "stats": { "strength": 4, "courage": 2, "magic": 1, "wisdom": 2 } + } +] diff --git a/docs/web-tests/mdx_snippets_gen.py b/docs/web-tests/mdx_snippets_gen.py new file mode 100644 index 000000000..1c2930fc0 --- /dev/null +++ b/docs/web-tests/mdx_snippets_gen.py @@ -0,0 +1,362 @@ +""" +Generate MDX-ready snippet modules that export raw string constants. + +The generator groups snippets by the originating test file name rather than by +language. For a source file like `tests/py/test_basic_usage.py`, the snippets are +written under `docs/snippets/basic.mdx`. Each exported constant is prefixed with +a language identifier (Py|Ts|Rs) followed by the snippet name converted to +TitleCase so that docs authors can selectively assemble `` blocks. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, Iterator, List, Mapping, MutableMapping, Tuple + +# Supported languages and relevant metadata. +LANG_ORDER = ("py", "ts", "rs") +LANG_PREFIX = {"py": "Py", "ts": "Ts", "rs": "Rs"} +LANG_EXTENSIONS = { + ".py": "py", + ".ts": "ts", + ".tsx": "ts", + ".rs": "rs", +} +LANG_MARKERS = {"py": ["#"], "ts": ["//"], "rs": ["//"]} +# Match each export block; we rely on the blank line we emit after each export to +# avoid accidentally truncating on semicolons inside the JSON string literal. +EXPORT_RE = re.compile(r"export const (\w+)\s*=\s*(.+?);\s*\n(?:\s*\n|$)", re.DOTALL) + +DEFAULT_SOURCE_DIRS = ( + Path("tests") / "py", + Path("tests") / "ts", + Path("tests") / "rs", +) + + +@dataclass(frozen=True) +class SnippetRecord: + lang: str + snippet_name: str + export_name: str + text: str + source_rel: str + + +def build_markers( + comment_markers: Iterable[str], +) -> Tuple[re.Pattern[str], re.Pattern[str]]: + escaped = "|".join(re.escape(c) for c in comment_markers) + start_re = re.compile(rf"^\s*(?:{escaped})\s*--8<--\s*\[start:([^\]]+)\]") + end_re = re.compile(rf"^\s*(?:{escaped})\s*--8<--\s*\[end:([^\]]+)\]") + return start_re, end_re + + +def parse_snippets( + lines: List[str], start_re: re.Pattern[str], end_re: re.Pattern[str] +) -> Dict[str, List[str]]: + snippets: Dict[str, List[str]] = {} + stack: List[Tuple[str, int]] = [] + + for idx, line in enumerate(lines): + m_start = start_re.match(line) + if m_start: + name = m_start.group(1).strip() + stack.append((name, idx)) + continue + + m_end = end_re.match(line) + if m_end: + name = m_end.group(1).strip() + if not stack: + raise ValueError( + f"End marker for '{name}' found at line {idx + 1} without matching start" + ) + start_name, start_idx = stack.pop() + if start_name != name: + raise ValueError( + f"Mismatched markers: start '{start_name}' at line {start_idx + 1}, " + f"end '{name}' at line {idx + 1}" + ) + snippets[name] = lines[start_idx + 1 : idx] + continue + + if stack: + open_names = ", ".join(f"{n}@{i + 1}" for n, i in stack) + raise ValueError(f"Unclosed snippet markers for: {open_names}") + + return snippets + + +def dedent(lines: List[str]) -> List[str]: + min_indent = None + for ln in lines: + if ln.strip() == "": + continue + expanded = ln.expandtabs(4) + indent = len(expanded) - len(expanded.lstrip(" ")) + if min_indent is None or indent < min_indent: + min_indent = indent + + if not lines: + return [] + if min_indent in (None, 0): + return [ln.rstrip("\n") for ln in lines] + + dedented: List[str] = [] + for ln in lines: + expanded = ln.expandtabs(4) + if expanded.strip() == "": + dedented.append(expanded.rstrip("\n")) + else: + dedented.append(expanded[min_indent:].rstrip("\n")) + return dedented + + +def ensure_dir(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + + +def detect_language(file_path: Path) -> str: + lang = LANG_EXTENSIONS.get(file_path.suffix.lower()) + if not lang: + raise ValueError(f"Unsupported file extension for {file_path}") + return lang + + +def markers_for_lang(lang: str) -> Tuple[re.Pattern[str], re.Pattern[str]]: + markers = LANG_MARKERS.get(lang) + if not markers: + raise ValueError(f"Unsupported language for markers: {lang}") + return build_markers(markers) + + +def normalize_target_name(file_path: Path) -> str: + stem = file_path.stem + if stem.startswith("test_"): + stem = stem[len("test_") :] + if stem.endswith("_test"): + stem = stem[: -len("_test")] + if stem.endswith(".test"): + stem = stem[: -len(".test")] + if stem.endswith(".spec"): + stem = stem[: -len(".spec")] + if stem == "": + stem = file_path.stem + return stem.replace(" ", "_") + + +def to_title_case(name: str) -> str: + parts = re.split(r"[^0-9a-zA-Z]+", name) + filtered = [p for p in parts if p] + return "".join(p[:1].upper() + p[1:].lower() for p in filtered) or "Snippet" + + +def format_export_name(lang: str, snippet_name: str) -> str: + prefix = LANG_PREFIX[lang] + return f"{prefix}{to_title_case(snippet_name)}" + + +def snippet_text(lines: List[str]) -> str: + if not lines: + return "" + joined = "\n".join(lines).rstrip("\n") + return (joined + "\n") if joined else "" + + +def to_js_literal(text: str) -> str: + return json.dumps(text, ensure_ascii=False) + + +def split_export_prefix(export_name: str) -> Tuple[str | None, str]: + for lang, prefix in LANG_PREFIX.items(): + if export_name.startswith(prefix): + suffix = export_name[len(prefix) :] + return lang, suffix or export_name + return None, export_name + + +def parse_existing_module(path: Path) -> Mapping[str, Mapping[str, SnippetRecord]]: + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return {} + + existing: Dict[str, Dict[str, SnippetRecord]] = {} + for match in EXPORT_RE.finditer(text): + export_name, literal = match.group(1), match.group(2).strip() + lang, snippet_suffix = split_export_prefix(export_name) + if not lang: + continue + try: + decoded = json.loads(literal) + except Exception: + decoded = literal.strip().strip(";") + + record = SnippetRecord( + lang=lang, + snippet_name=snippet_suffix or export_name, + export_name=export_name, + text=decoded, + source_rel="(existing module)", + ) + existing.setdefault(lang, {})[record.snippet_name] = record + + return existing + + +def iter_source_files(source_dirs: Iterable[Path]) -> Iterator[Path]: + for root in source_dirs: + for path in root.rglob("*"): + if path.is_file() and path.suffix.lower() in LANG_EXTENSIONS: + yield path + + +def write_if_changed(path: Path, content: str) -> bool: + if path.exists(): + try: + current = path.read_text(encoding="utf-8") + if current == content: + return False + except Exception: + pass + ensure_dir(path.parent) + path.write_text(content, encoding="utf-8") + return True + + +def collect_snippets( + source_dirs: Iterable[Path], +) -> Mapping[str, Mapping[str, Mapping[str, SnippetRecord]]]: + result: Dict[str, Dict[str, Dict[str, SnippetRecord]]] = {} + for file_path in iter_source_files(source_dirs): + lang = detect_language(file_path) + start_re, end_re = markers_for_lang(lang) + try: + text = file_path.read_text(encoding="utf-8") + except Exception as exc: # pragma: no cover + raise RuntimeError(f"Failed to read {file_path}: {exc}") from exc + lines = text.splitlines(keepends=True) + snippets = parse_snippets(lines, start_re, end_re) + if not snippets: + continue + + target = normalize_target_name(file_path) + lang_map = result.setdefault(target, {}).setdefault(lang, {}) + for snippet_name, content_lines in snippets.items(): + if snippet_name in lang_map: + raise ValueError( + f"Duplicate snippet '{snippet_name}' in {file_path} and {lang_map[snippet_name].source_rel}" + ) + dedented = dedent(content_lines) + export_name = format_export_name(lang, snippet_name) + source_rel = os.path.relpath(file_path, Path.cwd()) + lang_map[snippet_name] = SnippetRecord( + lang=lang, + snippet_name=snippet_name, + export_name=export_name, + text=snippet_text(dedented), + source_rel=source_rel, + ) + return result + + +def render_module( + target: str, lang_map: Mapping[str, Mapping[str, SnippetRecord]] +) -> str: + parts: List[str] = [] + parts.append( + "{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */}\n\n" + ) + for lang in LANG_ORDER: + snippets = lang_map.get(lang) + if not snippets: + continue + for snippet_name in sorted(snippets.keys()): + record = snippets[snippet_name] + literal = to_js_literal(record.text) + parts.append(f"export const {record.export_name} = {literal};\n\n") + return "".join(parts) + + +def generate_modules( + snippets_by_target: Mapping[str, Mapping[str, Mapping[str, SnippetRecord]]], + output_root: Path, +) -> None: + modules_written = 0 + total_exports = 0 + + for target in sorted(snippets_by_target.keys()): + module_path = output_root / f"{target}.mdx" + lang_map = snippets_by_target[target] + existing_langs = parse_existing_module(module_path) + for lang, snippets in existing_langs.items(): + if lang in lang_map: + continue + print(module_path, " -- ", lang) + merged_lang = lang_map.setdefault(lang, {}) + existing_export_names = {rec.export_name for rec in merged_lang.values()} + for snippet_name, record in snippets.items(): + # If an export with the same name already exists (e.g., regenerated), + # prefer the newly generated snippet and skip the old one to avoid duplicates. + if record.export_name in existing_export_names: + continue + merged_lang.setdefault(snippet_name, record) + + total_exports += sum(len(snippets) for snippets in lang_map.values()) + module_content = render_module(target, lang_map) + if write_if_changed(module_path, module_content): + modules_written += 1 + print( + f"\nGenerated {total_exports} snippets\n---" + ) + + +def resolve_source_dirs(args_dirs: List[str] | None) -> List[Path]: + if args_dirs: + dirs = [Path(p) for p in args_dirs] + else: + dirs = list(DEFAULT_SOURCE_DIRS) + + missing = [str(p) for p in dirs if not p.exists()] + if missing: + raise FileNotFoundError(f"Source directories not found: {', '.join(missing)}") + return dirs + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate MDX snippet modules grouped by test filename." + ) + parser.add_argument( + "-s", + "--source-dir", + action="append", + help="Path(s) to language test directories (default: tests/py, ts, rs)", + ) + parser.add_argument( + "-o", + "--output-dir", + default=str(Path("docs") / "snippets"), + help="Directory where snippet modules are written (default: docs/snippets)", + ) + args = parser.parse_args() + + source_dirs = resolve_source_dirs(args.source_dir) + snippets = collect_snippets(source_dirs) + if not snippets: + print("No snippets found.") + return 0 + + output_root = Path(args.output_dir) + generate_modules(snippets, output_root) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/web-tests/py/conftest.py b/docs/web-tests/py/conftest.py new file mode 100644 index 000000000..ebd2c811d --- /dev/null +++ b/docs/web-tests/py/conftest.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import shutil +from pathlib import Path + +import lancedb +import pytest +import pytest_asyncio +from lancedb.db import AsyncConnection, DBConnection + + +class DatabasePathFactory: + """Create per-test database directories and ensure they get removed.""" + + def __init__(self, base_dir: Path) -> None: + self._base_dir = base_dir + self._created: list[Path] = [] + + def __call__(self, name: str) -> Path: + safe_name = name.replace("/", "_") + path = self._base_dir / safe_name + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + path.mkdir(parents=True, exist_ok=True) + self._created.append(path) + return path + + def cleanup(self) -> None: + for path in reversed(self._created): + shutil.rmtree(path, ignore_errors=True) + self._created.clear() + + +@pytest.fixture +def db_path_factory(tmp_path: Path): + factory = DatabasePathFactory(tmp_path) + yield factory + factory.cleanup() + + +@pytest.fixture +def mem_db() -> DBConnection: + return lancedb.connect("memory://") + + +@pytest.fixture +def tmp_db(db_path_factory) -> DBConnection: + """Create a temporary database connection for testing.""" + db_path = db_path_factory("tmp_db") + return lancedb.connect(str(db_path)) + + +@pytest_asyncio.fixture +async def mem_db_async() -> AsyncConnection: + return await lancedb.connect_async("memory://") diff --git a/docs/web-tests/py/test_basic_usage.py b/docs/web-tests/py/test_basic_usage.py new file mode 100644 index 000000000..260ebb6f9 --- /dev/null +++ b/docs/web-tests/py/test_basic_usage.py @@ -0,0 +1,205 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +# --8<-- [start:basic_imports] +import json + +import lancedb +import pandas as pd +import polars as pl +import pyarrow as pa +# --8<-- [end:basic_imports] +import pytest + +data_path = "tests/camelot.json" + +def test_basic_usage(db_path_factory): + uri = "./basic_usage_db" + uri = db_path_factory("basic_usage_db") + db = lancedb.connect(uri) + + # --8<-- [start:data_load] + with open(data_path, "r") as f: + data = json.load(f) + # --8<-- [end:data_load] + + # --8<-- [start:basic_create_table] + table = db.create_table("camelot", data=data, mode="overwrite") + # --8<-- [end:basic_create_table] + assert len(table) == 8 + + # --8<-- [start:basic_open_table] + table = db.open_table("camelot") + # --8<-- [end:basic_open_table] + + # --8<-- [start:basic_create_table_pandas] + pandas_df = pd.DataFrame(data) + table_pd = db.create_table("camelot_pd", data=pandas_df, mode="overwrite") + # --8<-- [end:basic_create_table_pandas] + assert len(table_pd) == 8 + db.drop_table("camelot_pd") + + # --8<-- [start:basic_create_table_polars] + polars_df = pl.DataFrame(data) + table_pl = db.create_table("camelot_pl", data=polars_df, mode="overwrite") + # --8<-- [end:basic_create_table_polars] + assert len(table_pl) == 8 + db.drop_table("camelot_pl") + + # --8<-- [start:basic_create_empty_table] + schema = pa.schema( + [ + pa.field("id", pa.uint16()), + pa.field("name", pa.string()), + pa.field("role", pa.string()), + pa.field("description", pa.string()), + pa.field("vector", pa.list_(pa.float32(), 4)), + pa.field( + "stats", + pa.struct( + [ + pa.field("strength", pa.int8()), + pa.field("courage", pa.int8()), + pa.field("magic", pa.int8()), + pa.field("wisdom", pa.int8()), + ] + ), + ), + ] + ) + db.create_table("camelot_pa", schema=schema, mode="overwrite") + # --8<-- [end:basic_create_empty_table] + assert "camelot_pa" in db.list_tables().tables + db.drop_table("camelot_pa") + + # --8<-- [start:basic_add_data] + magical_characters = [ + { + "id": 9, + "name": "Morgan le Fay", + "role": "Sorceress", + "description": "A powerful enchantress, Arthur's half-sister, and a complex figure who oscillates between aiding and opposing Camelot.", + "vector": [0.10, 0.84, 0.25, 0.70], + "stats": { "strength": 2, "courage": 3, "magic": 5, "wisdom": 4 } + }, + { + "id": 10, + "name": "The Lady of the Lake", + "role": "Mystical Guardian", + "description": "A mysterious supernatural figure associated with Avalon, known for giving Arthur the sword Excalibur.", + "vector": [0.00, 0.90, 0.58, 0.88], + "stats": { "strength": 2, "courage": 3, "magic": 5, "wisdom": 5 } + } + ] + table.add(magical_characters) + # --8<-- [end:basic_add_data] + assert len(table) == 10 + + # --8<-- [start:basic_vector_search] + query_vector = [0.03, 0.85, 0.61, 0.90] + result = table.search(query_vector).limit(5).to_polars() + print(result) + # --8<-- [end:basic_vector_search] + + # --8<-- [start:basic_add_columns] + table.add_columns( + { + "power": "cast(((stats.strength + stats.courage + stats.magic + stats.wisdom) / 4.0) as float)" + } + ) + # --8<-- [end:basic_add_columns] + assert "power" in table.schema.names + + # Run examples to illustrate search + # --8<-- [start:basic_vector_search_q1] + # Who are the characters similar to "wizard"? + query_vector_1 = [0.03, 0.85, 0.61, 0.90] + r1 = ( + table.search(query_vector_1) + .limit(5) + .select(["name", "role", "description"]) + .to_polars() + ) + print(r1) + # --8<-- [end:basic_vector_search_q1] + + # --8<-- [start:basic_vector_search_q2] + # Who are the characters with high magic stats? + query_vector_2 = [0.03, 0.85, 0.61, 0.90] + r2 = ( + table.search(query_vector_2) + .where("stats.magic > 3") + .select(["name", "role", "description"]) + .limit(5) + .to_polars() + ) + print(r2) + # --8<-- [end:basic_vector_search_q2] + + # --8<-- [start:basic_vector_search_q3] + # Who are the strongest characters? + r3 = ( + table.search() + .where("stats.strength > 3") + .select(["name", "role", "description"]) + .limit(5) + .to_polars() + ) + print(r3) + # --8<-- [end:basic_vector_search_q3] + + # --8<-- [start:basic_vector_search_q4] + # Who are the strongest characters? + r4 = ( + table.search() + .select(["name", "role", "description", "power"]) + .to_polars() + ) + print(r4) + # --8<-- [end:basic_vector_search_q4] + + # --8<-- [start:basic_sort_polars] + # Sort Polars DataFrame by power in descending order + print(r4.sort("power", descending=True).limit(5)) + # --8<-- [end:basic_sort_polars] + sorted_power = r4.sort("power", descending=True)["power"].to_list() + assert sorted_power == sorted(sorted_power, reverse=True) + assert sorted_power[0] == 4.0 + + # --8<-- [start:basic_drop_columns] + table.drop_columns(["power"]) + # --8<-- [end:basic_drop_columns] + # --8<-- [start:basic_delete_rows] + table.delete('role = "Traitor Knight"') + # --8<-- [end:basic_delete_rows] + assert len(table) == 9 + # --8<-- [start:basic_drop_table] + db.drop_table("camelot") + # --8<-- [end:basic_drop_table] + assert "camelot" not in db.list_tables().tables + + +@pytest.mark.asyncio +async def test_basic_usage_async_api(db_path_factory): + uri = db_path_factory("basic_usage_async_db") + with open(data_path, "r") as f: + data = json.load(f) + + # --8<-- [start:basic_async_api] + import lancedb + + async_db = await lancedb.connect_async(uri) + async_table = await async_db.create_table( + "camelot_async", + data=data, + mode="overwrite", + ) + + query_vector = [0.03, 0.85, 0.61, 0.90] + async_results = await ( + await async_table.search(query_vector) + ).limit(5).select(["name", "role", "description"]).to_polars() + print(async_results) + # --8<-- [end:basic_async_api] + + assert async_results.height >= 1 diff --git a/docs/web-tests/py/test_build_with_ai_agents.py b/docs/web-tests/py/test_build_with_ai_agents.py new file mode 100644 index 000000000..1ab7c9cdc --- /dev/null +++ b/docs/web-tests/py/test_build_with_ai_agents.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +# --8<-- [start:camelot_schema] +from lancedb.pydantic import LanceModel +from pydantic import ConfigDict + + +class Stats(LanceModel): + model_config = ConfigDict(strict=True, extra="forbid") + + strength: int + courage: int + magic: int + wisdom: int + + +class Character(LanceModel): + model_config = ConfigDict(strict=True, extra="forbid") + + id: int + name: str + role: str + description: str + stats: Stats + image_filename: str + image: bytes +# --8<-- [end:camelot_schema] + + +# --8<-- [start:camelot_batches] +import json +from collections.abc import Iterator +from pathlib import Path + + +def validated_batches( + input_path: Path, batch_size: int +) -> Iterator[list[dict]]: + raw_records = json.loads(input_path.read_text()) + asset_root = input_path.parent.parent + batch: list[dict] = [] + + for raw in raw_records: + payload = dict(raw) + image_path = asset_root / payload.pop("img") + payload["image_filename"] = image_path.name + payload["image"] = image_path.read_bytes() + + character = Character.model_validate(payload) + batch.append(character.model_dump(mode="python")) + + if len(batch) == batch_size: + yield batch + batch = [] + + if batch: + yield batch +# --8<-- [end:camelot_batches] + + +# --8<-- [start:camelot_oss_ingestion] +import lancedb + + +def ingest_oss( + input_path: Path, + uri: str = "data/camelot.lancedb", + table_name: str = "camelot_multimodal", + batch_size: int = 64, +): + db = lancedb.connect(uri) + if table_name in db.list_tables(): + raise ValueError( + f"Table {table_name!r} already exists. Choose a fresh table name." + ) + + table = db.create_table(table_name, schema=Character) + for batch in validated_batches(input_path, batch_size): + table.add(batch) + + table.optimize() + return table + + +if __name__ == "__main__": + ingest_oss(Path("data/camelot.json")) +# --8<-- [end:camelot_oss_ingestion] + + +def test_camelot_oss_ingestion(tmp_path): + source = Path( + "docs/web/static/assets/tutorials/build-with-ai-agents/camelot/data/camelot.json" + ) + table = ingest_oss( + source, + uri=str(tmp_path / "camelot.lancedb"), + table_name="camelot", + ) + + rows = ( + table.search() + .select(["id", "name", "image", "image_filename"]) + .limit(8) + .to_list() + ) + assert len(rows) == 8 + assert all(row["image"] for row in rows) + assert all(row["image_filename"].endswith(".jpg") for row in rows) diff --git a/docs/web-tests/py/test_connection.py b/docs/web-tests/py/test_connection.py new file mode 100644 index 000000000..783ac2d8d --- /dev/null +++ b/docs/web-tests/py/test_connection.py @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +from pathlib import Path +import shutil + + +def test_connection(): + # --8<-- [start:connect] + import lancedb + + uri = "ex_lancedb" + db = lancedb.connect(uri) + # --8<-- [end:connect] + assert db is not None + shutil.rmtree(uri, ignore_errors=True) + assert not Path(uri).exists() + + +async def connect_async_example(): + # --8<-- [start:connect_async] + import lancedb + + uri = "ex_lancedb" + async_db = await lancedb.connect_async(uri) + # --8<-- [end:connect_async] + + return async_db + + +def connect_enterprise_quickstart_config(): + import lancedb + + # --8<-- [start:connect_enterprise_quickstart] + uri = "db://your-database-uri" + api_key = "your-api-key" + region = "us-east-1" + host_override = "https://your-enterprise-endpoint.com" + + db = lancedb.connect( + uri=uri, + api_key=api_key, + region=region, + host_override=host_override, + ) + # --8<-- [end:connect_enterprise_quickstart] + return db + + +def test_connect_enterprise_quickstart(monkeypatch): + import lancedb + + captured = {} + + def fake_connect(**kwargs): + captured.update(kwargs) + return object() + + monkeypatch.setattr(lancedb, "connect", fake_connect) + + db = connect_enterprise_quickstart_config() + assert db is not None + assert captured == { + "uri": "db://your-database-uri", + "api_key": "your-api-key", + "region": "us-east-1", + "host_override": "https://your-enterprise-endpoint.com", + } + + +def connect_object_storage_config(): + # --8<-- [start:connect_object_storage] + import lancedb + + uri = "s3://your-bucket/path" + # You can also use "gs://your-bucket/path" or "az://your-container/path". + db = lancedb.connect(uri) + # --8<-- [end:connect_object_storage] + + return db + + +def namespace_table_ops_example(): + # --8<-- [start:namespace_table_ops] + import lancedb + + db = lancedb.connect_namespace("dir", {"root": "./local_lancedb"}) + + # Create namespace tree: prod/search + db.create_namespace(["prod"], mode="exist_ok") + db.create_namespace(["prod", "search"], mode="exist_ok") + db.create_namespace(["prod", "recommendations"], mode="exist_ok") + + db.create_table( + "user", + data=[{"id": 1, "vector": [0.1, 0.2], "name": "alice"}], + namespace_path=["prod", "search"], + mode="create", # use "overwrite" only if you want to replace existing table + ) + + db.create_table( + "user", + data=[{"id": 2, "vector": [0.3, 0.4], "name": "bob"}], + namespace_path=["prod", "recommendations"], + mode="create", # use "overwrite" only if you want to replace existing table + ) + + # Verify + print(db.list_namespaces()) # ['prod'] + print(db.list_namespaces(namespace_path=["prod"])) # ['recommendations', 'search'] + print(db.list_tables(namespace_path=["prod", "search"])) # ['user'] + print(db.list_tables(namespace_path=["prod", "recommendations"])) # ['user'] + # --8<-- [end:namespace_table_ops] + + +def namespace_admin_ops_example(): + # --8<-- [start:namespace_admin_ops] + import lancedb + + db = lancedb.connect_namespace("dir", {"root": "./local_lancedb"}) + namespace = ["prod", "search"] + + db.create_namespace(["prod"]) + db.create_namespace(["prod", "search"]) + + child_namespaces = db.list_namespaces(namespace_path=["prod"]).namespaces + print(f"Child namespaces under {namespace}: {child_namespaces}") + # Child namespaces under ['prod', 'search']: ['search'] + + metadata = db.describe_namespace(["prod", "search"]) + print(f"Metadata for namespace {namespace}: {metadata}") + # Metadata for namespace ['prod', 'search']: properties=None + + db.drop_namespace(["prod", "search"], mode="skip") + db.drop_namespace(["prod"], mode="skip") + # --8<-- [end:namespace_admin_ops] + return child_namespaces, metadata + +async def connect_object_storage_config_async(): + # --8<-- [start:connect_object_storage_async] + import lancedb + + uri = "s3://your-bucket/path" + # You can also use "gs://your-bucket/path" or "az://your-container/path". + async_db = await lancedb.connect_async(uri) + # --8<-- [end:connect_object_storage_async] + + return async_db diff --git a/docs/web-tests/py/test_doc_function_examples.py b/docs/web-tests/py/test_doc_function_examples.py new file mode 100644 index 000000000..fa72e66ef --- /dev/null +++ b/docs/web-tests/py/test_doc_function_examples.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +from lancedb.index import FTS + + +def test_python_boolean_query_operators(tmp_db): + table = tmp_db.create_table( + "boolean_docs", + [ + {"id": 1, "text": "puppy"}, + {"id": 2, "text": "merrily"}, + {"id": 3, "text": "puppy merrily"}, + {"id": 4, "text": "other"}, + ], + ) + table.create_index("text", config=FTS()) + + # --8<-- [start:fts_boolean_queries] + from lancedb.query import MatchQuery + + intersection = MatchQuery("puppy", "text") & MatchQuery("merrily", "text") + union = MatchQuery("puppy", "text") | MatchQuery("merrily", "text") + # --8<-- [end:fts_boolean_queries] + + def ids(query): + return set(table.search(query, query_type="fts").to_arrow()["id"].to_pylist()) + + assert ids(intersection) == {3} + assert ids(union) == {1, 2, 3} + a = MatchQuery("puppy", "text") + b = MatchQuery("merrily", "text") + assert ids(a and b) == {2, 3} + assert ids(a or b) == {1, 3} diff --git a/docs/web-tests/py/test_doc_storage_examples.py b/docs/web-tests/py/test_doc_storage_examples.py new file mode 100644 index 000000000..13bedb731 --- /dev/null +++ b/docs/web-tests/py/test_doc_storage_examples.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import lancedb +import numpy as np +import pyarrow as pa +import pytest + + +def test_rebuild_vector_index(tmp_db): + rng = np.random.default_rng(7) + table = tmp_db.create_table( + "rebuild_index", + [{"id": i, "vector": v.tolist()} for i, v in enumerate(rng.random((512, 4)))], + ) + table.create_index( + "vector", config=lancedb.index.IvfFlat(distance_type="l2", num_partitions=4) + ) + original_index = table.list_indices()[0] + table.add( + [ + {"id": 512 + i, "vector": v.tolist()} + for i, v in enumerate(rng.random((64, 4)) + 5) + ] + ) + assert table.index_stats("vector_idx").num_unindexed_rows == 64 + + # --8<-- [start:storage_rebuild_vector_index] + from lancedb.index import IvfFlat + + table.create_index( + "vector", + config=IvfFlat(distance_type="l2", num_partitions=4), + replace=True, + ) + # --8<-- [end:storage_rebuild_vector_index] + + indices = table.list_indices() + assert len(indices) == 1 + assert indices[0].name == original_index.name + assert indices[0].index_uuid != original_index.index_uuid + stats = table.index_stats("vector_idx") + assert stats.num_indexed_rows == 576 + assert stats.num_unindexed_rows == 0 + + +def test_local_shallow_clone_pins_source_version(tmp_path): + db = lancedb.connect(str(tmp_path)) + source = db.create_table("source", [{"id": 1}]) + clone = db.clone_table("snapshot", source_uri=str(tmp_path / "source.lance")) + source.add([{"id": 2}]) + assert source.count_rows() == 2 + assert clone.to_arrow().to_pylist() == [{"id": 1}] + + +def _reader(): + return pa.RecordBatchReader.from_batches( + pa.schema([("id", pa.int64())]), + [pa.record_batch([[1, 2]], names=["id"]), pa.record_batch([[3]], names=["id"])], + ) + + +@pytest.mark.parametrize("parallelism", [1, 2]) +def test_streaming_write_parallelism(tmp_db, parallelism): + table = tmp_db.create_table("streaming", [{"id": 0}]) + progress = [] + table.add(_reader(), write_parallelism=parallelism, progress=progress.append) + assert sorted(table.to_arrow()["id"].to_pylist()) == [0, 1, 2, 3] + assert progress[-1]["done"] + assert progress[-1]["output_rows"] == 3 + assert {p["total_tasks"] for p in progress} == {parallelism} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("parallelism", [1, 2]) +async def test_async_streaming_write_parallelism(mem_db_async, parallelism): + table = await mem_db_async.create_table("streaming", [{"id": 0}]) + progress = [] + await table.add(_reader(), write_parallelism=parallelism, progress=progress.append) + data = await table.to_arrow() + assert sorted(data["id"].to_pylist()) == [0, 1, 2, 3] + assert progress[-1]["done"] + assert progress[-1]["output_rows"] == 3 + assert {p["total_tasks"] for p in progress} == {parallelism} diff --git a/docs/web-tests/py/test_embedding.py b/docs/web-tests/py/test_embedding.py new file mode 100644 index 000000000..490be268e --- /dev/null +++ b/docs/web-tests/py/test_embedding.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import lancedb + +# --8<-- [start:imports] +from lancedb.pydantic import LanceModel, Vector +from lancedb.embeddings import get_registry + +# --8<-- [end:imports] +import pytest + + +@pytest.mark.skip(reason="OpenAI is not available in the test environment") +def test_create_embedding_function(): + # --8<-- [start:create_embedding_function] + func = get_registry().get("openai").create( + name="text-embedding-3-small", + max_retries=7, + ) + # --8<-- [end:create_embedding_function] + assert func is not None + + +@pytest.mark.skip(reason="OpenAI is not available in the test environment") +def test_embeddings_openai(): + # --8<-- [start:openai_embeddings] + db = lancedb.connect("/tmp/db") + func = get_registry().get("openai").create(name="text-embedding-ada-002") + + class Words(LanceModel): + text: str = func.SourceField() + vector: Vector(func.ndims()) = func.VectorField() + + table = db.create_table("words", schema=Words, mode="overwrite") + table.add([{"text": "hello world"}, {"text": "goodbye world"}]) + + query = "greetings" + actual = table.search(query).limit(1).to_pydantic(Words)[0] + print(actual.text) + # --8<-- [end:openai_embeddings] + + +@pytest.mark.skip(reason="OpenAI is not available in the test environment") +@pytest.mark.asyncio +async def test_embeddings_openai_async(): + uri = "memory://" + # --8<-- [start:async_openai_embeddings] + db = await lancedb.connect_async(uri) + func = get_registry().get("openai").create(name="text-embedding-ada-002") + + class Words(LanceModel): + text: str = func.SourceField() + vector: Vector(func.ndims()) = func.VectorField() + + table = await db.create_table("words", schema=Words, mode="overwrite") + await table.add([{"text": "hello world"}, {"text": "goodbye world"}]) + + query = "greetings" + actual = await (await table.search(query)).limit(1).to_pydantic(Words)[0] + print(actual.text) + # --8<-- [end:async_openai_embeddings] + + +@pytest.mark.skip(reason="OpenAI is not available in the test environment") +def test_embeddings_manual_query(): + # --8<-- [start:manual_query_embeddings] + db = lancedb.connect("/tmp/db") + func = get_registry().get("openai").create(name="text-embedding-ada-002") + + class Words(LanceModel): + text: str = func.SourceField() + vector: Vector(func.ndims()) = func.VectorField() + + table = db.create_table("words", schema=Words, mode="overwrite") + table.add([{"text": "hello world"}, {"text": "goodbye world"}]) + + query_vector = func.generate_embeddings(["greetings"])[0] + # --8<-- [start:manual_query_search] + # query_vector is assumed to already be generated by your embedding function + actual = table.search(query_vector).limit(1).to_pydantic(Words)[0] + print(actual.text) + # --8<-- [end:manual_query_search] + # --8<-- [end:manual_query_embeddings] + + +def test_custom_embedding_function(): + # --8<-- [start:embedding_function] + from functools import cached_property + + from lancedb.embeddings import TextEmbeddingFunction, register + + class MyEmbeddingModel: + def __init__(self, model_name: str): + self.model_name = model_name + + def encode(self, texts: list[str]) -> list[list[float]]: + return [[1.0, 2.0, 3.0] for _ in texts] + + @register("my-embedder") + class MyTextEmbedder(TextEmbeddingFunction): + model_name: str = "my-model" + + def generate_embeddings(self, texts: list[str]) -> list[list[float]]: + # Your embedding logic here + return self._model.encode(texts) + + def ndims(self) -> int: + # Return the dimensionality of the embeddings + return len(self.generate_embeddings(["test"])[0]) + + @cached_property + def _model(self) -> MyEmbeddingModel: + # Initialize your model once + return MyEmbeddingModel(self.model_name) + # --8<-- [end:embedding_function] + + +def test_embeddings_secret(): + # --8<-- [start:register_secret] + registry = get_registry() + registry.set_var("api_key", "sk-...") + + func = registry.get("openai").create(api_key="$var:api_key") + # --8<-- [end:register_secret] + + try: + import torch + except ImportError: + pytest.skip("torch not installed") + + # --8<-- [start:register_device] + import torch + + registry = get_registry() + if torch.cuda.is_available(): + registry.set_var("device", "cuda") + + func = registry.get("huggingface").create(device="$var:device:cpu") + # --8<-- [end:register_device] + assert func.device == "cuda" if torch.cuda.is_available() else "cpu" diff --git a/docs/web-tests/py/test_function.py b/docs/web-tests/py/test_function.py new file mode 100644 index 000000000..629ab89bc --- /dev/null +++ b/docs/web-tests/py/test_function.py @@ -0,0 +1,242 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +"""Examples for docs/web/management/function.mdx. + +Registering a Function and filling its column need a LanceDB Enterprise +deployment, so `test_function_enterprise` runs only when the connection +variables the Enterprise examples already use are set: LANCEDB_URI, +LANCEDB_API_KEY and LANCEDB_HOST_OVERRIDE, plus LANCEDB_REGION if the +deployment needs one. The walkthrough calls its table `function_demo` and its +Function `double`; the test creates both under names unique to its run, never +replaces an existing object, and drops only what it created. + +The other tests run locally and check the client side of the same code: what +`@udf` sends for registration, how a registered version binds a column, and +where a local connection stops. They do not run a Function remotely. +""" + +import json +import os +import sys +import uuid +from pathlib import Path + +import lancedb +import pyarrow as pa +import pytest +from lancedb import col +from lancedb.functions import FunctionApplication, FunctionVersion + +# --8<-- [start:function_define] +from lancedb import udf + + +@udf +def double(value: float) -> float: + return value * 2 + + +# --8<-- [end:function_define] + +# The definition the walkthrough registers. The Enterprise test registers a copy +# of it under a name that only its run uses. +WALKTHROUGH_DOUBLE = double + + +def describe(value: float): + return {"half": value / 2, "note": None} + + +# A Function version as the service returns it after registration: the SDK's +# shared golden reply, with the name and signature of the definition above. +SERVICE_REPLY = ( + Path(__file__).parents[3] + / "rust" + / "lancedb" + / "tests" + / "fixtures" + / "first_class_functions" + / "v1" + / "remote_function_job.json" +) + + +def registered_version(definition) -> FunctionVersion: + request = definition.registration_request + reply = json.loads(SERVICE_REPLY.read_text())["result"] + reply["name"] = request.name + reply["signature"] = json.loads(request.signature.to_canonical_json()) + return FunctionVersion.from_json(json.dumps(reply)) + + +def require_env(var_name: str) -> str: + """Skip the test unless the required environment variable is provided.""" + value = os.environ.get(var_name) + if not value: + pytest.skip(f"Set {var_name} to run this Enterprise example") + return value + + +def test_function_runs_locally_as_python(): + # --8<-- [start:function_local_call] + print(double(1.5)) + # 3.0 + # --8<-- [end:function_local_call] + assert double(1.5) == 3.0 + + +def test_function_registration_request(): + request = double.registration_request + assert request.name == "double" + assert [ + (parameter.name, parameter.arrow_type, parameter.nullable) + for parameter in request.signature.inputs + ] == [("value", "float64", False)] + output = request.signature.output + assert (output.kind, output.arrow_type, output.nullable) == ( + "scalar", + "float64", + False, + ) + version = request.runtime.python_version + assert version == f"{sys.version_info.major}.{sys.version_info.minor}" + assert request.runtime.environment.packages == () + + +def test_function_output_nullability(): + inputs = pa.schema([pa.field("value", pa.float64(), nullable=False)]) + + # A named-struct output may declare nullable fields. + fields = [ + pa.field("half", pa.float64(), nullable=False), + pa.field("note", pa.string(), nullable=True), + ] + struct = udf(input_schema=inputs, output_schema=pa.schema(fields))(describe) + output = struct.registration_request.signature.output + assert output.kind == "named_struct" + assert [(field.name, field.nullable) for field in output.fields] == [ + ("half", False), + ("note", True), + ] + + # A scalar output, and a struct declared as one nullable field, may not be. + for nullable in ( + pa.field("result", pa.float64(), nullable=True), + pa.field("result", pa.struct(fields), nullable=True), + ): + with pytest.raises(ValueError, match="non-nullable"): + udf(input_schema=inputs, output_schema=nullable)(describe) + + +def test_function_registration_is_remote(tmp_path): + db = lancedb.connect(tmp_path) + with pytest.raises(NotImplementedError): + db.create_function(double) + + +def test_function_version_binds_a_column(tmp_path): + version = registered_version(double) + + application = version(value=col("value")) + + assert isinstance(application, FunctionApplication) + assert application.function.name == "double" + assert application.function.version == version.version + assert [ + (binding.parameter, binding.kind, binding.value["path"]) + for binding in application.inputs + ] == [("value", "column", "value")] + with pytest.raises(TypeError, match="direct col"): + version(value=1.5) + with pytest.raises(TypeError, match="missing"): + version(x=col("value")) + + # The binding is accepted by add_columns, which a local table refuses: + # a Function column needs LanceDB Cloud or Enterprise. + table = lancedb.connect(tmp_path).create_table("t", [{"value": 1.0}]) + with pytest.raises(NotImplementedError, match="Cloud and Enterprise"): + table.add_columns({"doubled": application}) + + +def test_function_enterprise(monkeypatch): + require_env("LANCEDB_URI") + require_env("LANCEDB_API_KEY") + require_env("LANCEDB_HOST_OVERRIDE") + + # --8<-- [start:function_connect] + import os + + import lancedb + + db = lancedb.connect( + uri=os.environ["LANCEDB_URI"], # db://your-database + api_key=os.environ["LANCEDB_API_KEY"], + region=os.environ.get("LANCEDB_REGION", "us-east-1"), + host_override=os.environ["LANCEDB_HOST_OVERRIDE"], + ) + # --8<-- [end:function_connect] + + # In a shared database `function_demo` or `double` may already exist, so + # this run creates both under names only it uses, and drops only those. + run = uuid.uuid4().hex[:12] + table_name = f"function_demo_{run}" + double = udf(name=f"double_{run}")(WALKTHROUGH_DOUBLE.__wrapped__) + create_table = db.create_table + + def create_run_table(name, *args, **kwargs): + assert name == "function_demo" + return create_table(table_name, *args, **kwargs) + + monkeypatch.setattr(db, "create_table", create_run_table) + + table = None + version = None + try: + # --8<-- [start:function_create_table] + import pyarrow as pa + + schema = pa.schema([pa.field("value", pa.float64(), nullable=False)]) + table = db.create_table( + "function_demo", + [{"value": 1.0}, {"value": 2.0}, {"value": 3.0}], + schema=schema, + ) + # --8<-- [end:function_create_table] + + # --8<-- [start:function_register] + job = db.create_function_async(double) + version = job.wait() + # --8<-- [end:function_register] + assert version.name == f"double_{run}" + + # --8<-- [start:function_apply] + from lancedb import col + + table.add_columns({"doubled": version(value=col("value"))}) + # --8<-- [end:function_apply] + + # --8<-- [start:function_refresh] + refresh = table.refresh_column_async("doubled") + result = refresh.wait() + print(result.rows_assigned) + # 3 + # --8<-- [end:function_refresh] + assert result.rows_assigned == 3 + assert result.rows_failed == 0 + + # --8<-- [start:function_inspect] + rows = table.search().select(["value", "doubled"]).to_list() + print(sorted((row["value"], row["doubled"]) for row in rows)) + # [(1.0, 2.0), (2.0, 4.0), (3.0, 6.0)] + # --8<-- [end:function_inspect] + assert sorted((row["value"], row["doubled"]) for row in rows) == [ + (1.0, 2.0), + (2.0, 4.0), + (3.0, 6.0), + ] + finally: + if table is not None: + db.drop_table(table_name) + if version is not None: + db.drop_function(version.name, version=version.version) diff --git a/docs/web-tests/py/test_indexing.py b/docs/web-tests/py/test_indexing.py new file mode 100644 index 000000000..80010343c --- /dev/null +++ b/docs/web-tests/py/test_indexing.py @@ -0,0 +1,757 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import random +import string +import uuid + +import numpy as np +import pyarrow as pa +import pytest + + +def _make_vector_rows(count: int, dim: int, column: str = "vector"): + rows = [ + {column: np.random.random(dim).astype(np.float32).tolist(), "id": i} + for i in range(count) + ] + assert len(rows) == count + assert all(len(row[column]) == dim for row in rows) + return rows + + +def test_vector_index_configure_ivf(tmp_db): + table = tmp_db.create_table( + "vector_index_configure_ivf", + _make_vector_rows(512, 4), + mode="overwrite", + ) + + # --8<-- [start:vector_index_configure_ivf] + table.create_index(metric="l2", num_partitions=16, num_sub_vectors=4) + # --8<-- [end:vector_index_configure_ivf] + + assert table.list_indices() + + +def test_vector_index_setup(tmp_db): + tmp_db.create_table( + "vector-index-tbl", + _make_vector_rows(8, 4), + mode="overwrite", + ) + + db = tmp_db + # --8<-- [start:vector_index_setup] + table_name = "vector-index-tbl" + table = db.open_table(table_name) + # --8<-- [end:vector_index_setup] + + assert table.name == table_name + + +def test_vector_index_build_ivf(tmp_db): + table = tmp_db.create_table( + "vector-index-build-ivf", + _make_vector_rows(512, 4, column="keywords_embeddings"), + mode="overwrite", + ) + db = tmp_db + # --8<-- [start:vector_index_build_ivf] + table_name = "vector-index-build-ivf" + table = db.open_table(table_name) + table.create_index( + metric="cosine", + vector_column_name="keywords_embeddings", + ) + # --8<-- [end:vector_index_build_ivf] + + assert table.list_indices() + + +def test_vector_index_nested_field(tmp_db): + dim = 2 + schema = pa.schema( + [ + pa.field("id", pa.int32()), + pa.field( + "image", + pa.struct([pa.field("embedding", pa.list_(pa.float32(), dim))]), + ), + ] + ) + data = [ + { + "id": i, + "image": {"embedding": np.random.random(dim).astype(np.float32).tolist()}, + } + for i in range(512) + ] + table = tmp_db.create_table( + "vector_index_nested_field", data=data, schema=schema, mode="overwrite" + ) + + # --8<-- [start:vector_index_nested_field] + # The vector column `embedding` is nested inside the `image` struct. + # Pass its full dotted path as `vector_column_name`; the same path is used + # at query time and is what `list_indices()` reports under `columns`. + table.create_index( + vector_column_name="image.embedding", + num_partitions=1, + num_sub_vectors=1, + name="image_embedding_idx", + ) + + results = ( + table.search([0.0, 1.0], vector_column_name="image.embedding") + .limit(1) + .to_list() + ) + # --8<-- [end:vector_index_nested_field] + + assert table.index_stats("image_embedding_idx") + assert len(results) == 1 + + +@pytest.mark.asyncio +async def test_vector_index_async_config(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + # --8<-- [start:vector_index_async_config] + import lancedb + import numpy as np + from lancedb.index import IvfPq + + async def main(): + data = [ + {"id": i, "vector": np.random.random(8).astype(np.float32).tolist()} + for i in range(512) + ] + + db = await lancedb.connect_async("ex_lancedb") + table = await db.create_table( + "vector_index_async", data=data, mode="overwrite" + ) + + await table.create_index( + "vector", + config=IvfPq( + distance_type="cosine", + num_partitions=16, + num_sub_vectors=4, + ), + ) + return await table.list_indices() + # --8<-- [end:vector_index_async_config] + + assert await main() + + +def test_vector_index_query_ivf(tmp_db): + dim = 1536 + data = [ + {"id": i, "keywords_embeddings": np.random.random(dim).tolist()} + for i in range(512) + ] + table = tmp_db.create_table("vector_index_query_ivf", data, mode="overwrite") + table.create_index( + metric="cosine", + vector_column_name="keywords_embeddings", + ) + + # --8<-- [start:vector_index_query_ivf] + tbl = table + tbl.search(np.random.random((1536))).limit(2).nprobes(20).refine_factor( + 10 + ).to_pandas() + # --8<-- [end:vector_index_query_ivf] + + df = ( + table.search(np.random.random((1536))) + .limit(2) + .nprobes(20) + .refine_factor(10) + .to_pandas() + ) + assert len(df) == 2 + + +def test_vector_index_nprobes(tmp_db): + dim = 128 + data = [ + {"id": i, "keywords_embeddings": np.random.random(dim).tolist()} + for i in range(512) + ] + table = tmp_db.create_table("vector_index_nprobes", data, mode="overwrite") + table.create_index( + metric="cosine", + vector_column_name="keywords_embeddings", + ) + + # --8<-- [start:vector_index_nprobes] + # Always scan 10 partitions; scan up to 50 only if the initial pass + # returns fewer than `limit` results (common with narrow filters). + ( + table.search(np.random.random(128)) + .minimum_nprobes(10) + .maximum_nprobes(50) + .where("id > 100") + .limit(5) + .to_pandas() + ) + # --8<-- [end:vector_index_nprobes] + + +def test_vector_index_distance_range(tmp_db): + dim = 128 + data = [ + {"id": i, "keywords_embeddings": np.random.random(dim).tolist()} + for i in range(256) + ] + table = tmp_db.create_table("vector_index_distance_range", data, mode="overwrite") + table.create_index( + metric="cosine", + vector_column_name="keywords_embeddings", + ) + + # --8<-- [start:vector_index_distance_range] + # Only return results whose distance falls within [0.0, 0.5). + # Useful for near-duplicate detection or thresholded similarity search. + ( + table.search(np.random.random(128)) + .distance_range(lower_bound=0.0, upper_bound=0.5) + .limit(10) + .to_pandas() + ) + # --8<-- [end:vector_index_distance_range] + + +def test_vector_index_bypass_recall(tmp_db): + dim = 128 + data = [ + {"id": i, "keywords_embeddings": np.random.random(dim).tolist()} + for i in range(256) + ] + table = tmp_db.create_table("vector_index_bypass_recall", data, mode="overwrite") + table.create_index( + metric="cosine", + vector_column_name="keywords_embeddings", + ) + + # --8<-- [start:vector_index_bypass_recall] + query = np.random.random(128) + k = 10 + + # Ground truth: flat (exhaustive) scan, ignoring the ANN index. + truth = set(table.search(query).bypass_vector_index().limit(k).to_pandas()["id"]) + + # ANN results with the current nprobes setting. + ann = set(table.search(query).nprobes(20).limit(k).to_pandas()["id"]) + + recall_at_k = len(truth & ann) / k + # --8<-- [end:vector_index_bypass_recall] + assert 0.0 <= recall_at_k <= 1.0 + + +def test_vector_index_custom_name(tmp_db): + table = tmp_db.create_table( + "vector_index_custom_name", + _make_vector_rows(512, 8, column="keywords_embeddings"), + mode="overwrite", + ) + + # --8<-- [start:vector_index_custom_name] + # Override the default `{column}_idx` convention by passing `name=...`. + table.create_index( + metric="cosine", + vector_column_name="keywords_embeddings", + name="my_custom_index", + ) + table.wait_for_index(["my_custom_index"]) + print(table.index_stats("my_custom_index")) + # --8<-- [end:vector_index_custom_name] + + assert table.index_stats("my_custom_index") + + +def test_vector_index_hnsw(tmp_db): + table = tmp_db.create_table( + "vector_index_hnsw", + _make_vector_rows(64, 16), + mode="overwrite", + ) + + # --8<-- [start:vector_index_build_hnsw] + table.create_index(index_type="IVF_HNSW_SQ") + # --8<-- [end:vector_index_build_hnsw] + + # --8<-- [start:vector_index_query_hnsw] + tbl = table + tbl.search(np.random.random((16))).limit(2).to_pandas() + # --8<-- [end:vector_index_query_hnsw] + + df = table.search(np.random.random((16))).limit(2).to_pandas() + assert len(df) == 2 + + +def test_vector_index_binary(tmp_db): + table_name = "hamming-index-tbl" + ndim = 256 + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("vector", pa.list_(pa.uint8(), ndim // 8)), + ] + ) + + # --8<-- [start:vector_index_binary_schema] + table = tmp_db.create_table(table_name, schema=schema, mode="overwrite") + # --8<-- [end:vector_index_binary_schema] + + data = [] + for i in range(64): + vector = np.random.randint(0, 2, size=ndim) + vector = np.packbits(vector) + data.append({"id": i, "vector": vector}) + + # --8<-- [start:vector_index_binary_add_data] + table.add(data) + # --8<-- [end:vector_index_binary_add_data] + + # --8<-- [start:vector_index_binary_build_index] + table.create_index( + metric="hamming", + vector_column_name="vector", + index_type="IVF_FLAT", + ) + # --8<-- [end:vector_index_binary_build_index] + + # --8<-- [start:vector_index_binary_search] + query = np.random.randint(0, 2, size=ndim) + query = np.packbits(query) + df = table.search(query).metric("hamming").limit(10).to_pandas() + df.vector = df.vector.apply(np.unpackbits) + # --8<-- [end:vector_index_binary_search] + + assert not df.empty + + +def test_quantization_custom_params(tmp_db): + table = tmp_db.create_table( + "quantization-custom-params", + _make_vector_rows(256, 64), + mode="overwrite", + ) + + # --8<-- [start:quantization_custom_params] + table.create_index( + index_type="IVF_RQ", + num_bits=2, + max_iterations=100, + sample_rate=512, + ) + # --8<-- [end:quantization_custom_params] + + assert table.list_indices() + + +def test_vector_index_check_status(tmp_db): + table = tmp_db.create_table( + "vector_index_check_status", + _make_vector_rows(512, 8, column="keywords_embeddings"), + mode="overwrite", + ) + table.create_index( + metric="cosine", + vector_column_name="keywords_embeddings", + ) + + # --8<-- [start:vector_index_check_status] + index_name = "keywords_embeddings_idx" + table.wait_for_index([index_name]) + print(table.index_stats(index_name)) + # --8<-- [end:vector_index_check_status] + + assert table.index_stats(index_name) + + +def test_scalar_index_build(tmp_db): + table = tmp_db.create_table( + "scalar_index_build", + [ + {"book_id": 1, "publisher": "A", "vector": [0.1, 0.2]}, + {"book_id": 2, "publisher": "B", "vector": [0.2, 0.3]}, + ], + mode="overwrite", + ) + db = tmp_db + # --8<-- [start:scalar_index_build] + tbl = db.open_table("scalar_index_build") + tbl.create_scalar_index("book_id") + tbl.create_scalar_index("publisher", index_type="BITMAP") + # --8<-- [end:scalar_index_build] + + assert tbl.list_indices() + + +def test_scalar_index_wait(tmp_db): + table = tmp_db.create_table( + "scalar_index_wait", + [{"label": "fiction"}], + mode="overwrite", + ) + table.create_scalar_index("label") + + # --8<-- [start:scalar_index_wait] + index_name = "label_idx" + table.wait_for_index([index_name]) + # --8<-- [end:scalar_index_wait] + + assert table.list_indices() + + +def test_scalar_index_optimize(tmp_db): + table = tmp_db.create_table( + "scalar_index_optimize", + [{"vector": [7.0, 8.0], "book_id": 3}], + mode="overwrite", + ) + + # --8<-- [start:scalar_index_optimize] + table.add([{"vector": [7, 8], "book_id": 4}]) + table.optimize() + # --8<-- [end:scalar_index_optimize] + + result = table.search().where("book_id = 4").limit(10).to_pandas() + assert len(result) == 1 + + +def test_scalar_index_filter(tmp_db): + table = tmp_db.create_table( + "books", + [ + {"vector": [1.1, 1.2], "book_id": 1}, + {"vector": [2.1, 2.2], "book_id": 2}, + ], + mode="overwrite", + ) + db = tmp_db + # --8<-- [start:scalar_index_filter] + table = db.open_table("books") + result = table.search().where("book_id = 2").limit(10).to_pandas() + # --8<-- [end:scalar_index_filter] + + assert len(result) == 1 + + +def test_scalar_index_prefilter(tmp_db): + table = tmp_db.create_table( + "book_with_embeddings", + [ + {"vector": [1.2, 1.3], "book_id": 1}, + {"vector": [4.2, 4.3], "book_id": 2}, + ], + mode="overwrite", + ) + db = tmp_db + # --8<-- [start:scalar_index_prefilter] + table = db.open_table("book_with_embeddings") + table.search([1.2] * 2).where("book_id != 3").limit(10).to_pandas() + # --8<-- [end:scalar_index_prefilter] + + result = table.search([1.2] * 2).where("book_id != 3").limit(10).to_pandas() + assert len(result) == 2 + + +def test_scalar_index_uuid(tmp_db): + # --8<-- [start:scalar_index_uuid_type] + import pyarrow as pa + # --8<-- [end:scalar_index_uuid_type] + + # --8<-- [start:scalar_index_uuid_data] + def generate_random_names(): + base_names = ["Alice", "Bob", "Carla", "David", "Eve", "Frank", "Grace"] + letter = random.choice(string.ascii_uppercase) + return f"{random.choice(base_names)} {letter}." + + def generate_uuids(num_items): + return [uuid.uuid4().bytes for _ in range(num_items)] + + # Generate some UUIDs and random names + n = 7 + uuids = generate_uuids(n) + names = [generate_random_names() for _ in range(n)] + # --8<-- [end:scalar_index_uuid_data] + + db = tmp_db + # --8<-- [start:scalar_index_uuid_table] + table_name = "index-on-uuid" + + uuid_array = pa.array(uuids, pa.uuid()) + name_array = pa.array(names, pa.string()) + schema = pa.schema( + [ + pa.field("id", pa.uuid()), + pa.field("name", pa.string()), + ] + ) + data_table = pa.Table.from_arrays([uuid_array, name_array], schema=schema) + table = db.create_table(table_name, data=data_table, mode="overwrite") + # --8<-- [end:scalar_index_uuid_table] + + # --8<-- [start:scalar_index_uuid_wait] + index_name = "id_idx" + table.create_scalar_index("id") + table.wait_for_index([index_name]) + # --8<-- [end:scalar_index_uuid_wait] + + # --8<-- [start:scalar_index_uuid_upsert] + new_users = [ + {"id": uuid.uuid4().bytes, "name": "Hannah D."}, + {"id": uuid.uuid4().bytes, "name": "Ian B."}, + ] + # Insert or update using the UUID index + table.merge_insert( + "id" + ).when_matched_update_all().when_not_matched_insert_all().execute(new_users) + # --8<-- [end:scalar_index_uuid_upsert] + + assert table.list_indices() + result = table.search().limit(100).to_pandas() + assert len(result) == n + len(new_users) + + +@pytest.mark.asyncio +async def test_scalar_index_nested_fields(mem_db_async): + db = mem_db_async + + # --8<-- [start:scalar_index_nested_fields] + import pyarrow as pa + from lancedb.index import BTree + + metadata_type = pa.struct( + [ + pa.field("user_id", pa.int32()), + pa.field("user.id", pa.int32()), + ] + ) + data = pa.Table.from_arrays( + [ + pa.array([1, 2, 3], type=pa.int32()), + pa.array( + [ + {"user_id": 10, "user.id": 100}, + {"user_id": 20, "user.id": 200}, + {"user_id": 30, "user.id": 300}, + ], + type=metadata_type, + ), + ], + names=["user_id", "metadata"], + ) + table = await db.create_table("nested_scalar_index", data) + + # Index a nested struct field. + await table.create_index( + "metadata.user_id", config=BTree(), name="nested_user_id_idx" + ) + + # Escape literal dots inside a segment with backticks. + await table.create_index( + "metadata.`user.id`", config=BTree(), name="escaped_user_id_idx" + ) + + # `columns` is returned as the canonical path you passed in. + for index in await table.list_indices(): + print(index.name, index.columns) + # nested_user_id_idx ['metadata.user_id'] + # escaped_user_id_idx ['metadata.`user.id`'] + # --8<-- [end:scalar_index_nested_fields] + + index_columns = {index.name: index.columns for index in await table.list_indices()} + assert index_columns["nested_user_id_idx"] == ["metadata.user_id"] + assert index_columns["escaped_user_id_idx"] == ["metadata.`user.id`"] + + +def test_fts_index_create(tmp_db): + table = tmp_db.create_table( + "fts-index-create", + [{"text": "hello world", "vector": [0.1, 0.2]}], + mode="overwrite", + ) + + db = tmp_db + # --8<-- [start:fts_index_create] + table_name = "fts-index-create" + table = db.open_table(table_name) + table.create_fts_index("text") + # --8<-- [end:fts_index_create] + + assert table.list_indices() + + +def test_fts_index_wait(tmp_db): + table = tmp_db.create_table( + "fts-index-wait", + [{"text": "full text search"}], + mode="overwrite", + ) + + db = tmp_db + # --8<-- [start:fts_index_wait] + table_name = "fts-index-wait" + + table = db.open_table(table_name) + table.create_fts_index("text") + + index_name = "text_idx" + table.wait_for_index([index_name]) + # --8<-- [end:fts_index_wait] + + assert table.list_indices() + + +def test_fts_index_nested_field(tmp_db): + nested_schema = pa.struct([ + pa.field("text", pa.string()), + pa.field("count", pa.int32()), + ]) + schema = pa.schema([ + pa.field("id", pa.int64()), + pa.field("payload", nested_schema), + ]) + tmp_db.create_table( + "fts-index-nested", + pa.table( + { + "id": pa.array([1, 2], pa.int64()), + "payload": pa.array( + [ + {"text": "Frodo was a happy puppy", "count": 1}, + {"text": "puppy runs through the meadow", "count": 2}, + ], + type=nested_schema, + ), + }, + schema=schema, + ), + mode="overwrite", + ) + + db = tmp_db + # --8<-- [start:fts_index_nested] + from lancedb.query import MatchQuery, PhraseQuery + + table = db.open_table("fts-index-nested") + + # Index a text leaf inside a struct column using a dotted path. + table.create_fts_index("payload.text", with_position=True) + + # The same dotted path works in MatchQuery and PhraseQuery. + matches = ( + table.search(MatchQuery("puppy", "payload.text")).limit(5).to_list() + ) + phrases = ( + table.search(PhraseQuery("puppy runs", "payload.text")) + .limit(5) + .to_list() + ) + # --8<-- [end:fts_index_nested] + + assert len(matches) > 0 + assert all("puppy" in row["payload"]["text"] for row in matches) + assert len(phrases) > 0 + assert all("puppy runs" in row["payload"]["text"] for row in phrases) + + +@pytest.mark.asyncio +async def test_fts_index_async(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + # --8<-- [start:fts_index_async] + import asyncio + + import lancedb + import polars as pl + from lancedb.index import FTS + + data = pl.DataFrame( + { + "id": [1, 2], + "text": [ + "His first language is spanish", + "Her first language is english", + ], + } + ) + + async def main(data: pl.DataFrame): + uri = "ex_lancedb" + db = await lancedb.connect_async(uri) + tbl = await db.create_table("my_text", data=data, mode="overwrite") + + await tbl.create_index("text", config=FTS(language="English")) + + response = await tbl.search("spanish", query_type="fts") + result = await response.limit(1).to_polars() + print(result) + return result + + if __name__ == "__main__": + asyncio.run(main(data)) + # --8<-- [end:fts_index_async] + + result = await main(data) + assert result.height == 1 + + +def test_gpu_index_snippets(tmp_db, monkeypatch): + table = tmp_db.create_table( + "gpu_index", + _make_vector_rows(32, 8), + mode="overwrite", + ) + + calls = [] + + def fake_create_index(*args, **kwargs): + calls.append(kwargs) + return None + + monkeypatch.setattr(table, "create_index", fake_create_index) + + # --8<-- [start:gpu_index_cuda] + table.create_index( + num_partitions=256, + num_sub_vectors=96, + accelerator="cuda", + ) + # --8<-- [end:gpu_index_cuda] + + # --8<-- [start:gpu_index_mps] + table.create_index( + num_partitions=256, + num_sub_vectors=96, + accelerator="mps", + ) + # --8<-- [end:gpu_index_mps] + + assert calls[0]["accelerator"] == "cuda" + assert calls[1]["accelerator"] == "mps" + + +def test_reindexing_incremental(tmp_db): + table = tmp_db.create_table( + "reindexing_incremental", + [{"vector": [3.1, 4.1], "text": "Frodo was a happy puppy"}], + mode="overwrite", + ) + db = tmp_db + # --8<-- [start:reindexing_incremental] + table = db.open_table("reindexing_incremental") + table.add([{"vector": [3.1, 4.1], "text": "Frodo was a happy puppy"}]) + table.optimize() + # --8<-- [end:reindexing_incremental] + + result = table.search().limit(10).to_pandas() + assert len(result) == 2 diff --git a/docs/web-tests/py/test_integrations.py b/docs/web-tests/py/test_integrations.py new file mode 100644 index 000000000..ff07dfae0 --- /dev/null +++ b/docs/web-tests/py/test_integrations.py @@ -0,0 +1,1767 @@ +import os + +import pytest + + +def require_env(var_name: str) -> str: + """Skip the test unless the required environment variable is provided.""" + value = os.environ.get(var_name) + if not value: + pytest.skip(f"Set {var_name} to run this integration snippet") + return value + + +def require_flag(flag_name: str) -> None: + """Skip unless a generic feature flag is set to a truthy value.""" + value = os.environ.get(flag_name, "").lower() + if value not in {"1", "true", "yes", "on"}: + pytest.skip(f"Enable {flag_name} to run this integration snippet") + + +def test_embedding_openai_basic() -> None: + require_env("OPENAI_API_KEY") + + # --8<-- [start:embedding_openai_basic] + import tempfile + from pathlib import Path + + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + + db_path = Path(tempfile.mkdtemp()) / "openai-embeddings" + db = lancedb.connect(str(db_path)) + func = get_registry().get("openai").create(name="text-embedding-ada-002") + + class Words(LanceModel): + text: str = func.SourceField() + vector: Vector(func.ndims()) = func.VectorField() + + table = db.create_table("words", schema=Words, mode="overwrite") + table.add( + [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + ) + + query = "greetings" + actual = table.search(query).limit(1).to_pydantic(Words)[0] + print(actual.text) + # --8<-- [end:embedding_openai_basic] + + +def test_embedding_aws_usage() -> None: + require_flag("RUN_AWS_BEDROCK_SNIPPETS") + + # --8<-- [start:embedding_aws_usage] + import tempfile + from pathlib import Path + + import lancedb + import pandas as pd + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + + model = get_registry().get("bedrock-text").create() + + class TextModel(LanceModel): + text: str = model.SourceField() + vector: Vector(model.ndims()) = model.VectorField() + + df = pd.DataFrame({"text": ["hello world", "goodbye world"]}) + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "bedrock-demo")) + tbl = db.create_table("test", schema=TextModel, mode="overwrite") + + tbl.add(df) + rs = tbl.search("hello").limit(1).to_pandas() + print(rs.head()) + # --8<-- [end:embedding_aws_usage] + + +def test_embedding_cohere_usage() -> None: + require_env("COHERE_API_KEY") + + # --8<-- [start:embedding_cohere_usage] + import tempfile + from pathlib import Path + + import lancedb + from lancedb.embeddings import EmbeddingFunctionRegistry + from lancedb.pydantic import LanceModel, Vector + + cohere = ( + EmbeddingFunctionRegistry.get_instance() + .get("cohere") + .create(name="embed-multilingual-v2.0") + ) + + class TextModel(LanceModel): + text: str = cohere.SourceField() + vector: Vector(cohere.ndims()) = cohere.VectorField() + + data = [{"text": "hello world"}, {"text": "goodbye world"}] + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "cohere-demo")) + tbl = db.create_table("test", schema=TextModel, mode="overwrite") + tbl.add(data) + # --8<-- [end:embedding_cohere_usage] + + +def test_embedding_gemini_usage() -> None: + require_flag("RUN_GEMINI_SNIPPETS") + + # --8<-- [start:embedding_gemini_usage] + import tempfile + from pathlib import Path + + import lancedb + import pandas as pd + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + + model = get_registry().get("gemini-text").create() + + class TextModel(LanceModel): + text: str = model.SourceField() + vector: Vector(model.ndims()) = model.VectorField() + + df = pd.DataFrame({"text": ["hello world", "goodbye world"]}) + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "gemini-demo")) + tbl = db.create_table("test", schema=TextModel, mode="overwrite") + + tbl.add(df) + rs = tbl.search("hello").limit(1).to_pandas() + print(rs.head()) + # --8<-- [end:embedding_gemini_usage] + + +def test_embedding_huggingface_usage() -> None: + require_flag("RUN_HUGGINGFACE_SNIPPETS") + + # --8<-- [start:embedding_huggingface_usage] + import tempfile + from pathlib import Path + + import lancedb + import pandas as pd + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "huggingface-demo")) + model = get_registry().get("huggingface").create(name="facebook/bart-base") + + class Words(LanceModel): + text: str = model.SourceField() + vector: Vector(model.ndims()) = model.VectorField() + + df = pd.DataFrame({"text": ["hi hello sayonara", "goodbye world"]}) + table = db.create_table("greets", schema=Words) + table.add(df) + query = "old greeting" + actual = table.search(query).limit(1).to_pydantic(Words)[0] + print(actual.text) + # --8<-- [end:embedding_huggingface_usage] + + +def test_frameworks_lerobot_lancedb_usage() -> None: + require_flag("RUN_LEROBOT_LANCEDB_SNIPPETS") + + # --8<-- [start:frameworks_lerobot_lancedb_image_dataset] + from lerobot_lancedb import LeRobotLanceDataset + + dataset = LeRobotLanceDataset( + repo_id="your-org/your-lerobot-lance-images", + delta_timestamps={ + "observation.images.front": [-0.2, -0.1, 0.0], + }, + return_uint8=True, + ) + + sample = dataset[0] + print(sample["observation.state"].shape) + print(sample["action"].shape) + # --8<-- [end:frameworks_lerobot_lancedb_image_dataset] + + # --8<-- [start:frameworks_lerobot_lancedb_video_dataset] + from lerobot_lancedb import LeRobotLanceVideoDataset + + video_dataset = LeRobotLanceVideoDataset( + repo_id="lance-format/lerobot-pusht-lance", + delta_timestamps={ + "observation.images.image": [-0.2, -0.1, 0.0], + }, + return_uint8=True, + ) + + video_sample = video_dataset[0] + print(video_sample["observation.images.image"].shape) + # --8<-- [end:frameworks_lerobot_lancedb_video_dataset] + + # --8<-- [start:frameworks_lerobot_open_lance_tables] + import lancedb + + db = lancedb.connect("hf://datasets/lance-format/lerobot-pusht-lance/data") + frames = db.open_table("frames") + episodes = db.open_table("episodes") + videos = db.open_table("videos") + + print(len(frames), len(episodes), len(videos)) + print(frames.schema) + # --8<-- [end:frameworks_lerobot_open_lance_tables] + + # --8<-- [start:frameworks_lerobot_filter_frames] + frame_rows = ( + frames.search() + .where("episode_index = 0 AND frame_index < 10", prefilter=True) + .select(["episode_index", "frame_index", "timestamp", "action"]) + .limit(10) + .to_list() + ) + + for row in frame_rows: + print(row["episode_index"], row["frame_index"], row["timestamp"]) + # --8<-- [end:frameworks_lerobot_filter_frames] + + +def test_frameworks_stable_worldmodel_usage() -> None: + require_flag("RUN_STABLE_WORLDMODEL_SNIPPETS") + + # --8<-- [start:frameworks_stable_worldmodel_collect_lance] + import stable_worldmodel as swm + + world = swm.World("swm/PushT-v1", num_envs=8) + world.set_policy(your_expert_policy) + world.collect("data/pusht_demo.lance", episodes=100, seed=0) + # --8<-- [end:frameworks_stable_worldmodel_collect_lance] + + # --8<-- [start:frameworks_stable_worldmodel_load_lance] + dataset = swm.data.load_dataset("data/pusht_demo.lance", num_steps=16) + + batch = dataset[0] + print(batch.keys()) + # --8<-- [end:frameworks_stable_worldmodel_load_lance] + + # --8<-- [start:frameworks_stable_worldmodel_convert] + swm.data.convert( + "data/pusht_demo.lance", + "data/pusht_video", + dest_format="video", + fps=30, + ) + # --8<-- [end:frameworks_stable_worldmodel_convert] + + # --8<-- [start:frameworks_stable_worldmodel_evaluate] + from stable_worldmodel.policy import PlanConfig, WorldModelPolicy + from stable_worldmodel.solver import CEMSolver + + solver = CEMSolver(model=world_model, num_samples=300) + policy = WorldModelPolicy(solver=solver, config=PlanConfig(horizon=10)) + + world.set_policy(policy) + results = world.evaluate(episodes=50) + print(f"Success Rate: {results['success_rate']:.1f}%") + # --8<-- [end:frameworks_stable_worldmodel_evaluate] + + +def test_embedding_ibm_usage() -> None: + require_flag("RUN_IBM_WATSONX_SNIPPETS") + + # --8<-- [start:embedding_ibm_usage] + import os + import tempfile + from pathlib import Path + + import lancedb + from lancedb.embeddings import EmbeddingFunctionRegistry + from lancedb.pydantic import LanceModel, Vector + + watsonx_embed = ( + EmbeddingFunctionRegistry.get_instance() + .get("watsonx") + .create( + name="ibm/slate-125m-english-rtrvr", + api_key=os.environ.get("WATSONX_API_KEY"), + project_id=os.environ.get("WATSONX_PROJECT_ID"), + ) + ) + + class TextModel(LanceModel): + text: str = watsonx_embed.SourceField() + vector: Vector(watsonx_embed.ndims()) = watsonx_embed.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "watsonx-demo")) + tbl = db.create_table("watsonx_test", schema=TextModel, mode="overwrite") + tbl.add(data) + + rs = tbl.search("hello").limit(1).to_pandas() + print(rs.head()) + # --8<-- [end:embedding_ibm_usage] + + +def test_embedding_imagebind_examples() -> None: + require_flag("RUN_IMAGEBIND_SNIPPETS") + pytest.importorskip("imagebind") + + # --8<-- [start:embedding_imagebind_setup] + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + + db = lancedb.connect("/tmp/imagebind-db") + func = get_registry().get("imagebind").create() + + class ImageBindModel(LanceModel): + text: str + image_uri: str = func.SourceField() + audio_path: str + vector: Vector(func.ndims()) = func.VectorField() + + text_list = ["A dog.", "A car", "A bird"] + image_paths = [ + "./assets/dog_image.jpg", + "./assets/car_image.jpg", + "./assets/bird_image.jpg", + ] + audio_paths = [ + "./assets/dog_audio.wav", + "./assets/car_audio.wav", + "./assets/bird_audio.wav", + ] + + inputs = [ + {"text": a, "audio_path": b, "image_uri": c} + for a, b, c in zip(text_list, audio_paths, image_paths) + ] + + table = db.create_table("img_bind", schema=ImageBindModel) + table.add(inputs) + # --8<-- [end:embedding_imagebind_setup] + + # --8<-- [start:embedding_imagebind_image_search] + query_image = "./assets/dog_image2.jpg" + actual = table.search(query_image).limit(1).to_pydantic(ImageBindModel)[0] + print(actual.text == "dog") + # --8<-- [end:embedding_imagebind_image_search] + + # --8<-- [start:embedding_imagebind_audio_search] + query_audio = "./assets/car_audio2.wav" + actual = table.search(query_audio).limit(1).to_pydantic(ImageBindModel)[0] + print(actual.text == "car") + # --8<-- [end:embedding_imagebind_audio_search] + + # --8<-- [start:embedding_imagebind_text_search] + query = "an animal which flies and tweets" + actual = table.search(query).limit(1).to_pydantic(ImageBindModel)[0] + print(actual.text == "bird") + # --8<-- [end:embedding_imagebind_text_search] + + +def test_embedding_colpali_examples() -> None: + require_flag("RUN_COLPALI_SNIPPETS") + pytest.importorskip("colpali_engine") + + # --8<-- [start:embedding_colpali_setup] + import tempfile + from pathlib import Path + + import lancedb + import pandas as pd + import requests + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, MultiVector + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "colpali-demo")) + func = get_registry().get("colpali").create() + + class Images(LanceModel): + label: str + image_uri: str = func.SourceField() + image_bytes: bytes = func.SourceField() + vector: MultiVector(func.ndims()) = func.VectorField() + vec_from_bytes: MultiVector(func.ndims()) = func.VectorField() + + table = db.create_table("images", schema=Images) + labels = ["cat", "dog", "horse"] + uris = [ + "http://farm1.staticflickr.com/53/167798175_7c7845bbbd_z.jpg", + "http://farm9.staticflickr.com/8387/8602747737_2e5c2a45d4_z.jpg", + "http://farm9.staticflickr.com/8216/8434969557_d37882c42d_z.jpg", + ] + image_bytes = [requests.get(uri).content for uri in uris] + table.add( + pd.DataFrame({"label": labels, "image_uri": uris, "image_bytes": image_bytes}) + ) + # --8<-- [end:embedding_colpali_setup] + + # --8<-- [start:embedding_colpali_text_search] + actual = ( + table.search("a furry pet", vector_column_name="vector") + .limit(1) + .to_pydantic(Images)[0] + ) + print(actual.label) + # --8<-- [end:embedding_colpali_text_search] + + +def test_embedding_instructor_usage() -> None: + require_flag("RUN_INSTRUCTOR_SNIPPETS") + + # --8<-- [start:embedding_instructor_usage] + import tempfile + from pathlib import Path + + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + + instructor = ( + get_registry() + .get("instructor") + .create( + source_instruction="represent the document for retrieval", + query_instruction="represent the document for retrieving the most similar documents", + ) + ) + + class Schema(LanceModel): + vector: Vector(instructor.ndims()) = instructor.VectorField() + text: str = instructor.SourceField() + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "instructor-demo")) + tbl = db.create_table("test", schema=Schema, mode="overwrite") + + texts = [ + { + "text": "Capitalism has been dominant in the Western world since the end of feudalism." + }, + { + "text": "The disparate impact theory is especially controversial under the Fair Housing Act." + }, + { + "text": "Disparate impact in United States labor law refers to practices in employment." + }, + ] + + tbl.add(texts) + # --8<-- [end:embedding_instructor_usage] + + +def test_embedding_jina_text() -> None: + require_env("JINA_API_KEY") + + # --8<-- [start:embedding_jina_text] + import os + import tempfile + from pathlib import Path + + import lancedb + from lancedb.embeddings import EmbeddingFunctionRegistry + from lancedb.pydantic import LanceModel, Vector + + os.environ["JINA_API_KEY"] = os.environ["JINA_API_KEY"] + + jina_embed = ( + EmbeddingFunctionRegistry.get_instance() + .get("jina") + .create(name="jina-embeddings-v2-base-en") + ) + + class TextModel(LanceModel): + text: str = jina_embed.SourceField() + vector: Vector(jina_embed.ndims()) = jina_embed.VectorField() + + data = [{"text": "hello world"}, {"text": "goodbye world"}] + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "jina-text")) + tbl = db.create_table("test", schema=TextModel, mode="overwrite") + + tbl.add(data) + # --8<-- [end:embedding_jina_text] + + +def test_embedding_jina_multimodal() -> None: + require_flag("RUN_JINA_MULTIMODAL_SNIPPETS") + + # --8<-- [start:embedding_jina_multimodal] + import os + import tempfile + from pathlib import Path + + import lancedb + import pandas as pd + import requests + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + + os.environ["JINA_API_KEY"] = os.environ.get("JINA_API_KEY", "jina_*") + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "jina-images")) + func = get_registry().get("jina").create() + + class Images(LanceModel): + label: str + image_uri: str = func.SourceField() + image_bytes: bytes = func.SourceField() + vector: Vector(func.ndims()) = func.VectorField() + vec_from_bytes: Vector(func.ndims()) = func.VectorField() + + table = db.create_table("images", schema=Images) + labels = ["cat", "cat", "dog", "dog", "horse", "horse"] + uris = [ + "http://farm1.staticflickr.com/53/167798175_7c7845bbbd_z.jpg", + "http://farm1.staticflickr.com/134/332220238_da527d8140_z.jpg", + "http://farm9.staticflickr.com/8387/8602747737_2e5c2a45d4_z.jpg", + "http://farm5.staticflickr.com/4092/5017326486_1f46057f5f_z.jpg", + "http://farm9.staticflickr.com/8216/8434969557_d37882c42d_z.jpg", + "http://farm6.staticflickr.com/5142/5835678453_4f3a4edb45_z.jpg", + ] + image_bytes = [requests.get(uri).content for uri in uris] + table.add( + pd.DataFrame({"label": labels, "image_uri": uris, "image_bytes": image_bytes}) + ) + # --8<-- [end:embedding_jina_multimodal] + + +def test_embedding_ollama_usage() -> None: + require_flag("RUN_OLLAMA_SNIPPETS") + + # --8<-- [start:embedding_ollama_usage] + import tempfile + from pathlib import Path + + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "ollama-demo")) + func = get_registry().get("ollama").create(name="nomic-embed-text") + + class Words(LanceModel): + text: str = func.SourceField() + vector: Vector(func.ndims()) = func.VectorField() + + table = db.create_table("words", schema=Words, mode="overwrite") + table.add( + [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + ) + + query = "greetings" + actual = table.search(query).limit(1).to_pydantic(Words)[0] + print(actual.text) + # --8<-- [end:embedding_ollama_usage] + + +def test_embedding_openclip_examples() -> None: + require_flag("RUN_OPENCLIP_SNIPPETS") + + # --8<-- [start:embedding_openclip_setup] + import tempfile + from pathlib import Path + + import lancedb + import pandas as pd + import requests + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "openclip-demo")) + func = get_registry().get("open-clip").create() + + class Images(LanceModel): + label: str + image_uri: str = func.SourceField() + image_bytes: bytes = func.SourceField() + vector: Vector(func.ndims()) = func.VectorField() + vec_from_bytes: Vector(func.ndims()) = func.VectorField() + + table = db.create_table("images", schema=Images) + labels = ["cat", "cat", "dog", "dog", "horse", "horse"] + uris = [ + "http://farm1.staticflickr.com/53/167798175_7c7845bbbd_z.jpg", + "http://farm1.staticflickr.com/134/332220238_da527d8140_z.jpg", + "http://farm9.staticflickr.com/8387/8602747737_2e5c2a45d4_z.jpg", + "http://farm5.staticflickr.com/4092/5017326486_1f46057f5f_z.jpg", + "http://farm9.staticflickr.com/8216/8434969557_d37882c42d_z.jpg", + "http://farm6.staticflickr.com/5142/5835678453_4f3a4edb45_z.jpg", + ] + image_bytes = [requests.get(uri).content for uri in uris] + table.add( + pd.DataFrame({"label": labels, "image_uri": uris, "image_bytes": image_bytes}) + ) + # --8<-- [end:embedding_openclip_setup] + + # --8<-- [start:embedding_openclip_text_search] + actual = table.search("man's best friend").limit(1).to_pydantic(Images)[0] + print(actual.label) + + frombytes = ( + table.search("man's best friend", vector_column_name="vec_from_bytes") + .limit(1) + .to_pydantic(Images)[0] + ) + print(frombytes.label) + # --8<-- [end:embedding_openclip_text_search] + + # --8<-- [start:embedding_openclip_image_search] + import io + + from PIL import Image + + query_image_uri = "http://farm1.staticflickr.com/200/467715466_ed4a31801f_z.jpg" + image_bytes = requests.get(query_image_uri).content + query_image = Image.open(io.BytesIO(image_bytes)) + actual = table.search(query_image).limit(1).to_pydantic(Images)[0] + print(actual.label == "dog") + + other = ( + table.search(query_image, vector_column_name="vec_from_bytes") + .limit(1) + .to_pydantic(Images)[0] + ) + print(other.label) + # --8<-- [end:embedding_openclip_image_search] + + +def test_embedding_sentence_transformers_baai() -> None: + require_flag("RUN_SENTENCE_TRANSFORMERS_SNIPPETS") + + # --8<-- [start:embedding_sentence_transformers_baai] + import tempfile + from pathlib import Path + + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "sentence-transformers")) + model = ( + get_registry() + .get("sentence-transformers") + .create(name="BAAI/bge-small-en-v1.5", device="cpu") + ) + + class Words(LanceModel): + text: str = model.SourceField() + vector: Vector(model.ndims()) = model.VectorField() + + table = db.create_table("words", schema=Words) + table.add( + [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + ) + + query = "greetings" + actual = table.search(query).limit(1).to_pydantic(Words)[0] + print(actual.text) + # --8<-- [end:embedding_sentence_transformers_baai] + + +def test_embedding_voyageai_usage() -> None: + require_env("VOYAGE_API_KEY") + + # --8<-- [start:embedding_voyageai_usage] + import tempfile + from pathlib import Path + + import lancedb + from lancedb.embeddings import EmbeddingFunctionRegistry + from lancedb.pydantic import LanceModel, Vector + + voyageai = ( + EmbeddingFunctionRegistry.get_instance().get("voyageai").create(name="voyage-3") + ) + + class TextModel(LanceModel): + text: str = voyageai.SourceField() + vector: Vector(voyageai.ndims()) = voyageai.VectorField() + + data = [{"text": "hello world"}, {"text": "goodbye world"}] + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "voyageai-demo")) + tbl = db.create_table("test", schema=TextModel, mode="overwrite") + + tbl.add(data) + # --8<-- [end:embedding_voyageai_usage] + + +def test_embedding_voyageai_multimodal() -> None: + require_env("VOYAGE_API_KEY") + + # --8<-- [start:embedding_voyageai_multimodal] + import tempfile + from pathlib import Path + + import lancedb + from lancedb.embeddings import EmbeddingFunctionRegistry + from lancedb.pydantic import LanceModel, Vector + + # Create multimodal embedding function with custom dimension + voyageai = ( + EmbeddingFunctionRegistry.get_instance() + .get("voyageai") + .create(name="voyage-multimodal-3.5", output_dimension=512) + ) + + class ImageModel(LanceModel): + image_uri: str = voyageai.SourceField() + vector: Vector(voyageai.ndims()) = voyageai.VectorField() + + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "voyageai-multimodal")) + tbl = db.create_table("images", schema=ImageModel, mode="overwrite") + + # Add images using URLs + tbl.add( + [ + {"image_uri": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/300px-PNG_transparency_demonstration_1.png"}, + ] + ) + + # Search with text query + results = tbl.search("dice").limit(1).to_list() + print(results) + # --8<-- [end:embedding_voyageai_multimodal] + + +# Reranking integrations + + +def test_reranking_answerdotai_usage() -> None: + require_flag("RUN_RERANKER_SNIPPETS") + + # --8<-- [start:reranking_answerdotai_usage] + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + from lancedb.rerankers import AnswerdotaiRerankers + + embedder = get_registry().get("sentence-transformers").create() + db = lancedb.connect("~/.lancedb") + + class Schema(LanceModel): + text: str = embedder.SourceField() + vector: Vector(embedder.ndims()) = embedder.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + tbl = db.create_table("test", schema=Schema, mode="overwrite") + tbl.add(data) + reranker = AnswerdotaiRerankers() + + # Run vector search with a reranker + result = tbl.search("hello").rerank(reranker=reranker).to_list() + + # Run FTS search with a reranker + result = tbl.search("hello", query_type="fts").rerank(reranker=reranker).to_list() + + # Run hybrid search with a reranker + tbl.create_fts_index("text", replace=True) + result = ( + tbl.search("hello", query_type="hybrid").rerank(reranker=reranker).to_list() + ) + # --8<-- [end:reranking_answerdotai_usage] + + +def test_reranking_cohere_usage() -> None: + require_flag("RUN_RERANKER_SNIPPETS") + os.environ["COHERE_API_KEY"] = require_env("COHERE_API_KEY") + + # --8<-- [start:reranking_cohere_usage] + import os + + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + from lancedb.rerankers import CohereReranker + + embedder = get_registry().get("sentence-transformers").create() + db = lancedb.connect("~/.lancedb") + + class Schema(LanceModel): + text: str = embedder.SourceField() + vector: Vector(embedder.ndims()) = embedder.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + tbl = db.create_table("test", schema=Schema, mode="overwrite") + tbl.add(data) + reranker = CohereReranker(api_key=os.environ["COHERE_API_KEY"]) + + # Run vector search with a reranker + result = tbl.search("hello").rerank(reranker=reranker).to_list() + + # Run FTS search with a reranker + result = tbl.search("hello", query_type="fts").rerank(reranker=reranker).to_list() + + # Run hybrid search with a reranker + tbl.create_fts_index("text", replace=True) + result = ( + tbl.search("hello", query_type="hybrid").rerank(reranker=reranker).to_list() + ) + # --8<-- [end:reranking_cohere_usage] + + +def test_reranking_colbert_usage() -> None: + require_flag("RUN_RERANKER_SNIPPETS") + + # --8<-- [start:reranking_colbert_usage] + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + from lancedb.rerankers import ColbertReranker + + embedder = get_registry().get("sentence-transformers").create() + db = lancedb.connect("~/.lancedb") + + class Schema(LanceModel): + text: str = embedder.SourceField() + vector: Vector(embedder.ndims()) = embedder.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + tbl = db.create_table("test", schema=Schema, mode="overwrite") + tbl.add(data) + reranker = ColbertReranker() + + # Run vector search with a reranker + result = tbl.search("hello").rerank(reranker=reranker).to_list() + + # Run FTS search with a reranker + result = tbl.search("hello", query_type="fts").rerank(reranker=reranker).to_list() + + # Run hybrid search with a reranker + tbl.create_fts_index("text", replace=True) + result = ( + tbl.search("hello", query_type="hybrid").rerank(reranker=reranker).to_list() + ) + # --8<-- [end:reranking_colbert_usage] + + +def test_reranking_cross_encoder_usage() -> None: + require_flag("RUN_RERANKER_SNIPPETS") + + # --8<-- [start:reranking_cross_encoder_usage] + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + from lancedb.rerankers import CrossEncoderReranker + + embedder = get_registry().get("sentence-transformers").create() + db = lancedb.connect("~/.lancedb") + + class Schema(LanceModel): + text: str = embedder.SourceField() + vector: Vector(embedder.ndims()) = embedder.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + tbl = db.create_table("test", schema=Schema, mode="overwrite") + tbl.add(data) + reranker = CrossEncoderReranker() + + # Run vector search with a reranker + result = tbl.search("hello").rerank(reranker=reranker).to_list() + + # Run FTS search with a reranker + result = tbl.search("hello", query_type="fts").rerank(reranker=reranker).to_list() + + # Run hybrid search with a reranker + tbl.create_fts_index("text", replace=True) + result = ( + tbl.search("hello", query_type="hybrid").rerank(reranker=reranker).to_list() + ) + # --8<-- [end:reranking_cross_encoder_usage] + + +def test_reranking_jina_usage() -> None: + require_flag("RUN_RERANKER_SNIPPETS") + os.environ["JINA_API_KEY"] = require_env("JINA_API_KEY") + + # --8<-- [start:reranking_jina_usage] + import os + + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + from lancedb.rerankers import JinaReranker + + embedder = get_registry().get("jina").create() + db = lancedb.connect("~/.lancedb") + + class Schema(LanceModel): + text: str = embedder.SourceField() + vector: Vector(embedder.ndims()) = embedder.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + tbl = db.create_table("test", schema=Schema, mode="overwrite") + tbl.add(data) + reranker = JinaReranker(api_key=os.environ["JINA_API_KEY"]) + + # Run vector search with a reranker + result = tbl.search("hello").rerank(reranker=reranker).to_list() + + # Run FTS search with a reranker + result = tbl.search("hello", query_type="fts").rerank(reranker=reranker).to_list() + + # Run hybrid search with a reranker + tbl.create_fts_index("text", replace=True) + result = ( + tbl.search("hello", query_type="hybrid").rerank(reranker=reranker).to_list() + ) + # --8<-- [end:reranking_jina_usage] + + +def test_reranking_linear_combination_usage() -> None: + require_flag("RUN_RERANKER_SNIPPETS") + + # --8<-- [start:reranking_linear_combination_usage] + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + from lancedb.rerankers import LinearCombinationReranker + + embedder = get_registry().get("sentence-transformers").create() + db = lancedb.connect("~/.lancedb") + + class Schema(LanceModel): + text: str = embedder.SourceField() + vector: Vector(embedder.ndims()) = embedder.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + tbl = db.create_table("test", schema=Schema, mode="overwrite") + tbl.add(data) + reranker = LinearCombinationReranker() + + # Run hybrid search with a reranker + tbl.create_fts_index("text", replace=True) + result = ( + tbl.search("hello", query_type="hybrid").rerank(reranker=reranker).to_list() + ) + # --8<-- [end:reranking_linear_combination_usage] + + +def test_reranking_openai_usage() -> None: + require_flag("RUN_RERANKER_SNIPPETS") + os.environ["OPENAI_API_KEY"] = require_env("OPENAI_API_KEY") + + # --8<-- [start:reranking_openai_usage] + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + from lancedb.rerankers import OpenaiReranker + + embedder = get_registry().get("sentence-transformers").create() + db = lancedb.connect("~/.lancedb") + + class Schema(LanceModel): + text: str = embedder.SourceField() + vector: Vector(embedder.ndims()) = embedder.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + tbl = db.create_table("test", schema=Schema, mode="overwrite") + tbl.add(data) + reranker = OpenaiReranker() + + # Run vector search with a reranker + result = tbl.search("hello").rerank(reranker=reranker).to_list() + + # Run FTS search with a reranker + result = tbl.search("hello", query_type="fts").rerank(reranker=reranker).to_list() + + # Run hybrid search with a reranker + tbl.create_fts_index("text", replace=True) + result = ( + tbl.search("hello", query_type="hybrid").rerank(reranker=reranker).to_list() + ) + # --8<-- [end:reranking_openai_usage] + + +def test_reranking_rrf_usage() -> None: + require_flag("RUN_RERANKER_SNIPPETS") + + # --8<-- [start:reranking_rrf_usage] + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + from lancedb.rerankers import RRFReranker + + embedder = get_registry().get("sentence-transformers").create() + db = lancedb.connect("~/.lancedb") + + class Schema(LanceModel): + text: str = embedder.SourceField() + vector: Vector(embedder.ndims()) = embedder.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + tbl = db.create_table("test", schema=Schema, mode="overwrite") + tbl.add(data) + reranker = RRFReranker() + + # Run hybrid search with a reranker + tbl.create_fts_index("text", replace=True) + result = ( + tbl.search("hello", query_type="hybrid").rerank(reranker=reranker).to_list() + ) + # --8<-- [end:reranking_rrf_usage] + + +def test_reranking_mrr_usage() -> None: + require_flag("RUN_RERANKER_SNIPPETS") + + # --8<-- [start:reranking_mrr_usage] + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + from lancedb.rerankers import MRRReranker + + embedder = get_registry().get("sentence-transformers").create() + db = lancedb.connect("~/.lancedb") + + class Schema(LanceModel): + text: str = embedder.SourceField() + vector: Vector(embedder.ndims()) = embedder.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + tbl = db.create_table("test", schema=Schema, mode="overwrite") + tbl.add(data) + reranker = MRRReranker(weight_vector=0.7, weight_fts=0.3) + + # Run hybrid search with a reranker + tbl.create_fts_index("text", replace=True) + result = ( + tbl.search("hello", query_type="hybrid").rerank(reranker=reranker).to_list() + ) + + # Run multivector search across multiple vector columns + rs1 = tbl.search("hello").limit(10).with_row_id(True).to_arrow() + rs2 = tbl.search("greeting").limit(10).with_row_id(True).to_arrow() + combined = MRRReranker().rerank_multivector([rs1, rs2]) + # --8<-- [end:reranking_mrr_usage] + + +def test_reranking_voyageai_usage() -> None: + require_flag("RUN_RERANKER_SNIPPETS") + os.environ["VOYAGE_API_KEY"] = require_env("VOYAGE_API_KEY") + + # --8<-- [start:reranking_voyageai_usage] + import os + + import lancedb + from lancedb.embeddings import get_registry + from lancedb.pydantic import LanceModel, Vector + from lancedb.rerankers import VoyageAIReranker + + embedder = get_registry().get("sentence-transformers").create() + db = lancedb.connect("~/.lancedb") + + class Schema(LanceModel): + text: str = embedder.SourceField() + vector: Vector(embedder.ndims()) = embedder.VectorField() + + data = [ + {"text": "hello world"}, + {"text": "goodbye world"}, + ] + tbl = db.create_table("test", schema=Schema, mode="overwrite") + tbl.add(data) + reranker = VoyageAIReranker(model_name="rerank-2") + + # Run vector search with a reranker + result = tbl.search("hello").rerank(reranker=reranker).to_list() + + # Run FTS search with a reranker + result = tbl.search("hello", query_type="fts").rerank(reranker=reranker).to_list() + + # Run hybrid search with a reranker + tbl.create_fts_index("text", replace=True) + result = ( + tbl.search("hello", query_type="hybrid").rerank(reranker=reranker).to_list() + ) + # --8<-- [end:reranking_voyageai_usage] + + +# Framework integrations + + +def test_frameworks_langchain_examples() -> None: + require_flag("RUN_LANGCHAIN_SNIPPETS") + pytest.importorskip("langchain") + pytest.importorskip("langchain_openai") + pytest.importorskip("langchain_text_splitters") + + # --8<-- [start:frameworks_langchain_quick_start] + import os + + from langchain.document_loaders import TextLoader + from langchain.vectorstores import LanceDB + from langchain_openai import OpenAIEmbeddings + from langchain_text_splitters import CharacterTextSplitter + + os.environ["OPENAI_API_KEY"] = "sk-..." + + loader = TextLoader( + "../../modules/state_of_the_union.txt" + ) # Replace with your data path + documents = loader.load() + + documents = CharacterTextSplitter().split_documents(documents) + embeddings = OpenAIEmbeddings() + + docsearch = LanceDB.from_documents(documents, embeddings) + query = "What did the president say about Ketanji Brown Jackson" + docs = docsearch.similarity_search(query) + print(docs[0].page_content) + # --8<-- [end:frameworks_langchain_quick_start] + + # --8<-- [start:frameworks_langchain_vector_store_config] + db_url = "db://lang_test" # url of db you created + api_key = "xxxxx" # your API key + region = "us-east-1-dev" # your selected region + + vector_store = LanceDB( + uri=db_url, + api_key=api_key, # (dont include for local API) + region=region, # (dont include for local API) + embedding=embeddings, + table_name="langchain_test", # Optional + ) + # --8<-- [end:frameworks_langchain_vector_store_config] + + # --8<-- [start:frameworks_langchain_add_texts] + vector_store.add_texts(texts=["test_123"], metadatas=[{"source": "wiki"}]) + + # Additionally, to explore the table you can load it into a df or save it in a csv file: + + tbl = vector_store.get_table() + print("tbl:", tbl) + pd_df = tbl.to_pandas() + pd_df.to_csv("docsearch.csv", index=False) + + # you can also create a new vector store object using an older connection object: + vector_store = LanceDB(connection=tbl, embedding=embeddings) + # --8<-- [end:frameworks_langchain_add_texts] + + # --8<-- [start:frameworks_langchain_create_index] + # for creating vector index + vector_store.create_index(vector_col="vector", metric="cosine") + + # for creating scalar index(for non-vector columns) + vector_store.create_index(col_name="text") + # --8<-- [end:frameworks_langchain_create_index] + + # --8<-- [start:frameworks_langchain_similarity_search] + docs = docsearch.similarity_search(query) + print(docs[0].page_content) + # --8<-- [end:frameworks_langchain_similarity_search] + + # --8<-- [start:frameworks_langchain_similarity_search_by_vector] + docs = docsearch.similarity_search_by_vector(query) + print(docs[0].page_content) + # --8<-- [end:frameworks_langchain_similarity_search_by_vector] + + # --8<-- [start:frameworks_langchain_similarity_search_with_scores] + docs = docsearch.similarity_search_with_relevance_scores(query) + print("relevance score - ", docs[0][1]) + print("text- ", docs[0][0].page_content[:1000]) + # --8<-- [end:frameworks_langchain_similarity_search_with_scores] + + # --8<-- [start:frameworks_langchain_similarity_search_by_vector_with_scores] + query_embedding = embeddings.embed_query("text") + docs = docsearch.similarity_search_by_vector_with_relevance_scores(query_embedding) + print("relevance score - ", docs[0][1]) + print("text- ", docs[0][0].page_content[:1000]) + # --8<-- [end:frameworks_langchain_similarity_search_by_vector_with_scores] + + # --8<-- [start:frameworks_langchain_max_marginal_relevance] + result = docsearch.max_marginal_relevance_search(query="text") + result_texts = [doc.page_content for doc in result] + print(result_texts) + + # search by vector : + result = docsearch.max_marginal_relevance_search_by_vector( + embeddings.embed_query("text") + ) + result_texts = [doc.page_content for doc in result] + print(result_texts) + # --8<-- [end:frameworks_langchain_max_marginal_relevance] + + # --8<-- [start:frameworks_langchain_add_images] + image_uris = ["./assets/image-1.png", "./assets/image-2.png"] + vector_store.add_images(uris=image_uris) + # here image_uris are local fs paths to the images. + # --8<-- [end:frameworks_langchain_add_images] + + +def test_frameworks_llamaindex_examples() -> None: + require_flag("RUN_LLAMAINDEX_SNIPPETS") + pytest.importorskip("llama_index") + pytest.importorskip("llama_index.vector_stores.lancedb") + + # --8<-- [start:frameworks_llamaindex_quick_start] + import logging + import sys + import textwrap + + import openai + + # Uncomment to see debug logs + # logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) + # logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout)) + from llama_index.core import ( + Document, + SimpleDirectoryReader, + StorageContext, + VectorStoreIndex, + ) + from llama_index.vector_stores.lancedb import LanceDBVectorStore + + openai.api_key = "sk-..." + + documents = SimpleDirectoryReader("./data/your-data-dir/").load_data() + print("Document ID:", documents[0].doc_id, "Document Hash:", documents[0].hash) + + ## For LanceDB Enterprise : + # vector_store = LanceDBVectorStore( + # uri="db://db_name", # your remote DB URI + # api_key="sk_..", # lancedb enterprise api key + # region="your-region" # the region you configured + # host_override="https://your-host.com" # if you have a custom host, otherwise omit this + # ) + + vector_store = LanceDBVectorStore( + uri="./lancedb", mode="overwrite", query_type="vector" + ) + storage_context = StorageContext.from_defaults(vector_store=vector_store) + + index = VectorStoreIndex.from_documents(documents, storage_context=storage_context) + lance_filter = "metadata.file_name = 'paul_graham_essay.txt' " + retriever = index.as_retriever(vector_store_kwargs={"where": lance_filter}) + response = retriever.retrieve("What did the author do growing up?") + # --8<-- [end:frameworks_llamaindex_quick_start] + + # --8<-- [start:frameworks_llamaindex_filtering] + from llama_index.core.vector_stores import ( + FilterCondition, + FilterOperator, + MetadataFilter, + MetadataFilters, + ) + + query_filters = MetadataFilters( + filters=[ + MetadataFilter( + key="creation_date", operator=FilterOperator.EQ, value="2024-05-23" + ), + MetadataFilter(key="file_size", value=75040, operator=FilterOperator.GT), + ], + condition=FilterCondition.AND, + ) + # --8<-- [end:frameworks_llamaindex_filtering] + + # --8<-- [start:frameworks_llamaindex_hybrid_search] + from lancedb.rerankers import ColbertReranker + + reranker = ColbertReranker() + vector_store._add_reranker(reranker) + + query_engine = index.as_query_engine( + filters=query_filters, + vector_store_kwargs={ + "query_type": "hybrid", + }, + ) + + response = query_engine.query("How much did Viaweb charge per month?") + # --8<-- [end:frameworks_llamaindex_hybrid_search] + + # --8<-- [start:frameworks_llamaindex_add_reranker] + from lancedb.rerankers import ColbertReranker + + reranker = ColbertReranker() + vector_store._add_reranker(reranker) + # --8<-- [end:frameworks_llamaindex_add_reranker] + + +def test_frameworks_pydantic_examples() -> None: + require_flag("RUN_PYDANTIC_SNIPPETS") + pytest.importorskip("pyarrow") + + # --8<-- [start:frameworks_pydantic_imports] + import tempfile + from pathlib import Path + + import lancedb + from lancedb.pydantic import LanceModel, Vector + + # --8<-- [end:frameworks_pydantic_imports] + # --8<-- [start:frameworks_pydantic_base_model] + class LanceDocs(LanceModel): + text: str + vector: Vector(2) + + # --8<-- [end:frameworks_pydantic_base_model] + + # --8<-- [start:frameworks_pydantic_set_url] + db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "pydantic-docs")) + # --8<-- [end:frameworks_pydantic_set_url] + + # --8<-- [start:frameworks_pydantic_vector_field] + import pyarrow as pa + import pydantic + from lancedb.pydantic import Vector, pydantic_to_schema + + class MyModel(pydantic.BaseModel): + id: int + url: str + embeddings: Vector(768) + + schema = pydantic_to_schema(MyModel) + assert schema == pa.schema( + [ + pa.field("id", pa.int64(), False), + pa.field("url", pa.utf8(), False), + pa.field("embeddings", pa.list_(pa.float32(), 768)), + ] + ) + # --8<-- [end:frameworks_pydantic_vector_field] + + # --8<-- [start:frameworks_pydantic_type_conversion] + from typing import List, Optional + + import pyarrow as pa + import pydantic + from lancedb.pydantic import Vector, pydantic_to_schema + + class FooModel(pydantic.BaseModel): + id: int + s: str + vec: Vector(1536) # fixed_size_list[1536] + li: List[int] + + schema = pydantic_to_schema(FooModel) + assert schema == pa.schema( + [ + pa.field("id", pa.int64(), False), + pa.field("s", pa.utf8(), False), + pa.field("vec", pa.list_(pa.float32(), 1536)), + pa.field("li", pa.list_(pa.int64()), False), + ] + ) + # --8<-- [end:frameworks_pydantic_type_conversion] + + # --8<-- [start:frameworks_pydantic_base_example] + table = db.create_table("docs", schema=LanceDocs, mode="overwrite") + table.add( + [ + {"text": "hello world", "vector": [1.0, 0.0]}, + {"text": "goodbye world", "vector": [0.0, 1.0]}, + ] + ) + results = table.search("hello world").limit(1).to_pydantic(LanceDocs) + print(results[0].text) + # --8<-- [end:frameworks_pydantic_base_example] + + +# Platform integrations + + +def test_platforms_dlt_examples() -> None: + require_flag("RUN_DLT_SNIPPETS") + pytest.importorskip("dlt") + + # --8<-- [start:platforms_dlt_pipeline] + # Import necessary modules + import dlt + from rest_api import rest_api_source + + # Configure the REST API source + movies_source = rest_api_source( + { + "client": { + "base_url": "https://www.omdbapi.com/", + "auth": { # authentication strategy for the OMDb API + "type": "api_key", + "name": "apikey", + "api_key": dlt.secrets[ + "sources.rest_api.api_token" + ], # read API credentials directly from secrets.toml + "location": "query", + }, + "paginator": { # pagination strategy for the OMDb API + "type": "page_number", + "base_page": 1, + "total_path": "totalResults", + "maximum_page": 5, + }, + }, + "resources": [ # list of API endpoints to request + { + "name": "movie_search", + "endpoint": { + "path": "/", + "params": { + "s": "godzilla", + "type": "movie", + }, + }, + } + ], + } + ) + + if __name__ == "__main__": + # Create a pipeline object + pipeline = dlt.pipeline( + pipeline_name="movies_pipeline", + destination="lancedb", # this tells dlt to load the data into LanceDB + dataset_name="movies_data_pipeline", + ) + + # Run the pipeline + load_info = pipeline.run(movies_source) + + # pretty print the information on data that was loaded + print(load_info) + # --8<-- [end:platforms_dlt_pipeline] + + # --8<-- [start:platforms_dlt_adapter_import] + from dlt.destinations.adapters import lancedb_adapter + + # --8<-- [end:platforms_dlt_adapter_import] + # --8<-- [start:platforms_dlt_adapter_usage] + load_info = pipeline.run( + lancedb_adapter( + movies_source, + embed="Title", + ) + ) + # --8<-- [end:platforms_dlt_adapter_usage] + + +def test_platforms_duckdb_examples() -> None: + require_flag("RUN_DUCKDB_SNIPPETS") + pytest.importorskip("duckdb") + + # --8<-- [start:platforms_duckdb_create_table] + import lancedb + + db = lancedb.connect("data/sample-lancedb") + data = [ + {"vector": [3.1, 4.1], "item": "foo", "price": 10.0}, + {"vector": [5.9, 26.5], "item": "bar", "price": 20.0}, + ] + table = db.create_table("pd_table", data=data) + # --8<-- [end:platforms_duckdb_create_table] + + # --8<-- [start:platforms_duckdb_query_table] + import duckdb + + arrow_table = table.to_lance() + + duckdb.query("SELECT * FROM arrow_table") + # --8<-- [end:platforms_duckdb_query_table] + + # --8<-- [start:platforms_duckdb_mean_price] + duckdb.query("SELECT mean(price) FROM arrow_table") + # --8<-- [end:platforms_duckdb_mean_price] + + +def test_frameworks_agno_openai_examples() -> None: + require_flag("RUN_AGNO_SNIPPETS") + pytest.importorskip("agno") + pytest.importorskip("youtube_transcript_api") + + # --8<-- [start:frameworks_agno_setup] + import os + import re + + from agno.agent import Agent + from agno.knowledge.embedder.openai import OpenAIEmbedder + from agno.knowledge.knowledge import Knowledge + from agno.models.openai import OpenAIResponses + from agno.vectordb.lancedb import LanceDb, SearchType + from youtube_transcript_api import YouTubeTranscriptApi + + if "OPENAI_API_KEY" not in os.environ: + os.environ["OPENAI_API_KEY"] = "sk-..." + + def extract_video_id(youtube_url: str) -> str: + match = re.search(r"(?<=v=)[\w-]+", youtube_url) or re.search( + r"(?<=be/)[\w-]+", youtube_url + ) + if not match: + raise ValueError("Could not parse YouTube video ID from URL") + return match.group(0) + + knowledge = Knowledge( + vector_db=LanceDb( + uri="./tmp/lancedb", + table_name="youtube_transcripts", + search_type=SearchType.hybrid, + embedder=OpenAIEmbedder(id="text-embedding-3-small"), + ), + ) + # --8<-- [end:frameworks_agno_setup] + + # --8<-- [start:frameworks_agno_ingest_youtube] + youtube_url = "https://www.youtube.com/watch?v=wl6mFyXoxos" + video_id = extract_video_id(youtube_url) + ytt = YouTubeTranscriptApi() + transcript_segments = ytt.fetch(video_id, languages=["en", "en-US"]).to_raw_data() + transcript_text = " ".join(segment["text"] for segment in transcript_segments) + + knowledge.insert( + name=f"YouTube Transcript ({video_id})", + text_content=transcript_text, + metadata={"source": "youtube", "video_id": video_id, "video_url": youtube_url}, + ) + # --8<-- [end:frameworks_agno_ingest_youtube] + + # --8<-- [start:frameworks_agno_agent] + agent = Agent( + model=OpenAIResponses(id="gpt-5-mini"), + knowledge=knowledge, + search_knowledge=True, + instructions="Search the transcript and answer only from retrieved context.", + markdown=True, + ) + # --8<-- [end:frameworks_agno_agent] + + # --8<-- [start:frameworks_agno_cli_chat] + agent.print_response( + "Summarize the loaded video transcript in 5 concise bullet points.", + stream=True, + ) + while True: + question = input("You: ").strip() + if question.lower() in {"exit", "quit", "bye"}: + break + agent.print_response(question, stream=True) + # --8<-- [end:frameworks_agno_cli_chat] + + +def test_platforms_voxel51_examples() -> None: + require_flag("RUN_VOXEL51_SNIPPETS") + pytest.importorskip("fiftyone") + + # --8<-- [start:platforms_voxel51_load_dataset] + import fiftyone as fo + import fiftyone.brain as fob + import fiftyone.zoo as foz + + # Step 1: Load your data into FiftyOne + dataset = foz.load_zoo_dataset("quickstart") + # --8<-- [end:platforms_voxel51_load_dataset] + + # --8<-- [start:platforms_voxel51_compute_similarity] + # Steps 2 and 3: Compute embeddings and create a similarity index + lancedb_index = fob.compute_similarity( + dataset, + model="clip-vit-base32-torch", + brain_key="lancedb_index", + backend="lancedb", + ) + # --8<-- [end:platforms_voxel51_compute_similarity] + + # --8<-- [start:platforms_voxel51_sort_by_similarity] + # Step 4: Query your data + query = dataset.first().id # query by sample ID + view = dataset.sort_by_similarity( + query, + brain_key="lancedb_index", + k=10, # limit to 10 most similar samples + ) + # --8<-- [end:platforms_voxel51_sort_by_similarity] + + # --8<-- [start:platforms_voxel51_cleanup] + # Step 5 (optional): Cleanup + + # Delete the LanceDB table + lancedb_index.cleanup() + + # Delete run record from FiftyOne + dataset.delete_brain_run("lancedb_index") + # --8<-- [end:platforms_voxel51_cleanup] + + if False: + # --8<-- [start:platforms_voxel51_backend_flag] + import fiftyone.brain as fob + + # Re-run similarity creation using the LanceDB backend explicitly + fob.compute_similarity( + dataset, + model="clip-vit-base32-torch", + brain_key="lancedb_index", + backend="lancedb", + ) + # --8<-- [end:platforms_voxel51_backend_flag] + + # --8<-- [start:platforms_voxel51_brain_config] + import fiftyone.brain as fob + + # Print your current brain config + print(fob.brain_config) + # --8<-- [end:platforms_voxel51_brain_config] + + if False: + # --8<-- [start:platforms_voxel51_backend_params] + lancedb_index = fob.compute_similarity( + dataset, + model="clip-vit-base32-torch", + backend="lancedb", + brain_key="lancedb_index", + table_name="your-table", + metric="euclidean", + uri="/tmp/lancedb", + ) + # --8<-- [end:platforms_voxel51_backend_params] + + +def test_platforms_pandas_examples() -> None: + require_flag("RUN_PANDAS_SNIPPETS") + pytest.importorskip("pandas") + + # --8<-- [start:platforms_pandas_imports] + import asyncio + import tempfile + from pathlib import Path + + import lancedb + import pandas as pd + + # --8<-- [end:platforms_pandas_imports] + # --8<-- [start:platforms_pandas_create_table] + pandas_df = pd.DataFrame( + [ + {"id": "1", "text": "dragon", "vector": [0.9, 0.1, 0.3]}, + {"id": "2", "text": "griffin", "vector": [0.4, 0.5, 0.2]}, + {"id": "3", "text": "phoenix", "vector": [0.7, 0.3, 0.6]}, + ] + ) + pandas_db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "pandas-demo")) + pandas_table = pandas_db.create_table("creatures", data=pandas_df, mode="overwrite") + # --8<-- [end:platforms_pandas_create_table] + + # --8<-- [start:platforms_pandas_vector_search] + pandas_results = ( + pandas_table.search([0.9, 0.1, 0.3]) + .select(["text", "_distance"]) + .limit(1) + .to_pandas() + ) + print(pandas_results) + # --8<-- [end:platforms_pandas_vector_search] + + # --8<-- [start:platforms_pandas_async_example] + async def run_pandas_async_example() -> None: + async_db = await lancedb.connect_async( + str(Path(tempfile.mkdtemp()) / "pandas-async") + ) + async_df = pd.DataFrame( + [ + {"id": "10", "text": "sage", "vector": [0.6, 0.4, 0.8]}, + {"id": "11", "text": "bard", "vector": [0.2, 0.7, 0.3]}, + ] + ) + async_table = await async_db.create_table( + "creatures_async", data=async_df, mode="overwrite" + ) + async_results = await ( + async_table.search([0.6, 0.4, 0.8]) + .select(["text", "_distance"]) + .limit(1) + .to_pandas() + ) + print(async_results) + + asyncio.run(run_pandas_async_example()) + # --8<-- [end:platforms_pandas_async_example] + + +def test_platforms_polars_examples() -> None: + require_flag("RUN_POLARS_SNIPPETS") + pytest.importorskip("polars") + + # --8<-- [start:platforms_polars_imports] + import tempfile + from pathlib import Path + + import lancedb + import polars as pl + from lancedb.pydantic import LanceModel, Vector + + # --8<-- [end:platforms_polars_imports] + # --8<-- [start:platforms_polars_create_table] + birds = pl.DataFrame( + { + "text": ["phoenix", "sparrow"], + "vector": [ + [0.1, 0.2, 0.3], + [0.8, 0.6, 0.5], + ], + } + ) + polars_db = lancedb.connect(str(Path(tempfile.mkdtemp()) / "polars-demo")) + polars_table = polars_db.create_table( + "birds", data=birds.to_arrow(), mode="overwrite" + ) + # --8<-- [end:platforms_polars_create_table] + + # --8<-- [start:platforms_polars_vector_search] + polars_results = ( + polars_table.search([0.1, 0.2, 0.3]) + .select(["text", "_distance"]) + .limit(1) + .to_polars() + ) + print(polars_results) + # --8<-- [end:platforms_polars_vector_search] + + # --8<-- [start:platforms_polars_lazyframe] + lazy_frame = polars_table.to_polars().lazy() + print(lazy_frame.select(["text"]).collect()) + # --8<-- [end:platforms_polars_lazyframe] + + # --8<-- [start:platforms_polars_pydantic] + class BirdModel(LanceModel): + text: str + vector: Vector(3) + + schema_table = polars_db.create_table( + "birds_schema", schema=BirdModel, mode="overwrite" + ) + schema_table.add(birds.to_dicts()) + # --8<-- [end:platforms_polars_pydantic] diff --git a/docs/web-tests/py/test_materialized_view.py b/docs/web-tests/py/test_materialized_view.py new file mode 100644 index 000000000..ec48e1033 --- /dev/null +++ b/docs/web-tests/py/test_materialized_view.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +"""Examples for docs/web/feature-engineering/materialized-views.mdx.""" + + +def test_materialized_view_create_and_refresh(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + # --8<-- [start:materialized_view_create] + import lancedb + + db = lancedb.connect("./.lancedb") + table = db.create_table( + "people", [{"name": "ada", "age": 36}, {"name": "kid", "age": 7}] + ) + + view = db.create_materialized_view( + "adults", + "people", + select=["name", ("shout", "upper(name)")], + where="age >= 18", + ) + print(view.table.count_rows()) + # 1 + # --8<-- [end:materialized_view_create] + assert view.table.count_rows() == 1 + + # --8<-- [start:materialized_view_refresh] + table.add([{"name": "bea", "age": 41}]) + + result = view.refresh() + print(result.rows_written) + # 2 + # --8<-- [end:materialized_view_refresh] + assert result.mode == "rebuild" + assert result.rows_written == 2 + assert sorted(row["shout"] for row in view.table.to_arrow().to_pylist()) == [ + "ADA", + "BEA", + ] + + +def test_materialized_view_with_no_data(tmp_path): + import lancedb + + db = lancedb.connect(tmp_path) + db.create_table("people", [{"name": "ada", "age": 36}, {"name": "kid", "age": 7}]) + view = db.create_materialized_view( + "adults", "people", where="age >= 18", with_no_data=True + ) + assert view.table.count_rows() == 0 + assert view.refresh().rows_written == 1 + assert view.table.count_rows() == 1 + + +def test_materialized_view_refresh_to_source_version(tmp_path): + import lancedb + + db = lancedb.connect(tmp_path) + table = db.create_table("people", [{"name": "ada", "age": 36}]) + view = db.create_materialized_view("adults", "people", where="age >= 18") + first = table.version + table.add([{"name": "bea", "age": 41}]) + assert view.refresh().rows_written == 2 + + result = view.refresh(source_version=first) + assert (result.source_version, result.rows_written) == (first, 1) + assert view.table.count_rows() == 1 diff --git a/docs/web-tests/py/test_multimodal.py b/docs/web-tests/py/test_multimodal.py new file mode 100644 index 000000000..cb4a647c3 --- /dev/null +++ b/docs/web-tests/py/test_multimodal.py @@ -0,0 +1,232 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import pytest +try: + import lancedb + import numpy as np + import pyarrow as pa + import io + from PIL import Image +except ImportError: + pass + +# --8<-- [start:multimodal_imports] +import lancedb +import pyarrow as pa +import pandas as pd +import numpy as np +import io +from PIL import Image +# --8<-- [end:multimodal_imports] + +def test_multimodal_ingestion(db_path_factory): + # Ensure dependencies are available + pytest.importorskip("PIL") + pytest.importorskip("lancedb") + pytest.importorskip("numpy") + + # --8<-- [start:create_dummy_data] + # Create some dummy images + def create_dummy_image(color): + img = Image.new('RGB', (100, 100), color=color) + buf = io.BytesIO() + img.save(buf, format='PNG') + return buf.getvalue() + + # Create dataset with metadata, vectors, and image blobs + data = [ + { + "id": 1, + "filename": "red_square.png", + "vector": np.random.rand(128).astype(np.float32), + "image_blob": create_dummy_image('red'), + "label": "red" + }, + { + "id": 2, + "filename": "blue_square.png", + "vector": np.random.rand(128).astype(np.float32), + "image_blob": create_dummy_image('blue'), + "label": "blue" + } + ] + # --8<-- [end:create_dummy_data] + + # --8<-- [start:define_schema] + # Define schema explicitly to ensure image_blob is treated as binary + schema = pa.schema([ + pa.field("id", pa.int32()), + pa.field("filename", pa.string()), + pa.field("vector", pa.list_(pa.float32(), 128)), + pa.field("image_blob", pa.binary()), # Important: Use pa.binary() for blobs + pa.field("label", pa.string()) + ]) + # --8<-- [end:define_schema] + + db_uri = db_path_factory("multimodal_db") + db = lancedb.connect(db_uri) + + # --8<-- [start:ingest_data] + tbl = db.create_table("images", data=data, schema=schema, mode="overwrite") + # --8<-- [end:ingest_data] + + assert len(tbl) == 2 + + # --8<-- [start:search_data] + # Search for similar images + query_vector = np.random.rand(128).astype(np.float32) + results = tbl.search(query_vector).limit(1).to_pandas() + # --8<-- [end:search_data] + + # --8<-- [start:process_results] + # Convert back to PIL Image + for _, row in results.iterrows(): + image_bytes = row['image_blob'] + image = Image.open(io.BytesIO(image_bytes)) + print(f"Retrieved image: {row['filename']}, Size: {image.size}") + # You can now use 'image' with other libraries or display it + # --8<-- [end:process_results] + + assert len(results) == 1 + +def test_blob_api_definition(db_path_factory): + # --8<-- [start:blob_api_schema] + import pyarrow as pa + + # Define schema with Blob API metadata for lazy loading + schema = pa.schema([ + pa.field("id", pa.int64()), + pa.field( + "video", + pa.large_binary(), + metadata={"lance-encoding:blob": "true"} # Enable Blob API + ), + ]) + # --8<-- [end:blob_api_schema] + + # --8<-- [start:blob_api_ingest] + import lancedb + + db = lancedb.connect(db_path_factory("blob_db")) + + # Create sample data + data = [ + {"id": 1, "video": b"fake_video_bytes_1"}, + {"id": 2, "video": b"fake_video_bytes_2"} + ] + + # Create the table + tbl = db.create_table("videos", data=data, schema=schema) + # --8<-- [end:blob_api_ingest] + assert len(tbl) == 2 + + +def test_blob_api_to_pandas(db_path_factory): + db = lancedb.connect(db_path_factory("blob_to_pandas_db")) + schema = pa.schema([ + pa.field("id", pa.int64()), + pa.field( + "video", + pa.large_binary(), + metadata={"lance-encoding:blob": "true"}, + ), + ]) + tbl = db.create_table( + "videos", + data=[ + {"id": 1, "video": b"fake_video_bytes_1"}, + {"id": 2, "video": b"fake_video_bytes_2"}, + ], + schema=schema, + mode="overwrite", + ) + + # --8<-- [start:blob_api_to_pandas] + # Default: blob columns come back lazily + df_lazy = tbl.to_pandas() + + # Materialize blob bytes eagerly + df_bytes = tbl.to_pandas(blob_mode="bytes") + + # Return descriptors instead of payloads + df_desc = tbl.to_pandas(blob_mode="descriptions") + + # Forward extra kwargs to PyArrow's to_pandas + df_typed = tbl.to_pandas(split_blocks=True, self_destruct=True) + # --8<-- [end:blob_api_to_pandas] + + assert len(df_lazy) == 2 + assert isinstance(df_bytes["video"].iloc[0], bytes) + assert df_bytes["video"].tolist() == [ + b"fake_video_bytes_1", + b"fake_video_bytes_2", + ] + assert len(df_desc) == 2 + assert len(df_typed) == 2 + + +@pytest.mark.asyncio +async def test_query_to_pandas_kwargs(db_path_factory): + schema = pa.schema([ + pa.field("id", pa.int64()), + pa.field("vector", pa.list_(pa.float32(), 128)), + pa.field( + "video", + pa.large_binary(), + metadata={"lance-encoding:blob": "true"}, + ), + ]) + data = [ + { + "id": i, + "vector": np.random.rand(128).astype(np.float32), + "video": f"fake_video_bytes_{i}".encode(), + } + for i in range(10) + ] + + db = lancedb.connect(db_path_factory("query_to_pandas_db")) + tbl = db.create_table("search_demo", data=data, schema=schema, mode="overwrite") + + async_db = await lancedb.connect_async( + str(db_path_factory("query_to_pandas_async_db")) + ) + tbl_async = await async_db.create_table( + "search_demo", data=data, schema=schema, mode="overwrite" + ) + + query_vector = np.random.rand(128).astype(np.float32) + + # --8<-- [start:query_to_pandas_kwargs] + # Plain scan query: blob_mode is supported end to end + df_lazy = ( + tbl.search() + .where("id = 1") + .select(["id", "video"]) + .to_pandas(blob_mode="lazy") + ) + + # Same call shape works on async query builders + df_bytes = await ( + tbl_async.query() + .where("id = 1") + .select(["id", "video"]) + .to_pandas(blob_mode="bytes") + ) + + # Vector / FTS / hybrid queries can't materialize blob columns, + # so omit them from the projection + df_vec = ( + tbl.search(query_vector) + .limit(10) + .select(["id", "vector"]) + .to_pandas(split_blocks=True, self_destruct=True) + ) + # --8<-- [end:query_to_pandas_kwargs] + + assert len(df_lazy) == 1 + assert len(df_bytes) == 1 + assert df_bytes["video"].iloc[0] == b"fake_video_bytes_1" + assert len(df_vec) == 10 + assert "video" not in df_vec.columns diff --git a/docs/web-tests/py/test_quickstart.py b/docs/web-tests/py/test_quickstart.py new file mode 100644 index 000000000..ee01e7317 --- /dev/null +++ b/docs/web-tests/py/test_quickstart.py @@ -0,0 +1,240 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import lancedb +import pytest + + +def test_quickstart(db_path_factory): + uri = db_path_factory("quickstart_db") + db = lancedb.connect(uri) + + # --8<-- [start:quickstart_data] + data = [ + { + "id": "1", + "name": "King Arthur", + "role": "King", + "description": "Leader of Camelot and wielder of Excalibur.", + "stats": {"strength": 4, "magic": 1, "leadership": 5, "wisdom": 4}, + "vector": [0.7, 0.1, 0.9, 0.7], + }, + { + "id": "2", + "name": "Merlin", + "role": "Wizard", + "description": "Advisor and prophet with deep magical knowledge.", + "stats": {"strength": 2, "magic": 5, "leadership": 4, "wisdom": 5}, + "vector": [0.2, 0.9, 0.4, 0.9], + }, + { + "id": "3", + "name": "Sir Lancelot", + "role": "Knight", + "description": "Legendary knight known for courage and combat skill.", + "stats": {"strength": 5, "magic": 1, "leadership": 3, "wisdom": 3}, + "vector": [0.9, 0.1, 0.5, 0.4], + }, + ] + # --8<-- [end:quickstart_data] + + # --8<-- [start:quickstart_create_table] + table = db.create_table("characters", data=data, mode="overwrite") + # --8<-- [end:quickstart_create_table] + assert len(table) == 3 + # Drop the table to test create without overwrite + db.drop_table("characters") + + # --8<-- [start:quickstart_create_table_no_overwrite] + table = db.create_table("characters", data=data) + # --8<-- [end:quickstart_create_table_no_overwrite] + assert len(table) == 3 + # --8<-- [start:quickstart_vector_search_1] + # Search for examples similar to a "wise magical advisor" + query_vector = [0.2, 0.8, 0.4, 0.9] + + # Ensure you run `pip install polars` beforehand + result = ( + table.search(query_vector) + .select(["name", "role", "description", "_distance"]) + .limit(2) + .to_polars() + ) + print(result) + # --8<-- [end:quickstart_vector_search_1] + assert result.head(1)["name"][0] == "Merlin" + + # --8<-- [start:quickstart_curate_with_metadata] + curated = ( + table.search(query_vector) + .where("stats.magic >= 4") + .select(["name", "role", "description", "_distance"]) + .limit(2) + .to_polars() + ) + print(curated) + # --8<-- [end:quickstart_curate_with_metadata] + assert curated.head(1)["name"][0] == "Merlin" + + # --8<-- [start:quickstart_output_pandas] + # Ensure you run `pip install pandas` beforehand + result = table.search(query_vector).limit(2).to_pandas() + print(result) + # --8<-- [end:quickstart_output_pandas] + assert result.iloc[0]["name"] == "Merlin" + + # --8<-- [start:quickstart_add_feature] + table.add_columns( + { + "power_score": "cast(((stats.strength + stats.magic + stats.leadership + stats.wisdom) / 4.0) as float)" + } + ) + # --8<-- [end:quickstart_add_feature] + assert "power_score" in table.schema.names + + # --8<-- [start:quickstart_query_feature] + features = table.search().select(["name", "role", "power_score"]).to_polars() + print(features) + # --8<-- [end:quickstart_query_feature] + assert "power_score" in features.columns + + # --8<-- [start:quickstart_multimodal_bytes] + from pathlib import Path + + image_path = Path("docs/web/static/assets/images/quickstart/sir-lancelot.jpg") + image_bytes = image_path.read_bytes() + + multimodal_table = db.create_table( + "character_images", + data=[ + { + "id": "lancelot", + "description": "Portrait of Sir Lancelot", + "image": image_bytes, + "vector": [0.9, 0.1, 0.5, 0.4], + } + ], + mode="overwrite", + ) + # --8<-- [end:quickstart_multimodal_bytes] + assert len(multimodal_table) == 1 + + # --8<-- [start:quickstart_open_table] + table = db.open_table("characters") + # --8<-- [end:quickstart_open_table] + + # --8<-- [start:quickstart_add_data] + more_data = [ + { + "id": "4", + "name": "Morgana", + "role": "Sorceress", + "description": "Powerful sorceress of Avalon.", + "stats": {"strength": 2, "magic": 5, "leadership": 4, "wisdom": 4}, + "vector": [0.3, 0.9, 0.6, 0.8], + "power_score": 3.75, + }, + ] + + # Add data to table + table.add(more_data) + # --8<-- [end:quickstart_add_data] + assert len(table) == 4 + + # --8<-- [start:quickstart_vector_search_2] + # Search for examples similar to a "powerful sorceress" + query_vector = [0.3, 0.9, 0.6, 0.8] + + results = table.search(query_vector).limit(2).to_polars() + print(results) + # --8<-- [end:quickstart_vector_search_2] + assert results.head(1)["name"][0] == "Morgana" + + # --8<-- [start:quickstart_computed_feature] + table.add_columns(computed={"combat_score": "(stats.magic + stats.strength) / 2.0"}) + table.refresh_column("combat_score") + # --8<-- [end:quickstart_computed_feature] + scores = ( + table.search().select(["combat_score"]).to_arrow()["combat_score"].to_pylist() + ) + assert scores == [2.5, 3.5, 3.0, 3.5] + + # --8<-- [start:quickstart_training] + import torch + from lancedb.permutation import Permutation + + dataset = Permutation.identity(table).select_columns( + ["power_score", "combat_score"] + ) + dataloader = torch.utils.data.DataLoader(dataset, batch_size=1024, shuffle=True) + # --8<-- [end:quickstart_training] + batches = list(dataloader) + assert len(batches) == 1 + assert set(batches[0]) == {"power_score", "combat_score"} + assert all(isinstance(value, torch.Tensor) for value in batches[0].values()) + assert sorted(batches[0]["combat_score"].tolist()) == sorted(scores) + + # --8<-- [start:quickstart_training_projection] + dataset = Permutation.identity(table).select_columns(["power_score"]) + dataloader = torch.utils.data.DataLoader(dataset, batch_size=1024) + # --8<-- [end:quickstart_training_projection] + batch = next(iter(dataloader)) + assert set(batch) == {"power_score"} + assert batch["power_score"].tolist() == [3.5, 4.0, 3.0, 3.75] + + +@pytest.mark.asyncio +async def test_quickstart_async_api(db_path_factory): + db_uri = db_path_factory("quickstart_async_db") + import lancedb + async_db = await lancedb.connect_async(db_uri) + + # --8<-- [start:quickstart_data_async] + data = [ + { + "id": "1", + "name": "King Arthur", + "role": "King", + "description": "Leader of Camelot and wielder of Excalibur.", + "stats": {"strength": 4, "magic": 1, "leadership": 5, "wisdom": 4}, + "vector": [0.7, 0.1, 0.9, 0.7], + }, + { + "id": "2", + "name": "Merlin", + "role": "Wizard", + "description": "Advisor and prophet with deep magical knowledge.", + "stats": {"strength": 2, "magic": 5, "leadership": 4, "wisdom": 5}, + "vector": [0.2, 0.9, 0.4, 0.9], + }, + { + "id": "3", + "name": "Sir Lancelot", + "role": "Knight", + "description": "Legendary knight known for courage and combat skill.", + "stats": {"strength": 5, "magic": 1, "leadership": 3, "wisdom": 3}, + "vector": [0.9, 0.1, 0.5, 0.4], + }, + ] + # --8<-- [end:quickstart_data_async] + + # --8<-- [start:quickstart_create_table_async] + async_table = await async_db.create_table( + "characters", + data=data, + mode="overwrite", + ) + # --8<-- [end:quickstart_create_table_async] + + # --8<-- [start:quickstart_vector_search_1_async] + # Search for examples similar to a "wise magical advisor" + query_vector = [0.2, 0.8, 0.4, 0.9] + + # Ensure you run `pip install polars` beforehand + async_result = await ( + await async_table.search(query_vector) + ).select(["name", "role", "description", "_distance"]).limit(2).to_polars() + print(async_result) + # --8<-- [end:quickstart_vector_search_1_async] + + assert async_result.head(1)["name"][0] == "Merlin" diff --git a/docs/web-tests/py/test_search.py b/docs/web-tests/py/test_search.py new file mode 100644 index 000000000..4b7e8a9c7 --- /dev/null +++ b/docs/web-tests/py/test_search.py @@ -0,0 +1,730 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +# --8<-- [start:import-lancedb] +import lancedb + +# --8<-- [end:import-lancedb] +# --8<-- [start:import-numpy] +from lancedb.query import BoostQuery, MatchQuery +import numpy as np +import pyarrow as pa + +# --8<-- [end:import-numpy] +# --8<-- [start:import-datetime] +from datetime import datetime + +# --8<-- [end:import-datetime] +# --8<-- [start:import-lancedb-pydantic] +from lancedb.pydantic import Vector, LanceModel + +# --8<-- [end:import-lancedb-pydantic] +# --8<-- [start:import-pydantic-base-model] +from pydantic import BaseModel + +# --8<-- [end:import-pydantic-base-model] +# --8<-- [start:import-lancedb-fts] +from lancedb.index import FTS + +# --8<-- [end:import-lancedb-fts] +# --8<-- [start:import-os] +import os + +# --8<-- [end:import-os] +# --8<-- [start:import-embeddings] +from lancedb.embeddings import get_registry + +# --8<-- [end:import-embeddings] +import pytest + + +# --8<-- [start:class-definition] +class Metadata(BaseModel): + source: str + timestamp: datetime + + +class Document(BaseModel): + content: str + meta: Metadata + + +class LanceSchema(LanceModel): + id: str + vector: Vector(1536) + payload: Document + + +# --8<-- [end:class-definition] + + +def test_vector_search(): + # --8<-- [start:exhaustive_search] + uri = "data/sample-lancedb" + db = lancedb.connect(uri) + data = [ + {"vector": row, "item": f"item {i}"} + for i, row in enumerate(np.random.random((10_000, 1536)).astype("float32")) + ] + tbl = db.create_table("vector_search", data=data, mode="overwrite") + tbl.search(np.random.random((1536))).limit(10).to_list() + # --8<-- [end:exhaustive_search] + # --8<-- [start:exhaustive_search_cosine] + tbl.search(np.random.random((1536))).distance_type("cosine").limit(10).to_list() + # --8<-- [end:exhaustive_search_cosine] + # --8<-- [start:create_table_with_nested_schema] + # Let's add 100 sample rows to our dataset + data = [ + LanceSchema( + id=f"id{i}", + vector=np.random.randn(1536), + payload=Document( + content=f"document{i}", + meta=Metadata(source=f"source{i % 10}", timestamp=datetime.now()), + ), + ) + for i in range(100) + ] + + # Synchronous client + tbl = db.create_table("documents", data=data, mode="overwrite") + # --8<-- [end:create_table_with_nested_schema] + # --8<-- [start:search_result_as_pyarrow] + tbl.search(np.random.randn(1536)).to_arrow() + # --8<-- [end:search_result_as_pyarrow] + # --8<-- [start:search_result_as_pandas] + tbl.search(np.random.randn(1536)).to_pandas() + # --8<-- [end:search_result_as_pandas] + # --8<-- [start:search_result_as_pandas_flatten_true] + tbl.search(np.random.randn(1536)).to_pandas(flatten=True) + # --8<-- [end:search_result_as_pandas_flatten_true] + # --8<-- [start:search_result_as_pandas_flatten_1] + tbl.search(np.random.randn(1536)).to_pandas(flatten=1) + # --8<-- [end:search_result_as_pandas_flatten_1] + # --8<-- [start:search_result_as_list] + tbl.search(np.random.randn(1536)).to_list() + # --8<-- [end:search_result_as_list] + # --8<-- [start:search_result_as_pydantic] + tbl.search(np.random.randn(1536)).to_pydantic(LanceSchema) + # --8<-- [end:search_result_as_pydantic] + + +@pytest.mark.asyncio +async def test_vector_search_async(): + # --8<-- [start:exhaustive_search_async] + uri = "data/sample-lancedb" + async_db = await lancedb.connect_async(uri) + data = [ + {"vector": row, "item": f"item {i}"} + for i, row in enumerate(np.random.random((10_000, 1536)).astype("float32")) + ] + async_tbl = await async_db.create_table( + "vector_search_async", data=data, mode="overwrite" + ) + (await (await async_tbl.search(np.random.random((1536)))).limit(10).to_list()) + # --8<-- [end:exhaustive_search_async] + # --8<-- [start:exhaustive_search_async_cosine] + ( + await (await async_tbl.search(np.random.random((1536)))) + .distance_type("cosine") + .limit(10) + .to_list() + ) + # --8<-- [end:exhaustive_search_async_cosine] + # --8<-- [start:create_table_async_with_nested_schema] + # Let's add 100 sample rows to our dataset + data = [ + LanceSchema( + id=f"id{i}", + vector=np.random.randn(1536), + payload=Document( + content=f"document{i}", + meta=Metadata(source=f"source{i % 10}", timestamp=datetime.now()), + ), + ) + for i in range(100) + ] + + async_tbl = await async_db.create_table( + "documents_async", data=data, mode="overwrite" + ) + # --8<-- [end:create_table_async_with_nested_schema] + # --8<-- [start:search_result_async_as_pyarrow] + await (await async_tbl.search(np.random.randn(1536))).to_arrow() + # --8<-- [end:search_result_async_as_pyarrow] + # --8<-- [start:search_result_async_as_pandas] + await (await async_tbl.search(np.random.randn(1536))).to_pandas() + # --8<-- [end:search_result_async_as_pandas] + # --8<-- [start:search_result_async_as_list] + await (await async_tbl.search(np.random.randn(1536))).to_list() + # --8<-- [end:search_result_async_as_list] + + +def test_fts_fuzzy_query(): + uri = "data/fuzzy-example" + db = lancedb.connect(uri) + + table = db.create_table( + "my_table_fts_fuzzy", + data=pa.table( + { + "text": [ + "fa", + "fo", # spellchecker:disable-line + "fob", + "focus", + "foo", + "food", + "foul", + ] + } + ), + mode="overwrite", + ) + table.create_fts_index("text", replace=True) + + results = table.search(MatchQuery("foo", "text", fuzziness=1)).to_pandas() + assert len(results) == 4 + assert set(results["text"].to_list()) == { + "foo", + "fo", # 1 deletion # spellchecker:disable-line + "fob", # 1 substitution + "food", # 1 insertion + } + + +def test_fts_boost_query(): + uri = "data/boost-example" + db = lancedb.connect(uri) + + table = db.create_table( + "my_table_fts_boost", + data=pa.table( + { + "title": [ + "The Hidden Gems of Travel", + "Exploring Nature's Wonders", + "Cultural Treasures Unveiled", + "The Nightlife Chronicles", + "Scenic Escapes and Challenges", + ], + "desc": [ + "A vibrant city with occasional traffic jams.", + "Beautiful landscapes but overpriced tourist spots.", + "Rich cultural heritage but humid summers.", + "Bustling nightlife but noisy streets.", + "Scenic views but limited public transport options.", + ], + } + ), + mode="overwrite", + ) + table.create_fts_index("desc", replace=True) + + results = table.search( + BoostQuery( + MatchQuery("beautiful, cultural, nightlife", "desc"), + MatchQuery("bad traffic jams, overpriced", "desc"), + ), + ).to_pandas() + + # we will hit 3 results because the positive query has 3 hits + assert len(results) == 3 + # the one containing "overpriced" will be negatively boosted, + # so it will be the last one + assert ( + results["desc"].to_list()[2] + == "Beautiful landscapes but overpriced tourist spots." + ) + + +def test_fts_native(): + # --8<-- [start:basic_fts] + uri = "data/sample-lancedb" + db = lancedb.connect(uri) + + table = db.create_table( + "my_table_fts", + data=[ + {"vector": [3.1, 4.1], "text": "Frodo was a happy puppy"}, + {"vector": [5.9, 26.5], "text": "There are several kittens playing"}, + ], + mode="overwrite", + ) + + table.create_fts_index("text") + table.search("puppy").limit(10).select(["text"]).to_list() + # [{'text': 'Frodo was a happy puppy', '_score': 0.6931471824645996}] + # ... + # --8<-- [end:basic_fts] + # --8<-- [start:fts_config_stem] + table.create_fts_index("text", tokenizer_name="en_stem", replace=True) + # --8<-- [end:fts_config_stem] + # --8<-- [start:fts_config_folding] + table.create_fts_index( + "text", + language="French", + stem=True, + ascii_folding=True, + replace=True, + ) + # --8<-- [end:fts_config_folding] + # --8<-- [start:fts_prefiltering] + table.search("puppy").limit(10).where("text='foo'", prefilter=True).to_list() + # --8<-- [end:fts_prefiltering] + # --8<-- [start:fts_postfiltering] + table.search("puppy").limit(10).where("text='foo'", prefilter=False).to_list() + # --8<-- [end:fts_postfiltering] + # --8<-- [start:fts_with_position] + table.create_fts_index("text", with_position=True, replace=True) + # --8<-- [end:fts_with_position] + # --8<-- [start:fts_incremental_index] + table.add([{"vector": [3.1, 4.1], "text": "Frodo was a happy puppy"}]) + table.optimize() + # --8<-- [end:fts_incremental_index] + + +@pytest.mark.asyncio +async def test_fts_native_async(): + # --8<-- [start:basic_fts_async] + uri = "data/sample-lancedb" + async_db = await lancedb.connect_async(uri) + + async_tbl = await async_db.create_table( + "my_table_fts_async", + data=[ + {"vector": [3.1, 4.1], "text": "Frodo was a happy puppy"}, + {"vector": [5.9, 26.5], "text": "There are several kittens playing"}, + ], + mode="overwrite", + ) + + # async API uses our native FTS algorithm + await async_tbl.create_index("text", config=FTS()) + await (await async_tbl.search("puppy")).select(["text"]).limit(10).to_list() + # [{'text': 'Frodo was a happy puppy', '_score': 0.6931471824645996}] + # ... + # --8<-- [end:basic_fts_async] + # --8<-- [start:fts_config_stem_async] + await async_tbl.create_index( + "text", config=FTS(language="English", stem=True, remove_stop_words=True) + ) + # --8<-- [end:fts_config_stem_async] + # --8<-- [start:fts_config_folding_async] + await async_tbl.create_index( + "text", config=FTS(language="French", stem=True, ascii_folding=True) + ) + # --8<-- [end:fts_config_folding_async] + # --8<-- [start:fts_prefiltering_async] + await (await async_tbl.search("puppy")).limit(10).where("text='foo'").to_list() + # --8<-- [end:fts_prefiltering_async] + # --8<-- [start:fts_postfiltering_async] + await ( + (await async_tbl.search("puppy")) + .limit(10) + .where("text='foo'") + .postfilter() + .to_list() + ) + # --8<-- [end:fts_postfiltering_async] + # --8<-- [start:fts_with_position_async] + await async_tbl.create_index("text", config=FTS(with_position=True)) + # --8<-- [end:fts_with_position_async] + # --8<-- [start:fts_incremental_index_async] + await async_tbl.add([{"vector": [3.1, 4.1], "text": "Frodo was a happy puppy"}]) + await async_tbl.optimize() + # --8<-- [end:fts_incremental_index_async] + + +def _vectors(n, dim, seed=0, column="vector"): + rng = np.random.default_rng(seed) + return [ + {column: rng.random(dim).astype("float32"), "id": i} for i in range(n) + ] + + +def _build_indexed_table(db, name, dim=128, n=512): + tbl = db.create_table(name, _vectors(n, dim), mode="overwrite") + tbl.create_index(metric="cosine", num_partitions=4, num_sub_vectors=8) + return tbl + + +def test_vs_distance_metric_and_brute_force(tmp_db): + tbl = tmp_db.create_table("vs_metric", _vectors(64, 1536), mode="overwrite") + + # --8<-- [start:configure_distance_metric] + tbl.search(np.random.random((1536))).distance_type("cosine").limit(10).to_list() + # --8<-- [end:configure_distance_metric] + + # --8<-- [start:brute_force_search] + tbl.search(np.random.random((1536))).limit(3).to_list() + # --8<-- [end:brute_force_search] + + +def test_vs_select_vector_column(tmp_db): + db = tmp_db + + # --8<-- [start:select_vector_column] + import pyarrow as pa + + schema = pa.schema([ + pa.field("id", pa.int32()), + pa.field( + "image", + pa.struct([pa.field("embedding", pa.list_(pa.float32(), 2))]), + ), + ]) + table = db.create_table( + "nested", + data=[{"id": 0, "image": {"embedding": [0.0, 1.0]}}], + schema=schema, + ) + + # Inferred: the only vector leaf is `image.embedding`. + table.search([0.0, 1.0]).limit(1).to_list() + + # Explicit: required when more than one vector column matches. + table.search([0.0, 1.0], vector_column_name="image.embedding").limit(1).to_list() + # --8<-- [end:select_vector_column] + + +def test_vs_index_nested_column(tmp_db): + dim = 16 + schema = pa.schema( + [ + pa.field("id", pa.int32()), + pa.field( + "image", + pa.struct([pa.field("embedding", pa.list_(pa.float32(), dim))]), + ), + ] + ) + rng = np.random.default_rng(0) + data = [ + {"id": i, "image": {"embedding": rng.random(dim).astype("float32").tolist()}} + for i in range(512) + ] + table = tmp_db.create_table( + "nested_index", data=data, schema=schema, mode="overwrite" + ) + + # --8<-- [start:index_nested_column] + table.create_index(vector_column_name="image.embedding") + # --8<-- [end:index_nested_column] + + assert table.list_indices() + + +def test_vs_indexed_queries(tmp_db): + table = _build_indexed_table(tmp_db, "vs_indexed", dim=128, n=512) + embedding = np.random.random(128) + + # --8<-- [start:exact_vs_approximate_distances] + # Indexed ANN search without refinement (fast, approximate `_distance`) + fast_results = ( + table.search(embedding) + .limit(10) + .to_pandas() + ) + + # Recompute distances on full vectors for reranked candidates + exact_distance_results = ( + table.search(embedding) + .limit(10) + .refine_factor(1) + .to_pandas() + ) + + # Rerank a larger candidate set for better recall (higher latency) + higher_recall_results = ( + table.search(embedding) + .limit(10) + .refine_factor(20) + .to_pandas() + ) + # --8<-- [end:exact_vs_approximate_distances] + + # --8<-- [start:bypass_vector_index] + table.search(embedding).bypass_vector_index().limit(5).to_pandas() + # --8<-- [end:bypass_vector_index] + + assert len(fast_results) == 10 + assert len(exact_distance_results) == 10 + assert len(higher_recall_results) == 10 + + +def test_vs_fast_search(tmp_db, monkeypatch): + table = _build_indexed_table(tmp_db, "vs_fast", dim=128, n=512) + embedding = np.random.random(128) + + # `fast_search` is an Enterprise/async query flag; strip it so the snippet + # runs against a local OSS table without changing what readers see. + real_search = table.search + monkeypatch.setattr( + table, + "search", + lambda *a, **k: real_search( + *a, **{key: val for key, val in k.items() if key != "fast_search"} + ), + ) + + # --8<-- [start:fast_search] + table.search(embedding, fast_search=True).limit(5).to_pandas() + # --8<-- [end:fast_search] + + +def test_vs_distance_range(tmp_db): + tbl = tmp_db.create_table("vs_distance_range", _vectors(256, 256), mode="overwrite") + + # --8<-- [start:search_distance_range] + query = np.random.random(256) + + # Search for the vectors within the range of [0.1, 0.5) + tbl.search(query).distance_range(0.1, 0.5).to_arrow() + + # Search for the vectors with the distance less than 0.5 + tbl.search(query).distance_range(upper_bound=0.5).to_arrow() + + # Search for the vectors with the distance greater or equal to 0.1 + tbl.search(query).distance_range(lower_bound=0.1).to_arrow() + # --8<-- [end:search_distance_range] + + +def test_vs_multivector_search(tmp_db): + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("vector", pa.list_(pa.list_(pa.float32(), 256))), + ] + ) + rng = np.random.default_rng(0) + data = [{"id": i, "vector": rng.random(size=(2, 256)).tolist()} for i in range(64)] + tbl = tmp_db.create_table( + "vs_multivector", data=data, schema=schema, mode="overwrite" + ) + + # --8<-- [start:multivector_search] + query_multi = np.random.random(size=(2, 256)) + results_multi = tbl.search(query_multi).limit(5).to_pandas() + # --8<-- [end:multivector_search] + + assert len(results_multi) <= 5 + + +def test_vs_binary_search(tmp_db): + db = tmp_db + + # --8<-- [start:search_binary_vectors] + import numpy as np + import pyarrow as pa + + schema = pa.schema( + [ + pa.field("id", pa.int64()), + # for dim=256, lance stores every 8 bits in a byte + # so the vector field should be a list of 256 / 8 = 32 bytes + pa.field("vector", pa.list_(pa.uint8(), 32)), + ] + ) + tbl = db.create_table("my_binary_vectors", schema=schema) + + data = [] + for i in range(1024): + vector = np.random.randint(0, 2, size=256) + # pack the binary vector into bytes to save space + packed_vector = np.packbits(vector) + data.append( + { + "id": i, + "vector": packed_vector, + } + ) + tbl.add(data) + + query = np.random.randint(0, 2, size=256) + packed_query = np.packbits(query) + tbl.search(packed_query).distance_type("hamming").to_arrow() + # --8<-- [end:search_binary_vectors] + + +def test_vs_enterprise_filtering(tmp_db, monkeypatch): + import sys + import types + + dim = 384 + rng = np.random.default_rng(0) + rows = [ + { + "vector": rng.random(dim).astype("float32"), + "text": f"story {i}", + "keywords": f"kw{i}", + "label": i % 4, + } + for i in range(50) + ] + tmp_db.create_table("lancedb-enterprise-quickstart", data=rows, mode="overwrite") + db = tmp_db + + # Mock the Hugging Face dataset loader to avoid network downloads. + class _FakeDataset: + def __init__(self, n): + self._emb = [rng.random(dim).astype("float32") for _ in range(n)] + self._kw = [f"kw{i}" for i in range(n)] + + def __getitem__(self, key): + if isinstance(key, str): + return {"keywords_embeddings": self._emb, "keywords": self._kw}[key] + return {"keywords": self._kw[key], "keywords_embeddings": self._emb[key]} + + fake_datasets = types.ModuleType("datasets") + fake_datasets.load_dataset = lambda *a, **k: _FakeDataset(10) + monkeypatch.setitem(sys.modules, "datasets", fake_datasets) + + # --8<-- [start:vector_search_prefilter] + from datasets import load_dataset + + # Load query vector from dataset + query_dataset = load_dataset("sunhaozhepy/ag_news_sbert_keywords_embeddings", split="test[5000:5001]") + print(f"Query keywords: {query_dataset[0]['keywords']}") + query_embed = query_dataset["keywords_embeddings"][0] + + # Open table and perform search + table_name = "lancedb-enterprise-quickstart" + table = db.open_table(table_name) + + # Vector search with filters (pre-filtering is the default) + search_results = ( + table.search(query_embed) + .where("label > 2") + .select(["text", "keywords", "label"]) + .limit(5) + .to_pandas() + ) + + print("Search results (with pre-filtering):") + print(search_results) + # --8<-- [end:vector_search_prefilter] + + # --8<-- [start:vector_search_postfilter] + results_post_filtered = ( + table.search(query_embed) + .where("label > 1", prefilter=False) + .select(["text", "keywords", "label"]) + .limit(5) + .to_pandas() + ) + + print("Vector search results with post-filter:") + print(results_post_filtered) + # --8<-- [end:vector_search_postfilter] + + # --8<-- [start:batch_search] + # Load a batch of query embeddings + query_dataset = load_dataset( + "sunhaozhepy/ag_news_sbert_keywords_embeddings", split="test[5000:5005]" + ) + query_embeds = query_dataset["keywords_embeddings"] + batch_results = table.search(query_embeds).limit(5).to_pandas() + print(batch_results) + # --8<-- [end:batch_search] + + +@pytest.mark.skip() +def test_hybrid_search(): + # --8<-- [start:import-openai] + import openai + + # --8<-- [end:import-openai] + # --8<-- [start:openai-embeddings] + # Ingest embedding function in LanceDB table + # Configuring the environment variable OPENAI_API_KEY + if "OPENAI_API_KEY" not in os.environ: + # OR set the key here as a variable + openai.api_key = "sk-..." + embeddings = get_registry().get("openai").create() + + # --8<-- [end:openai-embeddings] + # --8<-- [start:class-Documents] + class Documents(LanceModel): + vector: Vector(embeddings.ndims()) = embeddings.VectorField() + text: str = embeddings.SourceField() + + # --8<-- [end:class-Documents] + # --8<-- [start:basic_hybrid_search] + data = [ + {"text": "rebel spaceships striking from a hidden base"}, + {"text": "have won their first victory against the evil Galactic Empire"}, + {"text": "during the battle rebel spies managed to steal secret plans"}, + {"text": "to the Empire's ultimate weapon the Death Star"}, + ] + uri = "data/sample-lancedb" + db = lancedb.connect(uri) + table = db.create_table("documents", schema=Documents) + # ingest docs with auto-vectorization + table.add(data) + # Create a fts index before the hybrid search + table.create_fts_index("text") + # hybrid search with default re-ranker + table.search("flower moon", query_type="hybrid").to_pandas() + # --8<-- [end:basic_hybrid_search] + # --8<-- [start:hybrid_search_pass_vector_text] + vector_query = [0.1, 0.2, 0.3, 0.4, 0.5] + text_query = "flower moon" + ( + table.search(query_type="hybrid") + .vector(vector_query) + .text(text_query) + .limit(5) + .to_pandas() + ) + # --8<-- [end:hybrid_search_pass_vector_text] + + +@pytest.mark.skip +async def test_hybrid_search_async(): + import openai + + # --8<-- [start:openai-embeddings] + # Ingest embedding function in LanceDB table + # Configuring the environment variable OPENAI_API_KEY + if "OPENAI_API_KEY" not in os.environ: + # OR set the key here as a variable + openai.api_key = "sk-..." + embeddings = get_registry().get("openai").create() + + # --8<-- [end:openai-embeddings] + # --8<-- [start:class-Documents] + class Documents(LanceModel): + vector: Vector(embeddings.ndims()) = embeddings.VectorField() + text: str = embeddings.SourceField() + + # --8<-- [end:class-Documents] + # --8<-- [start:basic_hybrid_search_async] + uri = "data/sample-lancedb" + async_db = await lancedb.connect_async(uri) + data = [ + {"text": "rebel spaceships striking from a hidden base"}, + {"text": "have won their first victory against the evil Galactic Empire"}, + {"text": "during the battle rebel spies managed to steal secret plans"}, + {"text": "to the Empire's ultimate weapon the Death Star"}, + ] + async_tbl = await async_db.create_table("documents_async", schema=Documents) + # ingest docs with auto-vectorization + await async_tbl.add(data) + # Create a fts index before the hybrid search + await async_tbl.create_index("text", config=FTS()) + text_query = "flower moon" + # hybrid search with default re-ranker + await (await async_tbl.search("flower moon", query_type="hybrid")).to_pandas() + # --8<-- [end:basic_hybrid_search_async] + # --8<-- [start:hybrid_search_pass_vector_text_async] + vector_query = [0.1, 0.2, 0.3, 0.4, 0.5] + text_query = "flower moon" + await ( + async_tbl.query() + .nearest_to(vector_query) + .nearest_to_text(text_query) + .limit(5) + .to_pandas() + ) + # --8<-- [end:hybrid_search_pass_vector_text_async] diff --git a/docs/web-tests/py/test_storage.py b/docs/web-tests/py/test_storage.py new file mode 100644 index 000000000..f08ab07cf --- /dev/null +++ b/docs/web-tests/py/test_storage.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import lancedb +import pytest + + +class DummyTable: + def __init__(self, name: str, storage_options: dict | None = None): + self.name = name + self.storage_options = storage_options + + +class DummyConnection: + def __init__(self, uri: str, options: dict): + self.uri = uri + self.options = options + self.created_tables: list[DummyTable] = [] + + def create_table( + self, + name: str, + data, + storage_options: dict | None = None, + ): + table = DummyTable(name, storage_options=storage_options) + self.created_tables.append(table) + return table + + +@pytest.fixture +def fake_connect(monkeypatch): + calls: list[DummyConnection] = [] + + def _fake_connect(uri: str, **kwargs): + conn = DummyConnection(uri, kwargs) + calls.append(conn) + return conn + + monkeypatch.setattr(lancedb, "connect", _fake_connect) + return calls + + +def test_storage_snippets(fake_connect): + # --8<-- [start:storage_connect_s3] + db = lancedb.connect("s3://bucket/path") + # --8<-- [end:storage_connect_s3] + + # --8<-- [start:storage_connect_gcs] + db = lancedb.connect("gs://bucket/path") + # --8<-- [end:storage_connect_gcs] + + # --8<-- [start:storage_connect_azure] + db = lancedb.connect("az://bucket/path") + # --8<-- [end:storage_connect_azure] + + # --8<-- [start:storage_connect_timeout] + db = lancedb.connect( + "s3://bucket/path", + storage_options={"timeout": "60s"}, + ) + # --8<-- [end:storage_connect_timeout] + + # --8<-- [start:storage_table_timeout] + table = db.create_table( + "table", + [{"a": 1, "b": 2}], + storage_options={"timeout": "60s"}, + ) + # --8<-- [end:storage_table_timeout] + + # --8<-- [start:storage_s3_ddb] + db = lancedb.connect( + "s3+ddb://bucket/path?ddbTableName=my-dynamodb-table", + ) + # --8<-- [end:storage_s3_ddb] + + # --8<-- [start:storage_s3_ddb_local] + db = lancedb.connect( + "s3+ddb://bucket/path?ddbTableName=my-dynamodb-table", + storage_options={ + "endpoint": "http://localhost:4566", + "dynamodb_endpoint": "http://localhost:4566", + "allow_http": "true", + }, + ) + # --8<-- [end:storage_s3_ddb_local] + + # --8<-- [start:storage_s3_sse_kms] + db = lancedb.connect( + "s3://bucket/path", + storage_options={ + "aws_server_side_encryption": "aws:kms", + "aws_sse_kms_key_id": "", + }, + ) + # --8<-- [end:storage_s3_sse_kms] + + # --8<-- [start:storage_azure_sas] + db = lancedb.connect( + "az://my-container/my-database", + storage_options={ + "azure_storage_account_name": "some-account", + "azure_storage_sas_token": "", + }, + ) + # --8<-- [end:storage_azure_sas] + + # --8<-- [start:storage_s3_minio] + db = lancedb.connect( + "s3://bucket/path", + storage_options={ + "region": "us-east-1", + "endpoint": "http://minio:9000", + }, + ) + # --8<-- [end:storage_s3_minio] + + # --8<-- [start:storage_s3_express] + db = lancedb.connect( + "s3://my-bucket--use1-az4--x-s3/path", + storage_options={ + "region": "us-east-1", + "s3_express": "true", + }, + ) + # --8<-- [end:storage_s3_express] + + # --8<-- [start:storage_gcs_service_account] + db = lancedb.connect( + "gs://my-bucket/my-database", + storage_options={ + "service_account": "path/to/service-account.json", + }, + ) + # --8<-- [end:storage_gcs_service_account] + + # --8<-- [start:storage_azure_account] + db = lancedb.connect( + "az://my-container/my-database", + storage_options={ + "account_name": "some-account", + "account_key": "some-key", + }, + ) + # --8<-- [end:storage_azure_account] + + # --8<-- [start:storage_tigris_connect] + db = lancedb.connect( + "s3://your-bucket/path", + storage_options={ + "endpoint": "https://t3.storage.dev", + "region": "auto", + }, + ) + # --8<-- [end:storage_tigris_connect] + + assert len(fake_connect) == 13 + assert all( + conn.uri.startswith(("s3://", "gs://", "az://", "s3+ddb://")) + for conn in fake_connect + ) diff --git a/docs/web-tests/py/test_tables.py b/docs/web-tests/py/test_tables.py new file mode 100644 index 000000000..b3a456adc --- /dev/null +++ b/docs/web-tests/py/test_tables.py @@ -0,0 +1,1546 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +# --8<-- [start:tables_imports] +import lancedb +import numpy as np +import pandas as pd +import pyarrow as pa +import pytest +from numpy.random import randint, random + +# --8<-- [end:tables_imports] + + +# ============================================================================ +# Table Creation Examples +# ============================================================================ + + +def test_tables_basic_connect_snippet(monkeypatch): + calls = {} + + class DummyDB: + pass + + def fake_connect(uri): + calls["uri"] = uri + return DummyDB() + + import lancedb as _lancedb + + # Monkey patch is used only in this test. + # Maybe can just actually connect, and disconnect instead? + monkeypatch.setattr(_lancedb, "connect", fake_connect) + + # --8<-- [start:tables_basic_connect] + import lancedb + + uri = "data/sample-lancedb" + db = lancedb.connect(uri) + # --8<-- [end:tables_basic_connect] + + assert calls["uri"] == "data/sample-lancedb" + assert isinstance(db, DummyDB) + + +def test_update_connect_enterprise_snippet(monkeypatch): + calls = {} + + class DummyDB: + pass + + def fake_connect(**kwargs): + calls.update(kwargs) + return DummyDB() + + import lancedb as _lancedb + + monkeypatch.setattr(_lancedb, "connect", fake_connect) + + # --8<-- [start:update_connect_enterprise] + import lancedb + + db = lancedb.connect( + uri="db://your-project-slug", + api_key="your-api-key", + region="us-east-1", + ) + # --8<-- [end:update_connect_enterprise] + + assert calls["uri"] == "db://your-project-slug" + assert calls["api_key"] == "your-api-key" + assert calls["region"] == "us-east-1" + assert isinstance(db, DummyDB) + + +def test_update_connect_local_snippet(monkeypatch): + calls = {} + + class DummyDB: + pass + + def fake_connect(uri): + calls["uri"] = uri + return DummyDB() + + import lancedb as _lancedb + + monkeypatch.setattr(_lancedb, "connect", fake_connect) + + # --8<-- [start:update_connect_local] + import lancedb + + db = lancedb.connect("./data") + # --8<-- [end:update_connect_local] + + assert calls["uri"] == "./data" + assert isinstance(db, DummyDB) + + +def test_table_creation_from_dicts(tmp_db): + # --8<-- [start:create_table_from_dicts] + data = [ + {"vector": [1.1, 1.2], "lat": 45.5, "long": -122.7}, + {"vector": [0.2, 1.8], "lat": 40.1, "long": -74.1}, + ] + db = tmp_db + db.create_table("test_table", data, mode="overwrite") + tbl = db["test_table"] + tbl.head() + # --8<-- [end:create_table_from_dicts] + + +def test_create_table_conflict_handling(tmp_db): + db = tmp_db + data = [ + {"vector": [1.1, 1.2], "lat": 45.5, "long": -122.7}, + {"vector": [0.2, 1.8], "lat": 40.1, "long": -74.1}, + ] + db.create_table("conflict_table", data) + + # --8<-- [start:create_table_conflict_handling] + # Idempotent open: reuse the existing table if it exists. + # The provided data is ignored; the schema is validated against the + # existing table and a mismatch raises an error. + tbl = db.create_table("conflict_table", data, exist_ok=True) + + # Overwrite: drop the existing table and create a new one with the + # provided data. This permanently discards the old table's data. + tbl = db.create_table("conflict_table", data, mode="overwrite") + # --8<-- [end:create_table_conflict_handling] + assert tbl.count_rows() == 2 + + +def test_table_creation_from_pandas(tmp_db): + # --8<-- [start:create_table_from_pandas] + import pandas as pd + + data = pd.DataFrame( + { + "vector": [[1.1, 1.2, 1.3, 1.4], [0.2, 1.8, 0.4, 3.6]], + "lat": [45.5, 40.1], + "long": [-122.7, -74.1], + } + ) + db = tmp_db + db.create_table("my_table_pandas", data, mode="overwrite") + db["my_table_pandas"].head() + # --8<-- [end:create_table_from_pandas] + + +def test_table_creation_with_custom_schema(tmp_db): + # --8<-- [start:create_table_custom_schema] + import pyarrow as pa + + custom_schema = pa.schema( + [ + pa.field("vector", pa.list_(pa.float32(), 4)), + pa.field("lat", pa.float32()), + pa.field("long", pa.float32()), + ] + ) + + data = [ + {"vector": [1.1, 1.2, 1.3, 1.4], "lat": 45.5, "long": -122.7}, + {"vector": [0.2, 1.8, 0.4, 3.6], "lat": 40.1, "long": -74.1}, + ] + db = tmp_db + tbl = db.create_table( + "my_table_custom_schema", data, schema=custom_schema, mode="overwrite" + ) + # --8<-- [end:create_table_custom_schema] + + +def test_table_creation_from_polars(tmp_db): + # --8<-- [start:create_table_from_polars] + import polars as pl + + data = pl.DataFrame( + { + "vector": [[3.1, 4.1], [5.9, 26.5]], + "item": ["foo", "bar"], + "price": [10.0, 20.0], + } + ) + db = tmp_db + tbl = db.create_table("my_table_pl", data, mode="overwrite") + # --8<-- [end:create_table_from_polars] + + +def test_table_creation_from_arrow(tmp_db): + # --8<-- [start:create_table_from_arrow] + import numpy as np + import pyarrow as pa + + dim = 16 + total = 2 + schema = pa.schema( + [pa.field("vector", pa.list_(pa.float16(), dim)), pa.field("text", pa.string())] + ) + data = pa.Table.from_arrays( + [ + pa.array( + [np.random.randn(dim).astype(np.float16) for _ in range(total)], + pa.list_(pa.float16(), dim), + ), + pa.array(["foo", "bar"]), + ], + ["vector", "text"], + ) + db = tmp_db + tbl = db.create_table("f16_tbl", data, schema=schema, mode="overwrite") + # --8<-- [end:create_table_from_arrow] + + +def test_table_creation_from_pydantic(tmp_db): + # --8<-- [start:create_table_from_pydantic] + from lancedb.pydantic import LanceModel, Vector + + class Content(LanceModel): + movie_id: int + vector: Vector(128) + genres: str + title: str + imdb_id: int + + @property + def imdb_url(self) -> str: + return f"https://www.imdb.com/title/tt{self.imdb_id}" + + db = tmp_db + tbl = db.create_table("movielens_small", schema=Content, mode="overwrite") + # --8<-- [end:create_table_from_pydantic] + + +def test_table_creation_nested_schema(tmp_db): + # --8<-- [start:create_table_nested_schema] + from lancedb.pydantic import LanceModel, Vector + + # --8<-- [start:tables_document_model] + from pydantic import BaseModel + + class Document(BaseModel): + content: str + source: str + + # --8<-- [end:tables_document_model] + + class NestedSchema(LanceModel): + id: str + vector: Vector(1536) + document: Document + + db = tmp_db + tbl = db.create_table("nested_table", schema=NestedSchema, mode="overwrite") + # --8<-- [end:create_table_nested_schema] + + +def test_tables_tz_validator_snippet(): + # --8<-- [start:tables_tz_validator] + from datetime import datetime + from zoneinfo import ZoneInfo + + from lancedb.pydantic import LanceModel + from pydantic import Field, ValidationError, ValidationInfo, field_validator + + tzname = "America/New_York" + tz = ZoneInfo(tzname) + + class TestModel(LanceModel): + dt_with_tz: datetime = Field(json_schema_extra={"tz": tzname}) + + @field_validator("dt_with_tz") + @classmethod + def tz_must_match(cls, dt: datetime) -> datetime: + assert dt.tzinfo == tz + return dt + + ok = TestModel(dt_with_tz=datetime.now(tz)) + + try: + TestModel(dt_with_tz=datetime.now(ZoneInfo("Asia/Shanghai"))) + assert 0 == 1, "this should raise ValidationError" + except ValidationError: + print("A ValidationError was raised.") + pass + # --8<-- [end:tables_tz_validator] + + assert ok is not None + + +def test_add_from_dataset(tmp_db, tmp_path): + import pyarrow as pa + import pyarrow.dataset as ds + import pyarrow.parquet as pq + + schema = pa.schema( + [ + pa.field("vector", pa.list_(pa.float32(), 4)), + pa.field("item", pa.utf8()), + pa.field("price", pa.float32()), + ] + ) + for i in range(3): + batch = pa.table( + { + "vector": [[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]], + "item": [f"item{i * 2}", f"item{i * 2 + 1}"], + "price": [float(i * 2), float(i * 2 + 1)], + }, + schema=schema, + ) + pq.write_table(batch, tmp_path / f"part-{i}.parquet") + + data_path = str(tmp_path) + + # --8<-- [start:add_from_dataset] + import pyarrow.dataset as ds + + dataset = ds.dataset(data_path, format="parquet") + db = tmp_db + table = db.create_table("my_table", schema=dataset.schema, mode="overwrite") + table.add(dataset) + # --8<-- [end:add_from_dataset] + assert table.count_rows() == 6 + + +def test_table_creation_from_iterator(tmp_db): + # --8<-- [start:create_table_from_iterator] + import pyarrow as pa + + schema = pa.schema( + [ + pa.field("vector", pa.list_(pa.float32(), 4)), + pa.field("item", pa.utf8()), + pa.field("price", pa.float32()), + ] + ) + + def make_batches(): + for i in range(5): + yield pa.RecordBatch.from_arrays( + [ + pa.array( + [[3.1, 4.1, 5.1, 6.1], [5.9, 26.5, 4.7, 32.8]], + pa.list_(pa.float32(), 4), + ), + pa.array(["foo", "bar"]), + pa.array([10.0, 20.0]), + ], + ["vector", "item", "price"], + ) + + db = tmp_db + db.create_table("batched_table", make_batches(), schema=schema, mode="overwrite") + # --8<-- [end:create_table_from_iterator] + +def test_open_existing_table(tmp_db): + # --8<-- [start:open_existing_table] + db = tmp_db + # Create a table first + data = [{"vector": [1.1, 1.2], "lat": 45.5, "long": -122.7}] + db.create_table("test_table", data, mode="overwrite") + + # List table names + print(db.list_tables().tables) + + # Open existing table + tbl = db.open_table("test_table") + # --8<-- [end:open_existing_table] + + +def test_create_empty_table(tmp_db): + # --8<-- [start:create_empty_table] + import pyarrow as pa + + schema = pa.schema( + [ + pa.field("vector", pa.list_(pa.float32(), 2)), + pa.field("item", pa.string()), + pa.field("price", pa.float32()), + ] + ) + db = tmp_db + tbl = db.create_table("test_empty_table", schema=schema, mode="overwrite") + # --8<-- [end:create_empty_table] + + +def test_create_empty_table_pydantic(tmp_db): + # --8<-- [start:create_empty_table_pydantic] + from lancedb.pydantic import LanceModel, Vector + + class Item(LanceModel): + vector: Vector(2) + item: str + price: float + + db = tmp_db + tbl = db.create_table( + "test_empty_table_new", schema=Item.to_arrow_schema(), mode="overwrite" + ) + # --8<-- [end:create_empty_table_pydantic] + + +def test_drop_table(tmp_db): + # --8<-- [start:drop_table] + db = tmp_db + # Create a table first + data = [{"vector": [1.1, 1.2], "lat": 45.5}] + db.create_table("my_table", data, mode="overwrite") + + # Drop the table + db.drop_table("my_table") + # --8<-- [end:drop_table] + + +# ============================================================================ +# Data Update Examples +# ============================================================================ + + +def test_add_data_to_table(tmp_db): + db = tmp_db + + # --8<-- [start:add_data_to_table] + import pyarrow as pa + + # create an empty table with schema + data = [ + {"vector": [3.1, 4.1], "item": "foo", "price": 10.0}, + {"vector": [5.9, 26.5], "item": "bar", "price": 20.0}, + {"vector": [10.2, 100.8], "item": "baz", "price": 30.0}, + {"vector": [1.4, 9.5], "item": "fred", "price": 40.0}, + ] + + schema = pa.schema( + [ + pa.field("vector", pa.list_(pa.float32(), 2)), + pa.field("item", pa.utf8()), + pa.field("price", pa.float32()), + ] + ) + + table_name = "basic_ingestion_example" + table = db.create_table(table_name, schema=schema, mode="overwrite") + # Add data + table.add(data) + # --8<-- [end:add_data_to_table] + assert table.count_rows() == len(data) + + +def test_add_data_pydantic_model(tmp_db): + db = tmp_db + + # --8<-- [start:add_data_pydantic_model] + from lancedb.pydantic import LanceModel, Vector + + # Define a Pydantic model + class Content(LanceModel): + movie_id: int + vector: Vector(128) + genres: str + title: str + imdb_id: int + + @property + def imdb_url(self) -> str: + return f"https://www.imdb.com/title/tt{self.imdb_id}" + + # Create table with Pydantic model schema + table_name = "pydantic_example" + table = db.create_table(table_name, schema=Content, mode="overwrite") + # --8<-- [end:add_data_pydantic_model] + assert table.count_rows() == 0 + + +def test_add_data_nested_model(tmp_db): + db = tmp_db + + # --8<-- [start:add_data_nested_model] + from lancedb.pydantic import LanceModel, Vector + from pydantic import BaseModel + + class Document(BaseModel): + content: str + source: str + + class NestedSchema(LanceModel): + id: str + vector: Vector(128) + document: Document + + # Create table with nested schema + table_name = "nested_model_example" + table = db.create_table(table_name, schema=NestedSchema, mode="overwrite") + # --8<-- [end:add_data_nested_model] + assert table.count_rows() == 0 + + +def test_batch_data_insertion(tmp_db): + db = tmp_db + + # --8<-- [start:batch_data_insertion] + import pyarrow as pa + + def make_batches(): + for i in range(5): # Create 5 batches + yield pa.RecordBatch.from_arrays( + [ + pa.array([[3.1, 4.1], [5.9, 26.5]], pa.list_(pa.float32(), 2)), + pa.array([f"item{i * 2 + 1}", f"item{i * 2 + 2}"]), + pa.array([float((i * 2 + 1) * 10), float((i * 2 + 2) * 10)]), + ], + ["vector", "item", "price"], + ) + + schema = pa.schema( + [ + pa.field("vector", pa.list_(pa.float32(), 2)), + pa.field("item", pa.utf8()), + pa.field("price", pa.float32()), + ] + ) + # Create table with batches + table_name = "batch_ingestion_example" + table = db.create_table(table_name, make_batches(), schema=schema, mode="overwrite") + # --8<-- [end:batch_data_insertion] + assert table.count_rows() == 10 + + +def _create_users_example_table(db, table_name="users_example"): + return db.create_table( + table_name, + data=pa.table( + { + "id": [1, 2, 3], + "name": ["Alice", "Bob", "Charlie"], + "login_count": [10, 20, 5], + } + ), + mode="overwrite", + ) + + +def test_update_example_table_setup(tmp_db): + db = tmp_db + + # --8<-- [start:update_example_table_setup] + import pyarrow as pa + + table = db.create_table( + "users_example", + data=pa.table( + { + "id": [1, 2], + "name": ["Alice", "Bob"], + "login_count": [10, 20], + } + ), + mode="overwrite", + ) + # --8<-- [end:update_example_table_setup] + assert table.count_rows() == 2 + + +def test_update_operation(tmp_db): + db = tmp_db + + # --8<-- [start:update_operation] + import pyarrow as pa + + table = db.create_table( + "users_example", + data=pa.table( + { + "id": [1, 2], + "name": ["Alice", "Bob"], + "login_count": [10, 20], + } + ), + mode="overwrite", + ) + table.update(where="id = 2", values={"name": "Bobby"}) + # --8<-- [end:update_operation] + rows = table.to_arrow().sort_by("id").to_pylist() + assert rows == [ + {"id": 1, "name": "Alice", "login_count": 10}, + {"id": 2, "name": "Bobby", "login_count": 20}, + ] + + +def test_update_using_sql(tmp_db): + db = tmp_db + + # --8<-- [start:update_using_sql] + import pyarrow as pa + + table = db.create_table( + "users_example", + data=pa.table( + { + "id": [1, 2], + "name": ["Alice", "Bob"], + "login_count": [10, 20], + } + ), + mode="overwrite", + ) + table.update(where="id = 2", values_sql={"login_count": "login_count + 1"}) + # --8<-- [end:update_using_sql] + rows = table.to_arrow().sort_by("id").to_pylist() + assert rows == [ + {"id": 1, "name": "Alice", "login_count": 10}, + {"id": 2, "name": "Bob", "login_count": 21}, + ] + + +def test_merge_matched_update_only(tmp_db): + db = tmp_db + + # --8<-- [start:merge_matched_update_only] + import pyarrow as pa + + table = db.create_table( + "users_example", + data=pa.table( + { + "id": [1, 2], + "name": ["Alice", "Bob"], + "login_count": [10, 20], + } + ), + mode="overwrite", + ) + + incoming_users = pa.table( + { + "id": [2, 3], + "name": ["Bobby", "Charlie"], + "login_count": [21, 5], + } + ) + + (table.merge_insert("id").when_matched_update_all().execute(incoming_users)) + # --8<-- [end:merge_matched_update_only] + rows = table.to_arrow().sort_by("id").to_pylist() + assert rows == [ + {"id": 1, "name": "Alice", "login_count": 10}, + {"id": 2, "name": "Bobby", "login_count": 21}, + ] + + +def test_insert_if_not_exists(tmp_db): + db = tmp_db + + # --8<-- [start:insert_if_not_exists] + import pyarrow as pa + + table = db.create_table( + "users_example", + data=pa.table( + { + "id": [1, 2], + "name": ["Alice", "Bob"], + "login_count": [10, 20], + } + ), + mode="overwrite", + ) + + incoming_users = pa.table( + { + "id": [2, 3], + "name": ["Bobby", "Charlie"], + "login_count": [21, 5], + } + ) + + (table.merge_insert("id").when_not_matched_insert_all().execute(incoming_users)) + # --8<-- [end:insert_if_not_exists] + rows = table.to_arrow().sort_by("id").to_pylist() + assert rows == [ + {"id": 1, "name": "Alice", "login_count": 10}, + {"id": 2, "name": "Bob", "login_count": 20}, + {"id": 3, "name": "Charlie", "login_count": 5}, + ] + + +def test_merge_update_insert(tmp_db): + db = tmp_db + + # --8<-- [start:merge_update_insert] + import pyarrow as pa + + table = db.create_table( + "users_example", + data=pa.table( + { + "id": [1, 2], + "name": ["Alice", "Bob"], + "login_count": [10, 20], + } + ), + mode="overwrite", + ) + + incoming_users = pa.table( + { + "id": [2, 3], + "name": ["Bobby", "Charlie"], + "login_count": [21, 5], + } + ) + + ( + table.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(incoming_users) + ) + # --8<-- [end:merge_update_insert] + rows = table.to_arrow().sort_by("id").to_pylist() + assert rows == [ + {"id": 1, "name": "Alice", "login_count": 10}, + {"id": 2, "name": "Bobby", "login_count": 21}, + {"id": 3, "name": "Charlie", "login_count": 5}, + ] + + +def test_merge_delete_missing_by_source(tmp_db): + db = tmp_db + + # --8<-- [start:merge_delete_missing_by_source] + import pyarrow as pa + + table = db.create_table( + "users_example", + data=pa.table( + { + "id": [1, 2, 3], + "name": ["Alice", "Bob", "Charlie"], + "login_count": [10, 20, 5], + } + ), + mode="overwrite", + ) + + incoming_users = pa.table( + { + "id": [2, 3], + "name": ["Bobby", "Charlie"], + "login_count": [21, 5], + } + ) + + ( + table.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .when_not_matched_by_source_delete() + .execute(incoming_users) + ) + # --8<-- [end:merge_delete_missing_by_source] + rows = table.to_arrow().sort_by("id").to_pylist() + assert rows == [ + {"id": 2, "name": "Bobby", "login_count": 21}, + {"id": 3, "name": "Charlie", "login_count": 5}, + ] + + +def test_merge_partial_columns(tmp_db): + db = tmp_db + + # --8<-- [start:merge_partial_columns] + import pyarrow as pa + + table = db.create_table( + "users_example", + data=pa.table( + { + "id": [1, 2], + "name": ["Alice", "Bob"], + "login_count": [10, 20], + } + ), + mode="overwrite", + ) + + incoming_users = pa.table( + { + "id": [2, 3], + "name": ["Bobby", "Charlie"], + } + ) + + ( + table.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(incoming_users) + ) + # --8<-- [end:merge_partial_columns] + rows = table.to_arrow().sort_by("id").to_pylist() + assert rows == [ + {"id": 1, "name": "Alice", "login_count": 10}, + {"id": 2, "name": "Bobby", "login_count": 20}, + {"id": 3, "name": "Charlie", "login_count": None}, + ] + + +def test_delete_operation(tmp_db): + db = tmp_db + table = _create_users_example_table(db) + + # --8<-- [start:delete_operation] + # delete data + predicate = "id = 3" + table.delete(predicate) + # --8<-- [end:delete_operation] + assert table.count_rows() == 2 + + +def test_update_optimize_cleanup_snippet(tmp_db): + table = _create_users_example_table(tmp_db, table_name="users_cleanup_example") + + # --8<-- [start:update_optimize_cleanup] + from datetime import timedelta + + table.optimize(cleanup_older_than=timedelta(days=1)) + # --8<-- [end:update_optimize_cleanup] + + +# ============================================================================ +# Schema Evolution Examples +# ============================================================================ + + +def _setup_schema_add_table(tmp_db, data=None): + # --8<-- [start:schema_add_setup] + table_name = "schema_evolution_add_example" + if data is None: + data = [ + { + "id": 1, + "name": "Laptop", + "price": 1200.00, + "vector": np.random.random(128).tolist(), + }, + { + "id": 2, + "name": "Smartphone", + "price": 800.00, + "vector": np.random.random(128).tolist(), + }, + { + "id": 3, + "name": "Headphones", + "price": 150.00, + "vector": np.random.random(128).tolist(), + }, + ] + table = tmp_db.create_table(table_name, data, mode="overwrite") + # --8<-- [end:schema_add_setup] + return table + + +def _setup_schema_alter_table(tmp_db, data=None): + # --8<-- [start:schema_alter_setup] + table_name = "schema_evolution_alter_example" + if data is None: + data = [ + { + "id": 1, + "name": "Laptop", + "price": 1200, + "discount_price": 1080.0, + "vector": np.random.random(128).tolist(), + }, + { + "id": 2, + "name": "Smartphone", + "price": 800, + "discount_price": 720.0, + "vector": np.random.random(128).tolist(), + }, + ] + schema = pa.schema( + { + "id": pa.int64(), + "name": pa.string(), + "price": pa.int32(), + "discount_price": pa.float64(), + "vector": pa.list_(pa.float32(), 128), + } + ) + table = tmp_db.create_table(table_name, data, schema=schema, mode="overwrite") + # --8<-- [end:schema_alter_setup] + return table + + +def _setup_schema_drop_table(tmp_db, data=None): + # --8<-- [start:schema_drop_setup] + if data is None: + data = [ + { + "id": 1, + "name": "Laptop", + "price": 1200.00, + "temp_col1": "X", + "temp_col2": 100, + "vector": np.random.random(128).tolist(), + }, + { + "id": 2, + "name": "Smartphone", + "price": 800.00, + "temp_col1": "Y", + "temp_col2": 200, + "vector": np.random.random(128).tolist(), + }, + { + "id": 3, + "name": "Headphones", + "price": 150.00, + "temp_col1": "Z", + "temp_col2": 300, + "vector": np.random.random(128).tolist(), + }, + ] + table = tmp_db.create_table("schema_evolution_drop_example", data, mode="overwrite") + # --8<-- [end:schema_drop_setup] + return table + + +def test_add_columns_calculated(tmp_db): + table = _setup_schema_add_table(tmp_db) + + # --8<-- [start:add_columns_calculated] + # Add a discounted price column (10% discount) + table.add_columns({"discounted_price": "cast((price * 0.9) as float)"}) + # --8<-- [end:add_columns_calculated] + assert "discounted_price" in table.schema.names + + +def test_add_columns_default_values(tmp_db): + table = _setup_schema_add_table(tmp_db) + + # --8<-- [start:add_columns_default_values] + # Add a stock status column with default value + table.add_columns({"in_stock": "cast(true as boolean)"}) + # --8<-- [end:add_columns_default_values] + assert "in_stock" in table.schema.names + + +def test_add_columns_nullable(tmp_db): + table = _setup_schema_add_table( + tmp_db, + data=[ + { + "id": 1, + "name": "Laptop", + "price": 1200.00, + "vector": np.random.random(128).tolist(), + } + ], + ) + + # --8<-- [start:add_columns_nullable] + # Add a nullable timestamp column + table.add_columns({"last_ordered": "cast(NULL as timestamp)"}) + # --8<-- [end:add_columns_nullable] + assert "last_ordered" in table.schema.names + + +def test_add_feature_columns_sql(tmp_db): + table = _setup_schema_add_table(tmp_db) + + # --8<-- [start:add_feature_columns_sql] + table.add_columns( + { + "price_per_id": "cast(price / id as float)", + "price_log": "ln(price)", + "price_score": "cast(price / (price + 100.0) as float)", + } + ) + # --8<-- [end:add_feature_columns_sql] + assert {"price_per_id", "price_log", "price_score"}.issubset(table.schema.names) + + +def test_alter_columns_rename(tmp_db): + table = _setup_schema_alter_table(tmp_db) + + # --8<-- [start:alter_columns_rename] + # Rename discount_price to sale_price + table.alter_columns({"path": "discount_price", "rename": "sale_price"}) + # --8<-- [end:alter_columns_rename] + assert "sale_price" in table.schema.names + assert "discount_price" not in table.schema.names + + +def test_alter_columns_data_type(tmp_db): + table = _setup_schema_alter_table( + tmp_db, + data=[ + { + "id": 1, + "name": "Laptop", + "price": 1200, + "discount_price": 1080.0, + "vector": np.random.random(128).tolist(), + } + ], + ) + + # --8<-- [start:alter_columns_data_type] + # Change price from int32 to int64 for larger numbers + table.alter_columns({"path": "price", "data_type": pa.int64()}) + # --8<-- [end:alter_columns_data_type] + assert table.schema.field("price").type == pa.int64() + + +def test_alter_columns_nullable(tmp_db): + table = _setup_schema_alter_table( + tmp_db, + data=[ + { + "id": 1, + "name": "Laptop", + "price": 1200, + "discount_price": 1080.0, + "vector": np.random.random(128).tolist(), + } + ], + ) + + # --8<-- [start:alter_columns_nullable] + # Make the name column nullable + table.alter_columns({"path": "name", "nullable": True}) + # --8<-- [end:alter_columns_nullable] + assert table.schema.field("name").nullable is True + + +def test_alter_columns_multiple(tmp_db): + table = _setup_schema_alter_table( + tmp_db, + data=[ + { + "id": 1, + "name": "Laptop", + "price": 1200, + "discount_price": 1080.0, + "vector": np.random.random(128).tolist(), + } + ], + ) + table.alter_columns({"path": "discount_price", "rename": "sale_price"}) + + # --8<-- [start:alter_columns_multiple] + # Rename, change type, and make nullable in one operation + table.alter_columns( + { + "path": "sale_price", + "rename": "final_price", + "data_type": pa.float64(), + "nullable": True, + } + ) + # --8<-- [end:alter_columns_multiple] + assert "final_price" in table.schema.names + assert table.schema.field("final_price").nullable is True + + +def test_alter_columns_with_expression(tmp_db): + # --8<-- [start:alter_columns_with_expression] + # For custom transforms, create a new column from a SQL expression. + expression_table = tmp_db.create_table( + "schema_evolution_expression_example", + [{"id": 1, "price_text": "$100"}], + mode="overwrite", + ) + + expression_table.add_columns( + {"price_numeric": "cast(replace(price_text, '$', '') as int)"} + ) + expression_table.drop_columns(["price_text"]) + expression_table.alter_columns({"path": "price_numeric", "rename": "price"}) + # --8<-- [end:alter_columns_with_expression] + assert "price" in expression_table.schema.names + + +def test_drop_columns_single(tmp_db): + table = _setup_schema_drop_table(tmp_db) + + # --8<-- [start:drop_columns_single] + # Remove the first temporary column + table.drop_columns(["temp_col1"]) + # --8<-- [end:drop_columns_single] + assert "temp_col1" not in table.schema.names + + +def test_drop_columns_multiple(tmp_db): + table = _setup_schema_drop_table( + tmp_db, + data=[ + { + "id": 1, + "name": "Laptop", + "price": 1200.00, + "temp_col1": "X", + "temp_col2": 100, + "vector": np.random.random(128).tolist(), + }, + ], + ) + + # --8<-- [start:drop_columns_multiple] + # Remove the second temporary column + table.drop_columns(["temp_col2"]) + # --8<-- [end:drop_columns_multiple] + assert "temp_col2" not in table.schema.names + + +def test_alter_vector_column(tmp_db): + # --8<-- [start:alter_vector_column] + vector_dim = 768 # Your embedding dimension + table_name = "vector_alter_example" + db = tmp_db + data = [ + { + "id": 1, + "embedding": np.random.random(vector_dim).tolist(), + }, + ] + table = db.create_table(table_name, data, mode="overwrite") + + table.alter_columns( + dict(path="embedding", data_type=pa.list_(pa.float32(), vector_dim)) + ) + # --8<-- [end:alter_vector_column] + + +def test_schema_field_metadata(tmp_db): + table = tmp_db.create_table( + "schema_field_metadata_example", + pa.table({"id": [0, 1], "category": ["a", "b"]}), + mode="overwrite", + ) + + # --8<-- [start:schema_field_metadata_merge] + # Set two metadata keys on the `category` field. + res = table.update_field_metadata( + {"path": "category", "metadata": {"unit": "label", "pii": "false"}} + ) + print(res.version) + + # Merge: add a new key, delete one with None, keep the rest. + table.update_field_metadata( + {"path": "category", "metadata": {"source": "import", "pii": None}} + ) + + # Arrow stores field metadata as bytes. + assert table.schema.field("category").metadata == { + b"unit": b"label", + b"source": b"import", + } + # --8<-- [end:schema_field_metadata_merge] + + # --8<-- [start:schema_field_metadata_replace] + table.update_field_metadata( + { + "path": "category", + "metadata": {"owner": "search-team"}, + "replace": True, + } + ) + # --8<-- [end:schema_field_metadata_replace] + + assert table.schema.field("category").metadata == {b"owner": b"search-team"} + + +# ============================================================================ +# Versioning Examples +# ============================================================================ + + +def _setup_versioning_table(tmp_db, data=None, table_name="quotes_versioning_example"): + import pyarrow as pa + + if data is None: + data = [ + {"id": 1, "author": "Richard", "quote": "Wubba Lubba Dub Dub!"}, + {"id": 2, "author": "Morty", "quote": "Rick, what's going on?"}, + { + "id": 3, + "author": "Richard", + "quote": "I turned myself into a pickle, Morty!", + }, + ] + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("author", pa.string()), + pa.field("quote", pa.string()), + ] + ) + return tmp_db.create_table(table_name, data, schema=schema, mode="overwrite") + + +def test_versioning_basic_setup(tmp_db): + # --8<-- [start:versioning_basic_setup] + import pyarrow as pa + + db = tmp_db + + table_name = "quotes_versioning_example" + data = [ + {"id": 1, "author": "Richard", "quote": "Wubba Lubba Dub Dub!"}, + {"id": 2, "author": "Morty", "quote": "Rick, what's going on?"}, + { + "id": 3, + "author": "Richard", + "quote": "I turned myself into a pickle, Morty!", + }, + ] + + # Define schema + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("author", pa.string()), + pa.field("quote", pa.string()), + ] + ) + + table = db.create_table(table_name, data, schema=schema, mode="overwrite") + # --8<-- [end:versioning_basic_setup] + assert table.count_rows() == 3 + + +def test_versioning_check_initial_version(tmp_db): + table = _setup_versioning_table(tmp_db) + + # --8<-- [start:versioning_check_initial_version] + versions = table.list_versions() + current_version = table.version + print(f"Number of versions after creation: {len(versions)}") + print(f"Current version: {current_version}") + # --8<-- [end:versioning_check_initial_version] + assert len(versions) == 1 + assert current_version == versions[-1]["version"] + + +def test_versioning_flow(tmp_db): + table = _setup_versioning_table(tmp_db) + + # --8<-- [start:versioning_update_data] + table.update(where="author='Richard'", values={"author": "Richard Daniel Sanchez"}) + rows_after_update = table.count_rows("author = 'Richard Daniel Sanchez'") + print(f"Rows updated to Richard Daniel Sanchez: {rows_after_update}") + # --8<-- [end:versioning_update_data] + assert rows_after_update == 2 + + # --8<-- [start:versioning_add_data] + more_data = [ + { + "id": 4, + "author": "Richard Daniel Sanchez", + "quote": "That's the way the news goes!", + }, + {"id": 5, "author": "Morty", "quote": "Aww geez, Rick!"}, + ] + table.add(more_data) + # --8<-- [end:versioning_add_data] + assert table.count_rows() == 5 + + # --8<-- [start:versioning_check_versions_after_mod] + versions = table.list_versions() + version_count_after_mod = len(versions) + version_after_mod = table.version + print(f"Number of versions after modifications: {version_count_after_mod}") + print(f"Current version: {version_after_mod}") + # --8<-- [end:versioning_check_versions_after_mod] + assert version_count_after_mod >= 2 + assert version_after_mod == versions[-1]["version"] + + # --8<-- [start:versioning_list_all_versions] + versions = table.list_versions() + for v in versions: + print(f"Version {v['version']}, created at {v['timestamp']}") + # --8<-- [end:versioning_list_all_versions] + assert len(versions) >= 1 + + # --8<-- [start:versioning_rollback] + table.restore(version_after_mod) + versions = table.list_versions() + version_count_after_rollback = len(versions) + print(f"Total number of versions after rollback: {version_count_after_rollback}") + # --8<-- [end:versioning_rollback] + assert version_count_after_rollback == version_count_after_mod + 1 + assert table.version == versions[-1]["version"] + assert table.count_rows() == 5 + + # --8<-- [start:versioning_checkout_latest] + table.checkout_latest() + # --8<-- [end:versioning_checkout_latest] + assert table.version == table.list_versions()[-1]["version"] + + # --8<-- [start:versioning_delete_data] + table.delete("author = 'Morty'") + rows_after_deletion = table.count_rows() + print(f"Number of rows after deletion: {rows_after_deletion}") + # --8<-- [end:versioning_delete_data] + assert rows_after_deletion == 3 + + +def test_versioning_tags(tmp_db): + import pyarrow as pa + + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("author", pa.string()), + pa.field("quote", pa.string()), + ] + ) + table = tmp_db.create_table( + "quotes_tags_example", + [{"id": 1, "author": "Richard", "quote": "Wubba Lubba Dub Dub!"}], + schema=schema, + mode="overwrite", + ) # v1 + table.add([{"id": 2, "author": "Morty", "quote": "Aww geez, Rick!"}]) # v2 + table.add([{"id": 3, "author": "Summer", "quote": "Whatever, Grandpa"}]) # v3 + + # --8<-- [start:versioning_tags] + # Create a tag pointing at a specific version + table.tags.create("baseline", 1) + table.tags.create("with-edits", table.version) + + # List all tags on this table + print(table.tags.list()) + + # Look up the version a tag points at + print(table.tags.get_version("baseline")) + + # Move an existing tag to a different version + table.tags.update("baseline", 2) + + # Check out a version by tag name + table.checkout("baseline") + print(table.version) + + # Delete a tag (does not delete the underlying version) + table.tags.delete("with-edits") + + # Return to the latest version + table.checkout_latest() + # --8<-- [end:versioning_tags] + assert table.version == 3 + assert "baseline" in table.tags.list() + assert "with-edits" not in table.tags.list() + + +# ============================================================================ +# Branches Examples +# ============================================================================ + + +def test_branches(tmp_db): + import numpy as np + import pyarrow as pa + from lancedb.index import FTS, IvfPq + + db = tmp_db + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("author", pa.string()), + pa.field("quote", pa.string()), + ] + ) + table = db.create_table( + "quotes_branches_example", + [ + {"id": 1, "author": "Lancelot", "quote": "My lance never fails."}, + {"id": 2, "author": "Arthur", "quote": "Long live Camelot!"}, + {"id": 3, "author": "Merlin", "quote": "Magic always has a price."}, + ], + schema=schema, + mode="overwrite", + ) + + # --8<-- [start:branch_create] + # Fork an isolated, writable branch from main's latest version. + # `create` returns a table handle scoped to the new branch. + branch = table.branches.create("exp") + # --8<-- [end:branch_create] + + # --8<-- [start:branch_write] + # Writes land on the branch handle only; main is left untouched. + branch.add([{"id": 4, "author": "Lancelot", "quote": "For the realm!"}]) + print(branch.count_rows()) # 4 rows on the branch + print(table.count_rows()) # 3 rows; main is unaffected + + # List every branch, each mapped to its metadata (including its fork point). + print(table.branches.list()) + # --8<-- [end:branch_write] + + # --8<-- [start:branch_reopen] + # Reopen an existing branch by name from the table handle... + checked_out = table.branches.checkout("exp") + # ...or open it directly from the database connection. + branch_handle = db.open_table("quotes_branches_example", branch="exp") + print(checked_out.count_rows(), branch_handle.count_rows()) # both 4 + # --8<-- [end:branch_reopen] + + # --8<-- [start:branch_delete] + # Delete the branch and its branch-local history. Data on main is safe. + table.branches.delete("exp") + # --8<-- [end:branch_delete] + + assert table.count_rows() == 3 + assert "exp" not in table.branches.list() + + # Setup: a branch with row results that we want to apply to main. + candidate = table.branches.create("candidate") + candidate.update(where="id = 1", values={"quote": "Revised on the branch"}) + candidate.add( + [{"id": 4, "author": "Galahad", "quote": "The grail awaits."}] + ) + + # --8<-- [start:branch_upsert_to_main] + # This is a row-level upsert, not a merge of branch histories. + # `merge_insert` updates matching rows and inserts new rows using a stable + # unique key. Filter the branch read if you only want to apply some results. + rows_to_apply = candidate.to_arrow() + ( + table.merge_insert("id") + .when_matched_update_all() # update rows that already exist on main + .when_not_matched_insert_all() # insert rows that are new on the branch + .execute(rows_to_apply) + ) + # --8<-- [end:branch_upsert_to_main] + + main_rows = { + row["id"]: row["quote"] for row in table.to_arrow().to_pylist() + } + assert table.count_rows() == 4 + assert main_rows[1] == "Revised on the branch" + assert 4 in main_rows + table.branches.delete("candidate") + + # Setup: a larger table with a vector and a text column to index. + rng = np.random.default_rng(0) + products = db.create_table( + "products_branch_index", + [ + {"id": i, "vector": rng.random(4).tolist(), "text": f"product number {i}"} + for i in range(512) + ], + mode="overwrite", + ) + + # --8<-- [start:branch_index] + # Build and validate indexes on a branch before using the configuration on + # main. + dev = products.branches.create("index-dev") + + # A vector (ANN) index and a full-text search index, both branch-scoped. + dev.create_index( + "vector", + config=IvfPq(distance_type="cosine", num_partitions=1, num_sub_vectors=2), + ) + dev.create_index("text", config=FTS()) + + # Both indexes live only on the branch; main still has none. + print([ix.name for ix in dev.list_indices()]) # branch: two indexes + print([ix.name for ix in products.list_indices()]) # main: [] (untouched) + # --8<-- [end:branch_index] + + assert len(dev.list_indices()) == 2 + assert len(products.list_indices()) == 0 + products.branches.delete("index-dev") + + +# ============================================================================ +# Consistency Examples +# ============================================================================ + + +def test_consistency_strong(tmp_db): + # --8<-- [start:consistency_strong] + from datetime import timedelta + + uri = str(tmp_db.uri) + writer_db = lancedb.connect(uri) + reader_db = lancedb.connect(uri, read_consistency_interval=timedelta(0)) + writer_table = writer_db.create_table( + "consistency_strong_table", [{"id": 1}], mode="overwrite" + ) + reader_table = reader_db.open_table("consistency_strong_table") + writer_table.add([{"id": 2}]) + rows_after_write = reader_table.count_rows() + print(f"Rows visible with strong consistency: {rows_after_write}") + # --8<-- [end:consistency_strong] + assert rows_after_write == 2 + + +def test_consistency_eventual(tmp_db): + # --8<-- [start:consistency_eventual] + from datetime import timedelta + + uri = str(tmp_db.uri) + writer_db = lancedb.connect(uri) + reader_db = lancedb.connect(uri, read_consistency_interval=timedelta(seconds=3600)) + writer_table = writer_db.create_table( + "consistency_eventual_table", [{"id": 1}], mode="overwrite" + ) + reader_table = reader_db.open_table("consistency_eventual_table") + writer_table.add([{"id": 2}]) + rows_after_write = reader_table.count_rows() + print(f"Rows visible before eventual refresh interval: {rows_after_write}") + # --8<-- [end:consistency_eventual] + assert rows_after_write == 1 + + +def test_consistency_checkout_latest(tmp_db): + # --8<-- [start:consistency_checkout_latest] + uri = str(tmp_db.uri) + writer_db = lancedb.connect(uri) + reader_db = lancedb.connect(uri) + writer_table = writer_db.create_table( + "consistency_checkout_latest_table", [{"id": 1}], mode="overwrite" + ) + reader_table = reader_db.open_table("consistency_checkout_latest_table") + + writer_table.add([{"id": 2}]) + rows_before_refresh = reader_table.count_rows() + print(f"Rows before checkout_latest: {rows_before_refresh}") + + reader_table.checkout_latest() + rows_after_refresh = reader_table.count_rows() + print(f"Rows after checkout_latest: {rows_after_refresh}") + # --8<-- [end:consistency_checkout_latest] + assert rows_before_refresh == 1 + assert rows_after_refresh == 2 diff --git a/docs/web-tests/rs/Cargo.lock b/docs/web-tests/rs/Cargo.lock new file mode 100644 index 000000000..0dff8ddfc --- /dev/null +++ b/docs/web-tests/rs/Cargo.lock @@ -0,0 +1,7454 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ar_archive_writer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +dependencies = [ + "object 0.32.2", +] + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "argminmax" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f13d10a41ac8d2ec79ee34178d61e6f47a29c2edfe7ef1721c7383b0359e65" +dependencies = [ + "num-traits", +] + +[[package]] +name = "array-init-cursor" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed51fe0f224d1d4ea768be38c51f9f831dee9d05c163c11fba0b8c44387b1fc3" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arrow" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-csv", + "arrow-data", + "arrow-ipc", + "arrow-json", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ab212d2c1886e802f51c5212d78ebbcbb0bec980fff9dadc1eb8d45cd0b738" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "chrono-tz 0.10.4", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c5aefb56a2c02e9e2b30746241058b85f8983f0fcff2ba0c6d09006e1cded7f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-csv" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94e8cf7e517657a52b91ea1263acf38c4ca62a84655d72458a3359b12ab97de" +dependencies = [ + "arrow-array", + "arrow-cast", + "arrow-schema", + "chrono", + "csv", + "csv-core", + "regex", +] + +[[package]] +name = "arrow-data" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ipc" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238438f0834483703d88896db6fe5a7138b2230debc31b34c0336c2996e3c64f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", + "lz4_flex", + "zstd", +] + +[[package]] +name = "arrow-json" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "205ca2119e6d679d5c133c6f30e68f027738d95ed948cf77677ea69c7800036b" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-ord", + "arrow-schema", + "arrow-select", + "chrono", + "half", + "indexmap 2.14.0", + "itoa", + "lexical-core", + "memchr", + "num-traits", + "ryu", + "serde_core", + "serde_json", + "simdutf8", +] + +[[package]] +name = "arrow-ord" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bffd8fd2579286a5d63bac898159873e5094a79009940bcb42bbfce4f19f1d0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab5994731204603c73ba69267616c50f80780774c6bb0476f1f830625115e0c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" +dependencies = [ + "bitflags 2.10.0", + "serde_core", + "serde_json", +] + +[[package]] +name = "arrow-select" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async_cell" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ab28afbb345f5408b120702a44e5529ebf90b1796ec76e9528df8e288e6c2" +dependencies = [ + "loom", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atoi_simd" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae037714f313c1353189ead58ef9eec30a8e8dc101b2622d461418fd59e28a9" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object 0.37.3", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bigdecimal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560f42649de9fa436b73517378a147ec21f6c997a546581df4b4b31677828934" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitpacking" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92" +dependencies = [ + "crunchy", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecheck" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cedarwood" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0524a528a6a0288df1863c3c20fe92c301875b4941e7b6c4b394ab08c5a4c55" +dependencies = [ + "smallvec", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf 0.11.3", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "comfy-table" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d05af1e006a2407bedef5af410552494ce5be9090444dbbcb57258c1af3d56" +dependencies = [ + "crossterm 0.27.0", + "crossterm 0.28.1", + "strum 0.26.3", + "strum_macros 0.26.4", + "unicode-width", +] + +[[package]] +name = "compression-codecs" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "tokio", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "libc", + "parking_lot", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "parking_lot", + "rustix 0.38.44", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "daachorse" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db756b5eb7d81d31f31f660f4132f8cf5698de52fca144c143d0ae0cbb5f2e06" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "datafusion" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93db0e623840612f7f2cd757f7e8a8922064192363732c88692e0870016e141b" +dependencies = [ + "arrow", + "arrow-schema", + "async-trait", + "bytes", + "chrono", + "datafusion-catalog", + "datafusion-catalog-listing", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-datasource-arrow", + "datafusion-datasource-csv", + "datafusion-datasource-json", + "datafusion-execution", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-functions", + "datafusion-functions-aggregate", + "datafusion-functions-nested", + "datafusion-functions-table", + "datafusion-functions-window", + "datafusion-optimizer", + "datafusion-physical-expr", + "datafusion-physical-expr-adapter", + "datafusion-physical-expr-common", + "datafusion-physical-optimizer", + "datafusion-physical-plan", + "datafusion-session", + "datafusion-sql", + "futures", + "itertools 0.14.0", + "log", + "object_store", + "parking_lot", + "rand 0.9.2", + "regex", + "sqlparser 0.61.0", + "tempfile", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "datafusion-catalog" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37cefde60b26a7f4ff61e9d2ff2833322f91df2b568d7238afe67bde5bdffb66" +dependencies = [ + "arrow", + "async-trait", + "dashmap", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr", + "datafusion-physical-plan", + "datafusion-session", + "futures", + "itertools 0.14.0", + "log", + "object_store", + "parking_lot", + "tokio", +] + +[[package]] +name = "datafusion-catalog-listing" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e112307715d6a7a331111a4c2330ff54bc237183511c319e3708a4cff431fb" +dependencies = [ + "arrow", + "async-trait", + "datafusion-catalog", + "datafusion-common", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr", + "datafusion-physical-expr-adapter", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "futures", + "itertools 0.14.0", + "log", + "object_store", +] + +[[package]] +name = "datafusion-common" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d72a11ca44a95e1081870d3abb80c717496e8a7acb467a1d3e932bb636af5cc2" +dependencies = [ + "ahash", + "arrow", + "arrow-ipc", + "chrono", + "half", + "hashbrown 0.16.1", + "indexmap 2.14.0", + "itertools 0.14.0", + "libc", + "log", + "object_store", + "paste", + "sqlparser 0.61.0", + "tokio", + "web-time", +] + +[[package]] +name = "datafusion-common-runtime" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f4afaed29670ec4fd6053643adc749fe3f4bc9d1ce1b8c5679b22c67d12def" +dependencies = [ + "futures", + "log", + "tokio", +] + +[[package]] +name = "datafusion-datasource" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9fb386e1691355355a96419978a0022b7947b44d4a24a6ea99f00b6b485cbb6" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "chrono", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr", + "datafusion-physical-expr-adapter", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-session", + "futures", + "glob", + "itertools 0.14.0", + "log", + "object_store", + "rand 0.9.2", + "tokio", + "url", +] + +[[package]] +name = "datafusion-datasource-arrow" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa6c52cfed0734c5f93754d1c0175f558175248bf686c944fb05c373e5fc096" +dependencies = [ + "arrow", + "arrow-ipc", + "async-trait", + "bytes", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-session", + "futures", + "itertools 0.14.0", + "object_store", + "tokio", +] + +[[package]] +name = "datafusion-datasource-csv" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503f29e0582c1fc189578d665ff57d9300da1f80c282777d7eb67bb79fb8cdca" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-session", + "futures", + "object_store", + "regex", + "tokio", +] + +[[package]] +name = "datafusion-datasource-json" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33804749abc8d0c8cb7473228483cb8070e524c6f6086ee1b85a64debe2b3d2" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-session", + "futures", + "object_store", + "serde_json", + "tokio", + "tokio-stream", +] + +[[package]] +name = "datafusion-doc" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de6ac0df1662b9148ad3c987978b32cbec7c772f199b1d53520c8fa764a87ee" + +[[package]] +name = "datafusion-execution" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03c7fbdaefcca4ef6ffe425a5fc2325763bfb426599bb0bf4536466efabe709" +dependencies = [ + "arrow", + "arrow-buffer", + "async-trait", + "chrono", + "dashmap", + "datafusion-common", + "datafusion-expr", + "datafusion-physical-expr-common", + "futures", + "log", + "object_store", + "parking_lot", + "rand 0.9.2", + "tempfile", + "url", +] + +[[package]] +name = "datafusion-expr" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "574b9b6977fedbd2a611cbff12e5caf90f31640ad9dc5870f152836d94bad0dd" +dependencies = [ + "arrow", + "async-trait", + "chrono", + "datafusion-common", + "datafusion-doc", + "datafusion-expr-common", + "datafusion-functions-aggregate-common", + "datafusion-functions-window-common", + "datafusion-physical-expr-common", + "indexmap 2.14.0", + "itertools 0.14.0", + "paste", + "serde_json", + "sqlparser 0.61.0", +] + +[[package]] +name = "datafusion-expr-common" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d7c3adf3db8bf61e92eb90cb659c8e8b734593a8f7c8e12a843c7ddba24b87e" +dependencies = [ + "arrow", + "datafusion-common", + "indexmap 2.14.0", + "itertools 0.14.0", + "paste", +] + +[[package]] +name = "datafusion-functions" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28aa4e10384e782774b10e72aca4d93ef7b31aa653095d9d4536b0a3dbc51b6" +dependencies = [ + "arrow", + "arrow-buffer", + "base64 0.22.1", + "blake2", + "blake3", + "chrono", + "chrono-tz 0.10.4", + "datafusion-common", + "datafusion-doc", + "datafusion-execution", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-macros", + "hex", + "itertools 0.14.0", + "log", + "md-5", + "memchr", + "num-traits", + "rand 0.9.2", + "regex", + "sha2", + "unicode-segmentation", + "uuid", +] + +[[package]] +name = "datafusion-functions-aggregate" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00aa6217e56098ba84e0a338176fe52f0a84cca398021512c6c8c5eff806d0ad" +dependencies = [ + "ahash", + "arrow", + "datafusion-common", + "datafusion-doc", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions-aggregate-common", + "datafusion-macros", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "half", + "log", + "num-traits", + "paste", +] + +[[package]] +name = "datafusion-functions-aggregate-common" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b511250349407db7c43832ab2de63f5557b19a20dfd236b39ca2c04468b50d47" +dependencies = [ + "ahash", + "arrow", + "datafusion-common", + "datafusion-expr-common", + "datafusion-physical-expr-common", +] + +[[package]] +name = "datafusion-functions-nested" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef13a858e20d50f0a9bb5e96e7ac82b4e7597f247515bccca4fdd2992df0212a" +dependencies = [ + "arrow", + "arrow-ord", + "datafusion-common", + "datafusion-doc", + "datafusion-execution", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-functions", + "datafusion-functions-aggregate", + "datafusion-functions-aggregate-common", + "datafusion-macros", + "datafusion-physical-expr-common", + "hashbrown 0.16.1", + "itertools 0.14.0", + "itoa", + "log", + "paste", +] + +[[package]] +name = "datafusion-functions-table" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b40d3f5bbb3905f9ccb1ce9485a9595c77b69758a7c24d3ba79e334ff51e7e" +dependencies = [ + "arrow", + "async-trait", + "datafusion-catalog", + "datafusion-common", + "datafusion-expr", + "datafusion-physical-plan", + "parking_lot", + "paste", +] + +[[package]] +name = "datafusion-functions-window" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e88ec9d57c9b685d02f58bfee7be62d72610430ddcedb82a08e5d9925dbfb6" +dependencies = [ + "arrow", + "datafusion-common", + "datafusion-doc", + "datafusion-expr", + "datafusion-functions-window-common", + "datafusion-macros", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "log", + "paste", +] + +[[package]] +name = "datafusion-functions-window-common" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8307bb93519b1a91913723a1130cfafeee3f72200d870d88e91a6fc5470ede5c" +dependencies = [ + "datafusion-common", + "datafusion-physical-expr-common", +] + +[[package]] +name = "datafusion-macros" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e367e6a71051d0ebdd29b2f85d12059b38b1d1f172c6906e80016da662226bd" +dependencies = [ + "datafusion-doc", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "datafusion-optimizer" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e929015451a67f77d9d8b727b2bf3a40c4445fdef6cdc53281d7d97c76888ace" +dependencies = [ + "arrow", + "chrono", + "datafusion-common", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-physical-expr", + "indexmap 2.14.0", + "itertools 0.14.0", + "log", + "regex", + "regex-syntax", +] + +[[package]] +name = "datafusion-physical-expr" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b1e68aba7a4b350401cfdf25a3d6f989ad898a7410164afe9ca52080244cb59" +dependencies = [ + "ahash", + "arrow", + "datafusion-common", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-functions-aggregate-common", + "datafusion-physical-expr-common", + "half", + "hashbrown 0.16.1", + "indexmap 2.14.0", + "itertools 0.14.0", + "parking_lot", + "paste", + "petgraph", + "tokio", +] + +[[package]] +name = "datafusion-physical-expr-adapter" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea22315f33cf2e0adc104e8ec42e285f6ed93998d565c65e82fec6a9ee9f9db4" +dependencies = [ + "arrow", + "datafusion-common", + "datafusion-expr", + "datafusion-functions", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "itertools 0.14.0", +] + +[[package]] +name = "datafusion-physical-expr-common" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04b45ea8ad3ac2d78f2ea2a76053e06591c9629c7a603eda16c10649ecf4362" +dependencies = [ + "ahash", + "arrow", + "chrono", + "datafusion-common", + "datafusion-expr-common", + "hashbrown 0.16.1", + "indexmap 2.14.0", + "itertools 0.14.0", + "parking_lot", +] + +[[package]] +name = "datafusion-physical-optimizer" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cb13397809a425918f608dfe8653f332015a3e330004ab191b4404187238b95" +dependencies = [ + "arrow", + "datafusion-common", + "datafusion-execution", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-pruning", + "itertools 0.14.0", +] + +[[package]] +name = "datafusion-physical-plan" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5edc023675791af9d5fb4cc4c24abf5f7bd3bd4dcf9e5bd90ea1eff6976dcc79" +dependencies = [ + "ahash", + "arrow", + "arrow-ord", + "arrow-schema", + "async-trait", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions", + "datafusion-functions-aggregate-common", + "datafusion-functions-window-common", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "futures", + "half", + "hashbrown 0.16.1", + "indexmap 2.14.0", + "itertools 0.14.0", + "log", + "num-traits", + "parking_lot", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "datafusion-pruning" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8c76860e355616555081cab5968cec1af7a80701ff374510860bcd567e365a" +dependencies = [ + "arrow", + "datafusion-common", + "datafusion-datasource", + "datafusion-expr-common", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "itertools 0.14.0", + "log", +] + +[[package]] +name = "datafusion-session" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5412111aa48e2424ba926112e192f7a6b7e4ccb450145d25ce5ede9f19dc491e" +dependencies = [ + "async-trait", + "datafusion-common", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-plan", + "parking_lot", +] + +[[package]] +name = "datafusion-sql" +version = "53.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0d133ddf8b9b3b872acac900157f783e7b879fe9a6bccf389abebbfac45ec1" +dependencies = [ + "arrow", + "bigdecimal", + "chrono", + "datafusion-common", + "datafusion-expr", + "datafusion-functions-nested", + "indexmap 2.14.0", + "log", + "regex", + "sqlparser 0.61.0", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "deepsize" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb987ec36f6bf7bfbea3f928b75590b736fc42af8e54d97592481351b2b96c" +dependencies = [ + "deepsize_derive", +] + +[[package]] +name = "deepsize_derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990101d41f3bc8c1a45641024377ee284ecc338e5ecf3ea0f0e236d897c72796" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fast-float" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95765f67b4b18863968b4a1bd5bb576f732b29a4a28c7cd84c09fa3e2875f33c" + +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flatbuffers" +version = "25.9.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b6620799e7340ebd9968d2e0708eb82cf1971e9a16821e2091b6d6e475eed5" +dependencies = [ + "bitflags 2.10.0", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1b05cbd864bcaecbd3455d6d967862d446e4ebfc3c2e5e5b9841e53cba6673" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsst" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af6e24ed12cf382082d5a7f365df2b8e3d6b1518f615d54c85165e9b9718250" +dependencies = [ + "arrow-array", + "rand 0.9.2", +] + +[[package]] +name = "fst" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" +dependencies = [ + "utf8-ranges", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.61.3", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", + "rayon", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperloglogplus" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3" +dependencies = [ + "serde", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inferno" +version = "0.11.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232929e1d75fe899576a3d5c7416ad0d88dbfbb3c3d6aa00873a7408a50ddb88" +dependencies = [ + "ahash", + "indexmap 2.14.0", + "is-terminal", + "itoa", + "log", + "num-format", + "once_cell", + "quick-xml", + "rgb", + "str_stack", +] + +[[package]] +name = "io-uring" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "itoap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9028f49264629065d057f340a86acb84867925865f73bbf8d47b4d149a7e88b8" + +[[package]] +name = "jieba-macros" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46adade69b634535a8f495cf87710ed893cff53e1dbc9dd750c2ab81c5defb82" +dependencies = [ + "phf_codegen 0.13.1", +] + +[[package]] +name = "jieba-rs" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b53580aaa8ec8b713da271da434f8947409242c537a9ab3f7b76bdbb19e8a9" +dependencies = [ + "bytecount", + "cedarwood", + "jieba-macros", + "phf 0.13.1", + "regex", + "rustc-hash", +] + +[[package]] +name = "jiff" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonb" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a901f06163d352fbe41c3c2ff5e08b75330a003cc941e988fb501022f5421e6" +dependencies = [ + "byteorder", + "ethnum", + "fast-float2", + "itoa", + "jiff", + "nom", + "num-traits", + "ordered-float", + "rand 0.9.2", + "ryu", + "serde", + "serde_json", +] + +[[package]] +name = "kanaria" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f9d9652540055ac4fded998a73aca97d965899077ab1212587437da44196ff" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "lance" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2122e9f5f5f4b38bb9f0c4991c8d5171696402b3cc6187885c8385875f6df2e" +dependencies = [ + "arc-swap", + "arrow", + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-ipc", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "async-recursion", + "async-trait", + "async_cell", + "bitpacking", + "byteorder", + "bytes", + "chrono", + "crossbeam-queue", + "crossbeam-skiplist", + "dashmap", + "datafusion", + "datafusion-expr", + "datafusion-functions", + "datafusion-physical-expr", + "datafusion-physical-plan", + "either", + "fst", + "futures", + "half", + "humantime", + "itertools 0.13.0", + "lance-arrow 8.0.0", + "lance-core 8.0.0", + "lance-datafusion", + "lance-encoding", + "lance-file", + "lance-index", + "lance-io", + "lance-linalg", + "lance-namespace 8.0.0", + "lance-select", + "lance-table", + "lance-tokenizer", + "log", + "moka", + "object_store", + "permutation", + "pin-project", + "prost", + "prost-build", + "prost-types", + "rand 0.9.2", + "rayon", + "roaring", + "rustc-hash", + "semver", + "serde", + "serde_json", + "snafu 0.9.1", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "lance-arrow" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "253f4a0a70580c985b91e65e9ca6cad644825a4078de28d8efbacf3ffbd7ecdc" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ipc", + "arrow-ord", + "arrow-schema", + "arrow-select", + "bytes", + "futures", + "getrandom 0.2.16", + "half", + "jsonb", + "num-traits", + "rand 0.9.2", +] + +[[package]] +name = "lance-arrow" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95f45fb7e0822cc0233d686222ab451f6ec58879620029e0b7bae8192c2f7c7e" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ipc", + "arrow-ord", + "arrow-schema", + "arrow-select", + "bytes", + "futures", + "getrandom 0.2.16", + "half", + "jsonb", + "num-traits", + "rand 0.9.2", +] + +[[package]] +name = "lance-arrow-scalar" +version = "58.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771f68b04b47f3addf781116f65061808de94b05e1e9411c23c18f32d14ebe79" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-row", + "arrow-schema", + "half", +] + +[[package]] +name = "lance-arrow-stats" +version = "58.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd47ec33c90bf29f688fd02118e37d3a5ad5c339caa3163f89e417dc0867001f" +dependencies = [ + "arrow-array", + "arrow-schema", + "half", + "lance-arrow-scalar", +] + +[[package]] +name = "lance-bitpacking" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56c21a39860ca7bae712b4e24b9fd066029c570a72428a579faf697de5b8822" +dependencies = [ + "arrayref", + "paste", + "seq-macro", +] + +[[package]] +name = "lance-core" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f84020da5a484e2f07dd1796e09785ed7cd889857ebc4cb77e32ef214ee594" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "async-trait", + "byteorder", + "bytes", + "deepsize", + "futures", + "itertools 0.13.0", + "lance-arrow 7.0.0", + "libc", + "log", + "moka", + "num_cpus", + "object_store", + "pin-project", + "prost", + "rand 0.9.2", + "roaring", + "serde_json", + "snafu 0.9.1", + "tempfile", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "lance-core" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccc7b0b61c11bdb74f929111214553b36b1ee43c88445edda17bc9a2bda75e9" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "async-trait", + "byteorder", + "bytes", + "datafusion-common", + "datafusion-sql", + "futures", + "itertools 0.13.0", + "lance-arrow 8.0.0", + "lance-derive", + "libc", + "libm", + "log", + "moka", + "num_cpus", + "object_store", + "pin-project", + "prost", + "rand 0.9.2", + "roaring", + "serde_json", + "snafu 0.9.1", + "tempfile", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "twox-hash", + "url", +] + +[[package]] +name = "lance-datafusion" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0932140306faa6c3c4e17f0478c031e2be659ea2721311a530fb7c664e1b9a0" +dependencies = [ + "arrow", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-ord", + "arrow-schema", + "arrow-select", + "async-trait", + "chrono", + "datafusion", + "datafusion-common", + "datafusion-functions", + "datafusion-physical-expr", + "futures", + "jsonb", + "lance-arrow 8.0.0", + "lance-core 8.0.0", + "lance-datagen", + "log", + "pin-project", + "prost", + "prost-build", + "tokio", + "tracing", +] + +[[package]] +name = "lance-datagen" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075c8154e6b27ff6859f90886efd8cbac348cca255c8b855da630994b4fed714" +dependencies = [ + "arrow", + "arrow-array", + "arrow-cast", + "arrow-schema", + "chrono", + "futures", + "half", + "hex", + "rand 0.9.2", + "rand_distr 0.5.1", + "rand_xoshiro", +] + +[[package]] +name = "lance-derive" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7056da2e742fd466f6bdfaa876c91d3e0e99bb4f756be058e374ceae2630ec2f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "lance-encoding" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e165c668ae61fce336d5f6f9a999ee99b963bb395a3fb818737c533fc9528d1" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-schema", + "arrow-select", + "bytemuck", + "byteorder", + "bytes", + "fsst", + "futures", + "hex", + "hyperloglogplus", + "itertools 0.13.0", + "lance-arrow 8.0.0", + "lance-bitpacking", + "lance-core 8.0.0", + "log", + "lz4", + "num-traits", + "prost", + "prost-build", + "rand 0.9.2", + "strum 0.26.3", + "tokio", + "tracing", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "lance-file" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1691bd5c37bc5e07bde00e32950b5526c2e5653bd2493a3773712574366a37a1" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "async-recursion", + "async-trait", + "byteorder", + "bytes", + "datafusion-common", + "futures", + "lance-arrow 8.0.0", + "lance-core 8.0.0", + "lance-encoding", + "lance-io", + "log", + "num-traits", + "object_store", + "prost", + "prost-build", + "prost-types", + "tokio", + "tracing", +] + +[[package]] +name = "lance-index" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4792b56f0e047eed706d05a1eedc5f549090913fb4527585bb3a331b83416b" +dependencies = [ + "arc-swap", + "arrow", + "arrow-arith", + "arrow-array", + "arrow-ord", + "arrow-schema", + "arrow-select", + "async-channel", + "async-recursion", + "async-trait", + "bitpacking", + "bitvec", + "bytes", + "chrono", + "crossbeam-queue", + "datafusion", + "datafusion-common", + "datafusion-expr", + "datafusion-physical-expr", + "dirs", + "fst", + "futures", + "half", + "itertools 0.13.0", + "jieba-rs", + "jsonb", + "lance-arrow 8.0.0", + "lance-arrow-stats", + "lance-core 8.0.0", + "lance-datafusion", + "lance-datagen", + "lance-encoding", + "lance-file", + "lance-io", + "lance-linalg", + "lance-select", + "lance-table", + "lance-tokenizer", + "libsais-rs", + "log", + "ndarray", + "num-traits", + "object_store", + "prost", + "prost-build", + "prost-types", + "rand 0.9.2", + "rand_distr 0.5.1", + "rangemap", + "rayon", + "regex-syntax", + "roaring", + "serde", + "serde_json", + "smallvec", + "tempfile", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "lance-io" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c81dda99d5b8c8741f413d65650a28ff203d94eda3cbbdda6fd3af3ea9b2a69" +dependencies = [ + "arrow", + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-schema", + "arrow-select", + "async-recursion", + "async-trait", + "byteorder", + "bytes", + "chrono", + "futures", + "http", + "io-uring", + "lance-arrow 8.0.0", + "lance-core 8.0.0", + "lance-namespace 8.0.0", + "log", + "moka", + "object_store", + "path_abs", + "pin-project", + "prost", + "rand 0.9.2", + "serde", + "tempfile", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "lance-linalg" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0a8d2a2bc7e6b6229abe320ec6d9e2049a0e65e084684cba01cf032fc71896" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "cc", + "half", + "lance-arrow 8.0.0", + "lance-core 8.0.0", + "num-traits", + "rand 0.9.2", +] + +[[package]] +name = "lance-namespace" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014e8332ca0615506342e0d3af608639864b68396973be14239f09c9f21f1fc2" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "lance-core 7.0.0", + "lance-namespace-reqwest-client 0.7.7", + "snafu 0.9.1", +] + +[[package]] +name = "lance-namespace" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee2d75929caec41747e58ce090b1a061b650a16cb10d09998128d7912203b1d" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "lance-core 8.0.0", + "lance-namespace-reqwest-client 0.8.6", + "snafu 0.9.1", +] + +[[package]] +name = "lance-namespace-impls" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92dd5dcb98562a91650da0b5fa63726d435fad8b03327625cc3de445b7e191a" +dependencies = [ + "arrow", + "arrow-ipc", + "arrow-schema", + "async-trait", + "bytes", + "datafusion-common", + "datafusion-physical-plan", + "futures", + "lance", + "lance-core 8.0.0", + "lance-index", + "lance-io", + "lance-linalg", + "lance-namespace 8.0.0", + "lance-table", + "log", + "object_store", + "rand 0.9.2", + "roaring", + "serde_json", + "time", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "lance-namespace-reqwest-client" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6369eee4682fb11edf538388b43c61ce288b8302fe89bb40944d7daa7faaae99" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "url", +] + +[[package]] +name = "lance-namespace-reqwest-client" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3f0a235e3ed5f8805205649ccc7d7d0f3df23ce1294242c9265ad488d7f19d" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "url", +] + +[[package]] +name = "lance-select" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf3a2162385a4392376ed7a732e070afd1432bb6f86b0ebcb7c4304c7196953b" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "byteorder", + "bytes", + "itertools 0.13.0", + "lance-core 8.0.0", + "roaring", + "tracing", +] + +[[package]] +name = "lance-table" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73da3932a85489e80d5458d4bbc1d340e15c72b89bab529723f575d4d8fda5eb" +dependencies = [ + "arrow", + "arrow-array", + "arrow-buffer", + "arrow-ipc", + "arrow-schema", + "async-trait", + "byteorder", + "bytes", + "chrono", + "futures", + "lance-arrow 8.0.0", + "lance-core 8.0.0", + "lance-file", + "lance-io", + "lance-select", + "log", + "object_store", + "prost", + "prost-build", + "prost-types", + "rand 0.9.2", + "rangemap", + "roaring", + "semver", + "serde", + "serde_json", + "snafu 0.9.1", + "tokio", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "lance-testing" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a5a7b5dbda917170b705301d0c6a8753a8c02f501b20d8b7067b5463ba071d" +dependencies = [ + "arrow-array", + "arrow-schema", + "criterion", + "lance-arrow 8.0.0", + "num-traits", + "pprof", + "rand 0.9.2", +] + +[[package]] +name = "lance-tokenizer" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16326f6b6d3b736aac40a0ffa78d8aa17d3a53a3766cd0af6d23db87472bd5e" +dependencies = [ + "icu_segmenter", + "jieba-rs", + "lindera", + "rust-stemmers", + "serde", + "stop-words", + "unicode-normalization", +] + +[[package]] +name = "lancedb" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd0b54bb1cdd075efa5a8827ec16dcf5c0781253cd88e63988c174915c53fe2" +dependencies = [ + "ahash", + "arrow", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ipc", + "arrow-ord", + "arrow-schema", + "arrow-select", + "async-trait", + "bytes", + "chrono", + "datafusion", + "datafusion-catalog", + "datafusion-common", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions", + "datafusion-physical-expr", + "datafusion-physical-plan", + "datafusion-sql", + "futures", + "half", + "lance", + "lance-arrow 8.0.0", + "lance-core 8.0.0", + "lance-datafusion", + "lance-datagen", + "lance-encoding", + "lance-file", + "lance-index", + "lance-io", + "lance-linalg", + "lance-namespace 8.0.0", + "lance-namespace-impls", + "lance-table", + "lance-testing", + "lazy_static", + "log", + "moka", + "num-traits", + "object_store", + "pin-project", + "polars", + "polars-arrow", + "rand 0.9.2", + "regex", + "semver", + "serde", + "serde_json", + "serde_with", + "snafu 0.8.9", + "tempfile", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "libsais-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fe164dbd47ea0c20e78a121c980ef673326905f1d4fba55e3645a20ef6717f" +dependencies = [ + "rayon", +] + +[[package]] +name = "lindera" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74cda79d7161e99b414e4d292ff673cc3f8d22f070d8be3b6185c033363a9216" +dependencies = [ + "anyhow", + "byteorder", + "csv", + "daachorse", + "kanaria", + "lindera-dictionary", + "log", + "once_cell", + "percent-encoding", + "regex", + "serde", + "serde_json", + "serde_yaml_ng", + "strum 0.28.0", + "strum_macros 0.28.0", + "unicode-blocks", + "unicode-normalization", + "unicode-segmentation", + "url", +] + +[[package]] +name = "lindera-dictionary" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2385456ca9fe87c29072c5f156b52fdd5e28d5b5738ddfb3979501dbd736530" +dependencies = [ + "anyhow", + "byteorder", + "csv", + "daachorse", + "derive_builder", + "encoding_rs", + "encoding_rs_io", + "glob", + "log", + "memmap2 0.9.10", + "num_cpus", + "once_cell", + "regex", + "rkyv", + "serde", + "serde_json", + "strum 0.28.0", + "strum_macros 0.28.0", + "thiserror 2.0.18", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "num_cpus", + "once_cell", + "rawpointer", + "thread-tree", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49388d20533534cd19360ad3d6a7dadc885944aa802ba3995040c5ec11288c6" +dependencies = [ + "libc", +] + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "multiversion" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4851161a11d3ad0bf9402d90ffc3967bf231768bfd7aeb61755ad06dbf1a142" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79a74ddee9e0c27d2578323c13905793e91622148f138ba29738f9dddb835e90" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "target-features", +] + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "now" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89e9874397a1f0a52fc1f197a8effd9735223cb2390e9dcc83ac6cd02923d0" +dependencies = [ + "chrono", +] + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "object_store" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-core", + "futures-util", + "http", + "humantime", + "itertools 0.14.0", + "parking_lot", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parquet-format-safe" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1131c54b167dd4e4799ce762e1ab01549ebb94d5bdd13e6ec1b467491c378e1f" + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "path_abs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ef02f6342ac01d8a93b65f96db53fe68a92a15f41144f97fb00a9e669633c3" +dependencies = [ + "serde", + "serde_derive", + "std_prelude", + "stfu8", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "permutation" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df202b0b0f5b8e389955afd5f27b007b00fb948162953f1db9c70d2c7e3157d7" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "serde", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "planus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1691dd09e82f428ce8d6310bd6d5da2557c82ff17694d2a32cad7242aea89f" +dependencies = [ + "array-init-cursor", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "polars" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea21b858b16b9c0e17a12db2800d11aa5b4bd182be6b3022eb537bbfc1f2db5" +dependencies = [ + "getrandom 0.2.16", + "polars-arrow", + "polars-core", + "polars-error", + "polars-io", + "polars-lazy", + "polars-ops", + "polars-parquet", + "polars-sql", + "polars-time", + "polars-utils", + "version_check", +] + +[[package]] +name = "polars-arrow" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "725b09f2b5ef31279b66e27bbab63c58d49d8f6696b66b1f46c7eaab95e80f75" +dependencies = [ + "ahash", + "atoi", + "atoi_simd", + "bytemuck", + "chrono", + "chrono-tz 0.8.6", + "dyn-clone", + "either", + "ethnum", + "fast-float", + "foreign_vec", + "getrandom 0.2.16", + "hashbrown 0.14.5", + "itoa", + "itoap", + "lz4", + "multiversion", + "num-traits", + "polars-arrow-format", + "polars-error", + "polars-utils", + "ryu", + "simdutf8", + "streaming-iterator", + "strength_reduce", + "version_check", + "zstd", +] + +[[package]] +name = "polars-arrow-format" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b0ef2474af9396b19025b189d96e992311e6a47f90c53cd998b36c4c64b84c" +dependencies = [ + "planus", + "serde", +] + +[[package]] +name = "polars-compute" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a796945b14b14fbb79b91ef0406e6fddca2be636e889f81ea5d6ee7d36efb4fe" +dependencies = [ + "bytemuck", + "either", + "num-traits", + "polars-arrow", + "polars-error", + "polars-utils", + "strength_reduce", + "version_check", +] + +[[package]] +name = "polars-core" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465f70d3e96b6d0b1a43c358ba451286b8c8bd56696feff020d65702aa33e35c" +dependencies = [ + "ahash", + "bitflags 2.10.0", + "bytemuck", + "chrono", + "chrono-tz 0.8.6", + "comfy-table", + "either", + "hashbrown 0.14.5", + "indexmap 2.14.0", + "num-traits", + "once_cell", + "polars-arrow", + "polars-compute", + "polars-error", + "polars-row", + "polars-utils", + "rand 0.8.5", + "rand_distr 0.4.3", + "rayon", + "regex", + "smartstring", + "thiserror 1.0.69", + "version_check", + "xxhash-rust", +] + +[[package]] +name = "polars-error" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5224d5d05e6b8a6f78b75951ae1b5f82c8ab1979e11ffaf5fd41941e3d5b0757" +dependencies = [ + "polars-arrow-format", + "regex", + "simdutf8", + "thiserror 1.0.69", +] + +[[package]] +name = "polars-io" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2c8589e418cbe4a48228d64b2a8a40284a82ec3c98817c0c2bcc0267701338b" +dependencies = [ + "ahash", + "atoi_simd", + "bytes", + "chrono", + "fast-float", + "home", + "itoa", + "memchr", + "memmap2 0.7.1", + "num-traits", + "once_cell", + "percent-encoding", + "polars-arrow", + "polars-core", + "polars-error", + "polars-time", + "polars-utils", + "rayon", + "regex", + "ryu", + "simdutf8", + "smartstring", +] + +[[package]] +name = "polars-lazy" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2632b1af668e2058d5f8f916d8fbde3cac63d03ae29a705f598e41dcfeb7f" +dependencies = [ + "ahash", + "bitflags 2.10.0", + "glob", + "once_cell", + "polars-arrow", + "polars-core", + "polars-io", + "polars-ops", + "polars-pipe", + "polars-plan", + "polars-time", + "polars-utils", + "rayon", + "smartstring", + "version_check", +] + +[[package]] +name = "polars-ops" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efdbdb4d9a92109bc2e0ce8e17af5ae8ab643bb5b7ee9d1d74f0aeffd1fbc95f" +dependencies = [ + "ahash", + "argminmax", + "base64 0.21.7", + "bytemuck", + "chrono", + "chrono-tz 0.8.6", + "either", + "hashbrown 0.14.5", + "hex", + "indexmap 2.14.0", + "memchr", + "num-traits", + "polars-arrow", + "polars-compute", + "polars-core", + "polars-error", + "polars-utils", + "rayon", + "regex", + "smartstring", + "unicode-reverse", + "version_check", +] + +[[package]] +name = "polars-parquet" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b421d2196f786fdfe162db614c8485f8308fe41575d4de634a39bbe460d1eb6a" +dependencies = [ + "ahash", + "base64 0.21.7", + "ethnum", + "num-traits", + "parquet-format-safe", + "polars-arrow", + "polars-error", + "polars-utils", + "seq-macro", + "simdutf8", + "streaming-decompression", +] + +[[package]] +name = "polars-pipe" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48700f1d5bd56a15451e581f465c09541492750360f18637b196f995470a015c" +dependencies = [ + "crossbeam-channel", + "crossbeam-queue", + "enum_dispatch", + "hashbrown 0.14.5", + "num-traits", + "polars-arrow", + "polars-compute", + "polars-core", + "polars-io", + "polars-ops", + "polars-plan", + "polars-row", + "polars-utils", + "rayon", + "smartstring", + "uuid", + "version_check", +] + +[[package]] +name = "polars-plan" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb8e2302e20c44defd5be8cad9c96e75face63c3a5f609aced8c4ec3b3ac97d" +dependencies = [ + "ahash", + "bytemuck", + "chrono-tz 0.8.6", + "hashbrown 0.14.5", + "once_cell", + "percent-encoding", + "polars-arrow", + "polars-core", + "polars-io", + "polars-ops", + "polars-time", + "polars-utils", + "rayon", + "recursive", + "regex", + "smartstring", + "strum_macros 0.25.3", + "version_check", +] + +[[package]] +name = "polars-row" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a515bdc68c2ae3702e3de70d89601f3b71ca8137e282a226dddb53ee4bacfa2e" +dependencies = [ + "bytemuck", + "polars-arrow", + "polars-error", + "polars-utils", +] + +[[package]] +name = "polars-sql" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4bb7cc1c04c3023d1953b2f1dec50515e8fd8169a5a2bf4967b3b082232db7" +dependencies = [ + "hex", + "polars-arrow", + "polars-core", + "polars-error", + "polars-lazy", + "polars-plan", + "rand 0.8.5", + "serde", + "serde_json", + "sqlparser 0.39.0", +] + +[[package]] +name = "polars-time" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efc18e3ad92eec55db89d88f16c22d436559ba7030cf76f86f6ed7a754b673f1" +dependencies = [ + "atoi", + "chrono", + "chrono-tz 0.8.6", + "now", + "once_cell", + "polars-arrow", + "polars-core", + "polars-error", + "polars-ops", + "polars-utils", + "regex", + "smartstring", +] + +[[package]] +name = "polars-utils" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c760b6c698cfe2fbbbd93d6cfb408db14ececfe1d92445dae2229ce1b5b21ae8" +dependencies = [ + "ahash", + "bytemuck", + "hashbrown 0.14.5", + "indexmap 2.14.0", + "num-traits", + "once_cell", + "polars-error", + "raw-cpuid", + "rayon", + "smartstring", + "stacker", + "sysinfo", + "version_check", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "pprof" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a01da47675efa7673b032bf8efd8214f1917d89685e07e395ab125ea42b187" +dependencies = [ + "aligned-vec", + "backtrace", + "cfg-if", + "findshlibs", + "inferno", + "libc", + "log", + "nix", + "once_cell", + "smallvec", + "spin", + "symbolic-demangle", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "psm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "quick-xml" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f50b1c63b38611e7d4d7f68b82d3ad0cc71a2ad2e7f61fc10f1328d917c93cd" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.2", +] + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.3", +] + +[[package]] +name = "rangemap" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbbbbea733ec66275512d0b9694f34102e7d5406fdbe2ad8d21b28dce92887c" + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "roaring" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rs" +version = "0.1.0" +dependencies = [ + "arrow-array", + "arrow-json", + "arrow-schema", + "futures", + "futures-util", + "lance-namespace 7.0.0", + "lancedb", + "polars", + "polars-arrow", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.1.0", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive 0.8.9", +] + +[[package]] +name = "snafu" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522" +dependencies = [ + "snafu-derive 0.9.1", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "snafu-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlparser" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743b4dc2cbde11890ccb254a8fc9d537fa41b36da00de2a1c5e9848c9bc42bd7" +dependencies = [ + "log", +] + +[[package]] +name = "sqlparser" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf5ea8d4d7c808e1af1cbabebca9a2abe603bcefc22294c5b95018d53200cb7" +dependencies = [ + "log", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "std_prelude" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8207e78455ffdf55661170876f88daf85356e4edd54e0a3dbc79586ca1e50cbe" + +[[package]] +name = "stfu8" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51f1e89f093f99e7432c491c382b88a6860a5adbe6bf02574bf0a08efff1978" + +[[package]] +name = "stop-words" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68df56303396bcfb639455b3c166804aeb7994005010aab5e9e8a1277b8871d" +dependencies = [ + "serde_json", +] + +[[package]] +name = "str_stack" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f446288b699d66d0fd2e30d1cfe7869194312524b3b9252594868ed26ef056a" + +[[package]] +name = "streaming-decompression" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6cc3b19bfb128a8ad11026086e31d3ce9ad23f8ea37354b31383a187c44cf3" +dependencies = [ + "fallible-streaming-iterator", +] + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symbolic-common" +version = "12.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "332615d90111d8eeaf86a84dc9bbe9f65d0d8c5cf11b4caccedc37754eb0dcfd" +dependencies = [ + "debugid", + "memmap2 0.9.10", + "stable_deref_trait", + "uuid", +] + +[[package]] +name = "symbolic-demangle" +version = "12.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "912017718eb4d21930546245af9a3475c9dccf15675a5c215664e76621afc471" +dependencies = [ + "cpp_demangle", + "rustc-demangle", + "symbolic-common", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "windows 0.52.0", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread-tree" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbd370cb847953a25954d9f63e14824a36113f8c72eecf6eccef5dc4b45d630" +dependencies = [ + "crossbeam-channel", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +dependencies = [ + "rand 0.9.2", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-blocks" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b12e05d9e06373163a9bb6bb8c263c261b396643a99445fe6b9811fd376581b" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-reverse" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6f4888ebc23094adfb574fdca9fdc891826287a6397d2cd28802ffd6f20c76" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-ranges" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/docs/web-tests/rs/Cargo.toml b/docs/web-tests/rs/Cargo.toml new file mode 100644 index 000000000..c8a4cc7ca --- /dev/null +++ b/docs/web-tests/rs/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "rs" +version = "0.1.0" +edition = "2024" + +[dependencies] +arrow-array = "58.3.0" +arrow-json = "58.3.0" +arrow-schema = "58.3.0" +futures = "0.3.31" +futures-util = "0.3.31" +lance-namespace = "7.0.0" +lancedb = { version = "0.31.0", features = ["polars"] } +polars = ">=0.37,<0.40.0" +polars-arrow = ">=0.37,<0.40.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1.0.145" +tempfile = "3.23.0" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[[example]] +name = "basic_usage" +path = "basic_usage.rs" + +[[example]] +name = "connection" +path = "connection.rs" + +[[example]] +name = "quickstart" +path = "quickstart.rs" + +[[example]] +name = "tables" +path = "tables.rs" + +[[example]] +name = "multimodal" +path = "multimodal.rs" + +[[example]] +name = "search" +path = "search.rs" diff --git a/docs/web-tests/rs/basic_usage.rs b/docs/web-tests/rs/basic_usage.rs new file mode 100644 index 000000000..0adcef474 --- /dev/null +++ b/docs/web-tests/rs/basic_usage.rs @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; + +use serde::Deserialize; + +type BatchIter = Box; + +// --8<-- [start:basic_imports] +use arrow_array::types::Float32Type; +use arrow_array::{ + FixedSizeListArray, Int8Array, Int16Array, RecordBatch, RecordBatchIterator, StringArray, + StructArray, +}; +use arrow_schema::{DataType, Field, FieldRef, Schema}; +use futures_util::TryStreamExt; +use lancedb::database::CreateTableMode; +use lancedb::query::{ExecutableQuery, QueryBase, Select}; +use lancedb::{connect, table::NewColumnTransform}; +// --8<-- [end:basic_imports] + +#[derive(Debug, Clone, Deserialize)] +struct Stats { + strength: i8, + courage: i8, + magic: i8, + wisdom: i8, +} + +#[derive(Debug, Clone, Deserialize)] +struct Character { + id: i16, + name: String, + role: String, + description: String, + vector: [f32; 4], + stats: Stats, +} + +fn camelot_schema() -> Arc { + Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int16, false), + Field::new("name", DataType::Utf8, false), + Field::new("role", DataType::Utf8, false), + Field::new("description", DataType::Utf8, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + false, + ), + Field::new( + "stats", + DataType::Struct(arrow_schema::Fields::from(vec![ + Arc::new(Field::new("strength", DataType::Int8, false)), + Arc::new(Field::new("courage", DataType::Int8, false)), + Arc::new(Field::new("magic", DataType::Int8, false)), + Arc::new(Field::new("wisdom", DataType::Int8, false)), + ])), + false, + ), + ])) +} + +fn characters_to_record_batch(schema: Arc, characters: &[Character]) -> RecordBatch { + let ids = Int16Array::from_iter_values(characters.iter().map(|c| c.id)); + let names = StringArray::from_iter_values(characters.iter().map(|c| c.name.as_str())); + let roles = StringArray::from_iter_values(characters.iter().map(|c| c.role.as_str())); + let descriptions = + StringArray::from_iter_values(characters.iter().map(|c| c.description.as_str())); + + let vectors = FixedSizeListArray::from_iter_primitive::( + characters + .iter() + .map(|c| Some(c.vector.iter().copied().map(Some).collect::>())), + 4, + ); + + let strength = Int8Array::from_iter_values(characters.iter().map(|c| c.stats.strength)); + let courage = Int8Array::from_iter_values(characters.iter().map(|c| c.stats.courage)); + let magic = Int8Array::from_iter_values(characters.iter().map(|c| c.stats.magic)); + let wisdom = Int8Array::from_iter_values(characters.iter().map(|c| c.stats.wisdom)); + + let stats_fields: Vec = vec![ + Arc::new(Field::new("strength", DataType::Int8, false)), + Arc::new(Field::new("courage", DataType::Int8, false)), + Arc::new(Field::new("magic", DataType::Int8, false)), + Arc::new(Field::new("wisdom", DataType::Int8, false)), + ]; + let stats = StructArray::new( + stats_fields.into(), + vec![ + Arc::new(strength), + Arc::new(courage), + Arc::new(magic), + Arc::new(wisdom), + ], + None, + ); + + RecordBatch::try_new( + schema, + vec![ + Arc::new(ids), + Arc::new(names), + Arc::new(roles), + Arc::new(descriptions), + Arc::new(vectors), + Arc::new(stats), + ], + ) + .unwrap() +} + +fn characters_to_reader(schema: Arc, characters: &[Character]) -> BatchIter { + let batch = characters_to_record_batch(schema.clone(), characters); + Box::new(RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema)) +} + +fn camelot_json_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("camelot.json") +} + +#[tokio::main] +async fn main() { + let temp_dir = tempfile::tempdir().unwrap(); + let uri = temp_dir.path().to_str().unwrap(); + let db = connect(uri).execute().await.unwrap(); + + // --8<-- [start:data_load] + let data: Vec = + serde_json::from_str(&fs::read_to_string(camelot_json_path()).unwrap()).unwrap(); + // --8<-- [end:data_load] + + let schema = camelot_schema(); + + // --8<-- [start:basic_create_table] + let mut table = db + .create_table("camelot", characters_to_reader(schema.clone(), &data)) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:basic_create_table] + assert_eq!(table.count_rows(None).await.unwrap(), 8); + + // --8<-- [start:basic_open_table] + table = db.open_table("camelot").execute().await.unwrap(); + // --8<-- [end:basic_open_table] + + // --8<-- [start:basic_create_empty_table] + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int16, false), + Field::new("name", DataType::Utf8, false), + Field::new("role", DataType::Utf8, false), + Field::new("description", DataType::Utf8, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + false, + ), + Field::new( + "stats", + DataType::Struct(arrow_schema::Fields::from(vec![ + Arc::new(Field::new("strength", DataType::Int8, false)), + Arc::new(Field::new("courage", DataType::Int8, false)), + Arc::new(Field::new("magic", DataType::Int8, false)), + Arc::new(Field::new("wisdom", DataType::Int8, false)), + ])), + false, + ), + ])); + db.create_empty_table("camelot_empty", schema) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:basic_create_empty_table] + db.drop_table("camelot_empty", &[]).await.unwrap(); + + // --8<-- [start:basic_add_data] + let magical_characters = vec![ + Character { + id: 9, + name: "Morgan le Fay".to_string(), + role: "Sorceress".to_string(), + description: "A powerful enchantress, Arthur's half-sister, and a complex figure who oscillates between aiding and opposing Camelot.".to_string(), + vector: [0.10, 0.84, 0.25, 0.70], + stats: Stats { + strength: 2, + courage: 3, + magic: 5, + wisdom: 4, + }, + }, + Character { + id: 10, + name: "The Lady of the Lake".to_string(), + role: "Mystical Guardian".to_string(), + description: "A mysterious supernatural figure associated with Avalon, known for giving Arthur the sword Excalibur.".to_string(), + vector: [0.00, 0.90, 0.58, 0.88], + stats: Stats { + strength: 2, + courage: 3, + magic: 5, + wisdom: 5, + }, + }, + ]; + table + .add(characters_to_reader(camelot_schema(), &magical_characters)) + .execute() + .await + .unwrap(); + // --8<-- [end:basic_add_data] + + // --8<-- [start:basic_vector_search] + let query_vector = [0.03, 0.85, 0.61, 0.90]; + let result = table + .query() + .nearest_to(&query_vector) + .unwrap() + .limit(5) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + println!("{result:?}"); + // --8<-- [end:basic_vector_search] + + // --8<-- [start:basic_add_columns] + table + .add_columns( + NewColumnTransform::SqlExpressions(vec![( + "power".to_string(), + "cast(((stats.strength + stats.courage + stats.magic + stats.wisdom) / 4.0) as float)" + .to_string(), + )]), + None, + ) + .await + .unwrap(); + // --8<-- [end:basic_add_columns] + + // --8<-- [start:basic_vector_search_q1] + // Who are the characters similar to "wizard"? + let query_vector_1 = [0.03, 0.85, 0.61, 0.90]; + let r1 = table + .query() + .nearest_to(&query_vector_1) + .unwrap() + .limit(5) + .select(Select::Columns(vec![ + "name".to_string(), + "role".to_string(), + "description".to_string(), + ])) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + println!("{r1:?}"); + // --8<-- [end:basic_vector_search_q1] + + // --8<-- [start:basic_vector_search_q2] + // Who are the characters similar to "wizard" with high magic stats? + let query_vector_2 = [0.03, 0.85, 0.61, 0.90]; + let r2 = table + .query() + .nearest_to(&query_vector_2) + .unwrap() + .only_if("stats.magic > 3") + .select(Select::Columns(vec![ + "name".to_string(), + "role".to_string(), + "description".to_string(), + ])) + .limit(5) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + println!("{r2:?}"); + // --8<-- [end:basic_vector_search_q2] + + // --8<-- [start:basic_vector_search_q3] + // Who are the strongest characters? + let r3 = table + .query() + .only_if("stats.strength > 3") + .select(Select::Columns(vec![ + "name".to_string(), + "role".to_string(), + "description".to_string(), + ])) + .limit(5) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + println!("{r3:?}"); + // --8<-- [end:basic_vector_search_q3] + + // --8<-- [start:basic_vector_search_q4] + // Who are the strongest characters? + let r4 = table + .query() + .select(Select::Columns(vec![ + "name".to_string(), + "role".to_string(), + "description".to_string(), + "power".to_string(), + ])) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + println!("{r4:?}"); + // --8<-- [end:basic_vector_search_q4] + + // --8<-- [start:basic_drop_columns] + table.drop_columns(&["power"]).await.unwrap(); + // --8<-- [end:basic_drop_columns] + + // --8<-- [start:basic_delete_rows] + table.delete("role = 'Traitor Knight'").await.unwrap(); + // --8<-- [end:basic_delete_rows] + + // --8<-- [start:basic_drop_table] + db.drop_table("camelot", &[]).await.unwrap(); + // --8<-- [end:basic_drop_table] +} diff --git a/docs/web-tests/rs/bedrock.rs b/docs/web-tests/rs/bedrock.rs new file mode 100644 index 000000000..5cc7e0cbe --- /dev/null +++ b/docs/web-tests/rs/bedrock.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::{iter::once, sync::Arc}; + +use arrow_array::{Float64Array, Int32Array, RecordBatch, RecordBatchIterator, StringArray}; +use arrow_schema::{DataType, Field, Schema}; +use aws_config::Region; +use aws_sdk_bedrockruntime::Client; +use futures::StreamExt; +use lancedb::{ + arrow::IntoArrow, + connect, + embeddings::{bedrock::BedrockEmbeddingFunction, EmbeddingDefinition, EmbeddingFunction}, + query::{ExecutableQuery, QueryBase}, + Result, +}; + +#[tokio::main] +async fn main() -> Result<()> { + let tempdir = tempfile::tempdir().unwrap(); + let tempdir = tempdir.path().to_str().unwrap(); + + // create Bedrock embedding function + let region: String = "us-east-1".to_string(); + let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(Region::new(region)) + .load() + .await; + + let embedding = Arc::new(BedrockEmbeddingFunction::new( + Client::new(&config), // AWS Region + )); + + let db = connect(tempdir).execute().await?; + db.embedding_registry() + .register("bedrock", embedding.clone())?; + + let table = db + .create_table("vectors", make_data()) + .add_embedding(EmbeddingDefinition::new( + "text", + "bedrock", + Some("embeddings"), + ))? + .execute() + .await?; + + // execute vector search + let query = Arc::new(StringArray::from_iter_values(once("something warm"))); + let query_vector = embedding.compute_query_embeddings(query)?; + let mut results = table + .vector_search(query_vector)? + .limit(1) + .execute() + .await?; + + let rb = results.next().await.unwrap()?; + let out = rb + .column_by_name("text") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + let text = out.iter().next().unwrap().unwrap(); + println!("Closest match: {}", text); + Ok(()) +} + +fn make_data() -> impl IntoArrow { + let schema = Schema::new(vec![ + Field::new("id", DataType::Int32, true), + Field::new("text", DataType::Utf8, false), + Field::new("price", DataType::Float64, false), + ]); + + let id = Int32Array::from(vec![1, 2, 3, 4]); + let text = StringArray::from_iter_values(vec![ + "Black T-Shirt", + "Leather Jacket", + "Winter Parka", + "Hooded Sweatshirt", + ]); + let price = Float64Array::from(vec![10.0, 50.0, 100.0, 30.0]); + let schema = Arc::new(schema); + let rb = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(id), Arc::new(text), Arc::new(price)], + ) + .unwrap(); + Box::new(RecordBatchIterator::new(vec![Ok(rb)], schema)) +} diff --git a/docs/web-tests/rs/connection.rs b/docs/web-tests/rs/connection.rs new file mode 100644 index 000000000..4f7212ef5 --- /dev/null +++ b/docs/web-tests/rs/connection.rs @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::path::PathBuf; + +use lancedb::connect; + +// --8<-- [start:connect] +async fn connect_example(uri: &str) { + let db = connect(uri).execute().await.unwrap(); + let _ = db; +} +// --8<-- [end:connect] + +#[tokio::main] +async fn main() { + let temp_dir = tempfile::tempdir().unwrap(); + let uri = temp_dir.path().join("ex_lancedb"); + connect_example(uri.to_str().unwrap()).await; + + // Keep the enterprise snippet in this file, but don't run it in CI. + let _ = connect_enterprise_quickstart_config(); + let _ = connect_object_storage_config(); +} + +fn connect_enterprise_quickstart_config() -> (String, String, String, String) { + // --8<-- [start:connect_enterprise_quickstart] + let uri = "db://your-database-uri"; + let api_key = "your-api-key"; + let region = "us-east-1"; + let host_override = "https://your-enterprise-endpoint.com"; + // --8<-- [end:connect_enterprise_quickstart] + ( + uri.to_string(), + api_key.to_string(), + region.to_string(), + host_override.to_string(), + ) +} + +fn connect_object_storage_config() -> &'static str { + // --8<-- [start:connect_object_storage] + let uri = "s3://your-bucket/path"; + // You can also use "gs://your-bucket/path" or "az://your-container/path". + // --8<-- [end:connect_object_storage] + + uri +} + +async fn namespace_table_ops_example(uri: &str) -> lancedb::Result<()> { + // --8<-- [start:namespace_table_ops] + let conn = connect(uri).execute().await?; + let search_namespace = vec!["prod".to_string(), "search".to_string()]; + let recommendations_namespace = vec!["prod".to_string(), "recommendations".to_string()]; + + let schema = std::sync::Arc::new(arrow_schema::Schema::new(vec![ + arrow_schema::Field::new("id", arrow_schema::DataType::Int64, false), + ])); + + conn.create_empty_table("user", schema.clone()) + .namespace(search_namespace.clone()) + .execute() + .await?; + + conn.create_empty_table("user", schema) + .namespace(recommendations_namespace.clone()) + .execute() + .await?; + + let search_table_names = conn + .table_names() + .namespace(search_namespace) + .execute() + .await?; + let recommendation_table_names = conn + .table_names() + .namespace(recommendations_namespace) + .execute() + .await?; + + println!("{search_table_names:?}"); // ["user"] + println!("{recommendation_table_names:?}"); // ["user"] + // --8<-- [end:namespace_table_ops] + Ok(()) +} + +async fn namespace_admin_ops_example() -> lancedb::Result<()> { + // --8<-- [start:namespace_admin_ops] + let mut properties = std::collections::HashMap::new(); + properties.insert("root".to_string(), "./local_lancedb".to_string()); + let db = lancedb::connect_namespace("dir", properties).execute().await?; + let namespace = vec!["prod".to_string(), "search".to_string()]; + + db.create_namespace(lance_namespace::models::CreateNamespaceRequest { + id: Some(vec!["prod".to_string()]), + ..Default::default() + }) + .await?; + db.create_namespace(lance_namespace::models::CreateNamespaceRequest { + id: Some(namespace.clone()), + ..Default::default() + }) + .await?; + + let child_namespaces = db + .list_namespaces(lance_namespace::models::ListNamespacesRequest { + id: Some(vec!["prod".to_string()]), + ..Default::default() + }) + .await?; + println!( + "Child namespaces under {:?}: {:?}", + namespace, child_namespaces + ); + // Child namespaces under ["prod", "search"]: ["search"] + + db.drop_namespace(lance_namespace::models::DropNamespaceRequest { + id: Some(namespace.clone()), + ..Default::default() + }) + .await?; + db.drop_namespace(lance_namespace::models::DropNamespaceRequest { + id: Some(vec!["prod".to_string()]), + ..Default::default() + }) + .await?; + // --8<-- [end:namespace_admin_ops] + Ok(()) +} + +#[allow(dead_code)] +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} diff --git a/docs/web-tests/rs/embedding.rs b/docs/web-tests/rs/embedding.rs new file mode 100644 index 000000000..33bdec997 --- /dev/null +++ b/docs/web-tests/rs/embedding.rs @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +// --8<-- [start:openai_embeddings] +use std::{iter::once, sync::Arc}; + +use arrow_array::{record_batch, StringArray}; +use arrow_schema::{DataType, Field, Schema}; +use futures::StreamExt; +use lancedb::{ + connect, + embeddings::{openai::OpenAIEmbeddingFunction, EmbeddingDefinition, EmbeddingFunction}, + query::{ExecutableQuery, QueryBase}, + Result, +}; + +#[tokio::main] +async fn main() -> Result<()> { + let db = connect("./mydb").execute().await?; + let api_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY is not set"); + let embedding = Arc::new(OpenAIEmbeddingFunction::new_with_model( + api_key, + "text-embedding-3-large", + )?); + + db.embedding_registry().register("openai", embedding.clone())?; + + let schema = Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])); + let table = db + .create_empty_table("mytable", schema) + .add_embedding(EmbeddingDefinition::new("text", "openai", Some("vector")))? + .execute() + .await?; + + table + .add(record_batch!(("text", Utf8, ["This is a test.", "Another example."]))?) + .execute() + .await?; + + let query = Arc::new(StringArray::from_iter_values(once("test example"))); + let query_vector = embedding.compute_query_embeddings(query)?; + let mut results = table.vector_search(query_vector)?.limit(5).execute().await?; + + while let Some(batch) = results.next().await { + println!("{:?}", batch?); + } + + Ok(()) +} +// --8<-- [end:openai_embeddings] + +// --8<-- [start:create_embedding_function] +use std::sync::Arc; + +use lancedb::embeddings::openai::OpenAIEmbeddingFunction; + +let api_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY is not set"); +let embedding = Arc::new( + OpenAIEmbeddingFunction::new_with_model(api_key, "text-embedding-3-small") + .expect("failed to create OpenAI embedding function"), +); +// --8<-- [end:create_embedding_function] + +// --8<-- [start:manual_query_embeddings] +use std::{iter::once, sync::Arc}; + +use arrow_array::{record_batch, StringArray}; +use arrow_schema::{DataType, Field, Schema}; +use futures::StreamExt; +use lancedb::{ + connect, + embeddings::{openai::OpenAIEmbeddingFunction, EmbeddingDefinition, EmbeddingFunction}, + query::{ExecutableQuery, QueryBase}, + Result, +}; + +#[tokio::main] +async fn main() -> Result<()> { + let db = connect("./mydb").execute().await?; + let api_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY is not set"); + let embedding = Arc::new(OpenAIEmbeddingFunction::new_with_model( + api_key, + "text-embedding-3-large", + )?); + db.embedding_registry().register("openai", embedding.clone())?; + + let schema = Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])); + let table = db + .create_empty_table("mytable", schema) + .add_embedding(EmbeddingDefinition::new("text", "openai", Some("vector")))? + .execute() + .await?; + + table + .add(record_batch!(("text", Utf8, ["This is a test.", "Another example."]))?) + .execute() + .await?; + + // Manually generate embeddings for the query (Enterprise path) + let query = Arc::new(StringArray::from_iter_values(once("test example"))); + let query_vector = embedding.compute_query_embeddings(query)?; + // --8<-- [start:manual_query_search] + // query_vector is assumed to already be generated by your embedding function + let mut results = table.vector_search(query_vector)?.limit(5).execute().await?; + + while let Some(batch) = results.next().await { + println!("{:?}", batch?); + } + // --8<-- [end:manual_query_search] + + Ok(()) +} +// --8<-- [end:manual_query_embeddings] + +// --8<-- [start:embedding_function] +use std::{borrow::Cow, sync::Arc}; + +use arrow_array::{Array, FixedSizeListArray, Float32Array}; +use arrow_schema::{DataType, Field, Schema}; +use lancedb::{ + connect, + embeddings::{EmbeddingDefinition, EmbeddingFunction}, + Result, +}; + +#[derive(Debug, Clone)] +struct MyTextEmbedder { + dim: usize, +} + +impl EmbeddingFunction for MyTextEmbedder { + fn name(&self) -> &str { + "my-embedder" + } + + fn source_type(&self) -> Result> { + Ok(Cow::Owned(DataType::Utf8)) + } + + fn dest_type(&self) -> Result> { + Ok(Cow::Owned(DataType::new_fixed_size_list( + DataType::Float32, + self.dim as i32, + true, + ))) + } + + fn compute_source_embeddings(&self, source: Arc) -> Result> { + let values = Arc::new(Float32Array::from(vec![1.0f32; source.len() * self.dim])); + let field = Arc::new(Field::new("item", DataType::Float32, true)); + Ok(Arc::new(FixedSizeListArray::new( + field, + self.dim as i32, + values, + None, + ))) + } + + fn compute_query_embeddings(&self, _input: Arc) -> Result> { + unimplemented!() + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let db = connect("./mydb").execute().await?; + db.embedding_registry() + .register("my-embedder", Arc::new(MyTextEmbedder { dim: 3 }))?; + + let schema = Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])); + db.create_empty_table("mytable", schema) + .add_embedding(EmbeddingDefinition::new( + "text", + "my-embedder", + Some("vector"), + ))? + .execute() + .await?; + + Ok(()) +} +// --8<-- [end:embedding_function] diff --git a/docs/web-tests/rs/full_text_search.rs b/docs/web-tests/rs/full_text_search.rs new file mode 100644 index 000000000..b79400a57 --- /dev/null +++ b/docs/web-tests/rs/full_text_search.rs @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::sync::Arc; + +use arrow_array::{Int32Array, RecordBatch, RecordBatchIterator, RecordBatchReader, StringArray}; +use arrow_schema::{DataType, Field, Schema}; + +use futures::TryStreamExt; +use lance_index::scalar::FullTextSearchQuery; +use lancedb::connection::Connection; +use lancedb::index::scalar::FtsIndexBuilder; +use lancedb::index::Index; +use lancedb::query::{ExecutableQuery, QueryBase}; +use lancedb::{connect, Result, Table}; +use rand::random; + +#[tokio::main] +async fn main() -> Result<()> { + if std::path::Path::new("data").exists() { + std::fs::remove_dir_all("data").unwrap(); + } + let uri = "data/sample-lancedb"; + let db = connect(uri).execute().await?; + let tbl = create_table(&db).await?; + + create_index(&tbl).await?; + search_index(&tbl).await?; + Ok(()) +} + +fn create_some_records() -> Result> { + const TOTAL: usize = 1000; + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("doc", DataType::Utf8, true), + ])); + + let words = random_word::all(random_word::Lang::En) + .iter() + .step_by(1024) + .take(500) + .copied() + .collect::>(); + let n_terms = 3; + let batches = RecordBatchIterator::new( + vec![RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..TOTAL as i32)), + Arc::new(StringArray::from_iter_values((0..TOTAL).map(|_| { + (0..n_terms) + .map(|_| words[random::() % words.len()]) + .collect::>() + .join(" ") + }))), + ], + ) + .unwrap()] + .into_iter() + .map(Ok), + schema.clone(), + ); + Ok(Box::new(batches)) +} + +async fn create_table(db: &Connection) -> Result { + let initial_data: Box = create_some_records()?; + let tbl = db.create_table("my_table", initial_data).execute().await?; + Ok(tbl) +} + +async fn create_index(table: &Table) -> Result<()> { + table + .create_index(&["doc"], Index::FTS(FtsIndexBuilder::default())) + .execute() + .await?; + Ok(()) +} + +async fn search_index(table: &Table) -> Result<()> { + let words = random_word::all(random_word::Lang::En) + .iter() + .step_by(1024) + .take(500) + .copied() + .collect::>(); + let query = words[0].to_owned(); + println!("Searching for: {}", query); + + let mut results = table + .query() + .full_text_search(FullTextSearchQuery::new(words[0].to_owned())) + .select(lancedb::query::Select::Columns(vec!["doc".to_owned()])) + .limit(10) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + Ok(()) +} diff --git a/docs/web-tests/rs/ivf_pq.rs b/docs/web-tests/rs/ivf_pq.rs new file mode 100644 index 000000000..ccecc22f0 --- /dev/null +++ b/docs/web-tests/rs/ivf_pq.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! This example demonstrates setting advanced parameters when building an IVF PQ index +//! +//! Snippets from this example are used in the documentation on ANN indices. + +use std::sync::Arc; + +use arrow_array::types::Float32Type; +use arrow_array::{ + FixedSizeListArray, Int32Array, RecordBatch, RecordBatchIterator, RecordBatchReader, +}; +use arrow_schema::{DataType, Field, Schema}; + +use futures::TryStreamExt; +use lancedb::connection::Connection; +use lancedb::index::vector::IvfPqIndexBuilder; +use lancedb::index::Index; +use lancedb::query::{ExecutableQuery, QueryBase}; +use lancedb::{connect, DistanceType, Result, Table}; + +#[tokio::main] +async fn main() -> Result<()> { + if std::path::Path::new("data").exists() { + std::fs::remove_dir_all("data").unwrap(); + } + let uri = "data/sample-lancedb"; + let db = connect(uri).execute().await?; + let tbl = create_table(&db).await?; + + create_index(&tbl).await?; + search_index(&tbl).await?; + Ok(()) +} + +fn create_some_records() -> Result> { + const TOTAL: usize = 1000; + const DIM: usize = 128; + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + DIM as i32, + ), + true, + ), + ])); + + // Create a RecordBatch stream. + let batches = RecordBatchIterator::new( + vec![RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..TOTAL as i32)), + Arc::new( + FixedSizeListArray::from_iter_primitive::( + (0..TOTAL).map(|_| Some(vec![Some(1.0); DIM])), + DIM as i32, + ), + ), + ], + ) + .unwrap()] + .into_iter() + .map(Ok), + schema.clone(), + ); + Ok(Box::new(batches)) +} + +async fn create_table(db: &Connection) -> Result
{ + let initial_data: Box = create_some_records()?; + let tbl = db + .create_table("my_table", initial_data) + .execute() + .await + .unwrap(); + Ok(tbl) +} + +async fn create_index(table: &Table) -> Result<()> { + // --8<-- [start:create_index] + // For this example, `table` is a lancedb::Table with a column named + // "vector" that is a vector column with dimension 128. + + // By default, if the column "vector" appears to be a vector column, + // then an IVF_PQ index with reasonable defaults is created. + table + .create_index(&["vector"], Index::Auto) + .execute() + .await?; + // For advanced cases, it is also possible to specifically request an + // IVF_PQ index and provide custom parameters. + table + .create_index( + &["vector"], + Index::IvfPq( + // Here we specify advanced indexing parameters. In this case + // we are creating an index that my have better recall than the + // default but is also larger and slower. + IvfPqIndexBuilder::default() + // This overrides the default distance type of l2 + .distance_type(DistanceType::Cosine) + // With 1000 rows this have been ~31 by default + .num_partitions(50) + // With dimension 128 this would have been 8 by default + .num_sub_vectors(16), + ), + ) + .execute() + .await?; + // --8<-- [end:create_index] + Ok(()) +} + +async fn search_index(table: &Table) -> Result<()> { + // --8<-- [start:search1] + let query_vector = [1.0; 128]; + // By default the index will find the 10 closest results using default + // search parameters that give a reasonable tradeoff between accuracy + // and search latency + let mut results = table + .vector_search(&query_vector)? + // Note: you should always set the distance_type to match the value used + // to train the index + .distance_type(DistanceType::Cosine) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + // We can also provide custom search parameters. Here we perform a + // slower but more accurate search + let mut results = table + .vector_search(&query_vector)? + .distance_type(DistanceType::Cosine) + // Override the default of 10 to get more rows + .limit(15) + // Override the default of 20 to search more partitions + .nprobes(30) + // Override the default of None to apply a refine step + .refine_factor(1) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + Ok(()) + // --8<-- [end:search1] +} diff --git a/docs/web-tests/rs/multimodal.rs b/docs/web-tests/rs/multimodal.rs new file mode 100644 index 000000000..8cae02eee --- /dev/null +++ b/docs/web-tests/rs/multimodal.rs @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +// --8<-- [start:multimodal_imports] +use std::collections::HashMap; +use std::sync::Arc; + +use arrow_array::types::Float32Type; +use arrow_array::{ + BinaryArray, FixedSizeListArray, Int32Array, Int64Array, LargeBinaryArray, RecordBatch, + RecordBatchIterator, StringArray, +}; +use arrow_schema::{DataType, Field, Schema}; +use futures_util::TryStreamExt; +use lancedb::connect; +use lancedb::database::CreateTableMode; +use lancedb::query::{ExecutableQuery, QueryBase}; +// --8<-- [end:multimodal_imports] + +#[tokio::main] +async fn main() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_uri = temp_dir.path().to_str().unwrap().to_string(); + let db = connect(&db_uri).execute().await.unwrap(); + + // --8<-- [start:create_dummy_data] + let create_dummy_image = |color: u8| -> Vec { + let mut png_like = vec![137, 80, 78, 71, 13, 10, 26, 10]; + png_like.push(color); + png_like + }; + + let data = vec![ + ( + 1_i32, + "red_square.png", + vec![0.1_f32; 128], + create_dummy_image(1), + "red", + ), + ( + 2_i32, + "blue_square.png", + vec![0.2_f32; 128], + create_dummy_image(2), + "blue", + ), + ]; + // --8<-- [end:create_dummy_data] + + // --8<-- [start:define_schema] + let schema = Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("filename", DataType::Utf8, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 128), + false, + ), + Field::new("image_blob", DataType::Binary, false), + Field::new("label", DataType::Utf8, false), + ]); + // --8<-- [end:define_schema] + + // --8<-- [start:ingest_data] + let schema = Arc::new(schema); + let image_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(data.iter().map(|row| row.0))), + Arc::new(StringArray::from_iter_values(data.iter().map(|row| row.1))), + Arc::new( + FixedSizeListArray::from_iter_primitive::( + data.iter() + .map(|row| Some(row.2.iter().copied().map(Some).collect::>())), + 128, + ), + ), + Arc::new(BinaryArray::from_iter_values( + data.iter().map(|row| row.3.as_slice()), + )), + Arc::new(StringArray::from_iter_values(data.iter().map(|row| row.4))), + ], + ) + .unwrap(); + let image_reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(image_batch)].into_iter(), schema.clone())); + let table = db + .create_table("images", image_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:ingest_data] + assert_eq!(table.count_rows(None).await.unwrap(), 2); + + // --8<-- [start:search_data] + let query_vector = vec![0.1_f32; 128]; + let results = table + .query() + .nearest_to(query_vector) + .unwrap() + .limit(1) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + // --8<-- [end:search_data] + + // --8<-- [start:process_results] + for batch in &results { + let filenames = batch + .column_by_name("filename") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + let images = batch + .column_by_name("image_blob") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + + for row in 0..batch.num_rows() { + let image_bytes = images.value(row); + println!( + "Retrieved image: {}, Byte length: {}", + filenames.value(row), + image_bytes.len() + ); + } + } + // --8<-- [end:process_results] + let search_rows: usize = results.iter().map(|batch| batch.num_rows()).sum(); + assert_eq!(search_rows, 1); + + // --8<-- [start:blob_api_schema] + let blob_metadata = HashMap::from([( + "lance-encoding:blob".to_string(), + "true".to_string(), + )]); + let blob_schema = Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("video", DataType::LargeBinary, true).with_metadata(blob_metadata), + ]); + // --8<-- [end:blob_api_schema] + + // --8<-- [start:blob_api_ingest] + let blob_rows = vec![ + (1_i64, b"fake_video_bytes_1".to_vec()), + (2_i64, b"fake_video_bytes_2".to_vec()), + ]; + + let blob_schema = Arc::new(blob_schema); + let blob_batch = RecordBatch::try_new( + blob_schema.clone(), + vec![ + Arc::new(Int64Array::from_iter_values(blob_rows.iter().map(|row| row.0))), + Arc::new(LargeBinaryArray::from_iter_values( + blob_rows.iter().map(|row| row.1.as_slice()), + )), + ], + ) + .unwrap(); + let blob_reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(blob_batch)].into_iter(), blob_schema)); + let blob_table = db + .create_table("videos", blob_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:blob_api_ingest] + assert_eq!(blob_table.count_rows(None).await.unwrap(), 2); +} diff --git a/docs/web-tests/rs/openai.rs b/docs/web-tests/rs/openai.rs new file mode 100644 index 000000000..73954f34a --- /dev/null +++ b/docs/web-tests/rs/openai.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +// --8<-- [start:imports] + +use std::{iter::once, sync::Arc}; + +use arrow_array::{Float64Array, Int32Array, RecordBatch, RecordBatchIterator, StringArray}; +use arrow_schema::{DataType, Field, Schema}; +use futures::StreamExt; +use lancedb::{ + arrow::IntoArrow, + connect, + embeddings::{openai::OpenAIEmbeddingFunction, EmbeddingDefinition, EmbeddingFunction}, + query::{ExecutableQuery, QueryBase}, + Result, +}; + +// --8<-- [end:imports] + +// --8<-- [start:openai_embeddings] +#[tokio::main] +async fn main() -> Result<()> { + let tempdir = tempfile::tempdir().unwrap(); + let tempdir = tempdir.path().to_str().unwrap(); + let api_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY is not set"); + let embedding = Arc::new(OpenAIEmbeddingFunction::new_with_model( + api_key, + "text-embedding-3-large", + )?); + + let db = connect(tempdir).execute().await?; + db.embedding_registry() + .register("openai", embedding.clone())?; + + let table = db + .create_table("vectors", make_data()) + .add_embedding(EmbeddingDefinition::new( + "text", + "openai", + Some("embeddings"), + ))? + .execute() + .await?; + + let query = Arc::new(StringArray::from_iter_values(once("something warm"))); + let query_vector = embedding.compute_query_embeddings(query)?; + let mut results = table + .vector_search(query_vector)? + .limit(1) + .execute() + .await?; + + let rb = results.next().await.unwrap()?; + let out = rb + .column_by_name("text") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + let text = out.iter().next().unwrap().unwrap(); + println!("Closest match: {}", text); + Ok(()) +} +// --8<-- [end:openai_embeddings] + +fn make_data() -> impl IntoArrow { + let schema = Schema::new(vec![ + Field::new("id", DataType::Int32, true), + Field::new("text", DataType::Utf8, false), + Field::new("price", DataType::Float64, false), + ]); + + let id = Int32Array::from(vec![1, 2, 3, 4]); + let text = StringArray::from_iter_values(vec![ + "Black T-Shirt", + "Leather Jacket", + "Winter Parka", + "Hooded Sweatshirt", + ]); + let price = Float64Array::from(vec![10.0, 50.0, 100.0, 30.0]); + let schema = Arc::new(schema); + let rb = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(id), Arc::new(text), Arc::new(price)], + ) + .unwrap(); + Box::new(RecordBatchIterator::new(vec![Ok(rb)], schema)) +} diff --git a/docs/web-tests/rs/quickstart.rs b/docs/web-tests/rs/quickstart.rs new file mode 100644 index 000000000..fd26b012c --- /dev/null +++ b/docs/web-tests/rs/quickstart.rs @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::sync::Arc; + +use arrow_array::types::Float32Type; +use arrow_array::{ + FixedSizeListArray, Int8Array, LargeStringArray, RecordBatch, RecordBatchIterator, StructArray, +}; +use arrow_schema::{DataType, Field, FieldRef, Schema}; +use lancedb::arrow::IntoPolars; +use lancedb::database::CreateTableMode; +use lancedb::query::{ExecutableQuery, QueryBase, Select}; +use lancedb::{connect, table::NewColumnTransform}; +use polars::prelude::DataFrame; +use serde::{Deserialize, Serialize}; + +// --8<-- [start:quickstart_define_struct] +// Define structs representing the data schema +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Stats { + strength: i8, + magic: i8, + leadership: i8, + wisdom: i8, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Character { + id: String, + name: String, + role: String, + description: String, + stats: Stats, + vector: [f32; 4], +} + +fn characters_schema() -> Arc { + Arc::new(Schema::new(vec![ + Field::new("id", DataType::LargeUtf8, false), + Field::new("name", DataType::LargeUtf8, false), + Field::new("role", DataType::LargeUtf8, false), + Field::new("description", DataType::LargeUtf8, false), + Field::new( + "stats", + DataType::Struct(arrow_schema::Fields::from(vec![ + Arc::new(Field::new("strength", DataType::Int8, false)), + Arc::new(Field::new("magic", DataType::Int8, false)), + Arc::new(Field::new("leadership", DataType::Int8, false)), + Arc::new(Field::new("wisdom", DataType::Int8, false)), + ])), + false, + ), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + false, + ), + ])) +} +// --8<-- [end:quickstart_define_struct] + +type BatchIter = Box; + +fn characters_to_reader(schema: Arc, rows: &[Character]) -> BatchIter { + let ids = LargeStringArray::from_iter_values(rows.iter().map(|row| row.id.as_str())); + let names = LargeStringArray::from_iter_values(rows.iter().map(|row| row.name.as_str())); + let roles = LargeStringArray::from_iter_values(rows.iter().map(|row| row.role.as_str())); + let descriptions = + LargeStringArray::from_iter_values(rows.iter().map(|row| row.description.as_str())); + + let strength = Int8Array::from_iter_values(rows.iter().map(|row| row.stats.strength)); + let magic = Int8Array::from_iter_values(rows.iter().map(|row| row.stats.magic)); + let leadership = Int8Array::from_iter_values(rows.iter().map(|row| row.stats.leadership)); + let wisdom = Int8Array::from_iter_values(rows.iter().map(|row| row.stats.wisdom)); + let stats_fields: Vec = vec![ + Arc::new(Field::new("strength", DataType::Int8, false)), + Arc::new(Field::new("magic", DataType::Int8, false)), + Arc::new(Field::new("leadership", DataType::Int8, false)), + Arc::new(Field::new("wisdom", DataType::Int8, false)), + ]; + let stats = StructArray::new( + stats_fields.into(), + vec![ + Arc::new(strength), + Arc::new(magic), + Arc::new(leadership), + Arc::new(wisdom), + ], + None, + ); + + let vectors = FixedSizeListArray::from_iter_primitive::( + rows.iter() + .map(|row| Some(row.vector.iter().copied().map(Some).collect::>())), + 4, + ); + + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(ids), + Arc::new(names), + Arc::new(roles), + Arc::new(descriptions), + Arc::new(stats), + Arc::new(vectors), + ], + ) + .unwrap(); + + Box::new(RecordBatchIterator::new( + vec![Ok(batch)].into_iter(), + schema, + )) +} + +#[tokio::main] +async fn main() { + let temp_dir = tempfile::tempdir().unwrap(); + let uri = temp_dir.path().to_str().unwrap(); + let db = connect(uri).execute().await.unwrap(); + + // --8<-- [start:quickstart_data] + let data = vec![ + Character { + id: "1".to_string(), + name: "King Arthur".to_string(), + role: "King".to_string(), + description: "Leader of Camelot and wielder of Excalibur.".to_string(), + stats: Stats { + strength: 4, + magic: 1, + leadership: 5, + wisdom: 4, + }, + vector: [0.7, 0.1, 0.9, 0.7], + }, + Character { + id: "2".to_string(), + name: "Merlin".to_string(), + role: "Wizard".to_string(), + description: "Advisor and prophet with deep magical knowledge.".to_string(), + stats: Stats { + strength: 2, + magic: 5, + leadership: 4, + wisdom: 5, + }, + vector: [0.2, 0.9, 0.4, 0.9], + }, + Character { + id: "3".to_string(), + name: "Sir Lancelot".to_string(), + role: "Knight".to_string(), + description: "Legendary knight known for courage and combat skill.".to_string(), + stats: Stats { + strength: 5, + magic: 1, + leadership: 3, + wisdom: 3, + }, + vector: [0.9, 0.1, 0.5, 0.4], + }, + ]; + // --8<-- [end:quickstart_data] + + // --8<-- [start:quickstart_create_table] + let schema = characters_schema(); + let table = db + .create_table("characters", characters_to_reader(schema.clone(), &data)) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:quickstart_create_table] + assert_eq!(table.count_rows(None).await.unwrap(), 3); + db.drop_table("characters", &[]).await.unwrap(); + + // --8<-- [start:quickstart_create_table_no_overwrite] + let table = db + .create_table("characters", characters_to_reader(schema.clone(), &data)) + .execute() + .await + .unwrap(); + // --8<-- [end:quickstart_create_table_no_overwrite] + assert_eq!(table.count_rows(None).await.unwrap(), 3); + + // --8<-- [start:quickstart_vector_search_1] + // Search for examples similar to a "wise magical advisor" + let query_vector = [0.2, 0.8, 0.4, 0.9]; + + let result: DataFrame = table + .query() + .nearest_to(&query_vector) + .unwrap() + .select(Select::Columns(vec![ + "name".to_string(), + "role".to_string(), + "description".to_string(), + "_distance".to_string(), + ])) + .limit(2) + .execute() + .await + .unwrap() + .into_polars() + .await + .unwrap(); + println!("{result:?}"); + // --8<-- [end:quickstart_vector_search_1] + let name_col = result.column("name").unwrap().str().unwrap(); + assert_eq!(name_col.get(0).unwrap(), "Merlin"); + + // --8<-- [start:quickstart_curate_with_metadata] + let curated: DataFrame = table + .query() + .nearest_to(&query_vector) + .unwrap() + .only_if("stats.magic >= 4") + .select(Select::Columns(vec![ + "name".to_string(), + "role".to_string(), + "description".to_string(), + "_distance".to_string(), + ])) + .limit(2) + .execute() + .await + .unwrap() + .into_polars() + .await + .unwrap(); + println!("{curated:?}"); + // --8<-- [end:quickstart_curate_with_metadata] + let curated_name_col = curated.column("name").unwrap().str().unwrap(); + assert_eq!(curated_name_col.get(0).unwrap(), "Merlin"); + + // --8<-- [start:quickstart_output_array] + let result: DataFrame = table + .query() + .nearest_to(&query_vector) + .unwrap() + .select(Select::Columns(vec![ + "name".to_string(), + "role".to_string(), + "description".to_string(), + "_distance".to_string(), + ])) + .limit(2) + .execute() + .await + .unwrap() + .into_polars() + .await + .unwrap(); + println!("{result:?}"); + // --8<-- [end:quickstart_output_array] + let name_col = result.column("name").unwrap().str().unwrap(); + assert_eq!(name_col.get(0).unwrap(), "Merlin"); + + // --8<-- [start:quickstart_add_feature] + table + .add_columns( + NewColumnTransform::SqlExpressions(vec![( + "power_score".to_string(), + "cast(((stats.strength + stats.magic + stats.leadership + stats.wisdom) / 4.0) as float)" + .to_string(), + )]), + None, + ) + .await + .unwrap(); + // --8<-- [end:quickstart_add_feature] + + // --8<-- [start:quickstart_query_feature] + let features: DataFrame = table + .query() + .select(Select::Columns(vec![ + "name".to_string(), + "role".to_string(), + "power_score".to_string(), + ])) + .execute() + .await + .unwrap() + .into_polars() + .await + .unwrap(); + println!("{features:?}"); + // --8<-- [end:quickstart_query_feature] + assert!(features.column("power_score").is_ok()); + + // --8<-- [start:quickstart_multimodal_bytes] + use std::sync::Arc; + + use arrow_array::{ + BinaryArray, FixedSizeListArray, LargeStringArray, RecordBatch, RecordBatchIterator, + }; + use arrow_schema::{DataType, Field, Schema}; + + let image_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/static/assets/images/quickstart/sir-lancelot.jpg"); + let image_bytes = std::fs::read(image_path).unwrap(); + + let image_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::LargeUtf8, false), + Field::new("description", DataType::LargeUtf8, false), + Field::new("image", DataType::Binary, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + false, + ), + ])); + let image_vectors = [[0.9_f32, 0.1, 0.5, 0.4]]; + let image_batch = RecordBatch::try_new( + image_schema.clone(), + vec![ + Arc::new(LargeStringArray::from_iter_values(["lancelot"])), + Arc::new(LargeStringArray::from_iter_values([ + "Portrait of Sir Lancelot", + ])), + Arc::new(BinaryArray::from_iter_values([image_bytes.as_slice()])), + Arc::new( + FixedSizeListArray::from_iter_primitive::( + image_vectors + .iter() + .map(|vector| Some(vector.iter().copied().map(Some).collect::>())), + 4, + ), + ), + ], + ) + .unwrap(); + let image_reader: Box = Box::new( + RecordBatchIterator::new(vec![Ok(image_batch)].into_iter(), image_schema), + ); + let multimodal_table = db + .create_table("character_images", image_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:quickstart_multimodal_bytes] + assert_eq!(multimodal_table.count_rows(None).await.unwrap(), 1); +} diff --git a/docs/web-tests/rs/search.rs b/docs/web-tests/rs/search.rs new file mode 100644 index 000000000..bf510f08b --- /dev/null +++ b/docs/web-tests/rs/search.rs @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! This example demonstrates vector search query options. +//! +//! Snippets from this example are used in the documentation on vector search. + +use std::sync::Arc; + +use arrow_array::types::Float32Type; +use arrow_array::{ + Array, FixedSizeListArray, Int32Array, Int64Array, RecordBatch, RecordBatchIterator, + RecordBatchReader, UInt8Array, +}; +use arrow_schema::{DataType, Field, Schema}; + +use futures::TryStreamExt; +use lancedb::connection::Connection; +use lancedb::index::vector::IvfFlatIndexBuilder; +use lancedb::index::Index; +use lancedb::query::{ExecutableQuery, QueryBase, Select}; +use lancedb::{connect, DistanceType, Result, Table}; + +const DIM: usize = 128; + +#[tokio::main] +async fn main() -> Result<()> { + if std::path::Path::new("data").exists() { + std::fs::remove_dir_all("data").unwrap(); + } + let uri = "data/sample-lancedb"; + let db = connect(uri).execute().await?; + let tbl = create_table(&db).await?; + tbl.create_index(&["vector"], Index::Auto).execute().await?; + + configure_distance_metric(&tbl).await?; + exact_vs_approximate(&tbl).await?; + search_distance_range(&tbl).await?; + vector_search_prefilter(&tbl).await?; + vector_search_postfilter(&tbl).await?; + fast_search(&tbl).await?; + brute_force_search(&tbl).await?; + bypass_vector_index(&tbl).await?; + batch_search(&tbl).await?; + binary_search(&db).await?; + Ok(()) +} + +fn create_some_records() -> Result> { + const TOTAL: usize = 1000; + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + DIM as i32, + ), + true, + ), + ])); + + let batches = RecordBatchIterator::new( + vec![RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..TOTAL as i32)), + Arc::new( + FixedSizeListArray::from_iter_primitive::( + (0..TOTAL).map(|_| Some(vec![Some(1.0); DIM])), + DIM as i32, + ), + ), + ], + ) + .unwrap()] + .into_iter() + .map(Ok), + schema.clone(), + ); + Ok(Box::new(batches)) +} + +async fn create_table(db: &Connection) -> Result
{ + let initial_data: Box = create_some_records()?; + let tbl = db + .create_table("my_vectors", initial_data) + .execute() + .await + .unwrap(); + Ok(tbl) +} + +async fn configure_distance_metric(table: &Table) -> Result<()> { + let query_vector = [1.0; DIM]; + // --8<-- [start:configure_distance_metric] + // Use the same distance metric the index was trained with. + let mut results = table + .vector_search(&query_vector)? + .distance_type(DistanceType::Cosine) + .limit(10) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + // --8<-- [end:configure_distance_metric] + Ok(()) +} + +async fn exact_vs_approximate(table: &Table) -> Result<()> { + let query_vector = [1.0; DIM]; + // --8<-- [start:exact_vs_approximate] + // Approximate ANN search (fast, distances may come from the index representation) + let mut fast_results = table.vector_search(&query_vector)?.limit(10).execute().await?; + while let Some(batch) = fast_results.try_next().await? { + println!("{:?}", batch); + } + + // Rerank a larger candidate set on full vectors for better recall + let mut refined_results = table + .vector_search(&query_vector)? + .limit(10) + .refine_factor(20) + .execute() + .await?; + while let Some(batch) = refined_results.try_next().await? { + println!("{:?}", batch); + } + // --8<-- [end:exact_vs_approximate] + Ok(()) +} + +async fn search_distance_range(table: &Table) -> Result<()> { + let query_vector = [1.0; DIM]; + // --8<-- [start:search_distance_range] + // Only return rows whose distance falls within [0.1, 0.5). + let mut results = table + .vector_search(&query_vector)? + .distance_range(Some(0.1), Some(0.5)) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + // --8<-- [end:search_distance_range] + Ok(()) +} + +async fn vector_search_prefilter(table: &Table) -> Result<()> { + let query_vector = [1.0; DIM]; + // --8<-- [start:vector_search_prefilter] + // Prefiltering is the default: the filter is applied before vector search. + let mut results = table + .vector_search(&query_vector)? + .only_if("id > 100") + .select(Select::columns(&["id"])) + .limit(5) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + // --8<-- [end:vector_search_prefilter] + Ok(()) +} + +async fn vector_search_postfilter(table: &Table) -> Result<()> { + let query_vector = [1.0; DIM]; + // --8<-- [start:vector_search_postfilter] + // Apply the filter after vector search by calling postfilter(). + let mut results = table + .vector_search(&query_vector)? + .only_if("id > 100") + .postfilter() + .select(Select::columns(&["id"])) + .limit(5) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + // --8<-- [end:vector_search_postfilter] + Ok(()) +} + +async fn fast_search(table: &Table) -> Result<()> { + let query_vector = [1.0; DIM]; + // --8<-- [start:fast_search] + // Skip unindexed data for lower latency. + let mut results = table + .vector_search(&query_vector)? + .fast_search() + .limit(5) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + // --8<-- [end:fast_search] + Ok(()) +} + +async fn brute_force_search(table: &Table) -> Result<()> { + let query_vector = [1.0; DIM]; + // --8<-- [start:brute_force_search] + // A plain vector search returns the top-k closest rows. + let mut results = table.vector_search(&query_vector)?.limit(3).execute().await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + // --8<-- [end:brute_force_search] + Ok(()) +} + +async fn bypass_vector_index(table: &Table) -> Result<()> { + let query_vector = [1.0; DIM]; + // --8<-- [start:bypass_vector_index] + // Force an exhaustive (flat) scan for exact, ground-truth results. + let mut results = table + .vector_search(&query_vector)? + .bypass_vector_index() + .limit(5) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + // --8<-- [end:bypass_vector_index] + Ok(()) +} + +async fn batch_search(table: &Table) -> Result<()> { + let query_1 = [1.0; DIM]; + let query_2 = [0.5; DIM]; + // --8<-- [start:batch_search] + // Search multiple query vectors in one call. Each result row carries a + // `query_index` mapping it back to the query it matched. + let mut results = table + .vector_search(&query_1)? + .add_query_vector(&query_2)? + .limit(5) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + // --8<-- [end:batch_search] + Ok(()) +} + +async fn binary_search(db: &Connection) -> Result<()> { + // A 256-bit binary vector is stored as 256 / 8 = 32 packed uint8 bytes. + const NUM_BYTES: i32 = 32; + const TOTAL: usize = 1024; + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::UInt8, true)), + NUM_BYTES, + ), + true, + ), + ])); + + let values = + UInt8Array::from_iter_values((0..TOTAL * NUM_BYTES as usize).map(|i| (i % 256) as u8)); + let vectors = FixedSizeListArray::try_new( + Arc::new(Field::new("item", DataType::UInt8, true)), + NUM_BYTES, + Arc::new(values), + None, + ) + .unwrap(); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from_iter_values(0..TOTAL as i64)), + Arc::new(vectors), + ], + ) + .unwrap(); + let reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema.clone())); + let tbl = db.create_table("binary_vectors", reader).execute().await?; + tbl.create_index( + &["vector"], + Index::IvfFlat(IvfFlatIndexBuilder::default().distance_type(DistanceType::Hamming)), + ) + .execute() + .await?; + + // --8<-- [start:binary_search] + // Binary vectors use `hamming` distance over the packed uint8 bytes. + let query: Arc = Arc::new(UInt8Array::from(vec![1u8; NUM_BYTES as usize])); + let mut results = tbl + .vector_search(query)? + .distance_type(DistanceType::Hamming) + .limit(10) + .execute() + .await?; + while let Some(batch) = results.try_next().await? { + println!("{:?}", batch); + } + // --8<-- [end:binary_search] + Ok(()) +} diff --git a/docs/web-tests/rs/sentence_transformers.rs b/docs/web-tests/rs/sentence_transformers.rs new file mode 100644 index 000000000..9250430d4 --- /dev/null +++ b/docs/web-tests/rs/sentence_transformers.rs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::{iter::once, sync::Arc}; + +use arrow_array::{RecordBatch, RecordBatchIterator, StringArray}; +use arrow_schema::{DataType, Field, Schema}; +use futures::StreamExt; +use lancedb::{ + arrow::IntoArrow, + connect, + embeddings::{ + sentence_transformers::SentenceTransformersEmbeddings, EmbeddingDefinition, + EmbeddingFunction, + }, + query::{ExecutableQuery, QueryBase}, + Result, +}; + +#[tokio::main] +async fn main() -> Result<()> { + let tempdir = tempfile::tempdir().unwrap(); + let tempdir = tempdir.path().to_str().unwrap(); + let embedding = SentenceTransformersEmbeddings::builder().build()?; + let embedding = Arc::new(embedding); + let db = connect(tempdir).execute().await?; + db.embedding_registry() + .register("sentence-transformers", embedding.clone())?; + + let table = db + .create_table("vectors", make_data()) + .add_embedding(EmbeddingDefinition::new( + "facts", + "sentence-transformers", + Some("embeddings"), + ))? + .execute() + .await?; + + let query = Arc::new(StringArray::from_iter_values(once( + "How many bones are in the human body?", + ))); + let query_vector = embedding.compute_query_embeddings(query)?; + let mut results = table + .vector_search(query_vector)? + .limit(1) + .execute() + .await?; + + let rb = results.next().await.unwrap()?; + let out = rb + .column_by_name("facts") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + let text = out.iter().next().unwrap().unwrap(); + println!("Answer: {}", text); + Ok(()) +} + +fn make_data() -> impl IntoArrow { + let schema = Schema::new(vec![Field::new("facts", DataType::Utf8, false)]); + + let facts = StringArray::from_iter_values(vec![ + "Albert Einstein was a theoretical physicist.", + "The capital of France is Paris.", + "The Great Wall of China is one of the Seven Wonders of the World.", + "Python is a popular programming language.", + "Mount Everest is the highest mountain in the world.", + "Leonardo da Vinci painted the Mona Lisa.", + "Shakespeare wrote Hamlet.", + "The human body has 206 bones.", + "The speed of light is approximately 299,792 kilometers per second.", + "Water boils at 100 degrees Celsius.", + "The Earth orbits the Sun.", + "The Pyramids of Giza are located in Egypt.", + "Coffee is one of the most popular beverages in the world.", + "Tokyo is the capital city of Japan.", + "Photosynthesis is the process by which plants make their food.", + "The Pacific Ocean is the largest ocean on Earth.", + "Mozart was a prolific composer of classical music.", + "The Internet is a global network of computers.", + "Basketball is a sport played with a ball and a hoop.", + "The first computer virus was created in 1983.", + "Artificial neural networks are inspired by the human brain.", + "Deep learning is a subset of machine learning.", + "IBM's Watson won Jeopardy! in 2011.", + "The first computer programmer was Ada Lovelace.", + "The first chatbot was ELIZA, created in the 1960s.", + ]); + let schema = Arc::new(schema); + let rb = RecordBatch::try_new(schema.clone(), vec![Arc::new(facts)]).unwrap(); + Box::new(RecordBatchIterator::new(vec![Ok(rb)], schema)) +} diff --git a/docs/web-tests/rs/simple.rs b/docs/web-tests/rs/simple.rs new file mode 100644 index 000000000..ac98957ae --- /dev/null +++ b/docs/web-tests/rs/simple.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! This example demonstrates basic usage of LanceDb. +//! +//! Snippets from this example are used in the quickstart documentation. + +use std::sync::Arc; + +use arrow_array::types::Float32Type; +use arrow_array::{FixedSizeListArray, Int32Array, RecordBatch, RecordBatchIterator}; +use arrow_schema::{DataType, Field, Schema}; +use futures::TryStreamExt; + +use lancedb::arrow::IntoArrow; +use lancedb::connection::Connection; +use lancedb::index::Index; +use lancedb::query::{ExecutableQuery, QueryBase}; +use lancedb::{connect, Result, Table as LanceDbTable}; + +// --8<-- [start:connect] +#[tokio::main] +async fn main() -> Result<()> { + if std::path::Path::new("data").exists() { + std::fs::remove_dir_all("data").unwrap(); + } + // --8<-- [start:connect_uri] + let uri = "data/sample-lancedb"; + let db = connect(uri).execute().await?; + // --8<-- [end:connect_uri] + + // --8<-- [start:list_names] + println!("{:?}", db.table_names().execute().await?); + // --8<-- [end:list_names] + let tbl = create_table(&db).await?; + create_index(&tbl).await?; + let batches = search(&tbl).await?; + println!("{:?}", batches); + + create_empty_table(&db).await.unwrap(); + + // --8<-- [start:delete] + tbl.delete("id > 24").await.unwrap(); + // --8<-- [end:delete] + + // --8<-- [start:drop_table] + db.drop_table("my_table").await.unwrap(); + // --8<-- [end:drop_table] + Ok(()) +} +// --8<-- [end:connect] + +#[allow(dead_code)] +async fn open_with_existing_tbl() -> Result<()> { + let uri = "data/sample-lancedb"; + let db = connect(uri).execute().await?; + #[allow(unused_variables)] + // --8<-- [start:open_existing_tbl] + let table = db.open_table("my_table").execute().await.unwrap(); + // --8<-- [end:open_existing_tbl] + Ok(()) +} + +fn create_some_records() -> Result { + const TOTAL: usize = 1000; + const DIM: usize = 128; + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + DIM as i32, + ), + true, + ), + ])); + + // Create a RecordBatch stream. + let batches = RecordBatchIterator::new( + vec![RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..TOTAL as i32)), + Arc::new( + FixedSizeListArray::from_iter_primitive::( + (0..TOTAL).map(|_| Some(vec![Some(1.0); DIM])), + DIM as i32, + ), + ), + ], + ) + .unwrap()] + .into_iter() + .map(Ok), + schema.clone(), + ); + Ok(Box::new(batches)) +} + +async fn create_table(db: &Connection) -> Result { + // --8<-- [start:create_table] + let initial_data = create_some_records()?; + let tbl = db + .create_table("my_table", initial_data) + .execute() + .await + .unwrap(); + // --8<-- [end:create_table] + + // --8<-- [start:add] + let new_data = create_some_records()?; + tbl.add(new_data).execute().await.unwrap(); + // --8<-- [end:add] + + Ok(tbl) +} + +async fn create_empty_table(db: &Connection) -> Result { + // --8<-- [start:create_empty_table] + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("item", DataType::Utf8, true), + ])); + db.create_empty_table("empty_table", schema).execute().await + // --8<-- [end:create_empty_table] +} + +async fn create_index(table: &LanceDbTable) -> Result<()> { + // --8<-- [start:create_index] + table.create_index(&["vector"], Index::Auto).execute().await + // --8<-- [end:create_index] +} + +async fn search(table: &LanceDbTable) -> Result> { + // --8<-- [start:search] + table + .query() + .limit(2) + .nearest_to(&[1.0; 128])? + .execute() + .await? + .try_collect::>() + .await + // --8<-- [end:search] +} diff --git a/docs/web-tests/rs/tables.rs b/docs/web-tests/rs/tables.rs new file mode 100644 index 000000000..0d568530d --- /dev/null +++ b/docs/web-tests/rs/tables.rs @@ -0,0 +1,1516 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::sync::Arc; +use std::time::Duration as StdDuration; + +use arrow_array::types::Float32Type; +use arrow_array::{ + Array, FixedSizeListArray, Float32Array, Float64Array, Int32Array, Int64Array, RecordBatch, + RecordBatchIterator, RecordBatchReader, StringArray, +}; +use arrow_schema::{DataType, Field, Schema}; +use futures::TryStreamExt; +use lancedb::connect; +use lancedb::query::ExecutableQuery; +use lancedb::database::CreateTableMode; +use lancedb::table::{ + ColumnAlteration, Duration, FieldMetadataUpdate, NewColumnTransform, OptimizeAction, +}; + +// --8<-- [start:update_make_users_reader] +fn make_users_reader( + ids: Vec, + names: Vec<&str>, + login_counts: Option>, +) -> Box { + let mut fields = vec![ + Field::new("id", DataType::Int64, false), + Field::new("name", DataType::Utf8, false), + ]; + let mut columns: Vec> = + vec![Arc::new(Int64Array::from(ids)), Arc::new(StringArray::from(names))]; + + if let Some(login_counts) = login_counts { + fields.push(Field::new("login_count", DataType::Int64, true)); + columns.push(Arc::new(Int64Array::from(login_counts))); + } + + let schema = Arc::new(Schema::new(fields)); + let batch = RecordBatch::try_new(schema.clone(), columns).unwrap(); + let reader = RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema); + Box::new(reader) +} +// --8<-- [end:update_make_users_reader] + +// --8<-- [start:versioning_make_quotes_reader] +fn make_quotes_reader(rows: Vec<(i64, &str, &str)>) -> Box { + let ids: Vec = rows.iter().map(|(id, _, _)| *id).collect(); + let authors: Vec<&str> = rows.iter().map(|(_, author, _)| *author).collect(); + let quotes: Vec<&str> = rows.iter().map(|(_, _, quote)| *quote).collect(); + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("author", DataType::Utf8, false), + Field::new("quote", DataType::Utf8, false), + ])); + + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(ids)), + Arc::new(StringArray::from(authors)), + Arc::new(StringArray::from(quotes)), + ], + ) + .unwrap(); + let reader = RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema); + Box::new(reader) +} +// --8<-- [end:versioning_make_quotes_reader] + +// Helper: a table with a `vector` and a `text` column, used to demonstrate +// building indexes on a branch. +fn make_products_reader(n: i32) -> Box { + const DIM: usize = 4; + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + DIM as i32, + ), + true, + ), + Field::new("text", DataType::Utf8, false), + ])); + + let ids = Int32Array::from_iter_values(0..n); + let vectors = FixedSizeListArray::from_iter_primitive::( + (0..n).map(|i| { + Some( + (0..DIM) + .map(|d| Some((((i as usize * 31 + d * 7) % 97) as f32) / 97.0)) + .collect::>>(), + ) + }), + DIM as i32, + ); + let texts = StringArray::from_iter_values((0..n).map(|i| format!("product number {i}"))); + + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(ids), Arc::new(vectors), Arc::new(texts)], + ) + .unwrap(); + let reader = RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema); + Box::new(reader) +} + +#[allow(dead_code)] +async fn update_connect_enterprise_example() { + // --8<-- [start:update_connect_enterprise] + let uri = "db://your-project-slug"; + let api_key = "your-api-key"; + let region = "us-east-1"; + // --8<-- [end:update_connect_enterprise] + let _ = (uri, api_key, region); +} + +#[allow(dead_code)] +async fn update_connect_local_example() { + // --8<-- [start:update_connect_local] + let db = connect("./data").execute().await.unwrap(); + // --8<-- [end:update_connect_local] + let _ = db; +} + +#[tokio::main] +async fn main() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_uri = temp_dir.path().to_str().unwrap().to_string(); + let db = connect(&db_uri).execute().await.unwrap(); + + // --8<-- [start:create_table_from_dicts] + struct Location { + vector: [f32; 2], + lat: f32, + long: f32, + } + + let data = vec![ + Location { + vector: [1.1, 1.2], + lat: 45.5, + long: -122.7, + }, + Location { + vector: [0.2, 1.8], + lat: 40.1, + long: -74.1, + }, + ]; + + let schema = Arc::new(Schema::new(vec![ + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2), + false, + ), + Field::new("lat", DataType::Float32, false), + Field::new("long", DataType::Float32, false), + ])); + + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new( + FixedSizeListArray::from_iter_primitive::( + data.iter() + .map(|row| Some(row.vector.iter().copied().map(Some).collect::>())), + 2, + ), + ), + Arc::new(Float32Array::from_iter_values( + data.iter().map(|row| row.lat), + )), + Arc::new(Float32Array::from_iter_values( + data.iter().map(|row| row.long), + )), + ], + ) + .unwrap(); + let reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema.clone())); + let table = db + .create_table("test_table", reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:create_table_from_dicts] + assert_eq!(table.count_rows(None).await.unwrap(), 2); + + // Seed an existing table so the conflict-handling examples have something to act on. + let conflict_seed = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new( + FixedSizeListArray::from_iter_primitive::( + data.iter() + .map(|row| Some(row.vector.iter().copied().map(Some).collect::>())), + 2, + ), + ), + Arc::new(Float32Array::from_iter_values( + data.iter().map(|row| row.lat), + )), + Arc::new(Float32Array::from_iter_values( + data.iter().map(|row| row.long), + )), + ], + ) + .unwrap(); + let conflict_seed_reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(conflict_seed)].into_iter(), schema.clone())); + db.create_table("conflict_table", conflict_seed_reader) + .execute() + .await + .unwrap(); + + // Build readers for the rows we want to ingest. Outside the snippet markers + // because the docs only need to highlight the `.mode(...)` differences. + let make_reader = || { + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new( + FixedSizeListArray::from_iter_primitive::( + data.iter().map(|row| { + Some(row.vector.iter().copied().map(Some).collect::>()) + }), + 2, + ), + ), + Arc::new(Float32Array::from_iter_values( + data.iter().map(|row| row.lat), + )), + Arc::new(Float32Array::from_iter_values( + data.iter().map(|row| row.long), + )), + ], + ) + .unwrap(); + let reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema.clone())); + reader + }; + let exist_ok_reader = make_reader(); + let overwrite_reader = make_reader(); + + // --8<-- [start:create_table_conflict_handling] + // Idempotent open: reuse the existing table if it exists. + // The provided data is ignored; the schema is validated against the + // existing table and a mismatch raises an error. + let _conflict_table = db + .create_table("conflict_table", exist_ok_reader) + .mode(CreateTableMode::exist_ok(|req| req)) + .execute() + .await + .unwrap(); + + // Overwrite: drop the existing table and create a new one with the + // provided data. This permanently discards the old table's data. + let conflict_table = db + .create_table("conflict_table", overwrite_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:create_table_conflict_handling] + assert_eq!(conflict_table.count_rows(None).await.unwrap(), 2); + + // --8<-- [start:create_table_custom_schema] + let custom_schema = Arc::new(Schema::new(vec![ + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + false, + ), + Field::new("lat", DataType::Float32, false), + Field::new("long", DataType::Float32, false), + ])); + + let custom_batch = RecordBatch::try_new( + custom_schema.clone(), + vec![ + Arc::new( + FixedSizeListArray::from_iter_primitive::( + vec![ + Some(vec![Some(1.1), Some(1.2), Some(1.3), Some(1.4)]), + Some(vec![Some(0.2), Some(1.8), Some(0.4), Some(3.6)]), + ], + 4, + ), + ), + Arc::new(Float32Array::from(vec![45.5, 40.1])), + Arc::new(Float32Array::from(vec![-122.7, -74.1])), + ], + ) + .unwrap(); + let custom_reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(custom_batch)].into_iter(), custom_schema.clone())); + let custom_table = db + .create_table("my_table_custom_schema", custom_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:create_table_custom_schema] + assert_eq!(custom_table.count_rows(None).await.unwrap(), 2); + + // --8<-- [start:create_table_from_arrow] + let arrow_schema = Arc::new(Schema::new(vec![ + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 16), + false, + ), + Field::new("text", DataType::Utf8, false), + ])); + + let arrow_batch = RecordBatch::try_new( + arrow_schema.clone(), + vec![ + Arc::new( + FixedSizeListArray::from_iter_primitive::( + vec![Some(vec![Some(0.1); 16]), Some(vec![Some(0.2); 16])], + 16, + ), + ), + Arc::new(StringArray::from(vec!["foo", "bar"])), + ], + ) + .unwrap(); + let arrow_reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(arrow_batch)].into_iter(), arrow_schema.clone())); + let arrow_table = db + .create_table("arrow_table_example", arrow_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:create_table_from_arrow] + assert_eq!(arrow_table.count_rows(None).await.unwrap(), 2); + + // --8<-- [start:create_table_from_iterator] + let batch_schema = Arc::new(Schema::new(vec![ + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + false, + ), + Field::new("item", DataType::Utf8, false), + Field::new("price", DataType::Float32, false), + ])); + + let batches = (0..5) + .map(|i| { + RecordBatch::try_new( + batch_schema.clone(), + vec![ + Arc::new( + FixedSizeListArray::from_iter_primitive::( + vec![ + Some(vec![Some(3.1 + i as f32), Some(4.1), Some(5.1), Some(6.1)]), + Some(vec![ + Some(5.9), + Some(26.5 + i as f32), + Some(4.7), + Some(32.8), + ]), + ], + 4, + ), + ), + Arc::new(StringArray::from(vec![ + format!("item{}", i * 2 + 1), + format!("item{}", i * 2 + 2), + ])), + Arc::new(Float32Array::from(vec![ + ((i * 2 + 1) * 10) as f32, + ((i * 2 + 2) * 10) as f32, + ])), + ], + ) + .unwrap() + }) + .collect::>(); + + let batch_reader: Box = + Box::new(RecordBatchIterator::new(batches.into_iter().map(Ok), batch_schema.clone())); + let batch_table = db + .create_table("batched_table", batch_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:create_table_from_iterator] + assert_eq!(batch_table.count_rows(None).await.unwrap(), 10); + + // --8<-- [start:open_existing_table] + let open_schema = Arc::new(Schema::new(vec![ + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2), + false, + ), + Field::new("lat", DataType::Float32, false), + Field::new("long", DataType::Float32, false), + ])); + let open_batch = RecordBatch::try_new( + open_schema.clone(), + vec![ + Arc::new( + FixedSizeListArray::from_iter_primitive::( + vec![Some(vec![Some(1.1), Some(1.2)])], + 2, + ), + ), + Arc::new(Float32Array::from(vec![45.5])), + Arc::new(Float32Array::from(vec![-122.7])), + ], + ) + .unwrap(); + let open_reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(open_batch)].into_iter(), open_schema.clone())); + db.create_table("test_table", open_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + println!("{:?}", db.table_names().execute().await.unwrap()); + + let opened_table = db.open_table("test_table").execute().await.unwrap(); + // --8<-- [end:open_existing_table] + assert_eq!(opened_table.count_rows(None).await.unwrap(), 1); + + // --8<-- [start:create_empty_table] + let empty_schema = Arc::new(Schema::new(vec![ + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2), + false, + ), + Field::new("item", DataType::Utf8, false), + Field::new("price", DataType::Float32, false), + ])); + let empty_table = db + .create_empty_table("test_empty_table", empty_schema) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:create_empty_table] + assert_eq!(empty_table.count_rows(None).await.unwrap(), 0); + + // --8<-- [start:drop_table] + let drop_schema = Arc::new(Schema::new(vec![ + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2), + false, + ), + Field::new("lat", DataType::Float32, false), + ])); + let drop_batch = RecordBatch::try_new( + drop_schema.clone(), + vec![ + Arc::new( + FixedSizeListArray::from_iter_primitive::( + vec![Some(vec![Some(1.1), Some(1.2)])], + 2, + ), + ), + Arc::new(Float32Array::from(vec![45.5])), + ], + ) + .unwrap(); + let drop_reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(drop_batch)].into_iter(), drop_schema.clone())); + db.create_table("my_table", drop_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + db.drop_table("my_table", &[]).await.unwrap(); + // --8<-- [end:drop_table] + assert!( + !db.table_names() + .execute() + .await + .unwrap() + .contains(&"my_table".to_string()) + ); + + // --8<-- [start:schema_add_setup] + let schema_add_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("name", DataType::Utf8, false), + Field::new("price", DataType::Float64, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 128), + false, + ), + ])); + let schema_add_batch = RecordBatch::try_new( + schema_add_schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 2, 3])), + Arc::new(StringArray::from(vec!["Laptop", "Smartphone", "Headphones"])), + Arc::new(Float64Array::from(vec![1200.0, 800.0, 150.0])), + Arc::new( + FixedSizeListArray::from_iter_primitive::( + vec![ + Some(vec![Some(0.1_f32); 128]), + Some(vec![Some(0.2_f32); 128]), + Some(vec![Some(0.3_f32); 128]), + ], + 128, + ), + ), + ], + ) + .unwrap(); + let schema_add_reader: Box = Box::new(RecordBatchIterator::new( + vec![Ok(schema_add_batch)].into_iter(), + schema_add_schema.clone(), + )); + let schema_add_table = db + .create_table("schema_evolution_add_example", schema_add_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:schema_add_setup] + assert_eq!(schema_add_table.count_rows(None).await.unwrap(), 3); + + // --8<-- [start:add_columns_calculated] + // Add a discounted price column (10% discount) + schema_add_table + .add_columns( + NewColumnTransform::SqlExpressions(vec![( + "discounted_price".to_string(), + "cast((price * 0.9) as float)".to_string(), + )]), + None, + ) + .await + .unwrap(); + // --8<-- [end:add_columns_calculated] + + // --8<-- [start:add_columns_default_values] + // Add a stock status column with default value + schema_add_table + .add_columns( + NewColumnTransform::SqlExpressions(vec![( + "in_stock".to_string(), + "cast(true as boolean)".to_string(), + )]), + None, + ) + .await + .unwrap(); + // --8<-- [end:add_columns_default_values] + + // --8<-- [start:add_columns_nullable] + // Add a nullable timestamp column + schema_add_table + .add_columns( + NewColumnTransform::SqlExpressions(vec![( + "last_ordered".to_string(), + "cast(NULL as timestamp)".to_string(), + )]), + None, + ) + .await + .unwrap(); + // --8<-- [end:add_columns_nullable] + + // --8<-- [start:add_feature_columns_sql] + schema_add_table + .add_columns( + NewColumnTransform::SqlExpressions(vec![ + ( + "price_per_id".to_string(), + "cast(price / id as float)".to_string(), + ), + ("price_log".to_string(), "ln(price)".to_string()), + ( + "price_score".to_string(), + "cast(price / (price + 100.0) as float)".to_string(), + ), + ]), + None, + ) + .await + .unwrap(); + // --8<-- [end:add_feature_columns_sql] + + // --8<-- [start:schema_alter_setup] + let schema_alter_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("name", DataType::Utf8, false), + Field::new("price", DataType::Int32, false), + Field::new("discount_price", DataType::Float64, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 128), + false, + ), + ])); + let schema_alter_batch = RecordBatch::try_new( + schema_alter_schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 2])), + Arc::new(StringArray::from(vec!["Laptop", "Smartphone"])), + Arc::new(Int32Array::from(vec![1200, 800])), + Arc::new(Float64Array::from(vec![1080.0, 720.0])), + Arc::new( + FixedSizeListArray::from_iter_primitive::( + vec![Some(vec![Some(0.1_f32); 128]), Some(vec![Some(0.2_f32); 128])], + 128, + ), + ), + ], + ) + .unwrap(); + let schema_alter_reader: Box = Box::new(RecordBatchIterator::new( + vec![Ok(schema_alter_batch)].into_iter(), + schema_alter_schema.clone(), + )); + let schema_alter_table = db + .create_table("schema_evolution_alter_example", schema_alter_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:schema_alter_setup] + assert_eq!(schema_alter_table.count_rows(None).await.unwrap(), 2); + + // --8<-- [start:alter_columns_rename] + // Rename discount_price to sale_price + schema_alter_table + .alter_columns(&[ColumnAlteration::new("discount_price".to_string()) + .rename("sale_price".to_string())]) + .await + .unwrap(); + // --8<-- [end:alter_columns_rename] + + // --8<-- [start:alter_columns_data_type] + // Change price from int32 to int64 for larger numbers + schema_alter_table + .alter_columns(&[ColumnAlteration::new("price".to_string()).cast_to(DataType::Int64)]) + .await + .unwrap(); + // --8<-- [end:alter_columns_data_type] + + // --8<-- [start:alter_columns_nullable] + // Make the name column nullable + schema_alter_table + .alter_columns(&[ColumnAlteration::new("name".to_string()).set_nullable(true)]) + .await + .unwrap(); + // --8<-- [end:alter_columns_nullable] + + // --8<-- [start:alter_columns_multiple] + // Rename, change type, and make nullable in one operation + schema_alter_table + .alter_columns(&[ColumnAlteration::new("sale_price".to_string()) + .rename("final_price".to_string()) + .cast_to(DataType::Float64) + .set_nullable(true)]) + .await + .unwrap(); + // --8<-- [end:alter_columns_multiple] + + // --8<-- [start:alter_columns_with_expression] + // For custom transforms, create a new column from a SQL expression. + let expression_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("price_text", DataType::Utf8, false), + ])); + let expression_batch = RecordBatch::try_new( + expression_schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1])), + Arc::new(StringArray::from(vec!["$100"])), + ], + ) + .unwrap(); + let expression_reader: Box = Box::new(RecordBatchIterator::new( + vec![Ok(expression_batch)].into_iter(), + expression_schema.clone(), + )); + let expression_table = db + .create_table("schema_evolution_expression_example", expression_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + expression_table + .add_columns( + NewColumnTransform::SqlExpressions(vec![( + "price_numeric".to_string(), + "cast(replace(price_text, '$', '') as int)".to_string(), + )]), + None, + ) + .await + .unwrap(); + expression_table.drop_columns(&["price_text"]).await.unwrap(); + expression_table + .alter_columns(&[ColumnAlteration::new("price_numeric".to_string()) + .rename("price".to_string())]) + .await + .unwrap(); + // --8<-- [end:alter_columns_with_expression] + assert_eq!(expression_table.count_rows(None).await.unwrap(), 1); + + // --8<-- [start:schema_drop_setup] + let schema_drop_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("name", DataType::Utf8, false), + Field::new("price", DataType::Float64, false), + Field::new("temp_col1", DataType::Utf8, false), + Field::new("temp_col2", DataType::Int32, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 128), + false, + ), + ])); + let schema_drop_batch = RecordBatch::try_new( + schema_drop_schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 2, 3])), + Arc::new(StringArray::from(vec!["Laptop", "Smartphone", "Headphones"])), + Arc::new(Float64Array::from(vec![1200.0, 800.0, 150.0])), + Arc::new(StringArray::from(vec!["X", "Y", "Z"])), + Arc::new(Int32Array::from(vec![100, 200, 300])), + Arc::new( + FixedSizeListArray::from_iter_primitive::( + vec![ + Some(vec![Some(0.1_f32); 128]), + Some(vec![Some(0.2_f32); 128]), + Some(vec![Some(0.3_f32); 128]), + ], + 128, + ), + ), + ], + ) + .unwrap(); + let schema_drop_reader: Box = Box::new(RecordBatchIterator::new( + vec![Ok(schema_drop_batch)].into_iter(), + schema_drop_schema.clone(), + )); + let schema_drop_table = db + .create_table("schema_evolution_drop_example", schema_drop_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:schema_drop_setup] + assert_eq!(schema_drop_table.count_rows(None).await.unwrap(), 3); + + // --8<-- [start:drop_columns_single] + // Remove the first temporary column + schema_drop_table.drop_columns(&["temp_col1"]).await.unwrap(); + // --8<-- [end:drop_columns_single] + + // --8<-- [start:drop_columns_multiple] + // Remove the second temporary column + schema_drop_table.drop_columns(&["temp_col2"]).await.unwrap(); + // --8<-- [end:drop_columns_multiple] + + // --8<-- [start:alter_vector_column] + let old_dim = 384; + let new_dim = 1024; + let vector_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new( + "embedding", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + old_dim, + ), + true, + ), + ])); + let vector_batch = RecordBatch::try_new( + vector_schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1])), + Arc::new( + FixedSizeListArray::from_iter_primitive::( + vec![Some(vec![Some(0.1_f32); old_dim as usize])], + old_dim, + ), + ), + ], + ) + .unwrap(); + let vector_reader: Box = + Box::new(RecordBatchIterator::new(vec![Ok(vector_batch)].into_iter(), vector_schema.clone())); + let vector_table = db + .create_table("vector_alter_example", vector_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + // Changing FixedSizeList dimensions (384 -> 1024) is not supported via alter_columns. + // Use add_columns + drop_columns + alter_columns(rename) to replace the column. + vector_table + .add_columns( + NewColumnTransform::SqlExpressions(vec![( + "embedding_v2".to_string(), + format!("arrow_cast(NULL, 'FixedSizeList({}, Float32)')", new_dim), + )]), + None, + ) + .await + .unwrap(); + vector_table.drop_columns(&["embedding"]).await.unwrap(); + vector_table + .alter_columns(&[ColumnAlteration::new("embedding_v2".to_string()) + .rename("embedding".to_string())]) + .await + .unwrap(); + // --8<-- [end:alter_vector_column] + assert_eq!(vector_table.count_rows(None).await.unwrap(), 1); + + let field_metadata_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("category", DataType::Utf8, false), + ])); + let field_metadata_batch = RecordBatch::try_new( + field_metadata_schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![0, 1])), + Arc::new(StringArray::from(vec!["a", "b"])), + ], + ) + .unwrap(); + let field_metadata_reader: Box = Box::new( + RecordBatchIterator::new(vec![Ok(field_metadata_batch)].into_iter(), field_metadata_schema), + ); + let field_metadata_table = db + .create_table("schema_field_metadata_example", field_metadata_reader) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + // --8<-- [start:schema_field_metadata_merge] + // Set two metadata keys on the `category` field. + let res = field_metadata_table + .update_field_metadata(&[FieldMetadataUpdate::new("category") + .set("unit", "label") + .set("pii", "false")]) + .await + .unwrap(); + println!("version: {}", res.version); + + // Merge: add a new key, delete one with `.remove`, keep the rest. + field_metadata_table + .update_field_metadata(&[FieldMetadataUpdate::new("category") + .set("source", "import") + .remove("pii")]) + .await + .unwrap(); + // --8<-- [end:schema_field_metadata_merge] + + // --8<-- [start:schema_field_metadata_replace] + field_metadata_table + .update_field_metadata(&[FieldMetadataUpdate::new("category") + .set("owner", "search-team") + .replace()]) + .await + .unwrap(); + // --8<-- [end:schema_field_metadata_replace] + + // --8<-- [start:update_example_table_setup] + let table = db + .create_table( + "users_example", + make_users_reader(vec![1, 2], vec!["Alice", "Bob"], Some(vec![10, 20])), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:update_example_table_setup] + let _ = table; + + // --8<-- [start:update_operation] + let table = db + .create_table( + "users_example", + make_users_reader(vec![1, 2], vec!["Alice", "Bob"], Some(vec![10, 20])), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + table + .update() + .only_if("id = 2") + .column("name", "'Bobby'") + .execute() + .await + .unwrap(); + // --8<-- [end:update_operation] + + // --8<-- [start:update_using_sql] + let table = db + .create_table( + "users_example", + make_users_reader(vec![1, 2], vec!["Alice", "Bob"], Some(vec![10, 20])), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + table + .update() + .only_if("id = 2") + .column("login_count", "login_count + 1") + .execute() + .await + .unwrap(); + // --8<-- [end:update_using_sql] + + // --8<-- [start:merge_matched_update_only] + let table = db + .create_table( + "users_example", + make_users_reader(vec![1, 2], vec!["Alice", "Bob"], Some(vec![10, 20])), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + let mut merge_insert = table.merge_insert(&["id"]); + merge_insert.when_matched_update_all(None); + merge_insert + .execute(make_users_reader( + vec![2, 3], + vec!["Bobby", "Charlie"], + Some(vec![21, 5]), + )) + .await + .unwrap(); + // --8<-- [end:merge_matched_update_only] + + // --8<-- [start:insert_if_not_exists] + let table = db + .create_table( + "users_example", + make_users_reader(vec![1, 2], vec!["Alice", "Bob"], Some(vec![10, 20])), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + let mut merge_insert = table.merge_insert(&["id"]); + merge_insert.when_not_matched_insert_all(); + merge_insert + .execute(make_users_reader( + vec![2, 3], + vec!["Bobby", "Charlie"], + Some(vec![21, 5]), + )) + .await + .unwrap(); + // --8<-- [end:insert_if_not_exists] + + // --8<-- [start:merge_update_insert] + let table = db + .create_table( + "users_example", + make_users_reader(vec![1, 2], vec!["Alice", "Bob"], Some(vec![10, 20])), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + let mut merge_insert = table.merge_insert(&["id"]); + merge_insert + .when_matched_update_all(None) + .when_not_matched_insert_all(); + merge_insert + .execute(make_users_reader( + vec![2, 3], + vec!["Bobby", "Charlie"], + Some(vec![21, 5]), + )) + .await + .unwrap(); + // --8<-- [end:merge_update_insert] + + // --8<-- [start:merge_delete_missing_by_source] + let table = db + .create_table( + "users_example", + make_users_reader( + vec![1, 2, 3], + vec!["Alice", "Bob", "Charlie"], + Some(vec![10, 20, 5]), + ), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + let mut merge_insert = table.merge_insert(&["id"]); + merge_insert + .when_matched_update_all(None) + .when_not_matched_insert_all() + .when_not_matched_by_source_delete(None); + merge_insert + .execute(make_users_reader( + vec![2, 3], + vec!["Bobby", "Charlie"], + Some(vec![21, 5]), + )) + .await + .unwrap(); + // --8<-- [end:merge_delete_missing_by_source] + + // --8<-- [start:merge_partial_columns] + let table = db + .create_table( + "users_example", + make_users_reader(vec![1, 2], vec!["Alice", "Bob"], Some(vec![10, 20])), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + let mut merge_insert = table.merge_insert(&["id"]); + merge_insert + .when_matched_update_all(None) + .when_not_matched_insert_all(); + merge_insert + .execute(make_users_reader(vec![2, 3], vec!["Bobby", "Charlie"], None)) + .await + .unwrap(); + // --8<-- [end:merge_partial_columns] + + let table = db + .create_table( + "users_example", + make_users_reader( + vec![1, 2, 3], + vec!["Alice", "Bob", "Charlie"], + Some(vec![10, 20, 5]), + ), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + // --8<-- [start:delete_operation] + // delete data + let predicate = "id = 3"; + table.delete(predicate).await.unwrap(); + // --8<-- [end:delete_operation] + + let table = db + .create_table( + "users_cleanup_example", + make_users_reader( + vec![1, 2, 3], + vec!["Alice", "Bob", "Charlie"], + Some(vec![10, 20, 5]), + ), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + // --8<-- [start:update_optimize_cleanup] + table + .optimize(OptimizeAction::Prune { + older_than: Some(Duration::days(1)), + delete_unverified: None, + error_if_tagged_old_versions: None, + }) + .await + .unwrap(); + // --8<-- [end:update_optimize_cleanup] + + // --8<-- [start:consistency_strong] + let strong_writer_db = connect(&db_uri).execute().await.unwrap(); + let strong_reader_db = connect(&db_uri) + .read_consistency_interval(StdDuration::from_secs(0)) + .execute() + .await + .unwrap(); + let strong_writer_table = strong_writer_db + .create_table( + "consistency_strong_table", + make_users_reader(vec![1], vec!["Alice"], None), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + let strong_reader_table = strong_reader_db + .open_table("consistency_strong_table") + .execute() + .await + .unwrap(); + strong_writer_table + .add(make_users_reader(vec![2], vec!["Bob"], None)) + .execute() + .await + .unwrap(); + let strong_rows_after_write = strong_reader_table.count_rows(None).await.unwrap(); + println!( + "Rows visible with strong consistency: {}", + strong_rows_after_write + ); + // --8<-- [end:consistency_strong] + assert_eq!(strong_rows_after_write, 2); + + // --8<-- [start:consistency_eventual] + let eventual_writer_db = connect(&db_uri).execute().await.unwrap(); + let eventual_reader_db = connect(&db_uri) + .read_consistency_interval(StdDuration::from_secs(3600)) + .execute() + .await + .unwrap(); + let eventual_writer_table = eventual_writer_db + .create_table( + "consistency_eventual_table", + make_users_reader(vec![1], vec!["Alice"], None), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + let eventual_reader_table = eventual_reader_db + .open_table("consistency_eventual_table") + .execute() + .await + .unwrap(); + eventual_writer_table + .add(make_users_reader(vec![2], vec!["Bob"], None)) + .execute() + .await + .unwrap(); + let eventual_rows_after_write = eventual_reader_table.count_rows(None).await.unwrap(); + println!( + "Rows visible before eventual refresh interval: {}", + eventual_rows_after_write + ); + // --8<-- [end:consistency_eventual] + assert_eq!(eventual_rows_after_write, 1); + + // --8<-- [start:consistency_checkout_latest] + let checkout_writer_db = connect(&db_uri).execute().await.unwrap(); + let checkout_reader_db = connect(&db_uri).execute().await.unwrap(); + let checkout_writer_table = checkout_writer_db + .create_table( + "consistency_checkout_latest_table", + make_users_reader(vec![1], vec!["Alice"], None), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + let checkout_reader_table = checkout_reader_db + .open_table("consistency_checkout_latest_table") + .execute() + .await + .unwrap(); + checkout_writer_table + .add(make_users_reader(vec![2], vec!["Bob"], None)) + .execute() + .await + .unwrap(); + let rows_before_refresh = checkout_reader_table.count_rows(None).await.unwrap(); + println!("Rows before checkout_latest: {}", rows_before_refresh); + checkout_reader_table.checkout_latest().await.unwrap(); + let rows_after_refresh = checkout_reader_table.count_rows(None).await.unwrap(); + println!("Rows after checkout_latest: {}", rows_after_refresh); + // --8<-- [end:consistency_checkout_latest] + assert_eq!(rows_before_refresh, 1); + assert_eq!(rows_after_refresh, 2); + + // --8<-- [start:versioning_basic_setup] + let table_name = "quotes_versioning_example"; + let data = vec![ + (1, "Richard", "Wubba Lubba Dub Dub!"), + (2, "Morty", "Rick, what's going on?"), + (3, "Richard", "I turned myself into a pickle, Morty!"), + ]; + + let table = db + .create_table(table_name, make_quotes_reader(data)) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + // --8<-- [end:versioning_basic_setup] + assert_eq!(table.count_rows(None).await.unwrap(), 3); + + // --8<-- [start:versioning_check_initial_version] + let versions = table.list_versions().await.unwrap(); + let current_version = table.version().await.unwrap(); + println!("Number of versions after creation: {}", versions.len()); + println!("Current version: {}", current_version); + // --8<-- [end:versioning_check_initial_version] + assert_eq!(versions.len(), 1); + assert_eq!(current_version, versions.last().unwrap().version); + + // --8<-- [start:versioning_update_data] + table + .update() + .only_if("author = 'Richard'") + .column("author", "'Richard Daniel Sanchez'") + .execute() + .await + .unwrap(); + let rows_after_update = table + .count_rows(Some("author = 'Richard Daniel Sanchez'".to_string())) + .await + .unwrap(); + println!( + "Rows updated to Richard Daniel Sanchez: {}", + rows_after_update + ); + // --8<-- [end:versioning_update_data] + assert_eq!(rows_after_update, 2); + + // --8<-- [start:versioning_add_data] + let more_data = vec![ + (4, "Richard Daniel Sanchez", "That's the way the news goes!"), + (5, "Morty", "Aww geez, Rick!"), + ]; + table + .add(make_quotes_reader(more_data)) + .execute() + .await + .unwrap(); + // --8<-- [end:versioning_add_data] + assert_eq!(table.count_rows(None).await.unwrap(), 5); + + // --8<-- [start:versioning_check_versions_after_mod] + let versions_after_mod = table.list_versions().await.unwrap(); + let version_count_after_mod = versions_after_mod.len(); + let version_after_mod = table.version().await.unwrap(); + println!( + "Number of versions after modifications: {}", + version_count_after_mod + ); + println!("Current version: {}", version_after_mod); + // --8<-- [end:versioning_check_versions_after_mod] + assert!(version_count_after_mod >= 2); + assert_eq!(version_after_mod, versions_after_mod.last().unwrap().version); + + // --8<-- [start:versioning_list_all_versions] + let all_versions = table.list_versions().await.unwrap(); + for v in &all_versions { + println!("Version {}, created at {}", v.version, v.timestamp); + } + // --8<-- [end:versioning_list_all_versions] + assert!(!all_versions.is_empty()); + + // --8<-- [start:versioning_rollback] + table.checkout(version_after_mod).await.unwrap(); + table.restore().await.unwrap(); + let versions_after_rollback = table.list_versions().await.unwrap(); + let version_count_after_rollback = versions_after_rollback.len(); + println!( + "Total number of versions after rollback: {}", + version_count_after_rollback + ); + // --8<-- [end:versioning_rollback] + assert_eq!(version_count_after_rollback, version_count_after_mod + 1); + assert_eq!(table.count_rows(None).await.unwrap(), 5); + + // --8<-- [start:versioning_checkout_latest] + table.checkout_latest().await.unwrap(); + // --8<-- [end:versioning_checkout_latest] + let latest_version = table.version().await.unwrap(); + let versions_after_checkout = table.list_versions().await.unwrap(); + assert_eq!(latest_version, versions_after_checkout.last().unwrap().version); + + // --8<-- [start:versioning_delete_data] + table.delete("author = 'Morty'").await.unwrap(); + let rows_after_deletion = table.count_rows(None).await.unwrap(); + println!("Number of rows after deletion: {}", rows_after_deletion); + // --8<-- [end:versioning_delete_data] + assert_eq!(rows_after_deletion, 3); + + // Setup: build a table with three versions to operate on with tags. + let tags_table = db + .create_table( + "quotes_tags_example", + make_quotes_reader(vec![(1, "Richard", "Wubba Lubba Dub Dub!")]), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); // v1 + tags_table + .add(make_quotes_reader(vec![(2, "Morty", "Aww geez, Rick!")])) + .execute() + .await + .unwrap(); // v2 + tags_table + .add(make_quotes_reader(vec![(3, "Summer", "Whatever, Grandpa")])) + .execute() + .await + .unwrap(); // v3 + + // --8<-- [start:versioning_tags] + let mut tags = tags_table.tags().await.unwrap(); + + // Create a tag pointing at a specific version + tags.create("baseline", 1).await.unwrap(); + let current_version = tags_table.version().await.unwrap(); + tags.create("with-edits", current_version).await.unwrap(); + + // List all tags on this table + let all_tags = tags.list().await.unwrap(); + println!("Tags: {:?}", all_tags); + + // Look up the version a tag points at + let baseline_version = tags.get_version("baseline").await.unwrap(); + println!("baseline -> v{}", baseline_version); + + // Move an existing tag to a different version + tags.update("baseline", 2).await.unwrap(); + + // Check out a version by tag name (separate method in Rust) + tags_table.checkout_tag("baseline").await.unwrap(); + println!("Current version: {}", tags_table.version().await.unwrap()); + + // Delete a tag (does not delete the underlying version) + tags.delete("with-edits").await.unwrap(); + + // Return to the latest version + tags_table.checkout_latest().await.unwrap(); + // --8<-- [end:versioning_tags] + assert_eq!(tags_table.version().await.unwrap(), 3); + let remaining = tags.list().await.unwrap(); + assert!(remaining.contains_key("baseline")); + assert!(!remaining.contains_key("with-edits")); + + // Setup: a fresh quotes table to demonstrate branches on. + let branches_table = db + .create_table( + "quotes_branches_example", + make_quotes_reader(vec![ + (1, "Lancelot", "My lance never fails."), + (2, "Arthur", "Long live Camelot!"), + (3, "Merlin", "Magic always has a price."), + ]), + ) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + use lancedb::table::Ref; + + // --8<-- [start:branch_create] + // Fork an isolated, writable branch from main's latest version. + // `create_branch` returns a table handle scoped to the new branch. + let branch = branches_table + .create_branch("exp", Ref::Version(None, None)) + .await + .unwrap(); + // --8<-- [end:branch_create] + + // --8<-- [start:branch_write] + // Writes land on the branch handle only; main is left untouched. + branch + .add(make_quotes_reader(vec![(4, "Lancelot", "For the realm!")])) + .execute() + .await + .unwrap(); + println!("Branch rows: {}", branch.count_rows(None).await.unwrap()); // 4 + println!("Main rows: {}", branches_table.count_rows(None).await.unwrap()); // 3 + + // List every branch, each mapped to its metadata (including its fork point). + println!("Branches: {:?}", branches_table.list_branches().await.unwrap()); + // --8<-- [end:branch_write] + + // --8<-- [start:branch_reopen] + // Reopen an existing branch by name from the table handle... + let checked_out = branches_table.checkout_branch("exp", None).await.unwrap(); + // ...or open it directly via the connection's builder. + let opened = db + .open_table("quotes_branches_example") + .branch("exp") + .execute() + .await + .unwrap(); + println!( + "Reopened rows: {}, {}", + checked_out.count_rows(None).await.unwrap(), + opened.count_rows(None).await.unwrap() + ); // both 4 + // --8<-- [end:branch_reopen] + + // --8<-- [start:branch_delete] + // Delete the branch and its branch-local history. Data on main is safe. + branches_table.delete_branch("exp").await.unwrap(); + // --8<-- [end:branch_delete] + + assert_eq!(branches_table.count_rows(None).await.unwrap(), 3); + assert!(!branches_table.list_branches().await.unwrap().contains_key("exp")); + + // Setup: a branch with row results that we want to apply to main. + let candidate = branches_table + .create_branch("candidate", Ref::Version(None, None)) + .await + .unwrap(); + candidate + .update() + .only_if("id = 1") + .column("quote", "'Revised on the branch'") + .execute() + .await + .unwrap(); + candidate + .add(make_quotes_reader(vec![( + 4, + "Galahad", + "The grail awaits.", + )])) + .execute() + .await + .unwrap(); + + // --8<-- [start:branch_upsert_to_main] + // This is a row-level upsert, not a merge of branch histories. + // `merge_insert` updates matching rows and inserts new rows using a stable + // unique key. Filter the branch read if you only want to apply some results. + let schema = candidate.schema().await.unwrap(); + let batches = candidate + .query() + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + let rows_to_apply = RecordBatchIterator::new(batches.into_iter().map(Ok), schema); + + let mut merge = branches_table.merge_insert(&["id"]); + merge + .when_matched_update_all(None) // update rows that already exist on main + .when_not_matched_insert_all(); // insert rows that are new on the branch + merge.execute(Box::new(rows_to_apply)).await.unwrap(); + // --8<-- [end:branch_upsert_to_main] + + assert_eq!(branches_table.count_rows(None).await.unwrap(), 4); + branches_table.delete_branch("candidate").await.unwrap(); + + // Setup: a larger table with a vector and a text column to index. + let products = db + .create_table("products_branch_index", make_products_reader(512)) + .mode(CreateTableMode::Overwrite) + .execute() + .await + .unwrap(); + + // --8<-- [start:branch_index] + use lancedb::index::scalar::FtsIndexBuilder; + use lancedb::index::Index; + + // Build and validate indexes on a branch before using the configuration on + // main. + let dev = products + .create_branch("index-dev", Ref::Version(None, None)) + .await + .unwrap(); + + // A vector (ANN) index and a full-text search index, both branch-scoped. + dev.create_index(&["vector"], Index::Auto) + .execute() + .await + .unwrap(); + dev.create_index(&["text"], Index::FTS(FtsIndexBuilder::default())) + .execute() + .await + .unwrap(); + + // Both indexes live only on the branch; main still has none. + println!("Branch indexes: {}", dev.list_indices().await.unwrap().len()); // 2 + println!("Main indexes: {}", products.list_indices().await.unwrap().len()); // 0 + // --8<-- [end:branch_index] + + assert_eq!(dev.list_indices().await.unwrap().len(), 2); + assert_eq!(products.list_indices().await.unwrap().len(), 0); + products.delete_branch("index-dev").await.unwrap(); +} diff --git a/docs/web-tests/ts/ann_indexes.test.ts b/docs/web-tests/ts/ann_indexes.test.ts new file mode 100644 index 000000000..a37872c9f --- /dev/null +++ b/docs/web-tests/ts/ann_indexes.test.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +// --8<-- [start:import] +import * as lancedb from "@lancedb/lancedb"; +import type { VectorQuery } from "@lancedb/lancedb"; +// --8<-- [end:import] +import { withTempDirectory } from "./util.ts"; + +test("ann index examples", async () => { + await withTempDirectory(async (databaseDir) => { + // --8<-- [start:ingest] + const db = await lancedb.connect(databaseDir); + + const data = Array.from({ length: 5_000 }, (_, i) => ({ + vector: Array(128).fill(i), + id: `${i}`, + content: "", + longId: `${i}`, + })); + + const table = await db.createTable("my_vectors", data, { + mode: "overwrite", + }); + await table.createIndex("vector", { + config: lancedb.Index.ivfPq({ + numPartitions: 10, + numSubVectors: 16, + }), + }); + // --8<-- [end:ingest] + + // --8<-- [start:search1] + const search = table.search(Array(128).fill(1.2)).limit(2) as VectorQuery; + const results1 = await search.nprobes(20).refineFactor(10).toArray(); + // --8<-- [end:search1] + expect(results1.length).toBe(2); + + // --8<-- [start:search2] + const results2 = await table + .search(Array(128).fill(1.2)) + .where("id != '1141'") + .limit(2) + .toArray(); + // --8<-- [end:search2] + expect(results2.length).toBe(2); + + // --8<-- [start:search3] + const results3 = await table + .search(Array(128).fill(1.2)) + .select(["id"]) + .limit(2) + .toArray(); + // --8<-- [end:search3] + expect(results3.length).toBe(2); + }); +}, 100_000); diff --git a/docs/web-tests/ts/basic_usage.test.ts b/docs/web-tests/ts/basic_usage.test.ts new file mode 100644 index 000000000..f00e69ba4 --- /dev/null +++ b/docs/web-tests/ts/basic_usage.test.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +import * as fs from "node:fs"; +import * as path from "node:path"; +// --8<-- [start:basic_imports] +import * as lancedb from "@lancedb/lancedb"; +import * as arrow from "apache-arrow"; +// --8<-- [end:basic_imports] +import { withTempDirectory } from "./util.ts"; + +const dataPath = path.join(__dirname, "..", "camelot.json"); + +test("basic usage examples (async)", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + + // --8<-- [start:data_load] + const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")); + // --8<-- [end:data_load] + + // --8<-- [start:basic_create_table] + let table = await db.createTable("camelot", data, { + mode: "overwrite", + }); + // --8<-- [end:basic_create_table] + expect(await table.countRows()).toBe(8); + + // --8<-- [start:basic_open_table] + table = await db.openTable("camelot"); + // --8<-- [end:basic_open_table] + + // --8<-- [start:basic_create_empty_table] + const schema = new arrow.Schema([ + new arrow.Field("id", new arrow.Int16()), + new arrow.Field("name", new arrow.Utf8()), + new arrow.Field("role", new arrow.Utf8()), + new arrow.Field("description", new arrow.Utf8()), + new arrow.Field( + "vector", + new arrow.FixedSizeList( + 4, + new arrow.Field("item", new arrow.Float32(), true), + ), + ), + new arrow.Field( + "stats", + new arrow.Struct([ + new arrow.Field("strength", new arrow.Int8()), + new arrow.Field("courage", new arrow.Int8()), + new arrow.Field("magic", new arrow.Int8()), + new arrow.Field("wisdom", new arrow.Int8()), + ]), + ), + ]); + await db.createEmptyTable("camelot_empty", schema, { mode: "overwrite" }); + // --8<-- [end:basic_create_empty_table] + expect(await db.tableNames()).toContain("camelot_empty"); + await db.dropTable("camelot_empty"); + + // --8<-- [start:basic_add_data] + const magicalCharacters = [ + { + id: 9, + name: "Morgan le Fay", + role: "Sorceress", + description: + "A powerful enchantress, Arthur's half-sister, and a complex figure who oscillates between aiding and opposing Camelot.", + vector: [0.1, 0.84, 0.25, 0.7], + stats: { strength: 2, courage: 3, magic: 5, wisdom: 4 }, + }, + { + id: 10, + name: "The Lady of the Lake", + role: "Mystical Guardian", + description: + "A mysterious supernatural figure associated with Avalon, known for giving Arthur the sword Excalibur.", + vector: [0.0, 0.9, 0.58, 0.88], + stats: { strength: 2, courage: 3, magic: 5, wisdom: 5 }, + }, + ]; + await table.add(magicalCharacters); + // --8<-- [end:basic_add_data] + expect(await table.countRows()).toBe(10); + + // --8<-- [start:basic_vector_search] + const queryVector = [0.03, 0.85, 0.61, 0.9]; + const result = await table.search(queryVector).limit(5).toArray(); + console.log(result); + // --8<-- [end:basic_vector_search] + + // --8<-- [start:basic_add_columns] + await table.addColumns([ + { + name: "power", + valueSql: + "cast(((stats.strength + stats.courage + stats.magic + stats.wisdom) / 4.0) as float)", + }, + ]); + // --8<-- [end:basic_add_columns] + const schemaWithPower = await table.schema(); + expect(schemaWithPower.fields.some((f) => f.name === "power")).toBe(true); + + // --8<-- [start:basic_vector_search_q1] + // Who are the characters similar to "wizard"? + const queryVector1 = [0.03, 0.85, 0.61, 0.9]; + const r1 = await table + .search(queryVector1) + .limit(5) + .select(["name", "role", "description"]) + .toArray(); + console.log(r1); + // --8<-- [end:basic_vector_search_q1] + + // --8<-- [start:basic_vector_search_q2] + // Who are the characters similar to "wizard" with high magic stats? + const queryVector2 = [0.03, 0.85, 0.61, 0.9]; + const r2 = await table + .search(queryVector2) + .where("stats.magic > 3") + .select(["name", "role", "description"]) + .limit(5) + .toArray(); + console.log(r2); + // --8<-- [end:basic_vector_search_q2] + + // --8<-- [start:basic_vector_search_q3] + // Who are the strongest characters? + const r3 = await table + .query() + .where("stats.strength > 3") + .select(["name", "role", "description"]) + .limit(5) + .toArray(); + console.log(r3); + // --8<-- [end:basic_vector_search_q3] + + // --8<-- [start:basic_vector_search_q4] + // Who are the strongest characters? + const r4 = await table + .query() + .select(["name", "role", "description", "power"]) + .toArray(); + console.log(r4); + // --8<-- [end:basic_vector_search_q4] + + // --8<-- [start:basic_drop_columns] + await table.dropColumns(["power"]); + // --8<-- [end:basic_drop_columns] + + // --8<-- [start:basic_delete_rows] + await table.delete('role = "Traitor Knight"'); + // --8<-- [end:basic_delete_rows] + expect(await table.countRows()).toBe(9); + + // --8<-- [start:basic_drop_table] + await db.dropTable("camelot"); + // --8<-- [end:basic_drop_table] + expect(await db.tableNames()).not.toContain("camelot"); + }); +}); diff --git a/docs/web-tests/ts/biome.json b/docs/web-tests/ts/biome.json new file mode 100644 index 000000000..f8fb5b200 --- /dev/null +++ b/docs/web-tests/ts/biome.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "overrides": [ + { + "include": ["*"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + }, + { + "include": ["merge_insert.test.ts"], + "linter": { + "rules": { + "style": { + "useNamingConvention": "off" + } + } + } + } + ] +} diff --git a/docs/web-tests/ts/connection.test.ts b/docs/web-tests/ts/connection.test.ts new file mode 100644 index 000000000..f522654ec --- /dev/null +++ b/docs/web-tests/ts/connection.test.ts @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, jest, test } from "@jest/globals"; +import * as path from "node:path"; +import { withTempDirectory } from "./util.ts"; +// --8<-- [start:connect] +import * as lancedb from "@lancedb/lancedb"; + +async function connectExample(uri: string) { + const db = await lancedb.connect(uri); + return db; +} +// --8<-- [end:connect] + +test("connect to a local database", async () => { + await withTempDirectory(async (tempDir) => { + const uri = path.join(tempDir, "ex_lancedb"); + const db = await connectExample(uri); + expect(db).toBeDefined(); + }); +}); + +async function connectEnterpriseQuickstart() { + // --8<-- [start:connect_enterprise_quickstart] + const uri = "db://your-database-uri"; + const apiKey = "your-api-key"; + const region = "us-east-1"; + const hostOverride = "https://your-enterprise-endpoint.com"; + + const db = await lancedb.connect(uri, { + apiKey, + region, + hostOverride, + }); + // --8<-- [end:connect_enterprise_quickstart] + return db; +} + +test("enterprise quickstart connect uses placeholder config", async () => { + const mockDb = { __mock: true } as unknown as Awaited< + ReturnType + >; + const spy = jest.spyOn(lancedb, "connect").mockResolvedValue(mockDb); + + const db = await connectEnterpriseQuickstart(); + expect(db).toBe(mockDb); + expect(spy).toHaveBeenCalledWith("db://your-database-uri", { + apiKey: "your-api-key", + region: "us-east-1", + hostOverride: "https://your-enterprise-endpoint.com", + }); + spy.mockRestore(); +}); + +// --8<-- [start:connect_object_storage] +async function connectObjectStorageExample() { + const uri = "s3://your-bucket/path"; + // You can also use "gs://your-bucket/path" or "az://your-container/path". + const db = await lancedb.connect(uri); + return db; +} +// --8<-- [end:connect_object_storage] + +async function namespaceTableOpsExample() { + // --8<-- [start:namespace_table_ops] + const db = await lancedb.connectNamespace("dir", { root: "./local_lancedb" }); + + // Create namespace tree: prod/search and prod/recommendations + await db.createNamespace(["prod"], { mode: "exist_ok" }); + await db.createNamespace(["prod", "search"], { mode: "exist_ok" }); + await db.createNamespace(["prod", "recommendations"], { mode: "exist_ok" }); + + await db.createTable( + "user", + [{ id: 1, vector: [0.1, 0.2], name: "alice" }], + ["prod", "search"], + { mode: "create" }, // use "overwrite" only if you want to replace existing table + ); + + await db.createTable( + "user", + [{ id: 2, vector: [0.3, 0.4], name: "bob" }], + ["prod", "recommendations"], + { mode: "create" }, + ); + + // Verify + console.log((await db.listNamespaces()).namespaces); // ["prod"] + console.log((await db.listNamespaces(["prod"])).namespaces); // ["recommendations", "search"] + console.log(await db.tableNames(["prod", "search"])); // ["user"] + console.log(await db.tableNames(["prod", "recommendations"])); // ["user"] + // --8<-- [end:namespace_table_ops] +} + +async function namespaceAdminOpsExample() { + // --8<-- [start:namespace_admin_ops] + const db = await lancedb.connectNamespace("dir", { root: "./local_lancedb" }); + const namespace = ["prod", "search"]; + + await db.createNamespace(["prod"]); + await db.createNamespace(["prod", "search"]); + + const childNamespaces = (await db.listNamespaces(["prod"])).namespaces; + console.log(`Child namespaces under ${JSON.stringify(namespace)}:`, childNamespaces); + // Child namespaces under ["prod","search"]: [ 'search' ] + + const metadata = await db.describeNamespace(["prod", "search"]); + console.log(`Metadata for namespace ${JSON.stringify(namespace)}:`, metadata); + + await db.dropNamespace(["prod", "search"], { mode: "skip" }); + await db.dropNamespace(["prod"], { mode: "skip" }); + // --8<-- [end:namespace_admin_ops] + return { childNamespaces, metadata }; +} + +void [ + connectObjectStorageExample, + connectEnterpriseQuickstart, + namespaceTableOpsExample, + namespaceAdminOpsExample, +]; diff --git a/docs/web-tests/ts/custom_embedding_function.test.ts b/docs/web-tests/ts/custom_embedding_function.test.ts new file mode 100644 index 000000000..528928a9d --- /dev/null +++ b/docs/web-tests/ts/custom_embedding_function.test.ts @@ -0,0 +1,79 @@ +import { + type FeatureExtractionPipeline, + pipeline, +} from "@huggingface/transformers"; +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +// --8<-- [start:imports] +import * as lancedb from "@lancedb/lancedb"; +import { + LanceSchema, + TextEmbeddingFunction, + getRegistry, + register, +} from "@lancedb/lancedb/embedding"; +// --8<-- [end:imports] +import { withTempDirectory } from "./util.ts"; + +// --8<-- [start:embedding_impl] +@register("sentence-transformers") +class SentenceTransformersEmbeddings extends TextEmbeddingFunction { + name = "Xenova/all-miniLM-L6-v2"; + #ndims!: number; + extractor!: FeatureExtractionPipeline; + + async init() { + this.extractor = await pipeline("feature-extraction", this.name, { + dtype: "fp32", + }); + this.#ndims = await this.generateEmbeddings(["hello"]).then( + (e) => e[0].length, + ); + } + + ndims() { + return this.#ndims; + } + + toJSON() { + return { + name: this.name, + }; + } + async generateEmbeddings(texts: string[]) { + const output = await this.extractor(texts, { + pooling: "mean", + normalize: true, + }); + return output.tolist(); + } +} +// --8<-- [end:embedding_impl] + +test("Registry examples", async () => { + await withTempDirectory(async (databaseDir) => { + // --8<-- [start:call_custom_function] + const registry = getRegistry(); + + const sentenceTransformer = await registry + .get("sentence-transformers")! + .create(); + + const schema = LanceSchema({ + vector: sentenceTransformer.vectorField(), + text: sentenceTransformer.sourceField(), + }); + + const db = await lancedb.connect(databaseDir); + const table = await db.createEmptyTable("table", schema, { + mode: "overwrite", + }); + + await table.add([{ text: "hello" }, { text: "world" }]); + + const results = await table.search("greeting").limit(1).toArray(); + // --8<-- [end:call_custom_function] + expect(results.length).toBe(1); + }); +}, 100_000); diff --git a/docs/web-tests/ts/embedding.test.ts b/docs/web-tests/ts/embedding.test.ts new file mode 100644 index 000000000..a73b808ca --- /dev/null +++ b/docs/web-tests/ts/embedding.test.ts @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +// --8<-- [start:imports] +import * as lancedb from "@lancedb/lancedb"; +import "@lancedb/lancedb/embedding/openai"; +import { LanceSchema, getRegistry, register } from "@lancedb/lancedb/embedding"; +import { EmbeddingFunction } from "@lancedb/lancedb/embedding"; +import { type Float, Float32, Utf8 } from "apache-arrow"; +// --8<-- [end:imports] +import { withTempDirectory } from "./util.ts"; + +const openAiTest = process.env.OPENAI_API_KEY == null ? test.skip : test; + +openAiTest("create embedding function", () => { + // --8<-- [start:create_embedding_function] + const func = getRegistry().get("openai")!.create({ + model: "text-embedding-3-small", + }); + // --8<-- [end:create_embedding_function] + expect(func).toBeDefined(); +}); + +openAiTest("openai embeddings", async () => { + await withTempDirectory(async (databaseDir) => { + // --8<-- [start:openai_embeddings] + const db = await lancedb.connect(databaseDir); + const func = getRegistry() + .get("openai") + ?.create({ model: "text-embedding-ada-002" }) as EmbeddingFunction; + + const wordsSchema = LanceSchema({ + text: func.sourceField(new Utf8()), + vector: func.vectorField(), + }); + const tbl = await db.createEmptyTable("words", wordsSchema, { + mode: "overwrite", + }); + await tbl.add([{ text: "hello world" }, { text: "goodbye world" }]); + + const query = "greetings"; + const actual = (await tbl.search(query).limit(1).toArray())[0]; + // --8<-- [end:openai_embeddings] + expect(actual).toHaveProperty("text"); + }); +}); + +openAiTest("manual query embeddings", async () => { + await withTempDirectory(async (databaseDir) => { + // --8<-- [start:manual_query_embeddings] + const db = await lancedb.connect(databaseDir); + const func = getRegistry() + .get("openai") + ?.create({ model: "text-embedding-ada-002" }) as EmbeddingFunction; + + const wordsSchema = LanceSchema({ + text: func.sourceField(new Utf8()), + vector: func.vectorField(), + }); + const tbl = await db.createEmptyTable("words", wordsSchema, { + mode: "overwrite", + }); + await tbl.add([{ text: "hello world" }, { text: "goodbye world" }]); + + const queryVector = await func.computeQueryEmbeddings("greetings"); + // --8<-- [start:manual_query_search] + // queryVector is assumed to already be generated by your embedding function + const actual = (await tbl.search(queryVector).limit(1).toArray())[0]; + // --8<-- [end:manual_query_search] + // --8<-- [end:manual_query_embeddings] + expect(actual).toHaveProperty("text"); + }); +}); + +test("custom embedding function", async () => { + await withTempDirectory(async (databaseDir) => { + // --8<-- [start:embedding_function] + const db = await lancedb.connect(databaseDir); + + @register("my_embedding") + class MyEmbeddingFunction extends EmbeddingFunction { + constructor(optionsRaw = {}) { + super(); + const options = this.resolveVariables(optionsRaw); + // Initialize using options + } + ndims() { + return 3; + } + protected getSensitiveKeys(): string[] { + return []; + } + embeddingDataType(): Float { + return new Float32(); + } + async computeQueryEmbeddings(_data: string) { + // This is a placeholder for a real embedding function + return [1, 2, 3]; + } + async computeSourceEmbeddings(data: string[]) { + // This is a placeholder for a real embedding function + return Array.from({ length: data.length }).fill([ + 1, 2, 3, + ]) as number[][]; + } + } + + const func = new MyEmbeddingFunction(); + + const data = [{ text: "pepperoni" }, { text: "pineapple" }]; + + // Option 1: manually specify the embedding function + const table = await db.createTable("vectors", data, { + embeddingFunction: { + function: func, + sourceColumn: "text", + vectorColumn: "vector", + }, + mode: "overwrite", + }); + + // Option 2: provide the embedding function through a schema + + const schema = LanceSchema({ + text: func.sourceField(new Utf8()), + vector: func.vectorField(), + }); + + const table2 = await db.createTable("vectors2", data, { + schema, + mode: "overwrite", + }); + // --8<-- [end:embedding_function] + expect(await table.countRows()).toBe(2); + expect(await table2.countRows()).toBe(2); + }); +}); + +test("embedding function api_key", async () => { + // --8<-- [start:register_secret] + const registry = getRegistry(); + registry.setVar("api_key", "sk-..."); + + const func = registry.get("openai")!.create({ + apiKey: "$var:api_key", + }); + // --8<-- [end:register_secret] +}); + +openAiTest("embedding function variable fallback", () => { + // --8<-- [start:register_model_fallback] + const registry = getRegistry(); + registry.setVar("openai_model", "text-embedding-3-large"); + + const func = registry.get("openai")!.create({ + model: "$var:openai_model:text-embedding-3-small", + }); + // --8<-- [end:register_model_fallback] + expect(func).toBeDefined(); +}); diff --git a/docs/web-tests/ts/filtering.test.ts b/docs/web-tests/ts/filtering.test.ts new file mode 100644 index 000000000..1c35d5120 --- /dev/null +++ b/docs/web-tests/ts/filtering.test.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +import * as lancedb from "@lancedb/lancedb"; +import { withTempDirectory } from "./util.ts"; + +test("filtering examples", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + + const data = Array.from({ length: 10_000 }, (_, i) => ({ + vector: Array(1536).fill(i), + id: i, + item: `item ${i}`, + strId: `${i}`, + })); + + const tbl = await db.createTable("myVectors", data, { mode: "overwrite" }); + + // --8<-- [start:search] + const _result = await tbl + .search(Array(1536).fill(0.5)) + .limit(1) + .where("id = 10") + .toArray(); + // --8<-- [end:search] + + // --8<-- [start:vec_search] + const result = await ( + tbl.search(Array(1536).fill(0)) as lancedb.VectorQuery + ) + .where("(item IN ('item 0', 'item 2')) AND (id > 10)") + .postfilter() + .toArray(); + // --8<-- [end:vec_search] + expect(result.length).toBe(0); + + // --8<-- [start:sql_search] + await tbl.query().where("id = 10").limit(10).toArray(); + // --8<-- [end:sql_search] + }); +}); diff --git a/docs/web-tests/ts/full_text_search.test.ts b/docs/web-tests/ts/full_text_search.test.ts new file mode 100644 index 000000000..9777bda7f --- /dev/null +++ b/docs/web-tests/ts/full_text_search.test.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +import * as lancedb from "@lancedb/lancedb"; +import { withTempDirectory } from "./util.ts"; + +test("full text search", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + + const words = [ + "apple", + "banana", + "cherry", + "date", + "elderberry", + "fig", + "grape", + ]; + + const data = Array.from({ length: 10_000 }, (_, i) => ({ + vector: Array(1536).fill(i), + id: i, + item: `item ${i}`, + strId: `${i}`, + doc: words[i % words.length], + })); + + const tbl = await db.createTable("myVectors", data, { mode: "overwrite" }); + + await tbl.createIndex("doc", { + config: lancedb.Index.fts(), + }); + + // --8<-- [start:full_text_search] + const result = await tbl + .query() + .nearestToText("apple") + .select(["id", "doc"]) + .limit(10) + .toArray(); + expect(result.length).toBe(10); + // --8<-- [end:full_text_search] + }); +}, 10_000); diff --git a/docs/web-tests/ts/integrations.ts b/docs/web-tests/ts/integrations.ts new file mode 100644 index 000000000..3ae7967e9 --- /dev/null +++ b/docs/web-tests/ts/integrations.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +// --8<-- [start:frameworks_genkit_usage] +import { lancedbIndexerRef, lancedb, lancedbRetrieverRef, WriteMode } from "genkitx-lancedb"; +import { textEmbedding004, vertexAI } from "@genkit-ai/vertexai"; +import { gemini } from "@genkit-ai/vertexai"; +import { z, genkit } from "genkit"; +import { Document } from "genkit/retriever"; +import { chunk } from "llm-chunk"; +import { readFile } from "fs/promises"; +import path from "path"; +import pdf from "pdf-parse/lib/pdf-parse"; + +const ai = genkit({ + plugins: [ + // vertexAI provides the textEmbedding004 embedder + vertexAI(), + + // the local vector store requires an embedder to translate from text to vector + lancedb([ + { + dbUri: ".db", // optional lancedb uri, default to .db + tableName: "table", // optional table name, default to table + embedder: textEmbedding004, + }, + ]), + ], +}); +// --8<-- [end:frameworks_genkit_usage] + +// --8<-- [start:frameworks_genkit_custom_indexer] +export const menuPdfIndexer = lancedbIndexerRef({ + // Using all defaults, for dbUri, tableName, and embedder, etc +}); + +const chunkingConfig = { + minLength: 1000, + maxLength: 2000, + splitter: "sentence", + overlap: 100, + delimiters: "", +} as any; + +async function extractTextFromPdf(filePath: string) { + const pdfFile = path.resolve(filePath); + const dataBuffer = await readFile(pdfFile); + const data = await pdf(dataBuffer); + return data.text; +} + +export const indexMenu = ai.defineFlow( + { + name: "indexMenu", + inputSchema: z.string().describe("PDF file path"), + outputSchema: z.void(), + }, + async (filePath: string) => { + filePath = path.resolve(filePath); + + // Read the pdf. + const pdfTxt = await ai.run("extract-text", () => extractTextFromPdf(filePath)); + + // Divide the pdf text into segments. + const chunks = await ai.run("chunk-it", async () => chunk(pdfTxt, chunkingConfig)); + + // Convert chunks of text into documents to store in the index. + const documents = chunks.map((text) => { + return Document.fromText(text, { filePath }); + }); + + // Add documents to the index. + await ai.index({ + indexer: menuPdfIndexer, + documents, + options: { + writeMode: WriteMode.Overwrite, + } as any, + }); + }, +); +// --8<-- [end:frameworks_genkit_custom_indexer] + +// --8<-- [start:frameworks_genkit_custom_retriever] +export const menuRetriever = lancedbRetrieverRef({ + tableName: "table", // Use the same table name as the indexer. + displayName: "Menu", // Use a custom display name. +}); + +export const menuQAFlow = ai.defineFlow( + { name: "Menu", inputSchema: z.string(), outputSchema: z.string() }, + async (input: string) => { + // retrieve relevant documents + const docs = await ai.retrieve({ + retriever: menuRetriever, + query: input, + options: { + k: 3, + }, + }); + + const extractedContent = docs.map((doc) => { + if (doc.content && Array.isArray(doc.content) && doc.content.length > 0) { + if (doc.content[0].media && doc.content[0].media.url) { + return doc.content[0].media.url; + } + } + return "No content found"; + }); + + console.log("Extracted content:", extractedContent); + + const { text } = await ai.generate({ + model: gemini("gemini-2.0-flash"), + prompt: ` +You are acting as a helpful AI assistant that can answer +questions about the food available on the menu at Genkit Grub Pub. + +Use only the context provided to answer the question. +If you don't know, do not make up an answer. +Do not add or change items on the menu. + +Context: +${extractedContent.join("\n\n")} + +Question: ${input}`, + docs, + }); + + return text; + }, +); +// --8<-- [end:frameworks_genkit_custom_retriever] + diff --git a/docs/web-tests/ts/jest.config.cjs b/docs/web-tests/ts/jest.config.cjs new file mode 100644 index 000000000..db83d639c --- /dev/null +++ b/docs/web-tests/ts/jest.config.cjs @@ -0,0 +1,6 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testPathIgnorePatterns: ["./dist"], +}; diff --git a/docs/web-tests/ts/merge_insert.test.ts b/docs/web-tests/ts/merge_insert.test.ts new file mode 100644 index 000000000..1b33dab6a --- /dev/null +++ b/docs/web-tests/ts/merge_insert.test.ts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import { expect, test } from "@jest/globals"; +import * as lancedb from "@lancedb/lancedb"; + +test("basic upsert", async () => { + const db = await lancedb.connect("memory://"); + + // --8<-- [start:upsert_basic] + const table = await db.createTable("users", [ + { id: 0, name: "Alice" }, + { id: 1, name: "Bob" }, + ]); + + const newUsers = [ + { id: 1, name: "Bobby" }, + { id: 2, name: "Charlie" }, + ]; + await table + .mergeInsert("id") + .whenMatchedUpdateAll() + .whenNotMatchedInsertAll() + .execute(newUsers); + + await table.countRows(); // 3 + // --8<-- [end:upsert_basic] + expect(await table.countRows()).toBe(3); + + // --8<-- [start:insert_if_not_exists] + const table2 = await db.createTable("domains", [ + { domain: "google.com", name: "Google" }, + { domain: "github.com", name: "GitHub" }, + ]); + + const newDomains = [ + { domain: "google.com", name: "Google" }, + { domain: "facebook.com", name: "Facebook" }, + ]; + await table2 + .mergeInsert("domain") + .whenNotMatchedInsertAll() + .execute(newDomains); + await table2.countRows(); // 3 + // --8<-- [end:insert_if_not_exists] + expect(await table2.countRows()).toBe(3); + + // --8<-- [start:replace_range] + const table3 = await db.createTable("chunks", [ + { doc_id: 0, chunk_id: 0, text: "Hello" }, + { doc_id: 0, chunk_id: 1, text: "World" }, + { doc_id: 1, chunk_id: 0, text: "Foo" }, + { doc_id: 1, chunk_id: 1, text: "Bar" }, + ]); + + const newChunks = [{ doc_id: 1, chunk_id: 0, text: "Baz" }]; + + await table3 + .mergeInsert(["doc_id", "chunk_id"]) + .whenMatchedUpdateAll() + .whenNotMatchedInsertAll() + .whenNotMatchedBySourceDelete({ where: "doc_id = 1" }) + .execute(newChunks); + + await table3.countRows("doc_id = 1"); // 1 + // --8<-- [end:replace_range] + expect(await table3.countRows("doc_id = 1")).toBe(1); +}); diff --git a/docs/web-tests/ts/multimodal.test.ts b/docs/web-tests/ts/multimodal.test.ts new file mode 100644 index 000000000..ac9a8201e --- /dev/null +++ b/docs/web-tests/ts/multimodal.test.ts @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +// --8<-- [start:multimodal_imports] +import * as arrow from "apache-arrow"; +import { Buffer } from "node:buffer"; +import * as lancedb from "@lancedb/lancedb"; +// --8<-- [end:multimodal_imports] +import { withTempDirectory } from "./util.ts"; + +test("multimodal snippets (async)", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + + // --8<-- [start:create_dummy_data] + const createDummyImage = (color: string): Uint8Array => { + const pngHeader = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + return Buffer.concat([Buffer.from(pngHeader), Buffer.from(color, "utf8")]); + }; + + const data = [ + { + id: 1, + filename: "red_square.png", + vector: Array.from({ length: 128 }, (_, i) => (i % 16) / 16), + image_blob: createDummyImage("red"), + label: "red", + }, + { + id: 2, + filename: "blue_square.png", + vector: Array.from({ length: 128 }, (_, i) => ((i + 8) % 16) / 16), + image_blob: createDummyImage("blue"), + label: "blue", + }, + ]; + // --8<-- [end:create_dummy_data] + + // --8<-- [start:define_schema] + const schema = new arrow.Schema([ + new arrow.Field("id", new arrow.Int32()), + new arrow.Field("filename", new arrow.Utf8()), + new arrow.Field( + "vector", + new arrow.FixedSizeList( + 128, + new arrow.Field("item", new arrow.Float32(), true), + ), + ), + new arrow.Field("image_blob", new arrow.Binary()), + new arrow.Field("label", new arrow.Utf8()), + ]); + // --8<-- [end:define_schema] + + // --8<-- [start:ingest_data] + const multimodalData = lancedb.makeArrowTable(data, { schema }); + const tbl = await db.createTable("images", multimodalData, { + mode: "overwrite", + }); + // --8<-- [end:ingest_data] + expect(await tbl.countRows()).toBe(2); + + // --8<-- [start:search_data] + const queryVector = Array.from({ length: 128 }, (_, i) => (i % 16) / 16); + const results = await tbl.search(queryVector).limit(1).toArray(); + // --8<-- [end:search_data] + + // --8<-- [start:process_results] + for (const row of results) { + const imageBytes = row.image_blob as Uint8Array; + console.log( + `Retrieved image: ${row.filename}, Byte length: ${imageBytes.length}`, + ); + } + // --8<-- [end:process_results] + expect(results).toHaveLength(1); + + // --8<-- [start:blob_api_schema] + const blobSchema = new arrow.Schema([ + new arrow.Field("id", new arrow.Int64()), + new arrow.Field( + "video", + new arrow.LargeBinary(), + true, + new Map([["lance-encoding:blob", "true"]]), + ), + ]); + // --8<-- [end:blob_api_schema] + + // --8<-- [start:blob_api_ingest] + const blobData = lancedb.makeArrowTable( + [ + { id: 1, video: Buffer.from("fake_video_bytes_1") }, + { id: 2, video: Buffer.from("fake_video_bytes_2") }, + ], + { schema: blobSchema }, + ); + const blobTable = await db.createTable("videos", blobData, { + mode: "overwrite", + }); + // --8<-- [end:blob_api_ingest] + expect(await blobTable.countRows()).toBe(2); + }); +}); diff --git a/docs/web-tests/ts/package-lock.json b/docs/web-tests/ts/package-lock.json new file mode 100644 index 000000000..5acc87907 --- /dev/null +++ b/docs/web-tests/ts/package-lock.json @@ -0,0 +1,5400 @@ +{ + "name": "examples", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "examples", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/transformers": "^3.0.2", + "@lancedb/lancedb": "^0.31.0", + "openai": "^4.29.2", + "sharp": "^0.35.5" + }, + "devDependencies": { + "@biomejs/biome": "^1.7.3", + "@jest/globals": "^29.7.0", + "jest": "^29.7.0", + "jest-environment-node-single-context": "^29.4.0", + "ts-jest": "^29.2.5", + "typescript": "^5.5.4" + } + }, + "..": { + "name": "@lancedb/lancedb", + "version": "0.12.0", + "cpu": [ + "x64", + "arm64" + ], + "extraneous": true, + "license": "Apache 2.0", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "reflect-metadata": "^0.2.2" + }, + "devDependencies": { + "@aws-sdk/client-dynamodb": "^3.33.0", + "@aws-sdk/client-kms": "^3.33.0", + "@aws-sdk/client-s3": "^3.33.0", + "@biomejs/biome": "^1.7.3", + "@jest/globals": "^29.7.0", + "@napi-rs/cli": "^2.18.3", + "@types/axios": "^0.14.0", + "@types/jest": "^29.1.2", + "@types/node": "^22.7.4", + "@types/tmp": "^0.2.6", + "apache-arrow-13": "npm:apache-arrow@13.0.0", + "apache-arrow-14": "npm:apache-arrow@14.0.0", + "apache-arrow-15": "npm:apache-arrow@15.0.0", + "apache-arrow-16": "npm:apache-arrow@16.0.0", + "apache-arrow-17": "npm:apache-arrow@17.0.0", + "eslint": "^8.57.0", + "jest": "^29.7.0", + "shx": "^0.3.4", + "tmp": "^0.2.3", + "ts-jest": "^29.1.2", + "typedoc": "^0.26.4", + "typedoc-plugin-markdown": "^4.2.1", + "typescript": "^5.5.4", + "typescript-eslint": "^7.1.0" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@huggingface/transformers": "^3.0.2", + "openai": "^4.29.2" + }, + "peerDependencies": { + "apache-arrow": ">=13.0.0 <=17.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@huggingface/transformers": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.0.2.tgz", + "integrity": "sha512-lTyS81eQazMea5UCehDGFMfdcNRZyei7XQLH5X6j4AhA/18Ka0+5qPgMxUxuZLU4xkv60aY2KNz9Yzthv6WVJg==", + "dependencies": { + "@huggingface/jinja": "^0.3.0", + "onnxruntime-node": "1.19.2", + "onnxruntime-web": "1.21.0-dev.20241024-d9ca84ef96", + "sharp": "^0.33.5" + } + }, + "node_modules/@huggingface/transformers/node_modules/@huggingface/jinja": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.3.2.tgz", + "integrity": "sha512-F2FvuIc+w1blGsaqJI/OErRbWH6bVJDCBI8Rm5D86yZ2wlwrGERsfIaru7XUv9eYC3DMP3ixDRRtF0h6d8AZcQ==", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/transformers/node_modules/onnxruntime-common": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.19.2.tgz", + "integrity": "sha512-a4R7wYEVFbZBlp0BfhpbFWqe4opCor3KM+5Wm22Az3NGDcQMiU2hfG/0MfnBs+1ZrlSGmlgWeMcXQkDk1UFb8Q==" + }, + "node_modules/@huggingface/transformers/node_modules/onnxruntime-node": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.19.2.tgz", + "integrity": "sha512-9eHMP/HKbbeUcqte1JYzaaRC8JPn7ojWeCeoyShO86TOR97OCyIyAIOGX3V95ErjslVhJRXY8Em/caIUc0hm1Q==", + "hasInstallScript": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "1.19.2", + "tar": "^7.0.1" + } + }, + "node_modules/@huggingface/transformers/node_modules/onnxruntime-web": { + "version": "1.21.0-dev.20241024-d9ca84ef96", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.21.0-dev.20241024-d9ca84ef96.tgz", + "integrity": "sha512-ANSQfMALvCviN3Y4tvTViKofKToV1WUb2r2VjZVCi3uUBPaK15oNJyIxhsNyEckBr/Num3JmSXlkHOD8HfVzSQ==", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.20.0-dev.20241016-2b8fc5529b", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.20.0-dev.20241016-2b8fc5529b", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.20.0-dev.20241016-2b8fc5529b.tgz", + "integrity": "sha512-KZK8b6zCYGZFjd4ANze0pqBnqnFTS3GIVeclQpa2qseDpXrCQJfkWBixRcrZShNhm3LpFOZ8qJYFC5/qsJK9WQ==" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lancedb/lancedb": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@lancedb/lancedb/-/lancedb-0.31.0.tgz", + "integrity": "sha512-EUEVpheKhaCNE6ybcW760OUyfeei2dKR2ZwgLWeC/ntHL4BBiBLIErh9fuEuUP3/mAx4B5UFraB2m5nDUx5XEA==", + "cpu": [ + "x64", + "arm64" + ], + "license": "Apache-2.0", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "reflect-metadata": "^0.2.2" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@huggingface/transformers": "3.0.2", + "@lancedb/lancedb-darwin-arm64": "0.31.0", + "@lancedb/lancedb-linux-arm64-gnu": "0.31.0", + "@lancedb/lancedb-linux-arm64-musl": "0.31.0", + "@lancedb/lancedb-linux-x64-gnu": "0.31.0", + "@lancedb/lancedb-linux-x64-musl": "0.31.0", + "@lancedb/lancedb-win32-arm64-msvc": "0.31.0", + "@lancedb/lancedb-win32-x64-msvc": "0.31.0", + "openai": "4.29.2" + }, + "peerDependencies": { + "apache-arrow": ">=15.0.0 <=18.1.0" + } + }, + "node_modules/@lancedb/lancedb-darwin-arm64": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@lancedb/lancedb-darwin-arm64/-/lancedb-darwin-arm64-0.31.0.tgz", + "integrity": "sha512-6n3VxAenwcNWQpk9Ta4NL/KhpSywskafarBakzFPGe/OXzdKXbmbXdrhGdl8oMacbfgql6wmW3DcAJAbsiThvw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/@lancedb/lancedb-linux-arm64-gnu": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@lancedb/lancedb-linux-arm64-gnu/-/lancedb-linux-arm64-gnu-0.31.0.tgz", + "integrity": "sha512-dWVHk5xhTpXQt08y3pxQFf5DK/O2saiWT8AZIIlonFRbGVhLm1KzBCfCgS1WUVbdleSkqAqY3NnMxZzu/dhx9Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/@lancedb/lancedb-linux-arm64-musl": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@lancedb/lancedb-linux-arm64-musl/-/lancedb-linux-arm64-musl-0.31.0.tgz", + "integrity": "sha512-4y49+83R34IR75H2NPeDtHgSnUIKE9gJoSiELsGmCgICoFTZRVxq88atBEl0BfpsuuUc8S2bSx45mf3H4lZmWA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/@lancedb/lancedb-linux-x64-gnu": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@lancedb/lancedb-linux-x64-gnu/-/lancedb-linux-x64-gnu-0.31.0.tgz", + "integrity": "sha512-AFc0qTNdjoPor5bEHRuijft0BGNSiyHlF0cdUdsApLvjrl5jTBzw59+EqzjjCevWz5w/44BBjaTt6ZChWVTc/Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/@lancedb/lancedb-linux-x64-musl": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@lancedb/lancedb-linux-x64-musl/-/lancedb-linux-x64-musl-0.31.0.tgz", + "integrity": "sha512-VXtu/xTJXtkfTyUH+MXbMYCGVCAvHJvGDoPNIqDBtrnX7uHlgmxPbw7V97k18/5UVLU2v8NhsM5sCiMD4si+Tw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/@lancedb/lancedb-win32-arm64-msvc": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@lancedb/lancedb-win32-arm64-msvc/-/lancedb-win32-arm64-msvc-0.31.0.tgz", + "integrity": "sha512-AvbnX/24uPh6UP9IRdYZNLc+2iE4uLKIQcEbGXUMLsVIMWQvuHfRamZpIlWb50tz/C7Wo5aPiQIkolPJN5fiuw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/@lancedb/lancedb-win32-x64-msvc": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@lancedb/lancedb-win32-x64-msvc/-/lancedb-win32-x64-msvc-0.31.0.tgz", + "integrity": "sha512-reFE43TCr5Lifni2fC5YiyOnjDTAXOY37+rAY01FFKZyhjZkhRy9NZN1WvWJ35XHC4eeUVOW4+UXOvSV0djyCA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/@lancedb/lancedb/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@lancedb/lancedb/node_modules/openai": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.29.2.tgz", + "integrity": "sha512-cPkT6zjEcE4qU5OW/SoDDuXEsdOLrXlAORhzmaguj5xZSPlgKvLhi27sFWhLKj07Y6WKNWxcwIbzm512FzTBNQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "digest-fetch": "^1.3.0", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7", + "web-streams-polyfill": "^3.2.1" + }, + "bin": { + "openai": "bin/cli" + } + }, + "node_modules/@lancedb/lancedb/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "peer": true + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "peer": true + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/apache-arrow": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-18.1.0.tgz", + "integrity": "sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^20.13.0", + "command-line-args": "^5.2.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^24.3.25", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.js" + } + }, + "node_modules/apache-arrow/node_modules/flatbuffers": { + "version": "24.12.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-24.12.23.tgz", + "integrity": "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==", + "peer": true + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==", + "optional": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "peer": true, + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", + "dev": true + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "peer": true, + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/command-line-usage": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", + "peer": true, + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/command-line-usage/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "peer": true, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/command-line-usage/node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "peer": true, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "dev": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/digest-fetch": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz", + "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==", + "license": "ISC", + "optional": true, + "dependencies": { + "base-64": "^0.1.0", + "md5": "^2.3.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.449", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.449.tgz", + "integrity": "sha512-dKeYk/3SFQz+nXi5whp3OuJzeWABEG4zCtkWJ9+Vby1E1iFM2y0gElRtQvNXRPJTsj8WAF4HMP7hvP790QtJ9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-ex/node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "peer": true, + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==" + }, + "node_modules/handlebars": { + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.8", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT", + "optional": true + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node-single-context": { + "version": "29.4.0", + "resolved": "https://registry.npmjs.org/jest-environment-node-single-context/-/jest-environment-node-single-context-29.4.0.tgz", + "integrity": "sha512-VOuB0Pf3/+Tu0eImZ888SeHpFIiujRiW/3b6NTST1/zdv6ZdRAblCV2q5SisF0PlDA8y9SHJWjKFtFXNJ7U6CQ==", + "dev": true, + "dependencies": { + "jest-environment-node": "^29.7.0" + }, + "funding": { + "url": "https://github.com/kayahr/jest-environment-node-single-context?sponsor=1" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "peer": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "peer": true + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openai": { + "version": "4.71.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.71.0.tgz", + "integrity": "sha512-jeJ7+6cZvj+ZbIsbX/Ag8+pug2+vjKbrD/v3Hwp6uv3KZyWjSkZa5MdUshzpNC3jsFzakfbUhEEFQXsKWNgm/g==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.64", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.64.tgz", + "integrity": "sha512-955mDqvO2vFf/oL7V3WiUtiz+BugyX8uVbaT2H8oj3+8dRyH2FLiNdowe7eNqRM7IOIZvzDH76EoAT+gwm6aIQ==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==" + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "peer": true, + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/table-layout/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "peer": true, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/ts-jest": { + "version": "29.4.14", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.14.tgz", + "integrity": "sha512-+943G6wCX5E3VpzTQhBGBFntCmdT+QAkTvUlp03edTONFBvqskuXPhpyX16xdUotO4P5HQKyppldSWjocoltLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <9", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wordwrapjs": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.0.tgz", + "integrity": "sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==", + "peer": true, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/docs/web-tests/ts/package.json b/docs/web-tests/ts/package.json new file mode 100644 index 000000000..0a5e9639c --- /dev/null +++ b/docs/web-tests/ts/package.json @@ -0,0 +1,36 @@ +{ + "name": "examples", + "version": "1.0.0", + "description": "Examples for LanceDB", + "main": "index.js", + "type": "module", + "scripts": { + "//1": "--experimental-vm-modules is needed to run jest with sentence-transformers", + "//2": "--testEnvironment is needed to run jest with sentence-transformers", + "//3": "See: https://github.com/huggingface/transformers.js/issues/57", + "test": "node --experimental-vm-modules node_modules/.bin/jest --testEnvironment jest-environment-node-single-context --verbose", + "lint": "biome check *.ts && biome format *.ts", + "lint-ci": "biome ci .", + "lint-fix": "biome check --write *.ts && npm run format", + "format": "biome format --write *.ts" + }, + "author": "Lance Devs", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/transformers": "^3.0.2", + "@lancedb/lancedb": "^0.31.0", + "openai": "^4.29.2", + "sharp": "^0.35.5" + }, + "devDependencies": { + "@biomejs/biome": "^1.7.3", + "@jest/globals": "^29.7.0", + "jest": "^29.7.0", + "jest-environment-node-single-context": "^29.4.0", + "ts-jest": "^29.2.5", + "typescript": "^5.5.4" + }, + "overrides": { + "sharp": "$sharp" + } +} diff --git a/docs/web-tests/ts/quickstart.test.ts b/docs/web-tests/ts/quickstart.test.ts new file mode 100644 index 000000000..55ae9d56c --- /dev/null +++ b/docs/web-tests/ts/quickstart.test.ts @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +import * as lancedb from "@lancedb/lancedb"; +import { withTempDirectory } from "./util.ts"; + +test("quickstart example (async)", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + + // --8<-- [start:quickstart_data] + const data = [ + { + id: "1", + name: "King Arthur", + role: "King", + description: "Leader of Camelot and wielder of Excalibur.", + stats: { strength: 4, magic: 1, leadership: 5, wisdom: 4 }, + vector: [0.7, 0.1, 0.9, 0.7], + }, + { + id: "2", + name: "Merlin", + role: "Wizard", + description: "Advisor and prophet with deep magical knowledge.", + stats: { strength: 2, magic: 5, leadership: 4, wisdom: 5 }, + vector: [0.2, 0.9, 0.4, 0.9], + }, + { + id: "3", + name: "Sir Lancelot", + role: "Knight", + description: "Legendary knight known for courage and combat skill.", + stats: { strength: 5, magic: 1, leadership: 3, wisdom: 3 }, + vector: [0.9, 0.1, 0.5, 0.4], + }, + ]; + // --8<-- [end:quickstart_data] + + // --8<-- [start:quickstart_create_table] + let table = await db.createTable("characters", data, { mode: "overwrite" }); + // --8<-- [end:quickstart_create_table] + expect(await table.countRows()).toBe(3); + await db.dropTable("characters"); + + // --8<-- [start:quickstart_create_table_no_overwrite] + table = await db.createTable("characters", data); + // --8<-- [end:quickstart_create_table_no_overwrite] + expect(await table.countRows()).toBe(3); + + // --8<-- [start:quickstart_vector_search_1] + // Search for examples similar to a "wise magical advisor" + let queryVector = [0.2, 0.8, 0.4, 0.9]; + + let result = await table + .search(queryVector) + .select(["name", "role", "description", "_distance"]) + .limit(2) + .toArray(); + console.table(result); + // --8<-- [end:quickstart_vector_search_1] + expect(result[0].name).toBe("Merlin"); + + // --8<-- [start:quickstart_curate_with_metadata] + const curated = await table + .search(queryVector) + .where("stats.magic >= 4") + .select(["name", "role", "description", "_distance"]) + .limit(2) + .toArray(); + console.table(curated); + // --8<-- [end:quickstart_curate_with_metadata] + expect(curated[0].name).toBe("Merlin"); + + // --8<-- [start:quickstart_output_array] + result = await table.search(queryVector).limit(2).toArray(); + console.table(result); + // --8<-- [end:quickstart_output_array] + expect(result[0].name).toBe("Merlin"); + + // --8<-- [start:quickstart_add_feature] + await table.addColumns([ + { + name: "power_score", + valueSql: + "cast(((stats.strength + stats.magic + stats.leadership + stats.wisdom) / 4.0) as float)", + }, + ]); + // --8<-- [end:quickstart_add_feature] + const schemaWithFeature = await table.schema(); + expect(schemaWithFeature.fields.some((f) => f.name === "power_score")).toBe( + true, + ); + + // --8<-- [start:quickstart_query_feature] + const features = await table + .query() + .select(["name", "role", "power_score"]) + .toArray(); + console.table(features); + // --8<-- [end:quickstart_query_feature] + expect(features[0]).toHaveProperty("power_score"); + + // --8<-- [start:quickstart_multimodal_bytes] + const arrow = await import("apache-arrow"); + const path = await import("node:path"); + const { readFile } = await import("node:fs/promises"); + + const imagePath = path.join( + __dirname, + "../../web/static/assets/images/quickstart/sir-lancelot.jpg", + ); + const imageBytes = await readFile(imagePath); + const imageSchema = new arrow.Schema([ + new arrow.Field("id", new arrow.Utf8()), + new arrow.Field("description", new arrow.Utf8()), + new arrow.Field("image", new arrow.Binary()), + new arrow.Field( + "vector", + new arrow.FixedSizeList( + 4, + new arrow.Field("item", new arrow.Float32(), true), + ), + ), + ]); + const imageData = lancedb.makeArrowTable( + [ + { + id: "lancelot", + description: "Portrait of Sir Lancelot", + image: imageBytes, + vector: [0.9, 0.1, 0.5, 0.4], + }, + ], + { schema: imageSchema }, + ); + const multimodalTable = await db.createTable( + "character_images", + imageData, + { mode: "overwrite" }, + ); + // --8<-- [end:quickstart_multimodal_bytes] + expect(await multimodalTable.countRows()).toBe(1); + + // --8<-- [start:quickstart_open_table] + table = await db.openTable("characters"); + // --8<-- [end:quickstart_open_table] + + // --8<-- [start:quickstart_add_data] + const moreData = [ + { + id: "4", + name: "Morgana", + role: "Sorceress", + description: "Powerful sorceress of Avalon.", + stats: { strength: 2, magic: 5, leadership: 4, wisdom: 4 }, + vector: [0.3, 0.9, 0.6, 0.8], + power_score: 3.75, + }, + ]; + + // Add data to table + await table.add(moreData); + // --8<-- [end:quickstart_add_data] + expect(await table.countRows()).toBe(4); + + // --8<-- [start:quickstart_vector_search_2] + // Search for examples similar to a "powerful sorceress" + queryVector = [0.3, 0.9, 0.6, 0.8]; + + const results = await table.search(queryVector).limit(2).toArray(); + console.table(results); + // --8<-- [end:quickstart_vector_search_2] + expect(results[0].name).toBe("Morgana"); + }); +}); diff --git a/docs/web-tests/ts/search.test.ts b/docs/web-tests/ts/search.test.ts new file mode 100644 index 000000000..bc50968cc --- /dev/null +++ b/docs/web-tests/ts/search.test.ts @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +// --8<-- [start:import] +import * as lancedb from "@lancedb/lancedb"; +// --8<-- [end:import] +// --8<-- [start:import_bin_util] +import { Field, FixedSizeList, Int32, Schema, Uint8 } from "apache-arrow"; +// --8<-- [end:import_bin_util] +import { Float32, Struct } from "apache-arrow"; +import { withTempDirectory } from "./util.ts"; + +async function buildIndexedTable(db: lancedb.Connection, name: string) { + const data = Array.from({ length: 512 }, (_, i) => ({ + vector: Array.from({ length: 128 }, () => Math.random()), + id: i, + })); + const tbl = await db.createTable(name, data, { mode: "overwrite" }); + await tbl.createIndex("vector", { + config: lancedb.Index.ivfPq({ numPartitions: 4, numSubVectors: 8 }), + }); + return tbl; +} + +test("vector search", async () => { + await withTempDirectory(async (databaseDir) => { + { + const db = await lancedb.connect(databaseDir); + + const data = Array.from({ length: 10_000 }, (_, i) => ({ + vector: Array(128).fill(i), + id: `${i}`, + })); + + await db.createTable("my_vectors", data); + } + + // --8<-- [start:search1] + const db = await lancedb.connect(databaseDir); + const tbl = await db.openTable("my_vectors"); + + const results1 = await tbl.search(Array(128).fill(1.2)).limit(10).toArray(); + // --8<-- [end:search1] + expect(results1.length).toBe(10); + + // --8<-- [start:search2] + const results2 = await ( + tbl.search(Array(128).fill(1.2)) as lancedb.VectorQuery + ) + .distanceType("cosine") + .limit(10) + .toArray(); + // --8<-- [end:search2] + expect(results2.length).toBe(10); + + // --8<-- [start:distance_range] + const results3 = await ( + tbl.search(Array(128).fill(1.2)) as lancedb.VectorQuery + ) + .distanceType("cosine") + .distanceRange(0.1, 0.2) + .limit(10) + .toArray(); + // --8<-- [end:distance_range] + for (const r of results3) { + expect(r.distance).toBeGreaterThanOrEqual(0.1); + expect(r.distance).toBeLessThan(0.2); + } + + { + // --8<-- [start:ingest_binary_data] + const schema = new Schema([ + new Field("id", new Int32(), true), + new Field("vec", new FixedSizeList(32, new Field("item", new Uint8()))), + ]); + const data = lancedb.makeArrowTable( + Array(1_000) + .fill(0) + .map((_, i) => ({ + // the 256 bits would be store in 32 bytes, + // if your data is already in this format, you can skip the packBits step + id: i, + vec: lancedb.packBits(Array(256).fill(i % 2)), + })), + { schema: schema }, + ); + + const tbl = await db.createTable("binary_table", data); + await tbl.createIndex("vec", { + config: lancedb.Index.ivfFlat({ + numPartitions: 10, + distanceType: "hamming", + }), + }); + // --8<-- [end:ingest_binary_data] + + // --8<-- [start:search_binary_data] + const query = Array(32) + .fill(1) + .map(() => Math.floor(Math.random() * 255)); + const results = await tbl.query().nearestTo(query).limit(10).toArrow(); + // --8<-- [end:search_binary_data] + expect(results.numRows).toBe(10); + } + }); +}); + +test("vector search docs snippets", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + + // ---- Nested vector column: inference and explicit selection ---- + { + const nestedSchema = new Schema([ + new Field("id", new Int32()), + new Field( + "image", + new Struct([ + new Field( + "embedding", + new FixedSizeList(2, new Field("item", new Float32(), true)), + ), + ]), + ), + ]); + const arrowTbl = lancedb.makeArrowTable( + [{ id: 0, image: { embedding: [0.0, 1.0] } }], + { schema: nestedSchema }, + ); + await db.createTable("nested", arrowTbl, { mode: "overwrite" }); + + // --8<-- [start:select_vector_column] + const table = await db.openTable("nested"); + + // Inferred: LanceDB finds the single nested vector leaf automatically. + await table.query().nearestTo([0.0, 1.0]).limit(1).toArray(); + + // Explicit: required when more than one vector column matches. + await table + .query() + .nearestTo([0.0, 1.0]) + .column("image.embedding") + .limit(1) + .toArray(); + // --8<-- [end:select_vector_column] + } + + // ---- Index a nested vector column ---- + { + const dim = 16; + const nestedSchema = new Schema([ + new Field("id", new Int32()), + new Field( + "image", + new Struct([ + new Field( + "embedding", + new FixedSizeList(dim, new Field("item", new Float32(), true)), + ), + ]), + ), + ]); + const rows = Array.from({ length: 512 }, (_, i) => ({ + id: i, + image: { embedding: Array.from({ length: dim }, () => Math.random()) }, + })); + const arrowTbl = lancedb.makeArrowTable(rows, { schema: nestedSchema }); + const table = await db.createTable("nested_index", arrowTbl, { + mode: "overwrite", + }); + + // --8<-- [start:index_nested_column] + await table.createIndex("image.embedding"); + // --8<-- [end:index_nested_column] + } + + // ---- Indexed queries: refine, fast search, bypass index ---- + { + const table = await buildIndexedTable(db, "ann_table"); + const embedding = Array.from({ length: 128 }, () => Math.random()); + + // --8<-- [start:exact_vs_approximate] + // Indexed ANN search without refinement (fast, approximate `_distance`) + const fastResults = await (table.search(embedding) as lancedb.VectorQuery) + .limit(10) + .toArray(); + + // Recompute distances on full vectors for reranked candidates + const exactDistanceResults = await ( + table.search(embedding) as lancedb.VectorQuery + ) + .limit(10) + .refineFactor(1) + .toArray(); + + // Rerank a larger candidate set for better recall (higher latency) + const higherRecallResults = await ( + table.search(embedding) as lancedb.VectorQuery + ) + .limit(10) + .refineFactor(20) + .toArray(); + // --8<-- [end:exact_vs_approximate] + expect(fastResults.length).toBe(10); + expect(exactDistanceResults.length).toBe(10); + expect(higherRecallResults.length).toBe(10); + + // --8<-- [start:fast_search] + await table + .query() + .nearestTo(embedding) + .fastSearch() + .limit(5) + .toArray(); + // --8<-- [end:fast_search] + + // --8<-- [start:bypass_vector_index] + await table + .query() + .nearestTo(embedding) + .bypassVectorIndex() + .limit(5) + .toArray(); + // --8<-- [end:bypass_vector_index] + } + + // ---- Brute force search (no index) ---- + { + const data = Array.from({ length: 64 }, (_, i) => ({ + vector: Array(128).fill(i), + id: `${i}`, + })); + await db.createTable("my_vectors", data, { mode: "overwrite" }); + + // --8<-- [start:brute_force_search] + const tbl = await db.openTable("my_vectors"); + + const results1 = await tbl.search(Array(128).fill(1.2)).limit(3).toArray(); + // --8<-- [end:brute_force_search] + expect(results1.length).toBe(3); + } + + // ---- Binary (hamming) vector search ---- + { + const schema = new Schema([ + new Field("id", new Int32(), true), + new Field("vector", new FixedSizeList(32, new Field("item", new Uint8()))), + ]); + const data = lancedb.makeArrowTable( + Array(1000) + .fill(0) + .map((_, i) => ({ + // the 256 bits are stored in 32 bytes; if your data is already in + // this format, you can skip the packBits step + id: i, + vector: lancedb.packBits(Array(256).fill(i % 2)), + })), + { schema }, + ); + + // --8<-- [start:binary_search] + const tbl = await db.createTable("binary_vectors", data, { + mode: "overwrite", + }); + await tbl.createIndex("vector", { + config: lancedb.Index.ivfFlat({ + numPartitions: 10, + distanceType: "hamming", + }), + }); + + const query = Array(32) + .fill(1) + .map(() => Math.floor(Math.random() * 255)); + const results = await tbl.query().nearestTo(query).limit(10).toArray(); + // --8<-- [end:binary_search] + expect(results.length).toBeLessThanOrEqual(10); + } + + // ---- Enterprise-style prefilter / postfilter / batch search ---- + { + const dimensions = 768; + const rows = Array.from({ length: 50 }, (_, i) => ({ + vector: Array.from({ length: dimensions }, () => Math.random() * 2 - 1), + text: `story ${i}`, + keywords: `kw${i}`, + label: i % 4, + })); + await db.createTable("lancedb-enterprise-quickstart", rows, { + mode: "overwrite", + }); + + { + // --8<-- [start:vector_search_prefilter] + // Generate a sample 768-dimension embedding vector (typical for BERT-based models) + // In real applications, you would get this from an embedding model + const dimensions = 768; + const queryEmbed = Array.from( + { length: dimensions }, + () => Math.random() * 2 - 1, + ); + + // Open table and perform search + const tableName = "lancedb-enterprise-quickstart"; + const table = await db.openTable(tableName); + + // Vector search with filters (pre-filtering is the default) + const vectorResults = await table + .search(queryEmbed) + .where("label > 2") + .select(["text", "keywords", "label"]) + .limit(5) + .toArray(); + + console.log("Search results (with pre-filtering):"); + console.log(vectorResults); + // --8<-- [end:vector_search_prefilter] + + // --8<-- [start:vector_search_postfilter] + const vectorResultsWithPostFilter = await ( + table.search(queryEmbed) as lancedb.VectorQuery + ) + .where("label > 2") + .postfilter() + .select(["text", "keywords", "label"]) + .limit(5) + .toArray(); + + console.log("Vector search results with post-filter:"); + console.log(vectorResultsWithPostFilter); + // --8<-- [end:vector_search_postfilter] + + // --8<-- [start:batch_search] + // Batch query + console.log("Performing batch vector search..."); + const batchSize = 5; + const queryVectors = Array.from({ length: batchSize }, () => + Array.from({ length: dimensions }, () => Math.random() * 2 - 1), + ); + let batchQuery = table.search(queryVectors[0]) as lancedb.VectorQuery; + for (let i = 1; i < batchSize; i++) { + batchQuery = batchQuery.addQueryVector(queryVectors[i]); + } + const batchResults = await batchQuery + .select(["text", "keywords", "label"]) + .limit(5) + .toArray(); + console.log("Batch vector search results:"); + console.log(batchResults); + // --8<-- [end:batch_search] + expect(batchResults.length).toBeGreaterThan(0); + } + } + }); +}); diff --git a/docs/web-tests/ts/sentence-transformers.test.ts b/docs/web-tests/ts/sentence-transformers.test.ts new file mode 100644 index 000000000..a407e192b --- /dev/null +++ b/docs/web-tests/ts/sentence-transformers.test.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +import { withTempDirectory } from "./util.ts"; + +// --8<-- [start:quickstart_imports] +import * as lancedb from "@lancedb/lancedb"; +import "@lancedb/lancedb/embedding/transformers"; +import { Utf8 } from "apache-arrow"; +// --8<-- [end:quickstart_imports] + +test("full text search", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + const func = (await lancedb.embedding + .getRegistry() + .get("huggingface") + ?.create()) as lancedb.embedding.EmbeddingFunction; + + const facts = [ + "Albert Einstein was a theoretical physicist.", + "The capital of France is Paris.", + "The Great Wall of China is one of the Seven Wonders of the World.", + "Python is a popular programming language.", + "Mount Everest is the highest mountain in the world.", + "Leonardo da Vinci painted the Mona Lisa.", + "Shakespeare wrote Hamlet.", + "The human body has 206 bones.", + "The speed of light is approximately 299,792 kilometers per second.", + "Water boils at 100 degrees Celsius.", + "The Earth orbits the Sun.", + "The Pyramids of Giza are located in Egypt.", + "Coffee is one of the most popular beverages in the world.", + "Tokyo is the capital city of Japan.", + "Photosynthesis is the process by which plants make their food.", + "The Pacific Ocean is the largest ocean on Earth.", + "Mozart was a prolific composer of classical music.", + "The Internet is a global network of computers.", + "Basketball is a sport played with a ball and a hoop.", + "The first computer virus was created in 1983.", + "Artificial neural networks are inspired by the human brain.", + "Deep learning is a subset of machine learning.", + "IBM's Watson won Jeopardy! in 2011.", + "The first computer programmer was Ada Lovelace.", + "The first chatbot was ELIZA, created in the 1960s.", + ].map((text) => ({ text })); + + const factsSchema = lancedb.embedding.LanceSchema({ + text: func.sourceField(new Utf8()), + vector: func.vectorField(), + }); + + const tbl = await db.createTable("facts", facts, { + mode: "overwrite", + schema: factsSchema, + }); + + const query = "How many bones are in the human body?"; + const actual = await tbl.search(query).limit(1).toArray(); + + expect(actual[0].text).toBe("The human body has 206 bones."); + }); +}, 100_000); + +test.skip("embedding quickstart snippets", async () => { + // --8<-- [start:quickstart_connect] + const db = await lancedb.connect("data/sample-lancedb"); + // --8<-- [end:quickstart_connect] + + // --8<-- [start:quickstart_init_model] + const model = (await lancedb.embedding + .getRegistry() + .get("huggingface") + ?.create()) as lancedb.embedding.EmbeddingFunction; + // --8<-- [end:quickstart_init_model] + + // --8<-- [start:quickstart_schema] + const wordsSchema = lancedb.embedding.LanceSchema({ + text: model.sourceField(new Utf8()), + vector: model.vectorField(), + }); + // --8<-- [end:quickstart_schema] + + // --8<-- [start:quickstart_create_table] + const table = await db.createEmptyTable("words", wordsSchema, { + mode: "overwrite", + }); + await table.add([{ text: "hello world" }, { text: "goodbye world" }]); + // --8<-- [end:quickstart_create_table] + + // --8<-- [start:quickstart_query] + const query = "greetings"; + const actual = (await table.search(query).limit(1).toArray())[0]; + console.log(actual.text); + // --8<-- [end:quickstart_query] +}); diff --git a/docs/web-tests/ts/storage.test.ts b/docs/web-tests/ts/storage.test.ts new file mode 100644 index 000000000..38c27cf27 --- /dev/null +++ b/docs/web-tests/ts/storage.test.ts @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, test } from "@jest/globals"; +import * as lancedb from "@lancedb/lancedb"; + +// --8<-- [start:storage_connect_s3] +async function storageConnectS3() { + const db = await lancedb.connect("s3://bucket/path"); + return db; +} +// --8<-- [end:storage_connect_s3] + +// --8<-- [start:storage_connect_gcs] +async function storageConnectGcs() { + const db = await lancedb.connect("gs://bucket/path"); + return db; +} +// --8<-- [end:storage_connect_gcs] + +// --8<-- [start:storage_connect_azure] +async function storageConnectAzure() { + const db = await lancedb.connect("az://bucket/path"); + return db; +} +// --8<-- [end:storage_connect_azure] + +// --8<-- [start:storage_connect_timeout] +async function storageConnectTimeout() { + const db = await lancedb.connect("s3://bucket/path", { + storageOptions: { timeout: "60s" }, + }); + return db; +} +// --8<-- [end:storage_connect_timeout] + +// --8<-- [start:storage_table_timeout] +async function storageTableTimeout() { + const db = await lancedb.connect("s3://bucket/path"); + const table = await db.createTable( + "table", + [{ a: 1, b: 2 }], + { storageOptions: { timeout: "60s" } }, + ); + return table; +} +// --8<-- [end:storage_table_timeout] + +// --8<-- [start:storage_s3_ddb] +async function storageS3Ddb() { + const db = await lancedb.connect( + "s3+ddb://bucket/path?ddbTableName=my-dynamodb-table", + ); + return db; +} +// --8<-- [end:storage_s3_ddb] + +// --8<-- [start:storage_s3_ddb_local] +async function storageS3DdbLocal() { + const db = await lancedb.connect( + "s3+ddb://bucket/path?ddbTableName=my-dynamodb-table", + { + storageOptions: { + endpoint: "http://localhost:4566", + dynamodbEndpoint: "http://localhost:4566", + allowHttp: "true", + }, + }, + ); + return db; +} +// --8<-- [end:storage_s3_ddb_local] + +// --8<-- [start:storage_s3_sse_kms] +async function storageS3SseKms() { + const db = await lancedb.connect("s3://bucket/path", { + storageOptions: { + awsServerSideEncryption: "aws:kms", + awsSseKmsKeyId: "", + }, + }); + return db; +} +// --8<-- [end:storage_s3_sse_kms] + +// --8<-- [start:storage_azure_sas] +async function storageAzureSas() { + const db = await lancedb.connect( + "az://my-container/my-database", + { + storageOptions: { + azureStorageAccountName: "some-account", + azureStorageSasToken: "", + }, + }, + ); + return db; +} +// --8<-- [end:storage_azure_sas] + +// --8<-- [start:storage_s3_minio] +async function storageS3Minio() { + const db = await lancedb.connect("s3://bucket/path", { + storageOptions: { + region: "us-east-1", + endpoint: "http://minio:9000", + }, + }); + return db; +} +// --8<-- [end:storage_s3_minio] + +// --8<-- [start:storage_s3_express] +async function storageS3Express() { + const db = await lancedb.connect( + "s3://my-bucket--use1-az4--x-s3/path", + { + storageOptions: { + region: "us-east-1", + s3Express: "true", + }, + }, + ); + return db; +} +// --8<-- [end:storage_s3_express] + +// --8<-- [start:storage_gcs_service_account] +async function storageGcsServiceAccount() { + const db = await lancedb.connect( + "gs://my-bucket/my-database", + { + storageOptions: { + serviceAccount: "path/to/service-account.json", + }, + }, + ); + return db; +} +// --8<-- [end:storage_gcs_service_account] + +// --8<-- [start:storage_azure_account] +async function storageAzureAccount() { + const db = await lancedb.connect( + "az://my-container/my-database", + { + storageOptions: { + accountName: "some-account", + accountKey: "some-key", + }, + }, + ); + return db; +} +// --8<-- [end:storage_azure_account] + +// --8<-- [start:storage_tigris_connect] +async function storageTigrisConnect() { + const db = await lancedb.connect( + "s3://your-bucket/path", + { + storageOptions: { + endpoint: "https://t3.storage.dev", + region: "auto", + }, + }, + ); + return db; +} +// --8<-- [end:storage_tigris_connect] + +test("storage ts snippets compile", async () => { + expect(storageConnectS3).toBeDefined(); + expect(storageConnectGcs).toBeDefined(); + expect(storageConnectAzure).toBeDefined(); + expect(storageConnectTimeout).toBeDefined(); + expect(storageTableTimeout).toBeDefined(); + expect(storageS3Ddb).toBeDefined(); + expect(storageS3DdbLocal).toBeDefined(); + expect(storageS3Minio).toBeDefined(); + expect(storageS3Express).toBeDefined(); + expect(storageS3SseKms).toBeDefined(); + expect(storageGcsServiceAccount).toBeDefined(); + expect(storageAzureAccount).toBeDefined(); + expect(storageAzureSas).toBeDefined(); + expect(storageTigrisConnect).toBeDefined(); +}); + diff --git a/docs/web-tests/ts/tables.test.ts b/docs/web-tests/ts/tables.test.ts new file mode 100644 index 000000000..16fa4545c --- /dev/null +++ b/docs/web-tests/ts/tables.test.ts @@ -0,0 +1,1061 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { expect, jest, test } from "@jest/globals"; +import * as arrow from "apache-arrow"; +import * as lancedb from "@lancedb/lancedb"; +import { withTempDirectory } from "./util.ts"; + +async function updateConnectEnterpriseExample() { + // --8<-- [start:update_connect_enterprise] + const db = await lancedb.connect("db://your-project-slug", { + apiKey: "your-api-key", + region: "us-east-1", + }); + // --8<-- [end:update_connect_enterprise] + return db; +} + +async function updateConnectLocalExample() { + // --8<-- [start:update_connect_local] + const db = await lancedb.connect("./data"); + // --8<-- [end:update_connect_local] + return db; +} + +test("table creation snippets (async)", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + + // --8<-- [start:create_table_from_dicts] + type Location = { + vector: number[]; + lat: number; + long: number; + }; + + const data: Location[] = [ + { vector: [1.1, 1.2], lat: 45.5, long: -122.7 }, + { vector: [0.2, 1.8], lat: 40.1, long: -74.1 }, + ]; + const table = await db.createTable("test_table", data, { + mode: "overwrite", + }); + // --8<-- [end:create_table_from_dicts] + expect(await table.countRows()).toBe(2); + + await db.createTable("conflict_table", data); + // --8<-- [start:create_table_conflict_handling] + // Idempotent open: reuse the existing table if it exists. + // The provided data is ignored; the schema is validated against the + // existing table and a mismatch raises an error. + let conflictTable = await db.createTable("conflict_table", data, { + existOk: true, + }); + + // Overwrite: drop the existing table and create a new one with the + // provided data. This permanently discards the old table's data. + conflictTable = await db.createTable("conflict_table", data, { + mode: "overwrite", + }); + // --8<-- [end:create_table_conflict_handling] + expect(await conflictTable.countRows()).toBe(2); + + // --8<-- [start:create_table_custom_schema] + const customSchema = new arrow.Schema([ + new arrow.Field( + "vector", + new arrow.FixedSizeList( + 4, + new arrow.Field("item", new arrow.Float32(), true), + ), + ), + new arrow.Field("lat", new arrow.Float32()), + new arrow.Field("long", new arrow.Float32()), + ]); + + const customSchemaData = lancedb.makeArrowTable( + [ + { vector: [1.1, 1.2, 1.3, 1.4], lat: 45.5, long: -122.7 }, + { vector: [0.2, 1.8, 0.4, 3.6], lat: 40.1, long: -74.1 }, + ], + { schema: customSchema }, + ); + const customSchemaTable = await db.createTable( + "my_table_custom_schema", + customSchemaData, + { mode: "overwrite" }, + ); + // --8<-- [end:create_table_custom_schema] + expect(await customSchemaTable.countRows()).toBe(2); + + // --8<-- [start:create_table_from_arrow] + const arrowSchema = new arrow.Schema([ + new arrow.Field( + "vector", + new arrow.FixedSizeList( + 16, + new arrow.Field("item", new arrow.Float32(), true), + ), + ), + new arrow.Field("text", new arrow.Utf8()), + ]); + const arrowData = lancedb.makeArrowTable( + [ + { vector: Array(16).fill(0.1), text: "foo" }, + { vector: Array(16).fill(0.2), text: "bar" }, + ], + { schema: arrowSchema }, + ); + const arrowTable = await db.createTable("f32_tbl", arrowData, { + mode: "overwrite", + }); + // --8<-- [end:create_table_from_arrow] + expect(await arrowTable.countRows()).toBe(2); + + // --8<-- [start:create_table_from_iterator] + const batchSchema = new arrow.Schema([ + new arrow.Field( + "vector", + new arrow.FixedSizeList( + 4, + new arrow.Field("item", new arrow.Float32(), true), + ), + ), + new arrow.Field("item", new arrow.Utf8()), + new arrow.Field("price", new arrow.Float32()), + ]); + + const tableForBatches = await db.createEmptyTable( + "batched_table", + batchSchema, + { + mode: "overwrite", + }, + ); + + const rows = Array.from({ length: 10 }, (_, i) => ({ + vector: [i + 0.1, i + 0.2, i + 0.3, i + 0.4], + item: `item-${i + 1}`, + price: (i + 1) * 10, + })); + + const chunkSize = 2; + for (let i = 0; i < rows.length; i += chunkSize) { + const batch = lancedb.makeArrowTable(rows.slice(i, i + chunkSize), { + schema: batchSchema, + }); + await tableForBatches.add(batch); + } + // --8<-- [end:create_table_from_iterator] + expect(await tableForBatches.countRows()).toBe(10); + + // --8<-- [start:open_existing_table] + const openTableData = [{ vector: [1.1, 1.2], lat: 45.5, long: -122.7 }]; + await db.createTable("test_table_open", openTableData, { + mode: "overwrite", + }); + + console.log(await db.tableNames()); + + const openedTable = await db.openTable("test_table_open"); + // --8<-- [end:open_existing_table] + expect(await openedTable.countRows()).toBe(1); + + // --8<-- [start:create_empty_table] + const emptySchema = new arrow.Schema([ + new arrow.Field( + "vector", + new arrow.FixedSizeList( + 2, + new arrow.Field("item", new arrow.Float32(), true), + ), + ), + new arrow.Field("item", new arrow.Utf8()), + new arrow.Field("price", new arrow.Float32()), + ]); + const emptyTable = await db.createEmptyTable( + "test_empty_table", + emptySchema, + { + mode: "overwrite", + }, + ); + // --8<-- [end:create_empty_table] + expect(await emptyTable.countRows()).toBe(0); + + // --8<-- [start:drop_table] + await db.createTable("my_table", [{ vector: [1.1, 1.2], lat: 45.5 }], { + mode: "overwrite", + }); + + await db.dropTable("my_table"); + // --8<-- [end:drop_table] + expect(await db.tableNames()).not.toContain("my_table"); + }); +}); + +test("add progress snippet (async)", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + const table = await db.createTable("progress_table", [ + { vector: [1.1, 1.2], item: "foo" }, + ]); + const moreData = [ + { vector: [2.1, 2.2], item: "bar" }, + { vector: [3.1, 3.2], item: "baz" }, + ]; + const log = jest.spyOn(console, "log").mockImplementation(() => {}); + try { + // --8<-- [start:add_progress] + // Track ingestion progress as batches are written. + await table.add(moreData, { + progress: (p) => { + const rows = `${p.outputRows}/${p.totalRows ?? "?"} rows`; + const size = `${p.outputBytes} bytes, ${p.elapsedSeconds.toFixed(1)}s`; + const tasks = `${p.activeTasks}/${p.totalTasks} tasks active`; + const done = p.done ? " \u2014 done" : ""; + console.log(`wrote ${rows} (${size}, ${tasks})${done}`); + }, + }); + // --8<-- [end:add_progress] + expect(log.mock.calls.at(-1)?.[0]).toMatch(/^wrote 2\/2 rows .* — done$/); + } finally { + log.mockRestore(); + } + expect(await table.countRows()).toBe(3); + }); +}); + +test("schema evolution snippets (async)", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + + // --8<-- [start:schema_add_setup] + const schemaAddData = [ + { + id: 1, + name: "Laptop", + price: 1200.0, + vector: Array.from({ length: 128 }, () => Math.random()), + }, + { + id: 2, + name: "Smartphone", + price: 800.0, + vector: Array.from({ length: 128 }, () => Math.random()), + }, + { + id: 3, + name: "Headphones", + price: 150.0, + vector: Array.from({ length: 128 }, () => Math.random()), + }, + ]; + const schemaAddTable = await db.createTable( + "schema_evolution_add_example", + schemaAddData, + { mode: "overwrite" }, + ); + // --8<-- [end:schema_add_setup] + expect(await schemaAddTable.countRows()).toBe(3); + + // --8<-- [start:add_columns_calculated] + // Add a discounted price column (10% discount) + await schemaAddTable.addColumns([ + { + name: "discounted_price", + valueSql: "cast((price * 0.9) as float)", + }, + ]); + // --8<-- [end:add_columns_calculated] + + // --8<-- [start:add_columns_default_values] + // Add a stock status column with default value + await schemaAddTable.addColumns([ + { + name: "in_stock", + valueSql: "cast(true as boolean)", + }, + ]); + // --8<-- [end:add_columns_default_values] + + // --8<-- [start:add_columns_nullable] + // Add a nullable timestamp column + await schemaAddTable.addColumns([ + { + name: "last_ordered", + valueSql: "cast(NULL as timestamp)", + }, + ]); + // --8<-- [end:add_columns_nullable] + + // --8<-- [start:add_feature_columns_sql] + await schemaAddTable.addColumns([ + { + name: "price_per_id", + valueSql: "cast(price / id as float)", + }, + { + name: "price_log", + valueSql: "ln(price)", + }, + { + name: "price_score", + valueSql: "cast(price / (price + 100.0) as float)", + }, + ]); + // --8<-- [end:add_feature_columns_sql] + expect((await schemaAddTable.schema()).fields.map((field) => field.name)).toEqual( + expect.arrayContaining(["price_per_id", "price_log", "price_score"]), + ); + + // --8<-- [start:schema_alter_setup] + const schemaAlter = new arrow.Schema([ + new arrow.Field("id", new arrow.Int64()), + new arrow.Field("name", new arrow.Utf8()), + new arrow.Field("price", new arrow.Int32()), + new arrow.Field("discount_price", new arrow.Float64()), + new arrow.Field( + "vector", + new arrow.FixedSizeList( + 128, + new arrow.Field("item", new arrow.Float32(), true), + ), + ), + ]); + const schemaAlterData = lancedb.makeArrowTable( + [ + { + id: 1, + name: "Laptop", + price: 1200, + discount_price: 1080.0, + vector: Array.from({ length: 128 }, () => Math.random()), + }, + { + id: 2, + name: "Smartphone", + price: 800, + discount_price: 720.0, + vector: Array.from({ length: 128 }, () => Math.random()), + }, + ], + { schema: schemaAlter }, + ); + const schemaAlterTable = await db.createTable( + "schema_evolution_alter_example", + schemaAlterData, + { mode: "overwrite" }, + ); + // --8<-- [end:schema_alter_setup] + expect(await schemaAlterTable.countRows()).toBe(2); + + // --8<-- [start:alter_columns_rename] + // Rename discount_price to sale_price + await schemaAlterTable.alterColumns([ + { path: "discount_price", rename: "sale_price" }, + ]); + // --8<-- [end:alter_columns_rename] + + // --8<-- [start:alter_columns_data_type] + // Change price from int32 to int64 for larger numbers + await schemaAlterTable.alterColumns([ + { path: "price", dataType: new arrow.Int64() }, + ]); + // --8<-- [end:alter_columns_data_type] + + // --8<-- [start:alter_columns_nullable] + // Make the name column nullable + await schemaAlterTable.alterColumns([{ path: "name", nullable: true }]); + // --8<-- [end:alter_columns_nullable] + + // --8<-- [start:alter_columns_multiple] + // Rename, change type, and make nullable in one operation + await schemaAlterTable.alterColumns([ + { + path: "sale_price", + rename: "final_price", + dataType: new arrow.Float64(), + nullable: true, + }, + ]); + // --8<-- [end:alter_columns_multiple] + + // --8<-- [start:alter_columns_with_expression] + // For custom transforms, create a new column from a SQL expression. + const expressionTable = await db.createTable( + "schema_evolution_expression_example", + [{ id: 1, price_text: "$100" }], + { mode: "overwrite" }, + ); + + await expressionTable.addColumns([ + { + name: "price_numeric", + valueSql: "cast(replace(price_text, '$', '') as int)", + }, + ]); + await expressionTable.dropColumns(["price_text"]); + await expressionTable.alterColumns([ + { path: "price_numeric", rename: "price" }, + ]); + // --8<-- [end:alter_columns_with_expression] + expect(await expressionTable.countRows()).toBe(1); + + // --8<-- [start:schema_drop_setup] + const schemaDropData = [ + { + id: 1, + name: "Laptop", + price: 1200.0, + temp_col1: "X", + temp_col2: 100, + vector: Array.from({ length: 128 }, () => Math.random()), + }, + { + id: 2, + name: "Smartphone", + price: 800.0, + temp_col1: "Y", + temp_col2: 200, + vector: Array.from({ length: 128 }, () => Math.random()), + }, + { + id: 3, + name: "Headphones", + price: 150.0, + temp_col1: "Z", + temp_col2: 300, + vector: Array.from({ length: 128 }, () => Math.random()), + }, + ]; + const schemaDropTable = await db.createTable( + "schema_evolution_drop_example", + schemaDropData, + { mode: "overwrite" }, + ); + // --8<-- [end:schema_drop_setup] + expect(await schemaDropTable.countRows()).toBe(3); + + // --8<-- [start:drop_columns_single] + // Remove the first temporary column + await schemaDropTable.dropColumns(["temp_col1"]); + // --8<-- [end:drop_columns_single] + + // --8<-- [start:drop_columns_multiple] + // Remove the second temporary column + await schemaDropTable.dropColumns(["temp_col2"]); + // --8<-- [end:drop_columns_multiple] + + // --8<-- [start:alter_vector_column] + const oldDim = 384; + const newDim = 1024; + const vectorSchema = new arrow.Schema([ + new arrow.Field("id", new arrow.Int64()), + new arrow.Field( + "embedding", + new arrow.FixedSizeList( + oldDim, + new arrow.Field("item", new arrow.Float16(), true), + ), + true, + ), + ]); + const vectorData = lancedb.makeArrowTable( + [{ id: 1, embedding: Array.from({ length: oldDim }, () => Math.random()) }], + { schema: vectorSchema }, + ); + const vectorTable = await db.createTable("vector_alter_example", vectorData, { + mode: "overwrite", + }); + + // Changing FixedSizeList dimensions (384 -> 1024) is not supported via alterColumns. + // Use addColumns + dropColumns + alterColumns(rename) to replace the column. + await vectorTable.addColumns([ + { + name: "embedding_v2", + valueSql: `arrow_cast(NULL, 'FixedSizeList(${newDim}, Float16)')`, + }, + ]); + await vectorTable.dropColumns(["embedding"]); + await vectorTable.alterColumns([{ path: "embedding_v2", rename: "embedding" }]); + // --8<-- [end:alter_vector_column] + expect(await vectorTable.countRows()).toBe(1); + + const fieldMetadataTable = await db.createTable( + "schema_field_metadata_example", + [ + { id: 0, category: "a" }, + { id: 1, category: "b" }, + ], + { mode: "overwrite" }, + ); + + // --8<-- [start:schema_field_metadata_merge] + // Set two metadata keys on the `category` field. + const res = await fieldMetadataTable.updateFieldMetadata([ + { path: "category", metadata: { unit: "label", pii: "false" } }, + ]); + console.log(res.version); + + // Merge: add a new key, delete one via null, keep the rest. + await fieldMetadataTable.updateFieldMetadata([ + { path: "category", metadata: { source: "import", pii: null } }, + ]); + // --8<-- [end:schema_field_metadata_merge] + + // --8<-- [start:schema_field_metadata_replace] + await fieldMetadataTable.updateFieldMetadata([ + { + path: "category", + metadata: { owner: "search-team" }, + replace: true, + }, + ]); + // --8<-- [end:schema_field_metadata_replace] + }); +}); + +test("update snippets (async)", async () => { + // Keep connection snippets in this file, but do not run enterprise/local examples in CI. + void updateConnectEnterpriseExample; + void updateConnectLocalExample; + + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + + { + // --8<-- [start:update_example_table_setup] + const table = await db.createTable( + "users_example", + [ + { id: 1, name: "Alice", login_count: 10 }, + { id: 2, name: "Bob", login_count: 20 }, + ], + { mode: "overwrite" }, + ); + // --8<-- [end:update_example_table_setup] + await table.countRows(); + } + + { + // --8<-- [start:update_operation] + const table = await db.createTable( + "users_example", + [ + { id: 1, name: "Alice", login_count: 10 }, + { id: 2, name: "Bob", login_count: 20 }, + ], + { mode: "overwrite" }, + ); + await table.update({ where: "id = 2", values: { name: "Bobby" } }); + // --8<-- [end:update_operation] + await table.countRows(); + } + + { + // --8<-- [start:update_using_sql] + const table = await db.createTable( + "users_example", + [ + { id: 1, name: "Alice", login_count: 10 }, + { id: 2, name: "Bob", login_count: 20 }, + ], + { mode: "overwrite" }, + ); + await table.update({ + where: "id = 2", + valuesSql: { login_count: "login_count + 1" }, + }); + // --8<-- [end:update_using_sql] + await table.countRows(); + } + + { + // --8<-- [start:merge_matched_update_only] + const table = await db.createTable( + "users_example", + [ + { id: 1, name: "Alice", login_count: 10 }, + { id: 2, name: "Bob", login_count: 20 }, + ], + { mode: "overwrite" }, + ); + + const incomingUsers = [ + { id: 2, name: "Bobby", login_count: 21 }, + { id: 3, name: "Charlie", login_count: 5 }, + ]; + + await table + .mergeInsert("id") + .whenMatchedUpdateAll() + .execute(incomingUsers); + // --8<-- [end:merge_matched_update_only] + await table.countRows(); + } + + { + // --8<-- [start:insert_if_not_exists] + const table = await db.createTable( + "users_example", + [ + { id: 1, name: "Alice", login_count: 10 }, + { id: 2, name: "Bob", login_count: 20 }, + ], + { mode: "overwrite" }, + ); + + const incomingUsers = [ + { id: 2, name: "Bobby", login_count: 21 }, + { id: 3, name: "Charlie", login_count: 5 }, + ]; + + await table + .mergeInsert("id") + .whenNotMatchedInsertAll() + .execute(incomingUsers); + // --8<-- [end:insert_if_not_exists] + await table.countRows(); + } + + { + // --8<-- [start:merge_update_insert] + const table = await db.createTable( + "users_example", + [ + { id: 1, name: "Alice", login_count: 10 }, + { id: 2, name: "Bob", login_count: 20 }, + ], + { mode: "overwrite" }, + ); + + const incomingUsers = [ + { id: 2, name: "Bobby", login_count: 21 }, + { id: 3, name: "Charlie", login_count: 5 }, + ]; + + await table + .mergeInsert("id") + .whenMatchedUpdateAll() + .whenNotMatchedInsertAll() + .execute(incomingUsers); + // --8<-- [end:merge_update_insert] + await table.countRows(); + } + + { + // --8<-- [start:merge_delete_missing_by_source] + const table = await db.createTable( + "users_example", + [ + { id: 1, name: "Alice", login_count: 10 }, + { id: 2, name: "Bob", login_count: 20 }, + { id: 3, name: "Charlie", login_count: 5 }, + ], + { mode: "overwrite" }, + ); + + const incomingUsers = [ + { id: 2, name: "Bobby", login_count: 21 }, + { id: 3, name: "Charlie", login_count: 5 }, + ]; + + await table + .mergeInsert("id") + .whenMatchedUpdateAll() + .whenNotMatchedInsertAll() + .whenNotMatchedBySourceDelete() + .execute(incomingUsers); + // --8<-- [end:merge_delete_missing_by_source] + await table.countRows(); + } + + { + // --8<-- [start:merge_partial_columns] + const table = await db.createTable( + "users_example", + [ + { id: 1, name: "Alice", login_count: 10 }, + { id: 2, name: "Bob", login_count: 20 }, + ], + { mode: "overwrite" }, + ); + + const incomingUsers = [ + { id: 2, name: "Bobby" }, + { id: 3, name: "Charlie" }, + ]; + + await table + .mergeInsert("id") + .whenMatchedUpdateAll() + .whenNotMatchedInsertAll() + .execute(incomingUsers); + // --8<-- [end:merge_partial_columns] + await table.countRows(); + } + + { + const table = await db.createTable( + "users_example", + [ + { id: 1, name: "Alice", login_count: 10 }, + { id: 2, name: "Bob", login_count: 20 }, + { id: 3, name: "Charlie", login_count: 5 }, + ], + { mode: "overwrite" }, + ); + + // --8<-- [start:delete_operation] + // delete data + const predicate = "id = 3"; + await table.delete(predicate); + // --8<-- [end:delete_operation] + await table.countRows(); + } + + { + const table = await db.createTable( + "users_cleanup_example", + [ + { id: 1, name: "Alice", login_count: 10 }, + { id: 2, name: "Bob", login_count: 20 }, + { id: 3, name: "Charlie", login_count: 5 }, + ], + { mode: "overwrite" }, + ); + + // --8<-- [start:update_optimize_cleanup] + const olderThan = new Date(); + olderThan.setDate(olderThan.getDate() - 1); + await table.optimize({ cleanupOlderThan: olderThan }); + // --8<-- [end:update_optimize_cleanup] + } + }); +}); + +test("versioning snippets (async)", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + + // --8<-- [start:versioning_basic_setup] + const tableName = "quotes_versioning_example"; + const data = [ + { id: 1, author: "Richard", quote: "Wubba Lubba Dub Dub!" }, + { id: 2, author: "Morty", quote: "Rick, what's going on?" }, + { + id: 3, + author: "Richard", + quote: "I turned myself into a pickle, Morty!", + }, + ]; + const table = await db.createTable(tableName, data, { mode: "overwrite" }); + // --8<-- [end:versioning_basic_setup] + expect(await table.countRows()).toBe(3); + + // --8<-- [start:versioning_check_initial_version] + const versions = await table.listVersions(); + const currentVersion = await table.version(); + console.log(`Number of versions after creation: ${versions.length}`); + console.log(`Current version: ${currentVersion}`); + // --8<-- [end:versioning_check_initial_version] + expect(versions.length).toBe(1); + expect(currentVersion).toBe(versions[versions.length - 1].version); + + // --8<-- [start:versioning_update_data] + await table.update({ + where: "author = 'Richard'", + values: { author: "Richard Daniel Sanchez" }, + }); + const rowsAfterUpdate = await table.countRows( + "author = 'Richard Daniel Sanchez'", + ); + console.log(`Rows updated to Richard Daniel Sanchez: ${rowsAfterUpdate}`); + // --8<-- [end:versioning_update_data] + expect(rowsAfterUpdate).toBe(2); + + // --8<-- [start:versioning_add_data] + const moreData = [ + { + id: 4, + author: "Richard Daniel Sanchez", + quote: "That's the way the news goes!", + }, + { id: 5, author: "Morty", quote: "Aww geez, Rick!" }, + ]; + await table.add(moreData); + // --8<-- [end:versioning_add_data] + expect(await table.countRows()).toBe(5); + + // --8<-- [start:versioning_check_versions_after_mod] + const versionsAfterMod = await table.listVersions(); + const versionCountAfterMod = versionsAfterMod.length; + const versionAfterMod = await table.version(); + console.log( + `Number of versions after modifications: ${versionCountAfterMod}`, + ); + console.log(`Current version: ${versionAfterMod}`); + // --8<-- [end:versioning_check_versions_after_mod] + expect(versionCountAfterMod).toBeGreaterThanOrEqual(2); + expect(versionAfterMod).toBe(versionsAfterMod[versionsAfterMod.length - 1].version); + + // --8<-- [start:versioning_list_all_versions] + const allVersions = await table.listVersions(); + for (const v of allVersions) { + console.log(`Version ${v.version}, created at ${v.timestamp}`); + } + // --8<-- [end:versioning_list_all_versions] + expect(allVersions.length).toBeGreaterThanOrEqual(1); + + // --8<-- [start:versioning_rollback] + await table.checkout(versionAfterMod); + await table.restore(); + const versionsAfterRollback = await table.listVersions(); + const versionCountAfterRollback = versionsAfterRollback.length; + console.log( + `Total number of versions after rollback: ${versionCountAfterRollback}`, + ); + // --8<-- [end:versioning_rollback] + expect(versionCountAfterRollback).toBe(versionCountAfterMod + 1); + expect(await table.countRows()).toBe(5); + + // --8<-- [start:versioning_checkout_latest] + await table.checkoutLatest(); + // --8<-- [end:versioning_checkout_latest] + const latestVersion = await table.version(); + const versionsAfterCheckout = await table.listVersions(); + expect(latestVersion).toBe( + versionsAfterCheckout[versionsAfterCheckout.length - 1].version, + ); + + // --8<-- [start:versioning_delete_data] + await table.delete("author = 'Morty'"); + const rowsAfterDeletion = await table.countRows(); + console.log(`Number of rows after deletion: ${rowsAfterDeletion}`); + // --8<-- [end:versioning_delete_data] + expect(rowsAfterDeletion).toBe(3); + + const tagsTable = await db.createTable( + "quotes_tags_example", + [{ id: 1, author: "Richard", quote: "Wubba Lubba Dub Dub!" }], + { mode: "overwrite" }, + ); // v1 + await tagsTable.add([ + { id: 2, author: "Morty", quote: "Aww geez, Rick!" }, + ]); // v2 + await tagsTable.add([ + { id: 3, author: "Summer", quote: "Whatever, Grandpa" }, + ]); // v3 + + // --8<-- [start:versioning_tags] + const tags = await tagsTable.tags(); + + // Create a tag pointing at a specific version + await tags.create("baseline", 1); + await tags.create("with-edits", await tagsTable.version()); + + // List all tags on this table + console.log(await tags.list()); + + // Look up the version a tag points at + console.log(await tags.getVersion("baseline")); + + // Move an existing tag to a different version + await tags.update("baseline", 2); + + // Check out a version by tag name + await tagsTable.checkout("baseline"); + console.log(await tagsTable.version()); + + // Delete a tag (does not delete the underlying version) + await tags.delete("with-edits"); + + // Return to the latest version + await tagsTable.checkoutLatest(); + // --8<-- [end:versioning_tags] + expect(await tagsTable.version()).toBe(3); + const remainingTags = await tags.list(); + expect(remainingTags).toHaveProperty("baseline"); + expect(remainingTags).not.toHaveProperty("with-edits"); + }); +}); + +test("branch snippets (async)", async () => { + await withTempDirectory(async (databaseDir) => { + const db = await lancedb.connect(databaseDir); + const table = await db.createTable( + "quotes_branches_example", + [ + { id: 1, author: "Lancelot", quote: "My lance never fails." }, + { id: 2, author: "Arthur", quote: "Long live Camelot!" }, + { id: 3, author: "Merlin", quote: "Magic always has a price." }, + ], + { mode: "overwrite" }, + ); + + const branches = await table.branches(); + + // --8<-- [start:branch_create] + // Fork an isolated, writable branch from main's latest version. + // `create` returns a table handle scoped to the new branch. + const branch = await branches.create("exp"); + // --8<-- [end:branch_create] + + // --8<-- [start:branch_write] + // Writes land on the branch handle only; main is left untouched. + await branch.add([{ id: 4, author: "Lancelot", quote: "For the realm!" }]); + console.log(await branch.countRows()); // 4 rows on the branch + console.log(await table.countRows()); // 3 rows; main is unaffected + + // List every branch, each mapped to its metadata (including its fork point). + console.log(await branches.list()); + // --8<-- [end:branch_write] + + // --8<-- [start:branch_reopen] + // Reopen an existing branch by name from the table handle... + const checkedOut = await branches.checkout("exp"); + // ...or open it directly from the database connection. + const branchHandle = await db.openTable( + "quotes_branches_example", + undefined, + { branch: "exp" }, + ); + console.log(await checkedOut.countRows(), await branchHandle.countRows()); // both 4 + // --8<-- [end:branch_reopen] + + // --8<-- [start:branch_delete] + // Delete the branch and its branch-local history. Data on main is safe. + await branches.delete("exp"); + // --8<-- [end:branch_delete] + + expect(await table.countRows()).toBe(3); + expect(await branches.list()).not.toHaveProperty("exp"); + + // Setup: a branch with row results that we want to apply to main. + const candidate = await branches.create("candidate"); + await candidate.update({ + where: "id = 1", + values: { quote: "Revised on the branch" }, + }); + await candidate.add([ + { id: 4, author: "Galahad", quote: "The grail awaits." }, + ]); + + // --8<-- [start:branch_upsert_to_main] + // This is a row-level upsert, not a merge of branch histories. + // `mergeInsert` updates matching rows and inserts new rows using a stable + // unique key. Filter the branch read if you only want to apply some results. + const rowsToApply = await candidate.toArrow(); + await table + .mergeInsert("id") + .whenMatchedUpdateAll() // update rows that already exist on main + .whenNotMatchedInsertAll() // insert rows that are new on the branch + .execute(rowsToApply); + // --8<-- [end:branch_upsert_to_main] + + expect(await table.countRows()).toBe(4); + await branches.delete("candidate"); + + // Setup: a larger table with a vector and a text column to index. + const products = await db.createTable( + "products_branch_index", + Array.from({ length: 512 }, (_, i) => ({ + id: i, + vector: Array.from({ length: 4 }, () => Math.random()), + text: `product number ${i}`, + })), + { mode: "overwrite" }, + ); + const productBranches = await products.branches(); + + // --8<-- [start:branch_index] + // Build and validate indexes on a branch before using the configuration on + // main. + const dev = await productBranches.create("index-dev"); + + // A vector (ANN) index and a full-text search index, both branch-scoped. + await dev.createIndex("vector", { + config: lancedb.Index.ivfPq({ + distanceType: "cosine", + numPartitions: 1, + numSubVectors: 2, + }), + }); + await dev.createIndex("text", { config: lancedb.Index.fts() }); + + // Both indexes live only on the branch; main still has none. + console.log((await dev.listIndices()).map((ix) => ix.name)); // branch: two indexes + console.log((await products.listIndices()).map((ix) => ix.name)); // main: [] (untouched) + // --8<-- [end:branch_index] + + expect(await dev.listIndices()).toHaveLength(2); + expect(await products.listIndices()).toHaveLength(0); + await productBranches.delete("index-dev"); + }); +}); + +test("consistency snippets (async)", async () => { + await withTempDirectory(async (databaseDir) => { + // --8<-- [start:consistency_strong] + const strongWriterDb = await lancedb.connect(databaseDir); + const strongReaderDb = await lancedb.connect(databaseDir, { + readConsistencyInterval: 0, + }); + const strongWriterTable = await strongWriterDb.createTable( + "consistency_strong_table", + [{ id: 1 }], + { mode: "overwrite" }, + ); + const strongReaderTable = await strongReaderDb.openTable( + "consistency_strong_table", + ); + await strongWriterTable.add([{ id: 2 }]); + const strongRowsAfterWrite = await strongReaderTable.countRows(); + console.log(`Rows visible with strong consistency: ${strongRowsAfterWrite}`); + // --8<-- [end:consistency_strong] + expect(strongRowsAfterWrite).toBe(2); + + // --8<-- [start:consistency_eventual] + const eventualWriterDb = await lancedb.connect(databaseDir); + const eventualReaderDb = await lancedb.connect(databaseDir, { + readConsistencyInterval: 3600, + }); + const eventualWriterTable = await eventualWriterDb.createTable( + "consistency_eventual_table", + [{ id: 1 }], + { mode: "overwrite" }, + ); + const eventualReaderTable = await eventualReaderDb.openTable( + "consistency_eventual_table", + ); + await eventualWriterTable.add([{ id: 2 }]); + const eventualRowsAfterWrite = await eventualReaderTable.countRows(); + console.log( + `Rows visible before eventual refresh interval: ${eventualRowsAfterWrite}`, + ); + // --8<-- [end:consistency_eventual] + expect(eventualRowsAfterWrite).toBe(1); + + // --8<-- [start:consistency_checkout_latest] + const checkoutWriterDb = await lancedb.connect(databaseDir); + const checkoutReaderDb = await lancedb.connect(databaseDir); + const checkoutWriterTable = await checkoutWriterDb.createTable( + "consistency_checkout_latest_table", + [{ id: 1 }], + { mode: "overwrite" }, + ); + const checkoutReaderTable = await checkoutReaderDb.openTable( + "consistency_checkout_latest_table", + ); + await checkoutWriterTable.add([{ id: 2 }]); + const rowsBeforeRefresh = await checkoutReaderTable.countRows(); + console.log(`Rows before checkoutLatest: ${rowsBeforeRefresh}`); + await checkoutReaderTable.checkoutLatest(); + const rowsAfterRefresh = await checkoutReaderTable.countRows(); + console.log(`Rows after checkoutLatest: ${rowsAfterRefresh}`); + // --8<-- [end:consistency_checkout_latest] + expect(rowsBeforeRefresh).toBe(1); + expect(rowsAfterRefresh).toBe(2); + }); +}); diff --git a/docs/web-tests/ts/tsconfig.json b/docs/web-tests/ts/tsconfig.json new file mode 100644 index 000000000..49a04ac10 --- /dev/null +++ b/docs/web-tests/ts/tsconfig.json @@ -0,0 +1,17 @@ +{ + "include": ["*.test.ts"], + "compilerOptions": { + "target": "es2022", + "module": "NodeNext", + "declaration": true, + "outDir": "./dist", + "strict": true, + "allowJs": true, + "resolveJsonModule": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "emitDeclarationOnly": true + } +} diff --git a/docs/web-tests/ts/util.ts b/docs/web-tests/ts/util.ts new file mode 100644 index 000000000..404abedf7 --- /dev/null +++ b/docs/web-tests/ts/util.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; + +export async function withTempDirectory( + fn: (tempDir: string) => Promise, +) { + const tmpDirPath = fs.mkdtempSync(path.join(tmpdir(), "temp-dir-")); + try { + await fn(tmpDirPath); + } finally { + fs.rmSync(tmpDirPath, { recursive: true }); + } +} diff --git a/docs/web/agent-branch-experiments.mdx b/docs/web/agent-branch-experiments.mdx new file mode 100644 index 000000000..3f4f1cbfa --- /dev/null +++ b/docs/web/agent-branch-experiments.mdx @@ -0,0 +1,100 @@ +--- +title: "Run experiments on branches" +sidebarTitle: "Branch Experiments" +description: "Use LanceDB branches to isolate agent-driven experiments from main, evaluate them on a fixed test set, and promote only the winner." +icon: "flask" +keywords: ["AI agents", "branches", "experiments", "embeddings", "evaluation"] +--- + +LanceDB supports [branching](/tables/branching) to isolate experiments from the main table when working with agents. +Branches are useful when you instruct an agent to try several approaches without +affecting the production data on the `main` branch. +Each experiment gets its own writable table history. + +For example, you can generate and compare embeddings from two text embedding +models on the given table: + +| Branch | Model | Added column | +| --- | --- | --- | +| `embed-minilm` | `sentence-transformers/all-MiniLM-L6-v2` | `vector_minilm` | +| `embed-nomic` | `nomic-embed-text` from Ollama | `vector_nomic` | + +The branches share a logical table, but each has its own schema and history. +The original table handle still points to `main`. LanceDB does not have a +process-wide current branch. + +Install the model packages, then download the `nomic-embed-text` model from Ollama +(or any other model you prefer): + +```bash +uv add sentence-transformers ollama +ollama pull nomic-embed-text +``` + +It's always recommended to measure the results of an experiment on a fixed evaluation set. +Here's an example of how you could ask the agent to run both experiments and compare the results: + +```text Agent prompt +Use the lancedb plugin and the LanceDB branching documentation to compare two +embedding experiments on the `camelot_multimodal` table. + +1. Fork `embed-minilm` from `main`. Build normalized embeddings from + `"{role}. {description}"` with + `sentence-transformers/all-MiniLM-L6-v2` and add them as `vector_minilm`. +2. Fork `embed-nomic` from `main`. Embed the identical text with Ollama + `nomic-embed-text` and add the results as `vector_nomic`. +3. Process rows in batches and write only through each branch-scoped table + handle. Verify that neither new column appears on `main`. +4. Evaluate both branches with the queries "wise magical advisor", + "treacherous rebel", and "virtuous Grail knight". Report the top three names, + latency, and whether the expected character ranks first. +5. Do not modify `main` and do not delete either branch. Recommend a winner + based on the results. +``` + + +The vector dimensions may differ because the columns live on separate branches. +Use a different column name for each model. Within an experiment, use the same +model for the stored text and the search queries. + + +## Apply the winning experiment to `main` {#apply-the-winning-experiment-to-main} + +The branch experiments leave you with the results side by side, and they +deliberately never touch `main`. Once you've picked a winner, apply it to +`main` yourself by rerunning that experiment's validated transformation +directly against the `main` table. Rerunning the reviewed operation is the +reliable path on both OSS and Enterprise: it replays exactly the transformation +you validated on the branch, and it works the same regardless of how the branch +evolved. + +Suppose `nomic-embed-text` wins. Rerun the same Nomic embedding transformation +you validated on `embed-nomic` against `main` in batches, then call +`table.optimize()` on OSS. Verify a bounded vector search on `main`, and keep +the losing branch around until you're confident in the result. + +See [Branches](/tables/branching) for more on how branches, versions, and tags +relate. + +## More experiments you can run {#more-experiments-you-can-run} + +Swapping embedding models on a branch while working with agents is only one example +of what you can do with the LanceDB plugin. The table below shows other experiments +you can run with the plugin. + +| Hypothesis | Change on the branch | What to evaluate | +| --- | --- | --- | +| A different embedding model improves retrieval | Add a new vector column | Recall, ranking quality, latency, and cost | +| A new parser or OCR model improves source data | Reprocess files and backfill metadata | Validation failures and a reviewed sample | +| A new search setup works better | Build an index or change hybrid-search and reranking settings | Relevance and latency on a fixed query set | +| A curation rule improves the corpus | Deduplicate, classify, or filter records | False positives, false negatives, and row counts | +| A migration is safe to deploy | Add columns or run a backfill | Schema compatibility and application checks | + +For each experiment, ask the agent to state the hypothesis, use a fixed +evaluation set, report the comparison, and, if you want, work on an alternate +branch that's isolated from `main` until you decide you want the new derived +column in `main`. + +Once you've chosen a winner, rerun that validated transformation to ingest it into `main` +yourself, on either LanceDB OSS or Enterprise. See our documentation on [branches](/tables/branching) +for more information. diff --git a/docs/web/api-reference/rest/index.mdx b/docs/web/api-reference/rest/index.mdx new file mode 100644 index 000000000..805f73cda --- /dev/null +++ b/docs/web/api-reference/rest/index.mdx @@ -0,0 +1,84 @@ +--- +title: REST API Reference +sidebarTitle: "Overview" +description: "API reference for LanceDB" + +--- + +[Lance REST Namespace](https://lance.org/format/namespace/) spec +is an OpenAPI protocol that enables reading, writing and managing Lance tables by connecting +those metadata services or building a custom metadata server in a standardized way. + +LanceDB OSS allows you to interface with Lance tables via the REST Namespace. +LanceDB Enterprise provides an extended REST API with +additional endpoints for managing tables and data. +If you have specific needs or questions about the Enterprise REST API Namespace, +please [contact us](mailto:support@lancedb.com). + +## Authentication {#authentication} + +Enterprise + +All HTTP requests to LanceDB APIs must contain an x-api-key header that specifies a valid API key and +must be encoded as JSON or Arrow RPC. + +To authenticate to the Enterprise REST API, you need the endpoint for your deployment and a valid API key for that deployment. + +### Get your Enterprise credentials {#get-your-enterprise-credentials} + +1. Obtain the following values from your LanceDB administrator or the LanceDB team that provisioned your Enterprise deployment: + - an API key + - your Enterprise REST endpoint + - your database name, if your deployment uses a private endpoint or `host_override` + +2. Export those values in your terminal: + +```bash +export LANCEDB_API_KEY="" +export LANCEDB_URI="https://your-enterprise-endpoint.com" +export LANCEDB_DATABASE="your-database-name" +``` + +3. If your Enterprise deployment is private, connect through the private network endpoint provided for your deployment. For example, Azure Private Link deployments commonly use a private IP or an internal DNS name as the endpoint. + +### Verify authentication {#verify-authentication} + +4. Check that you can reach the deployment and list tables: + +```bash +curl -X GET "$LANCEDB_URI/v1/management/table" \ + -H "Content-Type: application/json" \ + -H "x-api-key: $LANCEDB_API_KEY" \ + -H "x-lancedb-database: $LANCEDB_DATABASE" +``` + +If your deployment endpoint already includes the database host name, you can omit the `x-lancedb-database` header. + +5. Create a table to confirm write access. Let's call it `words`. + +```bash +curl -X POST "$LANCEDB_URI/v1/tables/words" \ + -H "Content-Type: application/vnd.apache.arrow.stream" \ + -H "x-api-key: $LANCEDB_API_KEY" \ + -H "x-lancedb-database: $LANCEDB_DATABASE" +``` + +6. Check that the table has been created: + +```bash +curl -X GET "$LANCEDB_URI/v1/management/table" \ + -H "Content-Type: application/json" \ + -H "x-api-key: $LANCEDB_API_KEY" \ + -H "x-lancedb-database: $LANCEDB_DATABASE" +``` + +That's it -- you're connected! Now, you can start adding data and querying it. +You can visit the tutorial section to build your own applications with LanceDB. + + + Check out our tutorials on building various applications with LanceDB. + diff --git a/docs/web/architecture.mdx b/docs/web/architecture.mdx new file mode 100644 index 000000000..4ee1bc0e7 --- /dev/null +++ b/docs/web/architecture.mdx @@ -0,0 +1,153 @@ +--- +title: "Architecture" +sidebarTitle: "Architecture" +description: "How LanceDB is put together, and what changes between running it embedded and running it distributed." +icon: cubes +keywords: ["architecture", "embedded", "distributed", "object storage", "compute", "deployment"] +--- + +LanceDB has one storage layer and two ways to put compute on top of it. Which +one you run decides where queries execute, what runs on your behalf, and what +you have to operate — and almost nothing else. The data is the same +[Lance](/basics/lance) tables either way. + +## One storage layer {#storage} + +Everything durable lives in object storage: table data, manifests, indexes. No +machine owns a dataset, and no server has to be running for the data to exist. + +``` + ┌──────────────────────────────────────────┐ + │ object storage │ + │ table data · manifests · indexes │ + └──────────────────────────────────────────┘ +``` + +That is the whole persistence story, and it is why the two deployment shapes can +differ so much above it while staying compatible below it. A table written +embedded is opened by a deployment, and the reverse, without conversion. + +## Embedded {#embedded} + +``` + your process + ┌────────────────────────────────┐ + │ application │ + │ LanceDB library │ ← plans, executes, indexes, compacts + └────────────────────────────────┘ + │ + ┌─────────▼─────────┐ + │ object storage │ + └───────────────────┘ +``` + +`lancedb.connect("./data")` or `connect("s3://bucket/path")` gives you a +**library**, not a client. Your process opens the tables, plans the queries, +runs the scans, builds the indexes, and writes the new versions. There is no +server in the picture at all. + +**What follows from that.** Nothing runs unless you call it — no background +compaction, no server-side [jobs](/basics/jobs), no +[SQL endpoint](/basics/sql). Scale is your process's scale: a query gets the +cores and memory your process has. Access control is whatever your object store +enforces, since there is no other gatekeeper. + +**What it is good at.** Development, notebooks, single-node pipelines, embedding +LanceDB inside an application, and anything where "no service to operate" is +worth more than elasticity. Comfortably handles millions of vectors on one +node -- disk-based indexes keep it performant without extra infrastructure. + +## Distributed {#distributed} + +Enterprise + +``` + your process + ┌──────────────┐ + │ application │ + │ LanceDB SDK │ ← a thin client + └──────┬───────┘ + │ db:// (REST / Flight SQL) + ┌──────▼──────────────────────────────────────┐ + │ control plane config · identity · policy │ + ├─────────────────────────────────────────────┤ + │ data plane query serving · writes │ + │ indexers indexing · compaction │ + └──────┬──────────────────────────────────────┘ + │ + ┌──────▼────────────┐ + │ object storage │ + └───────────────────┘ +``` + +`connect("db://...")` gives you a **client**. Planning and execution move to a +cluster, and the work splits across tiers that scale independently: a control +plane governing configuration, identity and policy; a data plane serving reads +and writes; and indexers building indexes and compacting outside the request +path. + +**What follows from that.** Compute and storage are separated, so a query fleet +scales for interactive traffic without also scaling indexing capacity, and heavy +background work does not steal resources from user queries. Because a server +exists, so do the things that need one: [SQL](/sql), +[functions](/management/function), server-side jobs, and +[authorization](/governance/authorization) per object rather than per bucket. + +**What it is good at.** Many concurrent queries, continuous ingestion and +indexing, multi-team access with real permissions, and workloads too large for +one machine -- customers regularly operate on billions of rows and tens of +terabytes. + +## What changes, precisely {#differences} + +| | Embedded | Distributed | +| --- | --- | --- | +| What you connect to | a library in your process | a cluster | +| Where queries plan and execute | your process | the data plane | +| Table handle | `LanceTable` | `RemoteTable` | +| Indexing and compaction | when you call it | dedicated indexers | +| [Jobs](/basics/jobs) | run in-process, no server record | real jobs: id, status, events, cancel | +| [SQL](/sql) | unavailable | Flight SQL endpoint | +| [Functions](/management/function) | unavailable | run on workers | +| Access control | object-store permissions | [per-object grants](/governance/authorization) | +| Scale bound | your process | the cluster | +| What you operate | nothing | a deployment | + +Both expose the same table API — create, open, mutate, query — so application +code moves between them by changing the URI. What does not move is code that +depends on a row in the right-hand column. + +## Choosing, and changing your mind {#choosing} + +Start embedded. It has no operational cost and the API is the same one you will +keep using. + +Move when you hit something in the right-hand column — usually concurrent +serving, ingestion that outgrows one machine, or a permissions requirement. +Because the storage layer is identical, moving is a connection-string change +plus a data migration, not a rewrite. + + +The one thing to decide early is **stable row ids**: the SDK can give them to a +table only when it creates one on a local path or an object store, and a +deployment may require them on a +[materialized view](/management/materialized-view) source. See +[Limits](/basics/limits#schema). + + +## Where to go next {#next} + + + + What a write promises, and what a reader sees. + + + The open format underneath both shapes. + + + The distributed tiers in detail. + + + The bounds you can actually hit. + + diff --git a/docs/web/basics/agentic-skills.mdx b/docs/web/basics/agentic-skills.mdx new file mode 100644 index 000000000..319e770b4 --- /dev/null +++ b/docs/web/basics/agentic-skills.mdx @@ -0,0 +1,291 @@ +--- +title: "Agentic skills" +sidebarTitle: "Agentic Skills" +description: "Give a coding agent a current, maintained reference for the LanceDB APIs." +icon: robot +keywords: ["agents", "plugin", "claude code", "codex", "cursor", "copilot"] +--- + +import { + PyCamelotSchema, + PyCamelotBatches, + PyCamelotOssIngestion, +} from '/snippets/build_with_ai_agents.mdx'; + +A coding agent writing LanceDB code from memory writes whatever was in its +training data -- often an older API, sometimes one that never existed. The +LanceDB agent plugin fixes that by supplying the current reference at the point +the agent needs it. + +## The plugin {#plugin} + +It ships as the `lancedb` plugin in +the [lancedb-agent-plugins](https://github.com/lancedb/lancedb-agent-plugins) +repository, which is also a plugin marketplace. Install it with your agent's +plugin manager: + + +```text Claude Code +/plugin marketplace add lancedb/lancedb-agent-plugins +/plugin install lancedb@lancedb +``` + +```bash Codex icon="terminal" +codex plugin marketplace add lancedb/lancedb-agent-plugins +codex plugin add lancedb@lancedb +``` + +```bash Other agents icon="terminal" +npx plugins add lancedb/lancedb-agent-plugins +``` + + +The [`plugins`](https://www.npmjs.com/package/plugins) installer shown under +**Other agents** is a cross-tool option. It detects which agent CLIs are on your +`PATH` — Cursor, GitHub Copilot CLI, VS Code, Grok Build, and Kimi Code, as well +as Claude Code and Codex — and installs through each one's native plugin system. + +The plugin supplements the agent's training data with current LanceDB +instructions. To pick up later revisions, refresh the marketplace: + + +```text Claude Code +/plugin marketplace update lancedb +``` + +```bash Codex icon="terminal" +codex plugin marketplace upgrade +``` + +```bash Other agents icon="terminal" +npx plugins add lancedb/lancedb-agent-plugins +``` + + + +Each plugin manager clones the repository, reads its marketplace manifest to find +the `lancedb` plugin, and hands the plugin to the agent's own plugin store rather +than copying files into your project. Claude Code and Codex keep it under +`~/.claude/plugins` and `~/.codex/plugins` respectively, so the plugin is available +in every project on the machine. + +The `npx plugins` installer defaults to the same user-wide scope. Pass +`--scope project` to record the plugin in the current repository instead, so that +everyone working in it gets the same plugin, or `-t ` to install for a +single agent rather than every one it detects: + +```bash +npx plugins add lancedb/lancedb-agent-plugins --scope project -t cursor +``` + +Not every agent supports project scope; `npx plugins targets` lists what it +found and what each target supports. + + +### Get started with the LanceDB agent plugin {#get-started-with-the-lancedb-agent-plugin} + +This tutorial uses the Camelot dataset from the [quickstart](/quickstart), with +a portrait added for each character. Each LanceDB row contains validated +metadata and raw JPEG bytes. Text, images, and any embeddings you add later +remain in the same table. + +### 1\. Download the multimodal dataset {#1-download-the-multimodal-dataset} + +From a new project directory, download the JSON file and portraits: + +```bash +mkdir -p data/img + +BASE_URL="https://docs.lancedb.com/static/assets/tutorials/build-with-ai-agents/camelot/data" +curl -fsSL "$BASE_URL/camelot.json" -o data/camelot.json + +for image in \ + arthur.jpg \ + guinevere.jpg \ + merlin.jpg \ + mordred.jpg \ + sir_galahad.jpg \ + sir_gawain.jpg \ + sir_lancelot.jpg \ + sir_percival.jpg +do + curl -fsSL "$BASE_URL/img/$image" -o "data/img/$image" +done +``` + +Each JSON record has this shape: + +```json +{ + "id": 2, + "name": "Merlin", + "role": "Wizard and Advisor", + "description": "A powerful wizard and prophet who mentors Arthur.", + "stats": { + "strength": 2, + "courage": 4, + "magic": 5, + "wisdom": 5 + }, + "img": "data/img/merlin.jpg" +} +``` + +JSON input may have missing fields, unexpected fields, or values of the wrong +type. The LanceDB plugin tells the agent to validate each record with strict +Pydantic models before writing it. + +After the agent writes the pipeline, inspect the schema, batching, and write +path rather than assuming it followed the plugin's guidance correctly. + +### 2\. Prompt your agent to build the pipeline {#2-prompt-your-agent-to-build-the-pipeline} + +Install the Python packages used by the example: + +```bash icon="terminal" +uv init +uv add lancedb pyarrow pydantic +``` + +If you're using LanceDB Enterprise, ask the agent to ingest into an Enterprise table, +provide the relevant environment variables for connecting to your Enterprise deployment +in a local `.env` file, and point the agent to it. + +```text .env +LANCEDB_URI=db://your_project_name +LANCEDB_API_KEY=your_api_key_here +LANCEDB_REGION=us-east-1 +LANCEDB_HOST_OVERRIDE=https://your-enterprise-endpoint.com +``` + +If you're using LanceDB OSS, no connection settings are required, as it runs as an +embedded retrieval library. A simple prompt like this should work: + +```text Agent prompt +# If using OSS +Use the lancedb plugin to ingest the dataset in `data/` into a LanceDB +OSS table. + +# If using enterprise +Use the lancedb plugin to ingest the dataset in `data/` into a LanceDB +Enterprise table using the connection information in `.env`. +``` + +Because the plugin is registered with the agent's own plugin system, the agent +should pick it up on its own once you restart the session. If it does not, +simply ask it to use the `lancedb` plugin in the prompt, as shown above. + +That should be enough! The agent will create `ingest_multimodal.py`, or similar. +The following sections inspect the script to verify that it follows the plugin's guidance. + +#### Data validation {#data-validation} + +The plugin encourages the agent to validate each record with Pydantic before writing it. +The agent should ideally define a schema for the table and a nested schema for the +`stats` field. + + + {PyCamelotSchema} + + +In this case, our agent correctly defined `Character` and `Stats` Pydantic models +and validated the JSON before adding it to the table. + +#### Batched ingestion {#batched-ingestion} + +Naively calling `table.add()` once per row is slow, and is considered an anti-pattern +in LanceDB. The plugin encourages the agent to collect incoming rows into batches and +write them with a single `table.add()` call. When you use the plugin, the agent should +produce something like this: + + + {PyCamelotBatches} + + +The script calls `Character.model_validate(...)` before adding a record to the +batch. If validation fails, that batch is never written. The function yields up +to `batch_size` rows at a time, providing an iterable of batches for the ingestion step, +shown next. + +#### Table maintenance {#table-maintenance} +For LanceDB OSS, the plugin instructs the agent to call +`table.optimize()` after the ingestion loop. This compacts small fragments, cleans up +old versions according to the retention policy, and incorporates new data into indexes. + + + {PyCamelotOssIngestion} + + +If you're using LanceDB Enterprise, the plugin mentions that this step is not needed +because LanceDB Enterprise handles maintenance automatically. + + + +This example dataset has only eight rows, so the default batch size writes it in +one call. Larger inputs should still avoid single-row write commits. + + +### 3\. Run the OSS pipeline {#3-run-the-oss-pipeline} + +```bash +uv run python ingest_multimodal.py +``` + +The table now contains the validated character records and their JPEG bytes. +Here are the first three rows: + +| Image | Character | Role | +| --- | --- | --- | +| King Arthur | King Arthur | King of Camelot | +| Merlin | Merlin | Wizard and Advisor | +| Queen Guinevere | Queen Guinevere | Queen of Camelot | + +Once your pipeline works, you can [run experiments on branches](/agent-branch-experiments) +to try new embedding models, parsers, or search settings without touching `main`. + +### Takeaways {#takeaways} + +The example in this tutorial was small, but similar ideas apply to other workflows, too. +Give the agent the data source, the constraints it must respect, and the +artifacts it should return. + +The plugin supplies LanceDB-specific guidance, but it's the user's responsibility to +ensure the output makes sense for the application. + +### Try the plugin with your own dataset {#try-the-plugin-with-your-own-dataset} + +The plugin shown in this tutorial should generalize reasonably well to other use cases. +If you find any issues, open [an issue](https://github.com/lancedb/lancedb-agent-plugins/issues) +on GitHub, clearly describing the intended behavior. + +You can choose OSS or Enterprise based on how the work will run: + +Start with [LanceDB OSS](/quickstart) during the early stages of a project +when an agent is helping you prototype, +explore a dataset, or run small workflows on a subset of the data. The application owns +the storage and lifecycle work, so ask the agent to validate inputs, write in +batches, and it will include table maintenance operations such as `optimize()` +where appropriate. + +Choose [LanceDB Enterprise](/enterprise) when the resulting table becomes +shared production infrastructure, and the workload needs distributed capacity, +private deployment, or platform-managed operations. The underlying data format and table +API stay the same, so the pipeline does not need to be redesigned. The agent +instead connects to a remote `db://` table and lets the cluster handle +maintenance and background work. + +## What agents are good at here {#workflows} + +The walkthrough above is one pattern. The other has its own tutorial. + + + + Isolate agent-driven experiments on a branch, evaluate them on a fixed test + set, and promote only the winner. + + + +It is the more interesting pattern. [Branches](/management/table#versioning) +give an agent somewhere to be wrong safely: it can rewrite a schema, recompute a +column, or try three embedding models without touching `main`, and you keep only +what measured better. diff --git a/docs/web/basics/catalog.mdx b/docs/web/basics/catalog.mdx new file mode 100644 index 000000000..133599f16 --- /dev/null +++ b/docs/web/basics/catalog.mdx @@ -0,0 +1,107 @@ +--- +title: "Lance Catalog" +sidebarTitle: "Lance Catalog" +description: "The naming layer: what objects exist, how they nest, and who may reach them." +icon: sitemap +keywords: ["catalog", "namespace", "hierarchy", "governance", "database", "table", "view", "function"] +--- + +A catalog answers one question: **what exists, and what is it called.** It holds +names, the hierarchy they sit in, and the rules about who may reach them. It +does not store rows, run queries, or build indexes -- that is the +[lakehouse](/basics/lakehouse) on top of it. + +Keeping the two apart is what lets a Lance table be reached by something that is +not LanceDB. The catalog is a specification -- the +[Lance Namespace](/basics/lance#standard) -- so any engine that implements it +resolves the same names to the same tables. + +## The objects {#objects} + +| Object | What it is | +| --- | --- | +| [Database](/management/database) | The root. A connection opens exactly one. | +| [Namespace](/management/namespace) | A named grouping inside a database. Can nest. | +| [Table](/management/table) | Rows and columns, versioned. What most operations act on. | +| [Materialized view](/management/materialized-view) | A table whose contents come from a query, computed and stored. | +| [View](/management/view) | A saved query. Computed when read, not stored. | +| [Function](/management/function) | Registered code, applied to produce column values. | + +``` +database +└── namespace + ├── table + ├── materialized view (a table, defined by a query) + ├── view (a query, not stored) + └── function +``` + +A namespace can contain further namespaces, so the hierarchy goes as deep as +your organisation needs. A table is reached by its path through it: +`prod.events.clicks`. + +This page is about what the objects *are*. For the operations on them, see +[Management](/management). + +## Resolution {#resolution} + +A name is resolved by walking the path, and the walk is the point: reaching a +table means traversing the namespaces above it. That is what makes the hierarchy +more than decoration -- it is the unit of both organisation and access. + +A flat set of tables needs none of this. Namespaces start earning their keep when +several teams, environments or domains share one catalog and you want +`prod.search.documents` and `staging.search.documents` to be unambiguous. + +## Governance {#governance} + +Enterprise + +Because the catalog knows the hierarchy, it is the layer that can enforce +access. A grant names **one privilege** on **one object** for **one subject**, +and it inherits down the path: reaching a table requires `USAGE` on the +namespace containing it and on the database containing that. + +That is the practical reason to put a boundary in the hierarchy -- a namespace +per team gives you something to grant on. See +[Authorization](/governance/authorization). + +Embedded LanceDB has no such layer. Access is whatever the object store enforces +on the bucket, which has no concept of a table. + +## Views and materialized views are different objects {#views} + +The names are close enough to mislead. Both are catalog objects defined by a +query over other tables; the difference is when that query runs. + +- A **materialized view** stores its rows, and a refresh brings them up to + date. Reads are reads of stored data -- fast, and as current as the last + refresh. +- A **view** stores only the query. Every read runs it, so a view is never stale + and never faster than the query it wraps. + +Reach for a materialized view when the query is expensive and the data changes +less often than it is read. Reach for a view when correctness matters more than +latency, or when the query is cheap. + +## What the catalog does not hold {#elsewhere} + +**Indexes** belong to a table rather than standing alone. Creating one is a +performance decision, not a structural one -- see [Indexing](/indexing). + +**Jobs** are not stored data at all; they are handles to work in progress. See +[Jobs](/basics/jobs). + +**Rows** are in the [Lance](/basics/lance) files the catalog points at. The +catalog knows a table exists and where it lives; it does not know what is in it. + +## Next {#next} + + + + What operates on these objects: search, compute, maintenance. + + + Creating, listing and dropping each one. + + diff --git a/docs/web/basics/guarantees.mdx b/docs/web/basics/guarantees.mdx new file mode 100644 index 000000000..47c7c8d7c --- /dev/null +++ b/docs/web/basics/guarantees.mdx @@ -0,0 +1,134 @@ +--- +title: "Guarantees" +sidebarTitle: "Guarantees" +description: "What a write promises when it returns, what a reader sees, and what survives a failure." +icon: shield-check +keywords: ["guarantees", "consistency", "atomicity", "durability", "concurrency", "acid"] +--- + +What the system promises, and — just as usefully — what it does not. Most +surprises in practice come from assuming one of the guarantees below is +stronger or weaker than it is. + +## Writes {#writes} + +**A write is atomic.** Every write commits a new table version. Either the +version exists and holds all of the change, or it does not exist and holds none +of it. There is no state in which a reader sees half an `add()`. + +**A write returns only after the commit.** When the call returns, the version +is durable in object storage. Nothing is buffered in your process waiting to be +flushed. + +**Every write advances the version**, including one that affects zero rows. A +delete whose predicate matches nothing still commits. Version numbers count +commits, not changes. + +**Concurrent writes are resolved optimistically.** A transaction records the +change and the version it was based on. At commit time, a conflicting +concurrent operation is detected rather than silently overwritten, and the +commit is retried against the newer version. Operations that do not collide — +appending while another process appends — both succeed. + + +`merge_insert` reports its retry attempts in the result. A high count means +contention on the same keys, not an error. + + +## Reads {#reads} + +**A read sees a consistent snapshot.** A query reads one table version +throughout; it never sees a mixture of two. + +**Which version, by default, is the one open at the time the table was opened.** +This is the guarantee most often assumed to be stronger than it is: + + +A long-lived connection **does not observe other processes' writes** until it +reopens the table or its `read_consistency_interval` elapses. The default is +right for a batch job reading a stable snapshot and wrong for a service +expected to see fresh data. + + +Set `read_consistency_interval` when several processes write the same table and +readers must observe those writes. The cost is a metadata check on that +interval. See [Consistency](/tables/consistency). + +**Your own writes are always visible to you.** The interval governs other +writers, not yourself. + +## Durability and history {#durability} + +**Old versions are retained, not overwritten.** A new version does not destroy +the one before it, which is what makes [time travel](/management/table#versioning) work +and what lets a reader finish a scan that started on an older version. + +**Deletion is a mark, not an erase.** A deleted row is recorded in a deletion +file. The data is recoverable for as long as the version that contains it +exists. + + +Retention is finite. [Compaction](/maintenance/compaction) prunes versions +older than the retention window — **7 days by default** — and a tag or branch +pointing at a pruned version no longer resolves. If you rely on reading old +versions, widen the window before running it, not after. + + +**Dropping a table ends its history.** Versioning does not survive +`drop_table`; there is no undrop. + +## Jobs {#jobs} + +**A refresh recomputes only what changed.** It gives values to rows that lack +them and recomputes rows whose inputs changed since they were computed; it +leaves the rest alone. Two consequences, both relied on elsewhere: + +- **Retrying is safe.** Re-running a refresh fills what is still empty or stale. + It is not guaranteed to cost only the rows that did not complete: a retry can + recompute values that were already written. +- **A filled value catches up at the next refresh.** If a column derives from + `price` and a row's `price` later changes, the derived value keeps its old + value until the next refresh recomputes it. + +**What a failure keeps depends on where the refresh ran.** `refresh_column` in +your process commits the column once, so a failure keeps none of its values. A +refresh job on a deployment commits finished batches as it goes, so some values +can remain. + +**Cancellation is a request.** `cancel()` asks a job to stop; it does not +guarantee it stops before doing more work, and cancelling a finished job does +nothing. + +**A job id is opaque.** It correlates with server logs and the jobs API. +Parsing it, or storing it to resume the job later, is not supported. + +See [Jobs](/basics/jobs). + +## Schema {#schema} + +**Schema changes are versioned like data changes.** Adding, renaming, retyping +or dropping a column commits a version, so a schema change can be travelled back +through. + +**A computed column's inputs are protected.** While a declaration references a +column, that column cannot be renamed, retyped, or dropped — the declaration is +part of the schema and will not be invalidated out from under itself. + +**Most schema changes do not rewrite data.** Adding a derived column writes only +the new column. Type changes are the exception and can be substantial. + +## What is not guaranteed {#not-guaranteed} + +Worth stating plainly, because each is occasionally assumed: + +| | | +| --- | --- | +| Multi-table transactions | A commit covers one table. There is no cross-table atomic write. | +| Read-your-writes across processes | Governed by `read_consistency_interval`, not automatic. | +| Unenforced primary keys | `SET UNENFORCED PRIMARY KEY` records intent; it does not enforce uniqueness. | +| Ordering of concurrent appends | Two appends both succeed; their relative row order is not defined. | +| Index freshness | An index does not cover rows added after it was built until [optimized](/maintenance/compaction). | +| Automatic compaction | Nothing runs in the background. `optimize()` runs when you call it. | + +The last two are the most common source of "why is this query slow" — see +[Limits](/basics/limits) for the bounds that pair with them. diff --git a/docs/web/basics/jobs.mdx b/docs/web/basics/jobs.mdx new file mode 100644 index 000000000..652935ea2 --- /dev/null +++ b/docs/web/basics/jobs.mdx @@ -0,0 +1,196 @@ +--- +title: "Jobs" +sidebarTitle: "Job" +description: "Work that outlives the call that started it: submitting, waiting, inspecting, cancelling." +icon: hourglass-half +keywords: ["job", "async", "background", "events", "cancel", "refresh"] +--- + +Some operations take longer than a request should. Building an index over a +large table, refreshing a computed column, registering a function -- these run +on the server after the call that started them has returned. + +Each is submitted the same way and returns the same handle. + + +A [query](/basics/query) also has an `_async` form, but its handle streams rows +rather than waiting for an outcome. Both answer the same four questions -- +which one is this, how is it going, give me the result, stop it -- and differ +only in the third. + + +## The `_async` convention {#convention} + +An operation that can run in the background has two forms. The plain name blocks +until the work is done. The `_async` / `Async` name submits the work and returns +a **job** immediately. + +| Blocking | Returns a job | SQL | +| --- | --- | --- | +| `create_index` | `create_index_async` | `CREATE INDEX` | +| `refresh_column` | `refresh_column_async` | `REFRESH COLUMN` | +| `create_function` | `create_function_async` | `CREATE FUNCTION` | +| `drop_table` | `drop_table_async` | `DROP TABLE` | + + +The blocking form is not a different operation -- it submits the same job and +waits. Reach for the `_async` form when you want to do something else meanwhile, +run several at once, or survive the client going away. + + +## Submitting and waiting {#waiting} + + +```python Python icon="python" +from lancedb.index import IvfPq + +job = table.create_index_async("vector", config=IvfPq()) + +print(job.id) # the server's id for this operation +print(job.status()) # "running" | "finished" | "failed" | "cancelled" + +job.wait() # blocks until terminal; raises if it failed +``` + +```typescript TypeScript icon="square-js" +const job = await table.createIndexAsync("vector"); + +console.log(job.id); // the server's id for this operation +console.log(await job.status()); // "running" | "finished" | "failed" | "cancelled" + +await job.wait(); // resolves when terminal; rejects if it failed +``` + + +`wait` is the one call that raises on failure. `status` is a point snapshot and +returns the failed state rather than raising, which is what you want in a +polling loop. + +In Python `wait` takes an optional timeout and returns the operation's typed +result -- a `RefreshColumnJobResult`, a `FunctionVersion`, or `None` for an +operation that has no result: + +```python +from datetime import timedelta + +result = table.refresh_column_async("embedding").wait(timeout=timedelta(minutes=30)) +``` + +`TimeoutError` means the timeout elapsed, not that the job stopped. The job is +still running; the handle just gave up watching. + + +The TypeScript `wait()` resolves to `void`. Read the outcome from `result` after +it resolves. + + +## Inspecting a job {#inspecting} + +Submitting an operation returns only an id, so the detail fields are empty until +the handle fetches the record. `refresh()` does that; `status()` does it as a +side effect. + + +```python Python icon="python" +job.refresh() + +job.state # last observed state, without another round trip +job.job_type # what kind of job, as the server names it +job.creation_ms # when it was created, epoch milliseconds +job.spec # what it was submitted with +job.result # its terminal result, once it succeeds +job.failure # why it failed, when it did +``` + +```typescript TypeScript icon="square-js" +await job.refresh(); + +job.state; // last observed state, without another round trip +job.jobType; // what kind of job, as the server names it +job.creationMs; // when it was created, epoch milliseconds +job.spec; // what it was submitted with +job.result; // its terminal result, once it succeeds +job.failure; // why it failed, when it did +``` + + +Printing the handle renders every field it knows, which is usually faster than +reading them one at a time. + +## Progress, from events {#events} + +`result` only tells you anything once the job has finished. Events are written +as it runs, and they outlive the workers that produced them -- so a job that is +still going, or one that never finished, can still account for what it did. + + +```python Python icon="python" +events = job.events(limit=5000, filter="state = 'claim_complete'") +``` + +```typescript TypeScript icon="square-js" +const events = await job.events({ limit: 5000, filter: "state = 'claim_complete'" }); +``` + +```sql SQL +SHOW ERRORS ON my_table FOR JOB 'job-id' LIMIT 100 +``` + + +A distributed job records a `claim` / `claim_complete` pair per unit of work, +each carrying `rows_processed`. Summing `rows_processed` over the completed +claims is how you get progress on a job that has no percentage. + +`filter` is a SQL-like expression over `state`, `updated_by`, `emitted_from`, +`emitted_by`, and `claim_entity`. + + +The server returns 1000 event rows by default and 10,000 at most, and +**truncates without telling you**. A job that emits an event per fragment will +silently lose its tail unless you pass `limit`. + + +Events are recorded server-side, so this raises for an operation that ran +in-process. + +## Finding and cancelling jobs {#managing} + +The connection lists and reopens jobs, so a handle lost to a restarted process +is recoverable from its id. + + +```python Python icon="python" +db.list_jobs() # every job the server knows about +job = db.open_job(job_id) # reattach to one +db.cancel_job(job_id) # ask it to stop +``` + +```typescript TypeScript icon="square-js" +await db.listJobs(); +const job = await db.openJob(jobId); +await db.cancelJob(jobId); +``` + +```sql SQL +SHOW JOBS +``` + + +Cancellation is a request, not a guarantee, and cancelling a job that already +finished does nothing. `JobNotFoundError` means the id does not name a job the +server still has. + + +A job id is opaque. It correlates with server logs and the calls above, but +parsing it or storing it to resume the job later is not supported. + + +## In-process jobs {#in-process} + +A local database has no server to run anything, so its operations run in your +process. They still return a handle, so the same code works against both -- but +one with nothing behind it: `id` is `None`, `status()` reports `"finished"`, +`wait()` returns immediately, `cancel()` does nothing, and `events()` raises. + +This is the difference to expect when moving between embedded and deployed -- +see [SDKs](/basics/sdk). diff --git a/docs/web/basics/lakehouse.mdx b/docs/web/basics/lakehouse.mdx new file mode 100644 index 000000000..7dd631ac3 --- /dev/null +++ b/docs/web/basics/lakehouse.mdx @@ -0,0 +1,102 @@ +--- +title: "Lance Lakehouse" +sidebarTitle: "Lance Lakehouse" +description: "What operates on the catalog's objects: storage, search, computation, and maintenance." +icon: layer-group +keywords: ["lakehouse", "multimodal", "search", "compute", "maintenance", "feature engineering"] +--- + +The [catalog](/basics/catalog) says what exists. The **lakehouse** is everything +that acts on it: storing the rows, searching them, computing new ones, and +keeping the whole thing fast as it grows. + +The distinction matters because the two layers have different lifetimes. A +catalog entry is a name and a pointer. A lakehouse operation reads and writes +the [Lance](/basics/lance) files that name points at -- and those operations are +where almost all of the cost, and all of the tuning, lives. + +## Multimodal is the point {#multimodal} + +A lakehouse table holds images, video, audio, text, embeddings, annotations and +the features you derive from them **in one table**, side by side. + +That is the difference from assembling a warehouse plus a vector store plus a +feature store. There is one copy of the data, one set of versions, and no +pipeline keeping three systems agreeing about the same rows. A search can filter +on a label, rank by a vector, and return the blob, without leaving the table. + +| | | +| --- | --- | +| **Columns** | vectors, blobs, and columns [computed](/feature-engineering/add-computed-column) from other columns | +| **Versions** | every write commits one, so reads can [travel back](/management/table#versioning) | +| **Indexes** | vector, full-text and scalar, built over those columns | +| **Branches** | isolated lines of change you can [merge or discard](/tables/branching) | + +## The four capabilities {#capabilities} + +Everything the lakehouse does falls into one of four groups, and the rest of +these docs is organized the same way. + +### Ingest {#ingest} + +Getting rows in, and changing the ones that are there: append, merge, update, +delete. Every write commits a version rather than mutating in place, which is +what makes the other three safe to run against a moving table. See +[Data Ingestion](/ingestion). + +### Search {#search} + +Vector, full-text and hybrid retrieval over the same rows, with filters that +push into the scan rather than running after it. The search is a lakehouse +operation, not a separate service holding a second copy of your embeddings. See +[Semantic Search](/search). + +### Compute {#compute} + +Deriving new columns and tables from what is already stored: +[computed columns](/feature-engineering/add-computed-column), +[functions](/management/function) that run Python where the data is, and +[materialized views](/management/materialized-view) that persist a query's +result. + +The reason this belongs in the lakehouse rather than in a pipeline outside it is +the format. Adding a derived column writes only that column -- the images and +videos are never rewritten -- so deriving a feature is an ordinary operation +rather than a migration. See [Feature Engineering](/feature-engineering) and +[Data Curation](/curation). + +### Maintenance {#maintenance} + +The work that keeps the first three fast: building and repairing +[indexes](/indexing), and [compaction](/maintenance/compaction) to merge small +files, prune old versions and fold new rows into existing indexes. + + +None of it runs on its own. There is no background compactor -- `optimize()` +runs when you call it, and an index does not cover rows added after it was +built until you do. See [Guarantees](/basics/guarantees#not-guaranteed). + + +## Where the work happens {#where} + +The same lakehouse runs in two shapes, and which one you use decides where these +capabilities execute rather than whether you have them: + +- **Embedded** -- your process does all four itself, directly against object + storage. Nothing runs unless you call it. +- **Distributed** -- a cluster does them, with indexing and compaction on + workers separate from the ones serving queries, plus + [SQL](/sql) and server-side [jobs](/basics/jobs). + +See [Architecture](/architecture) for the comparison in full. + +## Next {#next} + + + + The objects these operations act on. + + + The open format underneath, and why column evolution is cheap. + + diff --git a/docs/web/basics/lance.mdx b/docs/web/basics/lance.mdx new file mode 100644 index 000000000..6552c3735 --- /dev/null +++ b/docs/web/basics/lance.mdx @@ -0,0 +1,122 @@ +--- +title: "Lance format" +sidebarTitle: "Lance Format" +description: "Lance is the open format underneath. LanceDB is what implements a lakehouse on top of it." +icon: "/static/assets/logo/lance-logo-gray.svg" +keywords: ["lance", "format", "open standard", "lakehouse", "namespace", "catalog"] +--- + +Two names, two things. [Lance](https://lance.org/) is an open format and +specification. LanceDB is an implementation built on it. + +The distinction matters when you are deciding what you are committing to: the +format is a standard other engines read, and your data stays readable by them +whichever LanceDB you run. + +## Lance is the open standard {#standard} + +Lance is an open-source, columnar lakehouse format for multimodal AI. It +specifies three things: + +| | | +| --- | --- | +| **File format** | how bytes are laid out on disk, optimized for random access without giving up scan throughput | +| **Table format** | how files compose into a versioned table -- schema evolution, deletions, indexes | +| **Namespace specification** | how tables are named, grouped, and resolved -- the catalog layer | + +Being a specification rather than a product is the point. Object storage holds +the bytes, and open engines -- PyTorch, Ray, Spark, Trino, DuckDB, Polars -- +read them without going through LanceDB at all. + +Lance exists because columnar formats derived from Parquet were not designed +for random access on vectors: [benchmarks](https://lancedb.com/blog/benchmarking-random-access-in-lance/) +show Lance up to 1000x faster than Parquet for that access pattern. + + + The format specification: design, layout, and how it enables the multimodal lakehouse. + + +## LanceDB embedded implements the storage half {#embedded} + +Connecting to a directory or a bucket gives you a **storage-only lakehouse**. +Your process opens Lance tables directly and does the work itself: it plans and +executes queries, builds indexes, and writes new versions. + +```python +db = lancedb.connect("./.lancedb") +db = lancedb.connect("s3://bucket/path") +``` + +There is no server, which is what makes it embedded -- and also what bounds it. +There is nothing to run work on your behalf, so there are no server-side +[jobs](/basics/jobs), no [SQL endpoint](/basics/sql), and no shared catalog +beyond what the storage layer itself provides. + +LanceDB OSS is Apache 2.0 licensed. LanceDB Enterprise is the commercial, +deployment-only product that implements the catalog specification below. + +## LanceDB Enterprise implements the catalog specification {#enterprise} + +Enterprise A `db://` connection talks to a deployment +that implements the **Lance REST Namespace** -- the REST server defined by the +Lance namespace specification, covering the catalog operations in the +[REST API reference](/api-reference/rest). + +```python +db = lancedb.connect("db://my-database", api_key="...") +``` + +Implementing the specification rather than inventing an interface is what makes +the catalog reachable by anything that speaks it, and what makes +[authorization](/governance/authorization), server-side jobs, and +[SQL](/basics/sql) possible: there is now a server to enforce and to run them. + +The tables are the same Lance tables either way. What changes is who resolves +their names and who does the work. + +## What the format gives you {#capabilities} + +These are properties of Lance, so they hold in both forms: + +| Capability | What it enables | +| --- | --- | +| Multimodal storage | Images, video, audio, text, embeddings, annotations and metadata in one table. | +| First-class blob API | Large binary objects in blob columns, with lazy reads and streaming byte access. | +| Fast random access and scans | Sample, shuffle, and fetch individual rows without giving up sequential throughput. | +| Zero-copy schema evolution | Add, drop, rename, or alter columns without rewriting existing data files. | +| Versioned tables | Reproduce experiments and tie artifacts to the exact table version they used. | +| Hybrid search and indexing | Vector search, full-text search, and scalar filters over the same dataset. | +| Open interoperability | Object storage underneath, open engines on top. | + +Two of them explain behaviour you will meet elsewhere in these docs. + +### Versioning is why old data lingers {#versioning} + +Every write creates a version, and old versions are not immediately destroyed -- +another reader may be part-way through one. A hundred versions are not a hundred +copies of the data, but they do carry a hundred times the metadata, which +eventually slows queries down. + +Deletion works the same way: a deleted row is marked in a deletion file rather +than erased, so the data is recoverable for as long as the version is. + +[Compaction](/maintenance/compaction) is what resolves both. + +### Column evolution is why feature engineering is cheap {#evolution} + +Adding a derived column writes only the new column. Existing data -- the +expensive part, the images and videos -- is untouched. That is what makes +[computed columns](/feature-engineering/add-computed-column) a reasonable thing to +do repeatedly on a large table rather than a full rewrite each time. + + + Lance is a separate open source project with its own documentation. + diff --git a/docs/web/basics/limits.mdx b/docs/web/basics/limits.mdx new file mode 100644 index 000000000..f135de8d4 --- /dev/null +++ b/docs/web/basics/limits.mdx @@ -0,0 +1,106 @@ +--- +title: "Limits" +sidebarTitle: "Limits" +description: "The bounds you can actually hit, in one place." +icon: ruler +keywords: ["limits", "maximum", "quota", "constraints", "bounds"] +--- + +Limits worth designing around, collected so you do not have to find them by +hitting them. Each one is enforced -- where a value is refused rather than +clamped, that is stated, because the two fail very differently. + +This page covers bounds that are the same for everyone. Deployment-specific +quotas -- storage, throughput, concurrency -- come from your deployment. + +## Names {#names} + +| Object | Maximum | | +| --- | --- | --- | +| Database | 64 characters | | +| Namespace | 64 characters | | +| Table | 128 characters | | +| Branch | 64 characters | | + +A name must not be empty, and must not contain `$` or `:` -- both are reserved +as path delimiters, so a name holding one could not be addressed unambiguously. +Length is counted in characters, not bytes. + +## Jobs {#jobs} + +| | | +| --- | --- | +| `job.events()` default | 1,000 rows | +| `job.events()` maximum | 10,000 rows | +| `SHOW ERRORS ... LIMIT` | between 1 and 100,000 | +| Failed jobs scanned by one `SHOW ERRORS` | 32 | + + +`job.events()` **truncates silently**. A job that emits an event per fragment +will lose its tail unless you pass `limit` explicitly. This is the limit on this +page most likely to mislead you, because nothing in the result says it was cut. + + +`SHOW ERRORS` without `FOR JOB` scans the most recent failed jobs and stops at +32 of them. Narrow with `FOR JOB` when you need a specific one rather than a +survey. + +See [Jobs](/basics/jobs). + +## Schema and types {#schema} + +| | | +| --- | --- | +| `map` with `keys_sorted = true` | rejected | +| `IVF_RQ` vector dimensions | must be divisible by 8 | +| FTS `max_token_length` | 40 by default; longer tokens are dropped | + +Through the SDK, a table gets **stable row ids** only at creation, from the +`new_table_enable_stable_row_ids` storage option on a local path or an object +store; no SDK call adds them later, so decide early. A local +[materialized view](/feature-engineering/materialized-views#stable-row-ids) does +not need them. On a deployment, whether a view's source needs them and how a +table gets them depend on the deployment; see +[Against a deployment](/feature-engineering/materialized-views#availability). + +See [Data types](/sql/data-types). + +## SQL {#sql} + +Enterprise + +| | | +| --- | --- | +| `TABLESAMPLE` percentage | 0 to 100, **refused** outside -- not clamped | +| `TABLESAMPLE (n ROWS)` | whole numbers only | +| `TABLESAMPLE BUCKET` | not supported | +| `TABLESAMPLE` on a WAL-backed table | not supported | +| `CREATE INDEX` | one column, server-chosen name | +| `REFRESH MATERIALIZED VIEW ... CASCADE` | depends on the deployment's version; see [REFRESH](/sql/dml/refresh#refresh-materialized-view) | + +A sampling percentage over 100 is an error rather than a full scan, because a +query asking for 150 percent has a mistake in it and reading it as 100 would +hide the mistake. See [Sampling](/sql/dql/sampling). + +## What is unavailable where {#availability} + +Not quotas, but the same class of surprise -- things that exist in one place and +not another. + +| | | +| --- | --- | +| SQL on a local database | unavailable; `execute_query` raises | +| SQL methods in the TypeScript SDK | none; use a Flight SQL client | +| Function methods in the TypeScript SDK | none; use Python or SQL | +| Vector search in SQL | no SQL form; use a query builder | +| Geospatial `st_*` functions | behind a build feature LanceDB does not ship | +| `tableExists` / `namespaceExists` in TypeScript | not present | +| Server-side jobs on a local database | run in-process; `events()` raises | + +See [SQL](/basics/sql) for why the first two hold. + +## Nothing here is a quota {#not-quotas} + +None of the above is a billing or capacity limit. Rows per table, table count, +storage, and request rate are bounded by your deployment rather than by the +format -- ask your deployment, not this page. diff --git a/docs/web/basics/query.mdx b/docs/web/basics/query.mdx new file mode 100644 index 000000000..449c9b498 --- /dev/null +++ b/docs/web/basics/query.mdx @@ -0,0 +1,94 @@ +--- +title: "Queries" +sidebarTitle: "Query" +description: "Running a query, and reading its results before it finishes." +icon: magnifying-glass +keywords: ["query", "async", "streaming", "arrow", "reader", "execute_query"] +--- + +A query returns rows. How you get them depends on two choices that are easy to +conflate: whether **your thread** blocks, and whether you take the results all +at once or stream them as they arrive. + +## Blocking or not is a language concern {#async-client} + +A concern of your language, not of LanceDB. The work is identical either way. + + +```python Python icon="python" +db = lancedb.connect("db://my-db", api_key="...") # blocking +tbl = db.open_table("documents") + +db = await lancedb.connect_async("db://my-db", api_key="...") # coroutines +tbl = await db.open_table("documents") +``` + +```typescript TypeScript icon="square-js" +// TypeScript is async throughout; there is no blocking form. +const db = await lancedb.connect("db://my-db", { apiKey: "..." }); +const tbl = await db.openTable("documents"); +``` + + +Python offers both. `AsyncConnection` exposes the same surface as coroutines, so +choosing between them is about the program you are writing -- a service wants +the async form, a script does not care. + +## Reading before the query finishes {#queries} + +A query is not a job, but it has the same shape of choice: wait for everything, +or take a handle and start reading. + + +```python Python icon="python" +reader = db.execute_query("SELECT id, text FROM my_docs LIMIT 1000") +table = reader.read_all() + +query = db.execute_query_async("SELECT id, text FROM my_docs") +``` + +```sql SQL +SELECT id, text FROM my_docs LIMIT 1000 +``` + + +`execute_query` returns a blocking Arrow reader **once results start arriving** +-- it does not wait for the whole query to finish. Results stream as record +batches, so a large result set does not have to fit in memory before you see its +first row. + +`execute_query_async` returns the query handle instead: + +```python +query = db.execute_query_async("SELECT id, text FROM my_docs") + +query.id # identifies it on the server +query.describe() # what it is doing, without consuming results +reader = query.reader() # stream the rows when you want them +query.cancel() # stop it +``` + +`describe_query(id)` reports on one from elsewhere, the way `open_job(id)` does +for a job. + + +SQL requires a deployment. See [SQL](/basics/sql#sql-is-remote). + + +Query-builder searches -- vector, full-text, hybrid -- are ordinary calls that +return results. They are fast enough not to need a handle. See +[Query](/search). + +## Searches are ordinary calls {#searches} + +Query-builder searches -- [vector](/search/vector-search), +[full-text](/search/full-text-search), [hybrid](/search/hybrid-search) -- return +results directly. They are fast enough not to need a handle, and they have no +SQL form. See [Semantic Search](/search). + +## When the work outlives the call {#jobs} + +Some operations take longer than a query should: building an index, refreshing a +computed column, registering a function. Those return a **job** rather than +rows, and a job is a different handle with a different shape. See +[Jobs](/basics/jobs). diff --git a/docs/web/basics/sdk.mdx b/docs/web/basics/sdk.mdx new file mode 100644 index 000000000..3f8170e11 --- /dev/null +++ b/docs/web/basics/sdk.mdx @@ -0,0 +1,99 @@ +--- +title: "SDKs" +sidebarTitle: "SDKs" +description: "The language clients — Python, TypeScript, Rust — and what stays true across all of them." +icon: code-compare +keywords: ["python", "typescript", "rust", "java", "sdk", "rest", "api reference"] +--- + +A language SDK is the primary way to work with LanceDB: create and manage +tables, write and query data, build indexes, run feature engineering. The same +table API works whether you're connected to an embedded database or an +Enterprise deployment -- application code moves between the two by changing +the connection URI. + +## Officially supported SDKs {#supported-sdks} + +Python, TypeScript and Rust are supported against both embedded and deployed +LanceDB. + +| Reference | Description | +|:--------------|-------------------| +| [Python](https://lancedb.github.io/lancedb/python/python/) | Full-featured Python client with pandas and numpy integration | +| [TypeScript](https://lancedb.github.io/lancedb/js/) | A TypeScript wrapper around the Rust library, built with `napi-rs` | +| [Rust](https://docs.rs/lancedb/latest/lancedb/index.html) | Native Rust library with persistent storage and high performance | + +Not every SDK covers the same surface, though: + +| | Python | TypeScript | +| --- | :---: | :---: | +| Create and manage catalog objects | yes | yes | +| Insert, merge, update, delete | yes | yes | +| Filter, project, aggregate, join | yes | yes | +| Vector search | yes | yes | +| Full-text search | yes | yes | +| Feature engineering, jobs | yes | partly | +| Runs against a local database | yes | yes | + +TypeScript has no methods for [functions](/management/function). Reach the +gap with SQL instead -- see [SQL](/basics/sql). + +## REST API {#rest} + +Enterprise A deployment implements the +[Lance REST Namespace](/basics/lance#enterprise), so the catalog is reachable +over HTTP by anything that speaks it -- see the +[REST API reference](/api-reference/rest). + +| Reference | Description | +|:--------------|-------------------| +| [Java](https://lancedb.github.io/lancedb/java/java/) | REST API Enterprise SDK in Java | + +## Community SDKs {#community-sdks} + +Contributed by the community rather than supported by LanceDB, so feature parity +and release cadence vary. + +| Reference | Description | +|:--------------|-------------------| +| [Go](https://pkg.go.dev/github.com/lancedb/lancedb-go/pkg/lancedb) | Community-contributed Go SDK | +| [Ruby](https://github.com/scientist-labs/lancelot) | Community-contributed Ruby bindings | +| [Swift](https://github.com/RyanLisse/LanceDbSwiftKit) | Community-contributed Swift SDK | +| [R](https://github.com/CathalByrneGit/lancedb) | Community-contributed R package | +| [Flutter](https://github.com/Alexcn/flutter_lancedb) | Community-contributed Flutter bindings | + +For any other language, [SQL](/basics/sql) reaches the same tables without a +LanceDB-specific SDK at all. + +## Python multiprocessing {#multiprocessing} + + +Do not use `fork` as the start method with Python's `multiprocessing` module. +Lance is multi-threaded internally, and `fork` with alive threads is unsafe -- +use `spawn` instead. See +[this discussion](https://discuss.python.org/t/concerns-regarding-deprecation-of-fork-with-alive-threads/33555) +for why. + + +## What stays the same {#invariants} + +Whichever you pick: + +- **Types are Arrow types.** A vector column is a `FixedSizeList` + everywhere, not a SQL type mapped onto storage. See + [Functions and types](/sql/functions). +- **Reads are Arrow.** Nothing is serialized into language objects on the way out + unless you convert it. +- **The catalog is one catalog.** A table created from TypeScript is visible to + Python and to SQL immediately -- there is no per-language namespace. + +## What to read next {#next} + + + + The same tables, reached by any Flight SQL client -- and the one way in for a language with no SDK. + + + Give a coding agent a current reference to this same API surface. + + diff --git a/docs/web/basics/sql.mdx b/docs/web/basics/sql.mdx new file mode 100644 index 000000000..6b74486eb --- /dev/null +++ b/docs/web/basics/sql.mdx @@ -0,0 +1,125 @@ +--- +title: "SQL" +sidebarTitle: "SQL" +description: "The same tables, reached with SQL — the universal access path, and where every operation is formally documented." +icon: table-list +keywords: ["sql", "flight sql", "datafusion", "reference", "dialect"] +--- + +Enterprise SQL runs on a deployment. A local +database has no engine to plan a statement, so `execute_query` raises there. + +SQL is a second way to reach the same tables the [SDKs](/basics/sdk) do -- +not a different product, a different interface onto one engine. A table +written from Python is queried from SQL without an export step, and a table +created from SQL is visible to every SDK immediately. + +## Any language, via Flight SQL {#flight-sql} + +SQL is served over an +[Arrow Flight SQL](https://arrow.apache.org/docs/format/FlightSql.html) +endpoint, so any Flight SQL client reaches it -- which makes SQL the way in +for a language LanceDB has no SDK for. You don't need a LanceDB-specific +client at all, just a Flight SQL driver for your language. + +The TypeScript SDK itself has no SQL method, for the same reason a Go or C# +application would reach for SQL first: connect a Flight SQL client instead of +waiting on a native binding. See [SDKs](/basics/sdk) for the officially +supported and community clients, and reach for Flight SQL whenever yours +isn't among them. + +## Every SDK operation, in SQL {#sdk-parity} + +Broadly, a public SDK method has a corresponding SQL statement -- creating a +table is `CREATE TABLE`, inserting rows is `INSERT INTO`, building an index is +`CREATE INDEX`. The two describe the same operations, so whichever one your +code speaks, the other is available too. + +| | SDK | SQL | +| --- | :---: | :---: | +| Create and manage catalog objects | yes | partly | +| Insert, merge, update, delete | yes | yes | +| Filter, project, aggregate, join | yes | yes | +| Vector search | yes | version-dependent | +| Full-text search | yes | yes | +| Feature engineering, jobs | yes | yes | +| Runs against a local database | yes | **no** | + +## Vector search depends on the deployment {#no-vector-sql} + +The SDK query builder provides vector search across embedded and deployed +databases. SQL support depends on the deployment version and supported query +shape. Some versions recognize `ORDER BY distance(vector, query_vector) LIMIT k` +over a table scan and plan vector search, including indexed and flat search. +Confirm support on your deployment and use SQL +[`EXPLAIN`](/sql/dql/explain) to check the chosen plan; this is not a guarantee +that every deployed release supports that form. + +Full-text search is available through the [`fts()`](/sql/functions#fts) table +function and the [`contains_tokens`](/sql/scalar-functions#contains-tokens) +predicate. + +The reverse gap also exists: a [view](/management/view) is a saved SQL query, +so SQL is the only way to create one -- there's nothing for an SDK method to +call. + +## SQL needs a deployment {#sql-is-remote} + +A connection to a directory or a bucket has no server to plan and execute a +statement, and asking it to raises `NotImplementedError`. This is the largest +behavioral difference between the embedded and deployed forms, and the reason +the SDKs are the portable choice: SDK code moves between the two by changing +the URI, SQL does not. + +```python +db = lancedb.connect("./.lancedb") # no SQL +db = lancedb.connect("db://my-database") # SQL +``` + +## Running SQL from Python {#sql-from-python} + +```python +reader = db.execute_query("SELECT id, text FROM my_docs LIMIT 10") +table = reader.read_all() +``` + +`execute_query` returns a blocking Arrow reader once results start arriving; it +does not wait for the whole query. `execute_query_async` returns a handle +instead -- see [Queries](/basics/query). + +## Choosing between them {#choosing} + +Reach for **the SDKs** when you are writing data, managing the catalog, running +vector search, or building an application that should work the same embedded and +deployed. + +Reach for **SQL** when the question is analytical -- joins across tables, +aggregates, window functions -- when the thing asking is a BI tool, a +notebook, or a person who already thinks in SQL, or when it's the only client +available for your language. + +The two compose. A common shape is SDK code to build and maintain tables, and +SQL for the analysis on top. + +## The complete reference {#reference} + +This page is the concept; [SQL Reference](/sql) is the specification. It has +one page per statement, syntax first -- and since every SDK operation has a +SQL form, those pages end up defining the precise behavior of the SDK +operations too, not just the SQL dialect. + + + DDL by object, then DML, then DQL -- the detailed technical documentation + that defines the behavior of every operation and SDK API. + + +## What to read next {#next} + + + + The language clients, and what stays true across all of them. + + + Give a coding agent a current reference to this same API surface. + + diff --git a/docs/web/curation/aggregate.mdx b/docs/web/curation/aggregate.mdx new file mode 100644 index 000000000..91c32fdd4 --- /dev/null +++ b/docs/web/curation/aggregate.mdx @@ -0,0 +1,113 @@ +--- +title: "Profiling a dataset" +sidebarTitle: "Aggregate" +description: "Counting, grouping and summarizing — finding out what your data actually contains." +icon: chart-column +keywords: ["aggregate", "group by", "class balance", "count", "profiling", "distinct"] +--- + +Enterprise Aggregation is SQL, and +[SQL needs a deployment](/basics/sql#sql-is-remote). + +Before curating anything, find out what you have. Most of the surprises in a +training set — a class that is 90% of the rows, a column that is half null, a +"unique" id that is not — are one `GROUP BY` away from being visible. + +## How big, and how complete {#size} + +```sql +SELECT + count(*) AS rows, + count(embedding) AS embedded, + count(caption) AS captioned +FROM images +``` + +`count(*)` counts rows; `count(col)` counts **non-null values**. The gap between +them is your coverage, which is the fastest way to check how far a +[column refresh](/feature-engineering/refresh-column) has got without waiting +for the job to report. + +## Class balance {#balance} + +The single most useful curation query: + +```sql +SELECT label, count(*) AS n +FROM images +GROUP BY label +ORDER BY n DESC +``` + +If the top class is an order of magnitude larger than the rest, sampling +uniformly will reproduce that imbalance. Two ways out: draw a fixed number per +class with [windowing](/curation/windowing#per-group), or oversample the rare +ones and accept the duplication. + +## Distinct values {#distinct} + +```sql +SELECT count(DISTINCT source) FROM images +``` + +On a high-cardinality column this is expensive, because exact distinctness means +holding every value seen. When an estimate will do: + +```sql +SELECT approx_distinct(source) FROM images +``` + +Cheap enough to run on a table where the exact version is not worth the wait. +The other approximate functions — `approx_median`, `approx_percentile_cont` — +are useful for the same reason when profiling a numeric column. + +## Conditional counts {#conditional} + +`FILTER` narrows one aggregate without narrowing the query, which is how you get +several slices in one pass rather than one query each: + +```sql +SELECT + label, + count(*) AS total, + count(*) FILTER (WHERE score > 0.9) AS confident, + count(*) FILTER (WHERE caption IS NULL) AS uncaptioned +FROM images +GROUP BY label +``` + +## Grouping by a bucket {#buckets} + +Raw values are rarely the grouping you want. `date_bin` buckets timestamps, and +ordinary arithmetic buckets numbers: + +```sql +SELECT date_bin(INTERVAL '1 day', created_at) AS day, count(*) +FROM images GROUP BY day ORDER BY day +``` + +A histogram of a score column is the same trick: + +```sql +SELECT floor(score * 10) / 10 AS bucket, count(*) +FROM images GROUP BY bucket ORDER BY bucket +``` + +## Profiling a sample {#sampling} + +On a very large table, profile a [sample](/curation/sampling) and scale: + +```sql +SELECT label, count(*) * 100 AS estimated +FROM images TABLESAMPLE (1) +GROUP BY label +``` + +Treat the result as an estimate. A 1% sample will miss classes rarer than about +1 in 10,000 entirely, which is exactly the tail you are usually looking for — so +use this to check the shape of the head, not the existence of the tail. + +## Reference {#reference} + +The complete clause and function list is in +[Aggregation](/sql/dql/aggregation). diff --git a/docs/web/curation/index.mdx b/docs/web/curation/index.mdx new file mode 100644 index 000000000..0bfc89b2d --- /dev/null +++ b/docs/web/curation/index.mdx @@ -0,0 +1,54 @@ +--- +title: "Data curation" +sidebarTitle: "Overview" +description: "Shaping a dataset before you train on it: profiling, sampling, joining, and ranking." +icon: filter +keywords: ["curation", "dataset", "training set", "sampling", "deduplication", "class balance"] +--- + +Curation is the work between having data and having a *dataset*: finding out +what you actually have, drawing a subset you can afford to train on, attaching +labels that live elsewhere, and picking one row per thing when you have several. + +These pages are task-oriented. Each shows the operation in the context you would +reach for it, and links to the [SQL reference](/sql) for the complete syntax. + + +Most curation is analytical -- grouping, joining, ranking -- which the query +builders do not do. It is therefore SQL, and SQL needs a deployment. See +[SQL](/basics/sql#sql-is-remote). + + +## The four questions {#questions} + +| | | +| --- | --- | +| [What do I have?](/curation/aggregate) | counts, class balance, null coverage, distinct values | +| [What can I afford?](/curation/sampling) | a random subset, reproducible when you need it fixed | +| [What else do I know?](/curation/join) | labels, metadata and scores that live in another table | +| [Which one do I keep?](/curation/windowing) | one row per key, ranking within a group, splits | + +## A worked shape {#shape} + +The four compose into the same pipeline most curation ends up being: + +```sql +WITH ranked AS ( + SELECT d.*, c.label, + row_number() OVER (PARTITION BY d.doc_id ORDER BY d.version DESC) AS rn + FROM documents AS d + JOIN labels AS c ON c.doc_id = d.doc_id +) +SELECT * FROM ranked WHERE rn = 1 +``` + +Join to bring in what you know, window to pick one row per document, filter to +keep it. Add [`TABLESAMPLE`](/curation/sampling) when the result is still larger +than you want, and [aggregate](/curation/aggregate) to check the balance of what +you ended up with. + +## Then what {#next} + +A curated result you want to keep is a +[materialized view](/management/materialized-view) -- stored, refreshed, and +readable by a [data loader](/training/data-loading) like any other table. diff --git a/docs/web/curation/join.mdx b/docs/web/curation/join.mdx new file mode 100644 index 000000000..b4c198ef9 --- /dev/null +++ b/docs/web/curation/join.mdx @@ -0,0 +1,91 @@ +--- +title: "Joining datasets" +sidebarTitle: "Join" +description: "Bringing labels, metadata and scores together with the rows they describe." +icon: link +keywords: ["join", "labels", "metadata", "enrichment", "namespace"] +--- + +Enterprise Joins are SQL, and +[SQL needs a deployment](/basics/sql#sql-is-remote). + +Curation usually means combining things that were produced separately — images +in one table, human labels in another, model scores in a third. A join is how +they become one dataset. + +## Attaching labels {#labels} + +```sql +SELECT i.id, i.embedding, l.label +FROM images AS i +JOIN labels AS l ON l.image_id = i.id +``` + +An inner join keeps only rows that matched, which for curation is often exactly +what you want: unlabelled rows are not training data. + +When you need to *see* what did not match, use a left join and filter: + +```sql +SELECT i.id +FROM images AS i +LEFT JOIN labels AS l ON l.image_id = i.id +WHERE l.image_id IS NULL +``` + +That is your unlabelled backlog, which is the thing worth counting before you +plan an annotation round. + +## Across namespaces {#namespaces} + +Tables in different [namespaces](/management/namespace) join by their dotted +paths — the catalog is one catalog, so there is nothing to federate: + +```sql +SELECT i.id, u.team +FROM prod.media.images AS i +JOIN prod.reference.uploaders AS u ON u.id = i.uploader_id +``` + +## Joining a search result {#search} + +Full-text search is a table function, so its result is a relation and joins like +any other. This is how you ask which categories a query matches, rather than +just which rows: + +```sql +SELECT c.name, count(*) AS hits +FROM fts('documents', 'puppy') AS m +JOIN categories AS c ON c.id = m.category_id +GROUP BY c.name +ORDER BY hits DESC +``` + + +Vector search has no SQL form, so a similarity result cannot be joined this way. +Run the search in an SDK, then join over its output — or write the result to a +table first. + + +## Making it fast {#performance} + +Two things dominate join cost on a lakehouse table. + +**Index the join key.** A [scalar index](/indexing/scalar-index) on the key lets +the planner look rows up instead of scanning for them. Check with +[`EXPLAIN`](/sql/dql/explain): an indexed key shows a `ScalarIndexQuery`, an +unindexed one shows a `LanceScan` feeding the join. + +**Do not carry wide columns through.** Select the columns you need before +joining, not after. Embeddings and blobs are the expensive part of a multimodal +table, and a join can materialize them many more times than the result has rows. + +```sql +SELECT i.id, l.label +FROM (SELECT id, uploader_id FROM images) AS i +JOIN labels AS l ON l.image_id = i.id +``` + +## Reference {#reference} + +The join types and their semantics are in [Joins](/sql/dql/join). diff --git a/docs/web/curation/sampling.mdx b/docs/web/curation/sampling.mdx new file mode 100644 index 000000000..4e8b000cd --- /dev/null +++ b/docs/web/curation/sampling.mdx @@ -0,0 +1,98 @@ +--- +title: "Sampling a dataset" +sidebarTitle: "Sampling" +description: "Drawing a subset you can afford to work with, and fixing it when it has to be reproducible." +icon: dice +keywords: ["sampling", "tablesample", "subset", "eval set", "reproducible", "shuffle"] +--- + +Most curation starts with a subset. A question you can answer in ten seconds on +1% of the data is worth asking twenty times; the same question on the whole +table is worth asking once. + +## A quick look {#quick} + +Enterprise + +```sql +SELECT * FROM images TABLESAMPLE (1) -- 1 percent +SELECT * FROM images TABLESAMPLE (500 ROWS) -- 500 rows +``` + +A bare number is a percentage; add `ROWS` for a count. Use it while you are +exploring — the cost of the query drops with the sample, and so does the cost +of being wrong about what you wanted to ask. + +## Rows or fragments {#methods} + +```sql +SELECT * FROM images TABLESAMPLE BERNOULLI (10) -- rows +SELECT * FROM images TABLESAMPLE SYSTEM (10) -- fragments +``` + +`BERNOULLI` considers each row independently and reads across the whole table. +`SYSTEM` picks whole Lance fragments and takes the rows in them — far less I/O, +because it skips files rather than rows. + +The trade is uniformity. A fragment sample is only as representative as your +write order. If rows arrived grouped by source, by date, or by label — which is +usual — then a fragment sample inherits that grouping. + + +Reach for `SYSTEM` when you want a cheap look, and `BERNOULLI` when the number +you compute has to be defensible. Omitting the method gives `BERNOULLI`. + + +## A fixed evaluation set {#fixed} + +Without a seed, every execution resamples. That is fine for exploring and wrong +for evaluation — a metric computed on different rows each run is not comparable +with itself. + +```sql +SELECT * FROM images TABLESAMPLE (10) REPEATABLE (42) +``` + +The same seed against the same data gives the same rows, which is what makes a +sample usable as a held-out set. If the underlying data changes, so does the +draw — when you need the set frozen against that too, materialize it: + +```sql +CREATE TABLE eval_set AS +SELECT * FROM images TABLESAMPLE (10) REPEATABLE (42) +``` + +A [materialized view](/management/materialized-view) is the other option when +you want it to track the source on a refresh rather than stay frozen. + +## Sampling both sides of a join {#joins} + +```sql +SELECT * FROM a TABLESAMPLE (10) JOIN b TABLESAMPLE (10) ON a.id = b.id +``` + +Each side samples independently, so a row survives only if both of its sides +were drawn. Sampling 10% of each side does **not** give 10% of the join — it +gives roughly 1%. Sample once, after the join, unless you specifically want the +smaller result. + +## Shuffling for training {#training} + +For a training loop rather than a query, the SDKs read a table in a shuffled +order without SQL at all: + +```python +from lancedb.permutation import Permutation + +permutation = Permutation.identity(table) +dataloader = torch.utils.data.DataLoader(permutation) +``` + +This is the right tool for epoch-level shuffling, where you want every row +eventually and only the order varies. `TABLESAMPLE` is for when you want fewer +rows. See [Training](/training/data-loading). + +## Reference {#reference} + +The full clause, its options and what it refuses are in +[Sampling](/sql/dql/sampling). diff --git a/docs/web/curation/windowing.mdx b/docs/web/curation/windowing.mdx new file mode 100644 index 000000000..fbb3cbbf2 --- /dev/null +++ b/docs/web/curation/windowing.mdx @@ -0,0 +1,124 @@ +--- +title: "Ranking and deduplication" +sidebarTitle: "Windowing" +description: "Picking one row per thing, ranking within a group, and splitting a dataset." +icon: chart-simple +keywords: ["window", "deduplication", "row_number", "rank", "train test split", "per class"] +--- + +Enterprise Window functions are SQL, and +[SQL needs a deployment](/basics/sql#sql-is-remote). + +A window function computes across a set of related rows and returns a value +**per row** — nothing is collapsed. That is what makes it the tool for "which of +these do I keep", where an aggregate would throw away the rows you are choosing +between. + +## One row per thing {#dedup} + +The most common curation query there is. You have several rows per document — +revisions, re-uploads, repeated annotations — and want one. + +```sql +WITH ranked AS ( + SELECT *, + row_number() OVER (PARTITION BY doc_id ORDER BY version DESC) AS rn + FROM revisions +) +SELECT * FROM ranked WHERE rn = 1 +``` + +`PARTITION BY` says what "per thing" means; `ORDER BY` says which one wins. The +outer query is not optional — a window function cannot appear in `WHERE`, +because windows are computed after filtering, so the predicate would have +nothing to read. + + +Swap `row_number()` for `rank()` when ties should both survive. `row_number()` +always picks exactly one; `rank()` gives tied rows the same rank, so `rank = 1` +can return several. + + +## A fixed number per class {#per-group} + +Class imbalance found by [profiling](/curation/aggregate#balance) is fixed here: +rank within each class, then take the top N. + +```sql +WITH ranked AS ( + SELECT *, row_number() OVER (PARTITION BY label ORDER BY score DESC) AS rn + FROM images +) +SELECT * FROM ranked WHERE rn <= 1000 +``` + +That gives at most 1,000 rows per label, taking the highest-scoring ones. Order +by something arbitrary but stable if you want an unbiased draw instead, or +combine with [sampling](/curation/sampling) to randomize within each class. + +## Splitting a dataset {#splits} + +`ntile` divides each partition into equal buckets, which is a train/test split +that stays balanced per class: + +```sql +WITH split AS ( + SELECT *, ntile(10) OVER (PARTITION BY label ORDER BY id) AS bucket + FROM images +) +SELECT * FROM split WHERE bucket <= 8 -- train +``` + +Buckets 9 and 10 are your validation and test sets. Because the partition is by +label, each split keeps the class proportions of the whole. + + +Order by something stable. Ordering by a value that changes — a score you +recompute, a timestamp that gets updated — means the split changes with it, and +a row can move from train to test between runs. + + +## Comparing a row to its neighbours {#offset} + +`lag` and `lead` read the previous and next row in the window, which is how you +find gaps and changes without a self-join: + +```sql +SELECT + id, + created_at, + created_at - lag(created_at) OVER (ORDER BY created_at) AS gap +FROM events +``` + +Useful for spotting ingestion gaps, duplicate timestamps, and runs of identical +values that suggest a broken upstream job. + +## Running totals {#running} + +Any [aggregate](/curation/aggregate) becomes a window function with `OVER`, +which is how cumulative coverage is expressed: + +```sql +SELECT + label, + count(*) AS n, + sum(count(*)) OVER (ORDER BY count(*) DESC) AS cumulative +FROM images +GROUP BY label +``` + +Reading down the `cumulative` column tells you how many classes cover most of +your data — usually far fewer than you expect. + +## Cost {#cost} + +Window functions sort within each partition, so on a large table they are among +the more expensive things you can ask for. Filter first, partition on something +selective, and check the plan with +[Explain Analyze](/search/explain). + +## Reference {#reference} + +The full window syntax, framing, and function list are in +[Window functions](/sql/dql/window). diff --git a/docs/web/demos/fineweb-edu-search.mdx b/docs/web/demos/fineweb-edu-search.mdx new file mode 100644 index 000000000..64d4decd4 --- /dev/null +++ b/docs/web/demos/fineweb-edu-search.mdx @@ -0,0 +1,20 @@ +--- +title: "Large scale vector search (1.5B)" +sidebarTitle: "Large Scale Vector Search" +description: "Semantic search over 1.5B+ FineWeb EDU embeddings." +icon: database +--- + +vector-search +education +bge-embeddings + + +Semantic search over curated FineWeb EDU pages, represented as 1.5B+ BGE embeddings. Inspect the +query plan to see how LanceDB serves vector search at billion scale. + + diff --git a/docs/web/demos/index.mdx b/docs/web/demos/index.mdx new file mode 100644 index 000000000..fb1bdc49a --- /dev/null +++ b/docs/web/demos/index.mdx @@ -0,0 +1,17 @@ +--- +title: "Demo application gallery" +sidebarTitle: "Overview" +description: "End-to-end applications built with LanceDB." +icon: grid-2 +--- + +Applications built on LanceDB, each with a live demo and a write-up of how it +works. + +| App | What it shows | +| --- | --- | +| [Semantic.art](/demos/semantic-art) | Multimodal art discovery using feelings, phrases, and images. | +| [Wikipedia 41M hybrid search](/demos/wikipedia-search) | Full-text and vector search combined, over 41M+ entries. | +| [Video search](/demos/video-search) | Natural-language search across a video library. | +| [Large scale vector search (1.5B)](/demos/fineweb-edu-search) | Semantic search over 1.5B+ FineWeb EDU embeddings. | +| [Robot data curation (DROID)](/demos/robot-data-curation) | Searching, scoring, and selecting robotics training examples. | diff --git a/docs/web/demos/robot-data-curation.mdx b/docs/web/demos/robot-data-curation.mdx new file mode 100644 index 000000000..b61f53afe --- /dev/null +++ b/docs/web/demos/robot-data-curation.mdx @@ -0,0 +1,21 @@ +--- +title: "Robot data curation (DROID)" +sidebarTitle: "Robot Data Curation" +description: "Search, score, and select robotics training examples from DROID." +icon: robot +--- + +multimodal +robotics +vector-search +fts + + +Search robot video frames from the DROID dataset, compute features, and select training examples in LanceDB. +Add captions and quality scores as columns, then write model errors back to guide the next training run. + + diff --git a/docs/web/demos/semantic-art.mdx b/docs/web/demos/semantic-art.mdx new file mode 100644 index 000000000..a7ed3859f --- /dev/null +++ b/docs/web/demos/semantic-art.mdx @@ -0,0 +1,28 @@ +--- +title: "Semantic.art" +sidebarTitle: "Semantic.art" +description: "Multimodal art discovery through feelings, phrases, and images." +icon: palette +--- + +multimodal +hybrid-search +vector-search +semantic-routing + + +Semantic.art turns real, human-made art discovery into a multimodal search experience using +feelings, phrases, and images. It's built with LanceDB hybrid search and semantic routing. + + +Read in detail about how Semantic.art is built in this blog post. + + + diff --git a/docs/web/demos/video-search.mdx b/docs/web/demos/video-search.mdx new file mode 100644 index 000000000..4a8ec3d4c --- /dev/null +++ b/docs/web/demos/video-search.mdx @@ -0,0 +1,20 @@ +--- +title: "Video search" +sidebarTitle: "Video Search" +description: "Search a video library with natural language queries." +icon: video +--- + +multimodal +video-search +vector-search + + +Search through a library of videos using natural language queries. This demo showcases how to use LanceDB +to perform semantic search on video content. + + diff --git a/docs/web/demos/wikipedia-search.mdx b/docs/web/demos/wikipedia-search.mdx new file mode 100644 index 000000000..73dac4998 --- /dev/null +++ b/docs/web/demos/wikipedia-search.mdx @@ -0,0 +1,28 @@ +--- +title: "Wikipedia 41M hybrid search" +sidebarTitle: "Wikipedia Search" +description: "Hybrid full-text and vector search across 41M+ Wikipedia entries." +icon: magnifying-glass +--- + +multimodal +hybrid-search +vector-search +fts + + +Interactive hybrid search, full-text search (FTS) and vector search demo with 41M+ Wikipedia entries. +Explore the power of combining FTS with vector search for more relevant results. + + +Read in detail about how the Wikipedia 41M hybrid search demo is built in this blog post. + + + diff --git a/docs/web/deployment/monitoring.mdx b/docs/web/deployment/monitoring.mdx new file mode 100644 index 000000000..6a66eb0ed --- /dev/null +++ b/docs/web/deployment/monitoring.mdx @@ -0,0 +1,128 @@ +--- +title: "Monitor LanceDB with OpenTelemetry" +sidebarTitle: "Monitoring" +description: "Export LanceDB object store request counts, bytes, latency, errors, and throttles to any OpenTelemetry backend." +icon: "chart-line" +keywords: ["monitoring", "observability", "opentelemetry", "otel", "metrics", "prometheus", "object store"] +--- + +LanceDB emits internal metrics (currently object store request counts, bytes transferred, request latency, retryable errors, and throttles) and can bridge them into any [OpenTelemetry](https://opentelemetry.io/) backend. Use this to watch how your application interacts with S3, GCS, Azure Blob, or the local filesystem in production: spot latency regressions, catch retry storms, and size your storage tier from real workload data. + +The bridge is available in the Python and TypeScript SDKs. It is a thin wrapper over LanceDB's `metrics` recorder; your application supplies and configures the OpenTelemetry SDK. + + +This page covers LanceDB OSS instrumentation. LanceDB Enterprise clusters emit their own metrics from the server side — see [Enterprise monitoring](#enterprise) below. + + +## What you get {#what-you-get} + +Once instrumented, LanceDB registers one observable instrument per metric on your `MeterProvider`. The current catalog covers the object store layer: + +| Metric | Kind | Description | +|--------|------|-------------| +| `lance_object_store_requests_total` | Counter | Total object store requests, labelled by `operation` and `base` (store scheme). | +| `lance_object_store_request_duration_seconds` | Histogram | Request latency in seconds. | +| `lance_object_store_bytes_transferred_total` | Counter | Bytes read from or written to the store. | +| `lance_object_store_retryable_responses_total` | Counter | Requests that returned a retryable error (throttles, transient failures). | +| `lance_object_store_in_flight_requests` | Gauge | Currently outstanding object store requests. | + +The recorder is process-global and pull-based: your configured `MetricReader` collects on its own schedule, so there is no hot-path overhead beyond the atomic aggregation that LanceDB does anyway. + + +**Histograms are exported Prometheus-style.** OpenTelemetry has no asynchronous histogram instrument, so each histogram surfaces as three observable counters: `_bucket` (with an `le` attribute per bucket boundary, including `+Inf`), `_count`, and `_sum`. Only `_sum` carries the histogram's unit; `_bucket` and `_count` are cumulative sample counts. + + +## Python {#python} + +Install LanceDB with the `otel` extra to pull in the OpenTelemetry API, plus an OpenTelemetry SDK of your choice. The SDK is intentionally not bundled, so you configure it and its readers and exporters however your platform expects. + +```bash +pip install "lancedb[otel]" opentelemetry-sdk +``` + +Call `instrument_lancedb_metrics()` once at startup, before opening any tables. It returns `True` when the recorder is installed and instruments are registered. + +```python Python icon="python" +import lancedb +from lancedb.otel import instrument_lancedb_metrics +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + PeriodicExportingMetricReader, +) +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( + OTLPMetricExporter, +) + +reader = PeriodicExportingMetricReader(OTLPMetricExporter()) +provider = MeterProvider(metric_readers=[reader]) + +instrument_lancedb_metrics(provider) + +# Any object store activity from this point on is now recorded. +db = lancedb.connect("s3://my-bucket/lancedb") +``` + +If you omit `meter_provider`, LanceDB uses the global provider returned by `opentelemetry.metrics.get_meter_provider()`. + + +`instrument_lancedb_metrics()` returns `False` and emits a warning if another `metrics`-crate recorder is already installed in the process. Only one global recorder is permitted, so instrument LanceDB before any other library that installs its own recorder. + + +## TypeScript {#typescript} + +The Node SDK depends on `@opentelemetry/api` directly, so no extra install step is needed to expose the entry point. You still need an OpenTelemetry SDK to actually export. + +```bash +npm install @opentelemetry/sdk-metrics @opentelemetry/exporter-metrics-otlp-grpc +``` + +```typescript TypeScript icon="square-js" +import { connect, instrumentLanceDbMetrics } from "@lancedb/lancedb"; +import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-grpc"; + +const reader = new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), +}); +const provider = new MeterProvider({ readers: [reader] }); + +instrumentLanceDbMetrics(provider); + +const db = await connect("s3://my-bucket/lancedb"); +``` + +`instrumentLanceDbMetrics()` also accepts no arguments, in which case it uses the global provider from `@opentelemetry/api`. Calling it more than once is safe: instruments are created only on the first successful call. + +## What to watch {#what-to-watch} + +A few starting points for dashboards and alerts: + +- **Request rate by operation:** `rate(lance_object_store_requests_total[1m])` broken down by `operation` shows read vs. write pressure and helps size ingestion and serving traffic separately. +- **Tail latency:** histogram quantiles over `lance_object_store_request_duration_seconds_bucket` catch object store slowdowns before they surface as query timeouts. +- **Retryable responses:** a rising `lance_object_store_retryable_responses_total` typically means you are being throttled and should back off or shard writes. +- **In-flight requests:** a growing `lance_object_store_in_flight_requests` gauge without a matching rise in throughput indicates queueing. + +## Enterprise monitoring {#enterprise} + +Enterprise A deployment emits its own metrics from +the server side, using Prometheus for collection and OpenTelemetry (OTel) to +export them with data enrichment -- no client-side instrumentation needed. + +| Component | Metrics | +| --- | --- | +| Query node | RPS, query latency, error codes, slow-query count, CPU/memory utilization | +| Plan executor | SSD cache hit/miss rate, CPU/memory utilization | + +[Contact support](/deployment/support) for the comprehensive metric list and +help integrating them with your monitoring dashboard. + +## Where to go next {#where-to-go-next} + + + + Tune ingestion, indexing, and query patterns once metrics highlight a hot spot. + + + Configure the object store backends whose requests these metrics measure. + + diff --git a/docs/web/deployment/storage.mdx b/docs/web/deployment/storage.mdx new file mode 100644 index 000000000..fd8550ea6 --- /dev/null +++ b/docs/web/deployment/storage.mdx @@ -0,0 +1,412 @@ +--- +title: "Storage" +sidebarTitle: "Storage" +description: "Choosing and configuring where LanceDB stores its data -- how you deploy an embedded database." +icon: database +keywords: ["storage", "s3", "gcs", "azure", "object store", "configuration", "deploy"] +--- + +LanceDB's storage layer is built on modular, disk-first components. That design makes it flexible enough to run across local NVMe, EBS, EFS, and any object store that exposes an S3-compatible API. It also supports region-specific backends such as [Tencent COS](/storage/configuration#tencent-cos) and cache-acceleration layers such as [GooseFS](/storage/configuration#goosefs). + +Choosing a backend is a balance between latency, scalability, cost, and operational complexity. Use this guide to pick the right fit for your workload. + +## Choosing a backend {#choosing} + +![](/static/assets/images/storage/lancedb_storage_tradeoffs.png) + +When architecting your system, ask yourself: + +- **Latency**: How fast do I need results? What do the p50 and p95 look like? +- **Scalability**: Can I scale data volume and QPS easily? +- **Cost**: What is the all-in cost of storage plus serving? +- **Reliability/Availability**: How will replication and disaster recovery work? + +### Comparison {#comparison} + +Below is a high-level comparison ordered from lowest cost to lowest latency. + +### 1\. Object storage (S3, GCS, Azure Blob) {#1-object-storage-s3-gcs-azure-blob} +- **Latency**: Highest; expect hundreds of milliseconds and higher p95. +- **Scalability**: Effectively unlimited storage; QPS bound by concurrency limits. +- **Cost**: Lowest overall. +- **Reliability/Availability**: Highly available, backed by cloud SLAs. + +LanceDB separates storage and compute and writes immutable fragments, making it a strong fit for stateless, horizontally scalable deployments. + + +**Concurrent writers on S3** + +S3 and S3 Express now support atomic writes natively, so LanceDB handles concurrent writers against the same table out-of-the-box — no external commit coordinator is required. Bucket-level [server-side encryption with KMS](/storage/configuration#server-side-encryption-with-kms) and [S3 Express One Zone](/storage/configuration#s3-express) are also supported on this tier. + + +### 2\. File storage (EFS, GCS Filestore, Azure File) {#2-file-storage-efs-gcs-filestore-azure-file} +- **Latency**: Better than object storage; p95 under ~<100ms is typical. +- **Scalability**: High, but limited by provisioned IOPS per volume. +- **Cost**: More than object storage but cheaper than in-memory options; cold data can tier down automatically. +- **Reliability/Availability**: Highly available; replication/backup must be managed separately. + +Keep a copy of data in object storage for disaster recovery. If zero downtime is required, provision a second network file system with replicated data. + +### 3\. Third-party storage (e.g., MinIO, WekaFS) {#3-third-party-storage-e-g--minio-wekafs} + +- **Latency**: Similar to EFS; typically under <100ms. +- **Scalability**: Determined by the chosen vendor’s cluster sizing. +- **Cost**: Higher than S3; may edge above EFS at larger scales. +- **Reliability/Availability**: Shareable across many nodes; replication depends on vendor capabilities. + +### 4\. Block storage (EBS, GCP Persistent Disk, Azure Managed Disk) {#4-block-storage-ebs-gcp-persistent-disk-azure-managed-disk} +- **Latency**: Near-local performance; often <30ms. +- **Scalability**: Not shareable across instances; shard or copy data when scaling. +- **Cost**: Higher than networked file systems, plus potential I/O charges. +- **Reliability/Availability**: Persists through instance restarts; backups and sharding must be managed. + +### 5\. Local storage (SSD, NVMe) {#5-local-storage-ssd-nvme} +- **Latency**: Fastest; p95 often under <10ms. +- **Scalability**: Hard to scale in cloud environments; requires sharding or additional copies for higher QPS. +- **Cost**: Highest; tightly coupling compute and storage makes horizontal scaling difficult. +- **Reliability/Availability**: Data is tied to the instance; backups must be rigorous. + +Use local disk only when you need extremely low latency and are comfortable owning the operational overhead. + +### File-format choices that interact with the backend {#file-format-choices} + +A few `storage_options` keys shape new tables in ways that depend on the backend you picked above. They are documented in full on [below](#new-table-configuration); the architecture-level summary is: + +- `new_table_enable_v2_manifest_paths` matters most on object stores, where opening a table with many versions is dominated by listing cost. Leave it off for backward compatibility with clients older than LanceDB 0.10.0. +- `new_table_enable_stable_row_ids` keeps row IDs stable across compaction, delete, and merge. The choice is independent of the backend but affects any system that joins on row ID. +- `new_table_data_storage_version` selects the on-disk format. The default `stable` is recommended for all new tables; pick `legacy` only when older readers must keep working. + +import { + PyStorageAzureAccount, + PyStorageAzureSas, + PyStorageConnectAzure, + PyStorageConnectGcs, + PyStorageConnectS3, + PyStorageConnectTimeout, + PyStorageGcsServiceAccount, + PyStorageS3Express, + PyStorageS3Minio, + PyStorageS3SseKms, + PyStorageTableTimeout, + PyStorageTigrisConnect, + PyStorageCosConnect, + PyStorageGoosefsConnect, + TsStorageAzureAccount, + TsStorageAzureSas, + TsStorageConnectAzure, + TsStorageConnectGcs, + TsStorageConnectS3, + TsStorageConnectTimeout, + TsStorageGcsServiceAccount, + TsStorageS3Express, + TsStorageS3Minio, + TsStorageS3SseKms, + TsStorageTableTimeout, + TsStorageTigrisConnect, + TsStorageGoosefsConnect, +} from '/snippets/storage.mdx'; + +### Configuring a backend {#configuring} + + +**LanceDB Enterprise storage configuration** + +In LanceDB Enterprise, you connect with `db://...` and the cluster owns the storage credentials, so `storage_options` are not passed at runtime. Cloud auth is set at deployment time. For federated databases, the namespace service vends per-request credentials automatically. See the [quickstart](/quickstart), [Enterprise overview](/enterprise/), and [Azure deployment guide](/enterprise/deployment/azure) for the Enterprise flow. + + +### Object stores {#object-stores} + +LanceDB supports AWS S3 (and compatible stores), Azure Blob Storage, and Google Cloud Storage. The URI scheme in your `connect` call selects the backend. + + + + {PyStorageConnectS3} + + + {TsStorageConnectS3} + + + + + + {PyStorageConnectGcs} + + + {TsStorageConnectGcs} + + + + + + {PyStorageConnectAzure} + + + {TsStorageConnectAzure} + + + +### Configuration options {#configuration-options} + +When running inside the target cloud with correct IAM bindings, LanceDB often needs no extra configuration. When running elsewhere, provide credentials via environment variables or `storage_options`. + + + + {PyStorageConnectTimeout} + + + {TsStorageConnectTimeout} + + + + +**Storage option casing** + +Keys are case-insensitive. Use lowercase in `storage_options` and uppercase in environment variables. + + +Table-level `storage_options` inherit every key from the connection and override on a per-key basis. Pass them to `create_table` or `open_table` for options that should apply to a single table: + + + + {PyStorageTableTimeout} + + + {TsStorageTableTimeout} + + + + +**Inspect the effective options** + +On `AsyncTable`, `await table.initial_storage_options()` returns the options the table was opened with, and `await table.latest_storage_options()` returns the current options after any provider-driven refresh. The deprecated `table.storage_options()` method will be removed in a future release. + + +#### General object store options {#general-object-store-options} + +| Key | Description | +| :-- | :-- | +| `allow_http` | Allow non-TLS connections. | +| `allow_invalid_certificates` | Skip certificate validation for TLS connections. | +| `connect_timeout` | Timeout for the connect phase. | +| `timeout` | Timeout for the full request. | +| `user_agent` | User agent string sent with requests. | +| `proxy_url` | Proxy URL to route requests through. | +| `proxy_ca_certificate` | PEM-formatted CA certificate for proxy connections. | +| `proxy_excludes` | Comma-separated hosts that bypass the proxy (domains or CIDR). | +| `download_retry_count` | Number of retries when downloading objects. | +| `client_max_retries` | Maximum retries for object-store client requests. | +| `client_retry_timeout` | Total retry timeout (seconds) for object-store client requests. | + + +**Option support varies by backend** + +These are commonly used options. Cloud-specific keys (for example `region`, `endpoint`, `service_account`, and Azure credential keys) are backend-dependent and can be provided in `storage_options` as needed. + + +#### New table configuration {#new-table-configuration} + +These options control the Lance file format and features used when creating new tables. Pass them via `storage_options` at connection or table level. They are evaluated only at table creation; setting them on an existing connection does not rewrite or alter tables that already exist. + +| Key | Values | Default | Description | +| :-- | :-- | :-- | :-- | +| `new_table_data_storage_version` | `legacy`, `stable` | `stable` | Lance file format version for new tables. Use `legacy` for backward compatibility with older clients, or `stable` for the current format with better performance. | +| `new_table_enable_v2_manifest_paths` | `true`, `false` | `false` | Use v2 manifest path naming. Requires LanceDB >= 0.10.0 to read. | +| `new_table_enable_stable_row_ids` | `true`, `false` | `false` | Keep row IDs stable across compaction, delete, and merge operations. | + +```mermaid +flowchart TD + A[Creating a new table] --> B{Need backward compatibility\nwith older LanceDB clients?} + B -->|Yes| C[new_table_data_storage_version: legacy] + B -->|No| D[new_table_data_storage_version: stable\nDefault — recommended] + D --> E{Need stable row IDs\nacross compaction and deletes?} + E -->|Yes| F[new_table_enable_stable_row_ids: true] + E -->|No| G[Default: false] + D --> H{All clients on LanceDB >= 0.10.0?} + H -->|Yes| I[new_table_enable_v2_manifest_paths: true] + H -->|No| J[Default: false] +``` + + +```python Python icon="python" +import lancedb + +# Set the Lance file format version at connection level +db = lancedb.connect( + "s3://bucket/path", + storage_options={ + "new_table_data_storage_version": "stable", + }, +) +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; + +// Set the Lance file format version at connection level +const db = await lancedb.connect("s3://bucket/path", { + storageOptions: { + newTableDataStorageVersion: "stable", + }, +}); +``` + + + +**Deprecated parameter** + +The `data_storage_version` parameter on `create_table()` is deprecated. Use `new_table_data_storage_version` in `storage_options` instead. + + +### AWS S3 {#aws-s3} + +![](/static/assets/images/storage/aws.jpg) + +Set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optionally `AWS_SESSION_TOKEN` as environment variables or pass them in `storage_options`. Region is optional for AWS but required for most S3-compatible stores. + +Minimum permissions usually include `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject`, `s3:ListBucket`, and `s3:GetBucketLocation` scoped to the relevant bucket/prefix. + +### S3-compatible stores {#s3-compatible-stores} + + + + {PyStorageS3Minio} + + + {TsStorageS3Minio} + + + +If the endpoint is `http://` (common in local development), also set `ALLOW_HTTP=true` or pass `allow_http=True` in `storage_options`. + +### S3 Express {#s3-express} + + + + {PyStorageS3Express} + + + {TsStorageS3Express} + + + +Consult AWS networking requirements for S3 Express before enabling. + + +**Clean up failed multipart uploads** + +LanceDB aborts multipart uploads on graceful shutdown, but crashes can leave incomplete uploads. Add an S3 lifecycle rule to delete in-progress uploads after a few days. + + +### Server-side encryption with KMS {#server-side-encryption-with-kms} + +To encrypt at rest with an AWS KMS key, set `aws_server_side_encryption` to `aws:kms` and `aws_sse_kms_key_id` to the key ID or ARN. The same options apply at connection or table level and combine with bucket-level default encryption. + + + + {PyStorageS3SseKms} + + + {TsStorageS3SseKms} + + + +The IAM principal needs `kms:Encrypt`, `kms:Decrypt`, and `kms:GenerateDataKey` on the configured KMS key. + +### Google Cloud Storage {#google-cloud-storage} + +![](/static/assets/images/storage/gcp.jpg) + +Provide credentials via `GOOGLE_SERVICE_ACCOUNT` (path to JSON) or include the path in `storage_options`. GCS defaults to HTTP/1; set `HTTP1_ONLY=false` if you need HTTP/2. + + + + {PyStorageGcsServiceAccount} + + + {TsStorageGcsServiceAccount} + + + +### Azure Blob Storage {#azure-blob-storage} + +![](/static/assets/images/storage/azure.jpg) + +Set `AZURE_STORAGE_ACCOUNT_NAME` and `AZURE_STORAGE_ACCOUNT_KEY` as environment variables, or pass them via `storage_options`. + + + + {PyStorageAzureAccount} + + + {TsStorageAzureAccount} + + + +For SAS-token auth, set `azure_storage_account_name` and `azure_storage_sas_token`: + + + + {PyStorageAzureSas} + + + {TsStorageAzureSas} + + + +Other supported keys include service principal credentials (`azure_client_id`, `azure_client_secret`, `azure_tenant_id`), managed identities, and custom endpoints. + +### Tigris Object Storage {#tigris-object-storage} + +![](/static/assets/images/storage/tigris.jpg) + +Tigris exposes an S3-compatible API. Configure the endpoint and region: + + + + {PyStorageTigrisConnect} + + + {TsStorageTigrisConnect} + + + +Environment variables `AWS_ENDPOINT=https://t3.storage.dev` and `AWS_DEFAULT_REGION=auto` achieve the same configuration. + +### Tencent COS {#tencent-cos} + +[Tencent Cloud Object Storage (COS)](https://www.tencentcloud.com/products/cos) is the primary object store for workloads running in the China region. Use the `cos://` URI scheme to connect directly to a COS bucket. + + + + {PyStorageCosConnect} + + + +Supported keys include `secret_id`, `secret_key`, `region`, and `endpoint`. You can also authenticate via the `TENCENTCLOUD_SECRET_ID` and `TENCENTCLOUD_SECRET_KEY` environment variables. + + +**Availability** + +COS is bundled in the Python wheel by default. To use it from Rust or the Node binding, build with the `cos` Cargo feature enabled. + + +### GooseFS {#goosefs} + +[GooseFS](https://www.tencentcloud.com/document/product/1424) is Tencent Cloud's distributed cache acceleration layer for COS and S3. It is a common choice when the same hot dataset is read repeatedly, such as vector search and AI training workloads. Connect using the `goosefs://` URI scheme. + + + + {PyStorageGoosefsConnect} + + + {TsStorageGoosefsConnect} + + + +GooseFS reads credentials and endpoint configuration from the GooseFS client environment. See the [GooseFS documentation](https://www.tencentcloud.com/document/product/1424) for cluster setup. + + +**Availability** + +GooseFS is bundled by default in the Python wheel and the Node binding. To use it from Rust, build with the `goosefs` Cargo feature enabled. + diff --git a/docs/web/deployment/support.mdx b/docs/web/deployment/support.mdx new file mode 100644 index 000000000..f758cab00 --- /dev/null +++ b/docs/web/deployment/support.mdx @@ -0,0 +1,37 @@ +--- +title: "Support" +sidebarTitle: "Support" +description: "Where to get help: community Discord for OSS, dedicated support for Enterprise." +icon: circle-question +keywords: ["support", "help", "discord", "community"] +--- + +## LanceDB OSS {#oss} + +For a quick question, the +[Discord community](https://discord.gg/AUEWnJ7Txb) is fastest -- ask under the +relevant language channel for Python, TypeScript, or Rust, and you reach both +the community and the engineering team. + +For anything worth keeping searchable for the next person, use GitHub instead: + +| | | +| --- | --- | +| [GitHub Discussions](https://github.com/lancedb/lancedb/discussions) | Open-ended questions, design questions, "what's the right way to do X" | +| [GitHub Issues](https://github.com/lancedb/lancedb/issues) | A specific bug, or a feature request | + +## LanceDB Enterprise {#enterprise} + +Enterprise Contact your support team directly at +[support@lancedb.com](mailto:support@lancedb.com) for dedicated assistance. + +## What to include {#what-to-include} + +A faster answer usually depends on the same handful of details: + +| | | +| --- | --- | +| SDK and version | `lancedb.__version__` (Python), `package.json` (TypeScript), the crate version (Rust) | +| Connection type | embedded path, or a `db://` deployment | +| The exact error | full traceback or error message, not a paraphrase | +| A minimal repro | the smallest table schema and call that reproduces it | diff --git a/docs/web/docs.json b/docs/web/docs.json new file mode 100644 index 000000000..4c002b0a5 --- /dev/null +++ b/docs/web/docs.json @@ -0,0 +1,605 @@ +{ + "$schema": "https://mintlify.com/docs.json", + "appearance": { + "strict": false + }, + "theme": "mint", + "name": "LanceDB", + "banner": { + "content": "[Why Multimodal Data Needs a Better Lakehouse? \u2014 Download the Research Study](https://lancedb.com/download/)", + "dismissible": true + }, + "colors": { + "primary": "#FF6B35", + "light": "#FF8A5C", + "dark": "#E55A2B" + }, + "fonts": { + "family": "Inter" + }, + "styling": { + "codeblocks": { + "theme": { + "light": "vitesse-light", + "dark": "catppuccin-mocha" + } + } + }, + "favicon": "/static/favicon.ico", + "redirects": [ + { + "source": "/tables/update", + "destination": "/ingestion/update" + }, + { + "source": "/search/fts-examples", + "destination": "/search/full-text-search" + }, + { + "source": "/search/optimize-queries", + "destination": "/search/explain" + }, + { + "source": "/lance", + "destination": "/basics/lance" + }, + { + "source": "/tables-and-namespaces", + "destination": "/basics/catalog" + }, + { + "source": "/catalog", + "destination": "/basics/catalog" + }, + { + "source": "/catalog/database", + "destination": "/basics/catalog" + }, + { + "source": "/api-reference", + "destination": "/basics/apis" + }, + { + "source": "/feature-engineering/computed-columns", + "destination": "/feature-engineering/add-computed-column" + }, + { + "source": "/feature-engineering/functions", + "destination": "/management/function" + }, + { + "source": "/tables/create", + "destination": "/ingestion" + }, + { + "source": "/tables/multimodal", + "destination": "/management/table#multimodal" + }, + { + "source": "/search/select", + "destination": "/sql/select" + }, + { + "source": "/tutorials/search", + "destination": "/tutorials/search/hybrid-search-beir" + }, + { + "source": "/tutorials/agents", + "destination": "/tutorials/agents/contextual-rag" + }, + { + "source": "/tutorials/feature-engineering", + "destination": "/tutorials/feature-engineering/feature-engineering-101" + }, + { + "source": "/tables", + "destination": "/management/table" + }, + { + "source": "/namespaces", + "destination": "/management/namespace" + }, + { + "source": "/namespaces/usage", + "destination": "/management/namespace" + }, + { + "source": "/basics/data-types", + "destination": "/sql/data-types" + }, + { + "source": "/tables/schema", + "destination": "/management/table#schema" + }, + { + "source": "/tables/versioning", + "destination": "/management/table#versioning" + }, + { + "source": "/search/multivector-search", + "destination": "/search/vector-search" + }, + { + "source": "/search/explain-analyze", + "destination": "/search/explain" + }, + { + "source": "/indexing/quantization", + "destination": "/indexing/vector-index" + }, + { + "source": "/performance", + "destination": "/search#performance" + }, + { + "source": "/reranking/:slug*", + "destination": "/reranking" + }, + { + "source": "/training", + "destination": "/training/data-loading" + }, + { + "source": "/training/lerobot", + "destination": "/integrations/lerobotdataset" + }, + { + "source": "/build-with-ai-agents", + "destination": "/basics/agentic-skills" + }, + { + "source": "/core/update", + "destination": "/ingestion/update" + }, + { + "source": "/storage", + "destination": "/deployment/storage" + }, + { + "source": "/storage/configuration", + "destination": "/deployment/storage" + }, + { + "source": "/storage/monitoring", + "destination": "/deployment/monitoring" + }, + { + "source": "/enterprise/deployment", + "destination": "/enterprise/deployment/index" + }, + { + "source": "/search/sql", + "destination": "/sql" + }, + { + "source": "/search/sql/index", + "destination": "/sql" + }, + { + "source": "/search/sql/fts-sql", + "destination": "/sql/dql/fts" + }, + { + "source": "/sql/select", + "destination": "/sql/dql/select" + }, + { + "source": "/sql/subqueries", + "destination": "/sql/dql/subqueries" + }, + { + "source": "/sql/join", + "destination": "/sql/dql/join" + }, + { + "source": "/sql/aggregation", + "destination": "/sql/dql/aggregation" + }, + { + "source": "/sql/window", + "destination": "/sql/dql/window" + }, + { + "source": "/sql/sampling", + "destination": "/sql/dql/sampling" + }, + { + "source": "/sql/explain", + "destination": "/sql/dql/explain" + }, + { + "source": "/sql/ddl", + "destination": "/sql/ddl/table" + }, + { + "source": "/sql/dml", + "destination": "/sql/dml/insert" + }, + { + "source": "/sql/refresh", + "destination": "/sql/dml/refresh" + }, + { + "source": "/sql/show", + "destination": "/sql/ddl/job" + }, + { + "source": "/faq", + "destination": "/deployment/support" + }, + { + "source": "/faq/faq-oss", + "destination": "/deployment/support" + }, + { + "source": "/faq/faq-enterprise", + "destination": "/deployment/support" + }, + { + "source": "/troubleshooting", + "destination": "/deployment/support" + }, + { + "source": "/basics/apis", + "destination": "/basics/sdk" + } + ], + "navigation": { + "tabs": [ + { + "tab": "Documentation", + "groups": [ + { + "group": "Getting Started", + "pages": [ + "index", + "quickstart", + "architecture", + "basics/sdk", + "basics/sql", + "basics/agentic-skills" + ] + }, + { + "group": "Concepts", + "pages": [ + "basics/lance", + "basics/catalog", + "basics/lakehouse", + "basics/query", + "basics/jobs", + "tables/consistency", + "basics/guarantees", + "basics/limits" + ] + }, + { + "group": "Data Ingestion", + "pages": [ + "ingestion/index", + "ingestion/create-table", + "ingestion/insert", + "ingestion/merge-insert", + "ingestion/update", + "ingestion/delete" + ] + }, + { + "group": "Semantic Search", + "pages": [ + "search/index", + "search/vector-search", + "search/full-text-search", + "search/hybrid-search", + "search/filtering", + "reranking/index", + "search/explain" + ] + }, + { + "group": "Data Curation", + "pages": [ + "curation/index", + "curation/aggregate", + "curation/sampling", + "curation/join", + "curation/windowing" + ] + }, + { + "group": "Feature Engineering", + "pages": [ + "feature-engineering/index", + "feature-engineering/data-evolution", + "feature-engineering/add-computed-column", + "feature-engineering/refresh-column", + "feature-engineering/materialized-views" + ] + }, + { + "group": "Training", + "pages": [ + "training/why-lancedb", + "training/data-loading", + "training/torch" + ] + }, + { + "group": "Management", + "pages": [ + "management/index", + "management/database", + "management/namespace", + "management/table", + "tables/branching", + "management/materialized-view", + "management/view", + "management/function" + ] + }, + { + "group": "Maintenance", + "pages": [ + "indexing/index", + "indexing/vector-index", + "indexing/fts-index", + "indexing/scalar-index", + "indexing/reindexing", + "maintenance/compaction", + "maintenance/cleanup", + "maintenance/background-jobs" + ] + }, + { + "group": "Deployment", + "pages": [ + "enterprise/index", + "enterprise/architecture", + "enterprise/deployment/index", + "deployment/storage", + "deployment/monitoring", + "enterprise/deployment/azure", + "enterprise/benchmarks", + "deployment/support" + ] + }, + { + "group": "Security", + "pages": [ + "enterprise/authentication", + "governance/authorization", + "enterprise/security" + ] + }, + { + "group": "SQL Reference", + "pages": [ + "sql/index", + "sql/data-types", + { + "group": "DDL", + "pages": [ + "sql/ddl/namespace", + "sql/ddl/table", + "sql/ddl/view", + "sql/ddl/materialized-view", + "sql/ddl/index", + "sql/ddl/function", + "sql/ddl/job" + ], + "icon": "sitemap" + }, + { + "group": "DML", + "pages": [ + "sql/dml/insert", + "sql/dml/update", + "sql/dml/delete", + "sql/dml/copy", + "sql/dml/refresh" + ], + "icon": "database" + }, + { + "group": "DQL", + "pages": [ + "sql/dql/select", + "sql/dql/subqueries", + "sql/dql/join", + "sql/dql/aggregation", + "sql/dql/window", + "sql/dql/sampling", + "sql/dql/fts", + "sql/dql/explain" + ], + "icon": "filter" + }, + { + "group": "Expressions", + "pages": [ + "sql/operators", + "sql/functions", + "sql/scalar-functions", + "sql/prepared-statements" + ], + "icon": "code" + } + ] + } + ] + }, + { + "tab": "Integrations", + "pages": [ + "integrations/index", + { + "group": "Embedding Registry", + "pages": [ + "embedding/index", + "embedding/quickstart" + ] + }, + { + "group": "Embedding Providers", + "pages": [ + "integrations/embedding/huggingface", + "integrations/embedding/aws", + "integrations/embedding/cohere", + "integrations/embedding/colpali", + "integrations/embedding/gemini", + "integrations/embedding/ibm", + "integrations/embedding/imagebind", + "integrations/embedding/instructor", + "integrations/embedding/jina", + "integrations/embedding/ollama", + "integrations/embedding/openai", + "integrations/embedding/openclip", + "integrations/embedding/sentence-transformers", + "integrations/embedding/voyageai", + "integrations/embedding/superlinked" + ] + }, + { + "group": "Rerankers", + "pages": [ + "integrations/reranking/answerdotai", + "integrations/reranking/cohere", + "integrations/reranking/colbert", + "integrations/reranking/jina", + "integrations/reranking/openai", + "integrations/reranking/voyageai", + "integrations/reranking/watsonx" + ] + }, + { + "group": "Data Platforms & Frameworks", + "expanded": true, + "pages": [ + "integrations/data/pydantic", + "integrations/data/duckdb", + "integrations/data/pandas_and_pyarrow", + "integrations/data/polars_arrow", + "integrations/data/dlt", + "integrations/data/voxel51" + ] + }, + { + "group": "AI Platforms & Frameworks", + "expanded": true, + "pages": [ + "integrations/ai/agno", + "integrations/ai/hermes-agent", + "integrations/ai/huggingface", + "integrations/ai/langchain", + "integrations/ai/llamaIndex", + "integrations/ai/genkit", + "integrations/ai/kiln", + "integrations/ai/prompttools", + "integrations/ai/synthetic-data-kit" + ] + } + ] + }, + { + "tab": "Tutorials", + "pages": [ + "tutorials/index", + { + "group": "Search", + "pages": [ + "tutorials/search/hybrid-search-beir", + "tutorials/search/multivector-xtr", + "tutorials/search/multivector-needle-in-a-haystack", + "tutorials/search/video-search", + "tutorials/search/vector-arithmetic", + "tutorials/search/ner-search", + "tutorials/search/reddit-summarizer" + ] + }, + { + "group": "RAG", + "pages": [ + "tutorials/agents/contextual-rag", + "tutorials/agents/nvidia-rag-blueprint/index", + "tutorials/agents/time-travel-rag/index", + "tutorials/agents/multimodal-agent/index", + "tutorials/agents/matryoshka-embeddings", + "tutorials/agents/hyde", + "tutorials/agents/late-chunking", + "tutorials/agents/agentic-rag" + ] + }, + { + "group": "Feature Engineering", + "pages": [ + "tutorials/feature-engineering/feature-engineering-101", + "tutorials/feature-engineering/materialized-views", + "agent-branch-experiments" + ] + }, + { + "group": "Training", + "pages": [ + "training/object-detection", + "training/vlm-finetuning", + "integrations/stable-worldmodel" + ] + } + ] + }, + { + "tab": "Demos", + "pages": [ + "demos/index", + "demos/semantic-art", + "demos/wikipedia-search", + "demos/video-search", + "demos/fineweb-edu-search", + "demos/robot-data-curation" + ] + } + ] + }, + "logo": { + "light": "/static/assets/logo/dark-lancedb-logo.svg", + "dark": "/static/assets/logo/light-lancedb-logo.svg", + "href": "https://lancedb.com" + }, + "background": { + "color": { + "light": "#FAF5F0", + "dark": "#000000" + } + }, + "navbar": { + "links": [ + { + "label": "Support", + "href": "https://www.lancedb.com/contact" + } + ] + }, + "footer": { + "socials": { + "github": "https://github.com/lancedb", + "linkedin": "https://www.linkedin.com/company/lancedb", + "x": "https://x.com/lancedb", + "discord": "https://discord.gg/AUEWnJ7Txb" + }, + "links": [ + { + "items": [ + { + "label": "Cookie settings", + "href": "#cookie-settings" + } + ] + } + ] + }, + "integrations": { + "ga4": { + "measurementId": "G-1TMC2PR69E" + } + } +} diff --git a/docs/web/embedding/index.mdx b/docs/web/embedding/index.mdx new file mode 100644 index 000000000..e3568604b --- /dev/null +++ b/docs/web/embedding/index.mdx @@ -0,0 +1,239 @@ +--- +title: "Managing Embeddings" +sidebarTitle: "Managing Embeddings" +description: "Use the embedding API in LanceDB -- registry, functions, schemas, and multi-language SDK support." +icon: "bars" +--- + +import { + PyOpenaiEmbeddings, + PyCreateEmbeddingFunction, + PyManualQuerySearch, + PyEmbeddingFunction, + PyRegisterDevice, + PyRegisterSecret, + TsOpenaiEmbeddings, + TsCreateEmbeddingFunction, + TsManualQuerySearch, + TsEmbeddingFunction, + TsRegisterModelFallback, + TsRegisterSecret, + RsOpenaiEmbeddings, + RsCreateEmbeddingFunction, + RsManualQuerySearch, + RsEmbeddingFunction, +} from '/snippets/embedding.mdx'; + +Modern machine learning models can be trained to convert raw data into embeddings, which are vectors +of floating point numbers. The position of an embedding in vector space captures the semantics of +the data, so vectors that are close to each other are considered similar. + +LanceDB provides an embedding function registry in OSS as well as its Enterprise versions +([see below](#embeddings-in-lancedb-enterprise)) +that automatically generates vector embeddings during data ingestion. Automatic query-time embedding +generation is available in LanceDB OSS, with SDK-specific query ergonomics. The API abstracts +embedding generation, allowing you to focus on your application logic. + +## Embedding Registry {#embedding-registry} + +You can get a supported embedding function from the registry, and then use it in your table schema. +Once configured, the embedding function will automatically generate embeddings when you insert data +into the table. Query-time behavior depends on SDK: Python/TypeScript can query with text directly, +while Rust examples typically compute query embeddings explicitly before vector search. + + + + {PyOpenaiEmbeddings} + + + + {TsOpenaiEmbeddings} + + + + {RsOpenaiEmbeddings} + + + +### Using an embedding function {#using-an-embedding-function} + +Create an embedding function before you attach it to table or schema metadata. Python and TypeScript fetch +provider implementations from the embedding registry, while Rust constructs the provider embedding function +directly and registers it on the connection before using it in an `EmbeddingDefinition`. + + + + {PyCreateEmbeddingFunction} + + + + {TsCreateEmbeddingFunction} + + + + {RsCreateEmbeddingFunction} + + + +Provider configuration is SDK-specific, so copy the option names from the provider page for the SDK you use. +For example, the OpenAI model is selected with `name` in Python, `model` in TypeScript, and the model argument +to `OpenAIEmbeddingFunction::new_with_model` in Rust. + +| Concept | Python | TypeScript | Rust | +| --- | --- | --- | --- | +| Model | `name="text-embedding-3-small"` | `{ model: "text-embedding-3-small" }` | `new_with_model(api_key, "text-embedding-3-small")` | +| Retry count | `max_retries=7` | Provider/client-specific | Provider/client-specific | +| API key | `api_key="..."`, environment variables, or `$var:` | `apiKey: "..."`, environment variables, or `$var:` | Constructor argument or environment variable | +| Device | Provider-specific, for example `device="cuda"` | Provider-specific | Provider-specific | + +When ingesting data with an embedding definition, LanceDB only computes the vector column if that +column is missing from the incoming batch or present but entirely null. If you provide any non-null +values in the vector column, LanceDB treats the column as user-supplied and does not backfill the +remaining rows in that batch. + +For reusable runtime configuration, the registry also supports `$var:` placeholders in embedding-function config. +This is useful for provider secrets and environment-specific settings in Python and TypeScript. + +- Python uses `registry.set_var(...)`. +- TypeScript uses `registry.setVar(...)`. +- You can provide a fallback with `$var:name:default`. +- Sensitive values such as API keys should be passed through registry variables instead of hardcoding them in config. + + + + {PyRegisterSecret} + + + + {TsRegisterSecret} + + + +For non-sensitive settings such as inference device selection, you can also use a default fallback: + + + + {PyRegisterDevice} + + + + {TsRegisterModelFallback} + + + +Find the full list of arguments for each provider in the [integrations](/integrations/embedding) section. + +## Multiple embedding columns {#multiple-embedding-columns} + +A single table can include more than one embedding definition when you want to store multiple semantic views +of the same data, or generate embeddings from different source columns. In practice, each embedding definition +maps one source column to one vector column, and the table schema can contain multiple such pairs. + +The exact setup differs by SDK, but the underlying pattern is the same: define a distinct source/vector pair +for each embedding function you want applied during ingest. + +In TypeScript, automatic query embedding currently uses the first embedding function stored in the +table metadata. If a table has multiple embedding definitions and you need to query a specific vector +column, compute the query embedding explicitly and pass the vector to the search builder. + +## Embedding model providers {#embedding-model-providers} + +LanceDB supports most popular embedding providers. + +### Text embeddings {#text-embeddings} + +| Provider | Model ID | Default Model | +|----------|----------|---------------| +| OpenAI | `openai` | `text-embedding-ada-002` | +| Sentence Transformers | `sentence-transformers` | `all-MiniLM-L6-v2` | +| Hugging Face | `huggingface` | `colbert-ir/colbertv2.0` | +| Cohere | `cohere` | `embed-english-v3.0` | +| ... | ... | ... | + +### Multimodal embedding {#multimodal-embedding} + +| Provider | Model ID | Supported Inputs | +|----------|----------|------------------| +| OpenCLIP | `open-clip` | Text, Images | +| ImageBind | `imagebind` | Text, Images, Audio, Video | +| ... | ... | ... | + +You can find all supported embedding models in the [integrations](/integrations/embedding) section. + +## Embeddings in LanceDB Enterprise {#embeddings-in-lancedb-enterprise} +Enterprise + +In LanceDB Enterprise, embedding generation during data ingestion is client-side and the resulting vectors are +stored on the remote table. + + +The Enterprise server does not currently generate embeddings from query text on its own. Any automatic +query-time embedding happens on the client side. + + +### How string queries are interpreted {#how-string-queries-are-interpreted} + +For the Python remote client, `table.search("hello")` can take two different paths: + +- If the selected vector column has embedding metadata + (i.e., the table schema stores the source-column, vector-column, and + embedding-function mapping created from fields like `SourceField()` and + `VectorField()` during table creation), then the embeddings are computed in the Python client process. + The client uses the same local LanceDB embedding registry used by OSS tables to + reconstruct the embedding function from schema metadata, compute the query vector in + the client process, and send that vector to Enterprise for search. + ```python + result = table.search("hello").limit(1).to_list() + # The Python client computes the query embedding locally, then sends a vector search. + ``` +- If the table does not have embedding metadata for that search, `table.search("hello")` in `auto` mode is + treated as an FTS query instead. + ```python + result = table.search("hello").limit(5).to_list() + # In auto mode this is treated as an FTS query, not a vector query. + ``` + +If you want explicit vector or hybrid behavior and the client cannot resolve an embedding function from the +table metadata, generate the query embedding yourself and pass the vector directly. + +TypeScript has a similar string-query distinction in `auto` mode: if no embedding providers have been +registered in the process, `search("text")` falls back to FTS. Once an embedding provider has been +imported and registered, the client expects table embedding metadata for automatic vector search and +raises an error if the table has none. + +The manual query-embedding flow below works across Enterprise SDKs and is an explicit path you can use when you +want full control over query-time behavior. + + + + {PyManualQuerySearch} + + + + {TsManualQuerySearch} + + + + {RsManualQuerySearch} + + + +## Custom Embedding Functions {#custom-embedding-functions} + +You can always implement your own embedding function: +- Python/TypeScript: subclass `TextEmbeddingFunction` (text) or `EmbeddingFunction` (multimodal). +- Rust: implement the `EmbeddingFunction` trait. + + + + {PyEmbeddingFunction} + + + + {TsEmbeddingFunction} + + + + {RsEmbeddingFunction} + + diff --git a/docs/web/embedding/quickstart.mdx b/docs/web/embedding/quickstart.mdx new file mode 100644 index 000000000..bcaf90f6f --- /dev/null +++ b/docs/web/embedding/quickstart.mdx @@ -0,0 +1,155 @@ +--- +title: "Embeddings: Quickstart" +sidebarTitle: "Quickstart" +description: "Quickstart guide for generating and working with embeddings." +icon: "rocket" +--- + +import { + TsQuickstartConnect, + TsQuickstartCreateTable, + TsQuickstartImports, + TsQuickstartInitModel, + TsQuickstartQuery, + TsQuickstartSchema, +} from '/snippets/sentence-transformers.mdx'; + +LanceDB will automatically vectorize the data both at ingestion and query time. All you need to do is specify which model to use. +Popular embedding models like OpenAI, Hugging Face, Sentence Transformers, CLIP, and more, are supported. + +## Step 1: Import Required Libraries {#step-1-import-required-libraries} + +First, import the necessary LanceDB components: + + +```python Python icon="python" +import lancedb +from lancedb.pydantic import LanceModel, Vector +from lancedb.embeddings import get_registry +``` + + + {TsQuickstartImports} + + + +- `lancedb`: The main database connection and operations +- `LanceModel`: Pydantic model for defining table schemas +- `Vector`: Field type for storing vector embeddings +- `get_registry()`: Access to the embedding function registry. It has all the supported as well as custom embedding functions registered by the user +- TypeScript uses `lancedb.embedding.getRegistry()` and `lancedb.embedding.LanceSchema()` for the same registry/schema workflow +- In TypeScript, import the provider module before calling `getRegistry().get(...)`; the provider import is what registers names such as `"huggingface"` or `"openai"` + +## Step 2: Connect to LanceDB {#step-2-connect-to-lancedb} + +Establish a connection to your LanceDB OSS directory or Enterprise cluster: + + +```python Python icon="python" +# Enter your LanceDB connection URI for OSS or Enterprise here +db = lancedb.connect(...) +``` + + + {TsQuickstartConnect} + + + +## Step 3: Initialize the Embedding Function {#step-3-initialize-the-embedding-function} + +Choose and configure your embedding model: + + +```python Python icon="python" +model = get_registry().get("sentence-transformers").create(name="BAAI/bge-small-en-v1.5", ) +``` + + + {TsQuickstartInitModel} + + + +This creates an embedding function from the local embedding registry. The Python snippet uses the +`sentence-transformers` provider with the BGE model; the TypeScript snippet uses the Transformers-backed +`huggingface` provider. You can: +- Change `"sentence-transformers"` to other providers like `"openai"`, `"cohere"`, etc. +- Modify the model name for different embedding models +- Set `device="cuda"` for GPU acceleration if available + +## Step 4: Define Your Schema {#step-4-define-your-schema} + +Create a Pydantic model that defines your table structure: + + +```python Python icon="python" +class Words(LanceModel): + text: str = model.SourceField() + vector: Vector(model.ndims()) = model.VectorField() +``` + + + {TsQuickstartSchema} + + + +- `SourceField()`: This field will be embedded +- `VectorField()`: This stores the embeddings +- `model.ndims()`: Sets vector dimensions for your model +- In TypeScript, use `model.sourceField(...)` and `model.vectorField()` inside `LanceSchema(...)` + +## Step 5: Create Table and Ingest Data {#step-5-create-table-and-ingest-data} + +Create a table with your schema and add data: + + +```python Python icon="python" +table = db.create_table("words", schema=Words) +table.add([ + {"text": "hello world"}, + {"text": "goodbye world"} +]) +``` + + + {TsQuickstartCreateTable} + + + +The `table.add()` call automatically: +- Takes the text from each document +- Generates embeddings using your chosen model +- Stores both the original text and the vector embeddings + +If your input already includes the vector column, automatic embedding only runs when that column is +absent or entirely null for the batch. Partially supplied vectors are treated as manual data, so +LanceDB preserves them instead of filling only the missing rows. + +## Step 6: Query with Automatic Embedding {#step-6-query-with-automatic-embedding} + +Note: On LanceDB Enterprise, the server does not generate embeddings from query text. In the Python remote +client, `table.search("greetings")` can still work when the table schema includes embedding metadata, because +the client computes the query embedding before sending the vector search. If there is no embedding metadata for +that search, `search("greetings")` in `auto` mode is treated as FTS instead. + +Search your data using natural language queries: + + +```python Python icon="python" +query = "greetings" +actual = table.search(query).limit(1).to_pydantic(Words)[0] +print(actual.text) +``` + + + {TsQuickstartQuery} + + + +The search process: +1. Automatically converts your query text to embeddings +2. Finds the most similar vectors in your table +3. Returns the matching documents + +Automatic text search depends on the table's embedding metadata. If the client cannot reconstruct an +embedding function from that metadata, compute the query embedding yourself and search with the +vector directly. diff --git a/docs/web/enterprise/architecture.mdx b/docs/web/enterprise/architecture.mdx new file mode 100644 index 000000000..fc1c1c7db --- /dev/null +++ b/docs/web/enterprise/architecture.mdx @@ -0,0 +1,20 @@ +--- +title: "Enterprise architecture" +sidebarTitle: "Architecture" +description: "The tiers a deployment runs, and how they scale independently." +icon: cubes +keywords: ["architecture", "control plane", "data plane", "indexer"] +--- + +Enterprise + +This page describes a LanceDB Enterprise capability. The embedded form +of LanceDB has no server to provide it -- see +[Architecture](/architecture) for what differs between the two. + +A deployment separates compute from storage so each scales on its own: table +data and indexes live in object storage, while query serving and background work +run on machines that can be replaced without any of them owning the data. + +[Architecture](/architecture) covers this alongside the embedded form, which is +the comparison most readers want first. diff --git a/docs/web/enterprise/authentication.mdx b/docs/web/enterprise/authentication.mdx new file mode 100644 index 000000000..f41603fdf --- /dev/null +++ b/docs/web/enterprise/authentication.mdx @@ -0,0 +1,170 @@ +--- +title: "Authentication" +sidebarTitle: "Authentication" +description: "Authentication modes for LanceDB Enterprise: API keys and OAuth credentials." +icon: key +keywords: ["authentication", "oauth", "api key", "azure managed identity", "client credentials"] +--- + +Enterprise + +This page describes a LanceDB Enterprise capability. The embedded form +of LanceDB has no server to provide it -- see +[Architecture](/architecture) for what differs between the two. + +A deployment authenticates every request before +[authorization](/governance/authorization) decides what it may do, against a +`db://` remote table, in one of two ways: + +- **API keys** -- a long-lived shared secret passed as `api_key` on connect. Works in every SDK. +- **OAuth 2.0** -- short-lived bearer tokens obtained from your identity provider, refreshed automatically by the client. + +OAuth is the recommended option when you want to rotate credentials centrally, +plug into an existing identity provider, or run on Azure with managed +identities so no secret material lives on the client. + +## API key {#api-key} + +Pass the API key from your Enterprise tenant on `connect`. This works with +both the synchronous and asynchronous Python clients, as well as TypeScript +and Rust. + +```python +import lancedb + +db = lancedb.connect( + uri="db://your-database-uri", + api_key="your-api-key", + region="us-east-1", + host_override="https://your-enterprise-endpoint.com", +) +``` + +An API key belongs in the environment rather than in source. See +[Database](/management/database#deployment) for the connection options. + +## OAuth {#oauth} + +The async Python client and the TypeScript client can obtain bearer tokens +from an OIDC issuer and attach them to every request. Token acquisition, +caching, and refresh are handled inside the client -- your application code +only provides the configuration. + + +In Python, OAuth is supported through `lancedb.connect_async`. The synchronous +`connect` entry point continues to use API key authentication for `db://` +URIs. In TypeScript, `lancedb.connect` accepts `oauthConfig` directly. + + +### Supported flows {#supported-flows} + +`OAuthFlowType` selects how the client acquires tokens: + +| Flow | Python value | TypeScript value | When to use | +| --- | --- | --- | --- | +| Client Credentials | `OAuthFlowType.CLIENT_CREDENTIALS` | `OAuthFlowType.ClientCredentials` | Service-to-service / machine-to-machine. Requires a client ID and client secret registered with your identity provider. | +| Azure Managed Identity | `OAuthFlowType.AZURE_MANAGED_IDENTITY` | `OAuthFlowType.AzureManagedIdentity` | Workloads running on Azure compute (VMs, AKS, App Service, Container Apps). Tokens are fetched from the Azure IMDS endpoint, so no client secret is stored on the client. | + +### Configure OAuth {#configure-oauth} + +Build an OAuth config and pass it on connect. Use `oauth_config` in Python and +`oauthConfig` in TypeScript. + + +```python Python icon="python" +import lancedb +from lancedb.remote import OAuthConfig, OAuthFlowType + +# Client Credentials (service-to-service) +oauth_config = OAuthConfig( + issuer_url="https://login.microsoftonline.com/{tenant}/v2.0", + client_id="your-application-client-id", + client_secret="your-application-client-secret", + scopes=["api://lancedb-api/.default"], + flow=OAuthFlowType.CLIENT_CREDENTIALS, +) + +db = await lancedb.connect_async( + uri="db://your-database-uri", + region="us-east-1", + host_override="https://your-enterprise-endpoint.com", + oauth_config=oauth_config, +) +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; +import { OAuthConfig, OAuthFlowType } from "@lancedb/lancedb"; + +// Client Credentials (service-to-service) +const oauthConfig: OAuthConfig = { + issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0", + clientId: "your-application-client-id", + clientSecret: "your-application-client-secret", + scopes: ["api://lancedb-api/.default"], + flow: OAuthFlowType.ClientCredentials, +}; + +const db = await lancedb.connect("db://your-database-uri", { + region: "us-east-1", + hostOverride: "https://your-enterprise-endpoint.com", + oauthConfig, +}); +``` + + +For workloads running on Azure compute, use a managed identity instead of a +client secret: + + +```python Python icon="python" +from lancedb.remote import OAuthConfig, OAuthFlowType + +oauth_config = OAuthConfig( + issuer_url="https://login.microsoftonline.com/{tenant}/v2.0", + client_id="your-application-client-id", + scopes=["api://lancedb-api/.default"], + flow=OAuthFlowType.AZURE_MANAGED_IDENTITY, + # Optional: required only for user-assigned managed identities. + managed_identity_client_id="your-user-assigned-identity-client-id", +) +``` + +```typescript TypeScript icon="square-js" +import { OAuthConfig, OAuthFlowType } from "@lancedb/lancedb"; + +const oauthConfig: OAuthConfig = { + issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0", + clientId: "your-application-client-id", + scopes: ["api://lancedb-api/.default"], + flow: OAuthFlowType.AzureManagedIdentity, + // Optional: required only for user-assigned managed identities. + managedIdentityClientId: "your-user-assigned-identity-client-id", +}; +``` + + +### Configuration reference {#configuration-reference} + +The same configuration is available in both SDKs. Python uses `snake_case` +field names; TypeScript uses `camelCase`. + +| Python field / TypeScript field | Required | Description | +| --- | --- | --- | +| `issuer_url` / `issuerUrl` | Yes | OIDC issuer URL or OAuth authority URL. For Azure, use `https://login.microsoftonline.com/{tenant_id}/v2.0`. | +| `client_id` / `clientId` | Yes | Application / client ID registered with your identity provider. | +| `scopes` | Yes | List of OAuth scopes to request. For Azure managed identity, provide exactly one scope or resource, for example `["api://your-app-id/.default"]`. | +| `flow` | No | Selects the OAuth flow. Defaults to Client Credentials. | +| `client_secret` / `clientSecret` | Conditional | Required for Client Credentials. Redacted from the config's `repr` (Python) and `Debug` output (TypeScript native binding) so it does not leak into logs. | +| `managed_identity_client_id` / `managedIdentityClientId` | No | Client ID of a user-assigned managed identity. Only used with Azure Managed Identity; omit for system-assigned identities. | +| `refresh_buffer_secs` / `refreshBufferSecs` | No | How many seconds before token expiry to proactively refresh. Defaults to 300. Keep this well below the token TTL -- setting it greater than or equal to the TTL forces a refresh on every request. | + + +Treat the client secret like any other production credential. Load it from a +secret manager or environment variable rather than committing it to source +control. Both SDKs deliberately omit the secret from their debug/repr output +so accidental log lines do not expose the value. + + +Embedded LanceDB has nothing to authenticate to -- your process opens the files +directly, and the object store's own credentials are the only gate. diff --git a/docs/web/enterprise/benchmarks.mdx b/docs/web/enterprise/benchmarks.mdx new file mode 100644 index 000000000..3626f7b81 --- /dev/null +++ b/docs/web/enterprise/benchmarks.mdx @@ -0,0 +1,20 @@ +--- +title: "Benchmarks" +sidebarTitle: "Benchmarks" +description: "Published performance results for LanceDB Enterprise." +icon: chart-line +keywords: ["benchmarks", "performance", "throughput", "latency"] +--- + +Enterprise + +This page describes a LanceDB Enterprise capability. The embedded form +of LanceDB has no server to provide it -- see +[Architecture](/architecture) for what differs between the two. + +Benchmark results are specific to a deployment's shape -- cluster size, storage +class, dataset -- so they are published with LanceDB Enterprise. + +For tuning your own workload rather than comparing published numbers, see +[Query plans](/search/explain) and the performance notes on +[Search](/search#performance) and [Insert](/ingestion/insert#performance). diff --git a/docs/web/enterprise/deployment/azure.mdx b/docs/web/enterprise/deployment/azure.mdx new file mode 100644 index 000000000..349393249 --- /dev/null +++ b/docs/web/enterprise/deployment/azure.mdx @@ -0,0 +1,17 @@ +--- +title: "Azure deployment guide" +sidebarTitle: "Azure" +description: "Deploying LanceDB Enterprise on Azure." +icon: cloud +keywords: ["azure", "deployment", "aks"] +--- + +Enterprise + +This page describes a LanceDB Enterprise capability. The embedded form +of LanceDB has no server to provide it -- see +[Architecture](/architecture) for what differs between the two. + +The Azure deployment procedure is published with LanceDB Enterprise. See +[Deployment](/enterprise/deployment) for the general shape, and +[contact us](https://www.lancedb.com/contact) to get started. diff --git a/docs/web/enterprise/deployment/index.mdx b/docs/web/enterprise/deployment/index.mdx new file mode 100644 index 000000000..c718cc32b --- /dev/null +++ b/docs/web/enterprise/deployment/index.mdx @@ -0,0 +1,20 @@ +--- +title: "Deployment guide" +sidebarTitle: "Deployment Models" +description: "Standing up a LanceDB Enterprise deployment." +icon: server +keywords: ["deployment", "install", "cluster", "kubernetes"] +--- + +Enterprise + +This page describes a LanceDB Enterprise capability. The embedded form +of LanceDB has no server to provide it -- see +[Architecture](/architecture) for what differs between the two. + +Deployment is specific to your cloud and account, so the procedure is published +with LanceDB Enterprise. [Contact us](https://www.lancedb.com/contact) to get +started. + +Embedded LanceDB has nothing to deploy -- it is a library your process links +against. See [Architecture](/architecture#embedded). diff --git a/docs/web/enterprise/index.mdx b/docs/web/enterprise/index.mdx new file mode 100644 index 000000000..ed4d232c1 --- /dev/null +++ b/docs/web/enterprise/index.mdx @@ -0,0 +1,29 @@ +--- +title: "LanceDB Enterprise" +sidebarTitle: "Overview" +description: "What a deployment adds over embedded LanceDB." +icon: building +keywords: ["enterprise", "deployment", "cluster"] +--- + +Enterprise + +This page describes a LanceDB Enterprise capability. The embedded form +of LanceDB has no server to provide it -- see +[Architecture](/architecture) for what differs between the two. + +LanceDB Enterprise runs the lakehouse as a service: a control plane for +configuration, identity and policy; a data plane serving reads and writes; and +indexers building indexes and compacting outside the request path. + +What it adds over the embedded form: + +| | | +| --- | --- | +| [SQL](/sql) | an Arrow Flight SQL endpoint | +| [Functions](/management/function) | Python running on workers | +| [Jobs](/basics/jobs) | server-side work with ids, status and events | +| [Authorization](/governance/authorization) | per-object grants | +| Scale | a cluster rather than your process | + +See [Architecture](/architecture) for the two shapes side by side. diff --git a/docs/web/enterprise/security.mdx b/docs/web/enterprise/security.mdx new file mode 100644 index 000000000..a2b60a242 --- /dev/null +++ b/docs/web/enterprise/security.mdx @@ -0,0 +1,20 @@ +--- +title: "Compliance" +sidebarTitle: "Compliance" +description: "Certifications, data handling, and the controls a deployment provides." +icon: certificate +keywords: ["compliance", "security", "soc2", "encryption", "audit"] +--- + +Enterprise + +This page describes a LanceDB Enterprise capability. The embedded form +of LanceDB has no server to provide it -- see +[Architecture](/architecture) for what differs between the two. + +Compliance posture -- certifications, data residency, encryption at rest and in +transit, audit logging -- is a property of a deployment, so it is documented +with LanceDB Enterprise. + +For embedded LanceDB, these are properties of the object store you point it at +and the process you run it in; LanceDB adds no layer of its own. diff --git a/docs/web/feature-engineering/add-computed-column.mdx b/docs/web/feature-engineering/add-computed-column.mdx new file mode 100644 index 000000000..571797d4e --- /dev/null +++ b/docs/web/feature-engineering/add-computed-column.mdx @@ -0,0 +1,91 @@ +--- +title: "Add a computed column" +sidebarTitle: "Computed Columns" +description: "Declare a column from a SQL expression. Nothing is evaluated until you refresh it." +icon: table-columns +keywords: ["computed columns", "add_columns", "transforms", "schema"] +--- + +A computed column is declared with an expression and starts with no values. +Declaring it is cheap on any table size, because nothing is evaluated until +[a refresh](/feature-engineering/refresh-column) asks for it. + +## Declaring one {#declare} + +```python +import lancedb + +db = lancedb.connect("./.lancedb") +table = db.create_table("computed_demo", [{"x": 1}, {"x": 2}]) + +table.add_columns(computed={"doubled": "x * 2"}) +# AddColumnsResult(version=2) +``` + +That commits a new table version with the column present and empty. It is an +ordinary table version, so the usual [versioning](/management/table#versioning) applies -- +including that you can travel back to before the column existed. + +## `computed` and `transforms` are different {#computed-vs-transforms} + +`add_columns` takes either, and the difference matters: + +- **SQL expressions in `transforms`** are evaluated *now*, for every row, as + part of the call. Good for a small table, or a column you want populated + immediately. A `FunctionApplication` passed through `transforms` instead + declares empty Function-bound columns; a refresh fills them. +- **`computed`** stores the expression and evaluates nothing. The column is + committed empty and filled later by + [`refresh_column`](/feature-engineering/refresh-column). + +`computed` and `transforms` cannot be combined in one call. Reach for `computed` when the table is +large enough that you would rather control when the work happens. + +## The expression {#expressions} + +The expression is SQL, evaluated over the row. It can reference any column in +the table, including another computed column: + +```python +table.add_columns(computed={ + "power_score": "(stats.magic + stats.strength) / 2.0", + "is_strong": "power_score > 3.0", +}) +``` + +Declaration order is dependency order -- a computed column may reference one +declared earlier in the same mapping, but not one declared after it. + +The functions available are the ones the +[SQL dialect](/sql/functions) documents. + +## Its inputs become load-bearing {#input-constraints} + + +While a computed column's declaration references another column, that column +cannot be renamed, retyped, or dropped. The declaration is part of the schema, +and LanceDB will not let you invalidate it out from under itself. + + +This is worth knowing before deriving a column from something you expect to +reshape later. Dropping the computed column releases the constraint. + +## Filling with Python instead of SQL {#functions} + +Enterprise + +When the value cannot be expressed in SQL -- an embedding, a model call, an +image decode -- the column is filled by a registered +[function](/management/function) instead of an expression. Declaring it looks +the same; what differs is what fills it. + +## What to read next {#next} + + + + Fill the rows that lack values. + + + Fill a column with Python. + + diff --git a/docs/web/feature-engineering/data-evolution.mdx b/docs/web/feature-engineering/data-evolution.mdx new file mode 100644 index 000000000..5f50bdcbb --- /dev/null +++ b/docs/web/feature-engineering/data-evolution.mdx @@ -0,0 +1,117 @@ +--- +title: "Data evolution" +sidebarTitle: "Data Evolution" +description: "Derive new columns from data already in the table, without rewriting what is already there." +icon: wand-magic-sparkles +keywords: ["data evolution", "derived columns", "add_columns", "zero copy", "feature engineering"] +--- + +import { + PySchemaAddSetup, + TsSchemaAddSetup as TsSchemaAddSetup, + RsSchemaAddSetup as RsSchemaAddSetup, + PyAddColumnsCalculated as AddColumnsCalculated, + TsAddColumnsCalculated as TsAddColumnsCalculated, + RsAddColumnsCalculated as RsAddColumnsCalculated, + PyAddFeatureColumnsSql as AddFeatureColumnsSql, + TsAddFeatureColumnsSql as TsAddFeatureColumnsSql, + RsAddFeatureColumnsSql as RsAddFeatureColumnsSql, +} from '/snippets/tables.mdx'; + +Data evolution is adding **values** derived from what a table already holds. +[Schema evolution](/management/table#schema) changes the table's shape; this fills the +shape with something new. + +The reason it is cheap is the format. Adding a derived column writes only that +column -- the existing data, which is the expensive part on a multimodal table, +is never rewritten. See [why that works](/basics/lance#evolution). + + +In SQL this is [`ALTER TABLE ... ADD COLUMN ... AS`](/sql/ddl/table#computed-column). + + +## Set up the example table {#set-up-the-example-table} + + + + {PySchemaAddSetup} + + + + {TsSchemaAddSetup} + + + + {RsSchemaAddSetup} + + + +## Add derived columns {#add-derived-columns} + +You can add new columns that are derived from existing data using SQL expressions. +For feature engineering on large existing tables, group related derived features into +one `add_columns` operation instead of running many separate writes. This creates one +new table version for the schema change and computes the new columns from the existing +rows, which avoids growing the table's version history with many small updates. + + + + {AddColumnsCalculated} + + + + {TsAddColumnsCalculated} + + + + {RsAddColumnsCalculated} + + + +The same call can add multiple derived columns at once. For example, if you are +building several lightweight features from existing product fields, pass all of the +new column expressions together: + + + + {AddFeatureColumnsSql} + + + + {TsAddFeatureColumnsSql} + + + + {RsAddFeatureColumnsSql} + + + + +LanceDB `add_columns` does not currently accept Python callables, batch UDFs, or +PyArrow `RecordBatch` iterators for populating new columns. New column values must be +defined with SQL expressions, or added as NULL columns from an Arrow field or schema. +If your transformation cannot be expressed in SQL, compute the values outside +`add_columns` before writing them back through another workflow. + + +## When SQL is not enough {#beyond-sql} + +The expressions above are evaluated once, as part of the call. Two other shapes +exist when that does not fit: + +- A [computed column](/feature-engineering/add-computed-column) declares the + expression and leaves the column empty, so you choose when the work happens. + That is the right default on a large table. +- A [function](/management/function) fills a column with Python, for values SQL + cannot express -- an embedding, a model call, an image decode. + +## What to read next {#next} + + + + Declare now, fill later. + + + Change the table's shape instead of its contents. + + diff --git a/docs/web/feature-engineering/index.mdx b/docs/web/feature-engineering/index.mdx new file mode 100644 index 000000000..4b8200d40 --- /dev/null +++ b/docs/web/feature-engineering/index.mdx @@ -0,0 +1,116 @@ +--- +title: "Feature engineering" +sidebarTitle: "Overview" +description: "Derive columns and tables from data already in the lakehouse, without moving it somewhere else to compute." +icon: layer-group +keywords: ["feature engineering", "computed columns", "materialized views", "functions", "lakehouse", "jobs"] +--- + +Feature engineering happens where the data already is. Rather than exporting a +table, computing somewhere else and writing the result back, you declare what a +column or a table should contain and let LanceDB fill it. + +[Functions](/management/function) and +[materialized views](/feature-engineering/materialized-views) are ordinary +[catalog objects](/basics/catalog), alongside tables and indexes -- created, +versioned, and refreshed like anything else, not a separate system bolted +alongside. + + +Every operation here has a SQL form -- see [DDL](/sql/ddl/table#alter) to declare a column and [REFRESH](/sql/dml/refresh) to fill it. + + +## The shape everything shares {#the-shape} + +Every mechanism here separates **declaring** from **filling**, and that +separation is the point: + +- Declaring is cheap and immediate. It records intent in the schema and commits + a new table version, with no data written. Declaring a column on a + billion-row table costs what declaring one on an empty table costs. +- Filling is a separate call you control, can retry, and -- against a deployment + -- can hand to the server as a [job](/basics/jobs) rather than waiting on. + +**Filling does not redo current work.** A refresh gives values to rows that +lack them and recomputes rows whose inputs changed since they were computed. It +leaves the rest alone, so calling it twice is not twice the work, and appending +rows means the next refresh handles only the new ones. + +The corollary is worth stating plainly: **a filled value catches up with its +inputs only when you refresh.** If a column derives from `price` and a row's +`price` is later updated, the derived value keeps its old value until the next +refresh recomputes it. That is a deliberate trade -- it is what makes refreshes +idempotent and cheap -- but it will surprise you if you expect a spreadsheet. + +## Which one to reach for {#which-one} + +| | What it produces | Filled by | Available | +| --- | --- | --- | --- | +| [Computed column](/feature-engineering/add-computed-column) | a new column on an existing table | a SQL expression | everywhere | +| [Materialized view](/feature-engineering/materialized-views) | a new table derived by a query | a refresh | everywhere | +| [Function](/management/function) | a computed column filled by Python | registered code | Enterprise | + +**Computed columns** are the common case: a column declared with a SQL +expression, filled by [a refresh](/feature-engineering/refresh-column). + +**Materialized views** derive a whole table -- a filtered, projected, or +aggregated view of a source -- and bring it up to date on each refresh. The +result is an ordinary table: queryable, indexable, searchable. + +**Functions** run Python where the data is, for work SQL cannot express: +embedding a column, calling a model, decoding an image. Registered once, +versioned, then applied as a column. + +They compose. A materialized view can select computed columns, and a computed +column can be filled by a registered function. + +## Where the work runs {#where} + +Enterprise + +Locally, a refresh runs in your process. Against a deployment it runs across the +cluster, and the API reflects that: `refresh_column` raises, and +`refresh_column_async` returns a [job](/basics/jobs) handle instead. + +```python +job = table.refresh_column_async("embedding") +result = job.wait() +result.rows_assigned +``` + +A backfill over a large table can therefore be started, left, and checked on +from a different process -- the connection does not have to stay open. + +That relocation is the main thing to understand about Enterprise feature +engineering. The API is the same; what changes is that the code runs somewhere +else, which is also why a function must declare the environment it needs. + +## Recovering from a failure {#failures} + +A function that raises fails the job. There is no per-row retry or skip policy +in the function definition, so a function calling a flaky external service +should handle that itself, inside the function body. + +The alternative is to let the job fail, fix the cause, and run the refresh +again. The new refresh fills what is still empty or stale. How much of the +failed run was kept depends on where it ran (see +[Refresh a column](/feature-engineering/refresh-column#failures)), and a retry +can recompute values that were already written, so do not count on it costing +only the rows that did not complete. + +## Where to go next {#next} + + + + Declare a column from a SQL expression. + + + Fill the rows that lack values, locally or as a job. + + + Bring a derived table up to date with its source. + + + Register Python to fill a column with. + + diff --git a/docs/web/feature-engineering/materialized-views.mdx b/docs/web/feature-engineering/materialized-views.mdx new file mode 100644 index 000000000..7b00a6ce4 --- /dev/null +++ b/docs/web/feature-engineering/materialized-views.mdx @@ -0,0 +1,124 @@ +--- +title: "Refresh a materialized view" +sidebarTitle: "Materialized View Refresh" +description: "A lakehouse object: a table defined by a query over another table, brought up to date by each refresh." +icon: table +keywords: ["materialized view", "create_materialized_view", "refresh", "full refresh"] +--- + +import { + PyMaterializedViewCreate as MaterializedViewCreate, + PyMaterializedViewRefresh as MaterializedViewRefresh, +} from '/snippets/materialized_view.mdx'; + +A materialized view is a lakehouse object: a table whose contents are defined by +a query over another table. The query is recorded in the view's schema; +creating the view computes its rows, and a refresh brings them up to date. + +The result is an ordinary table. It can be queried, indexed, and searched, and +it appears in `table_names()` — nothing about reading it is special. + + +In SQL this is [`REFRESH MATERIALIZED VIEW`](/sql/refresh#refresh-materialized-view). + + +## Create and refresh {#create-and-refresh} + + +{MaterializedViewCreate} + + +Creating the view computes its rows, so it is ready to read. Pass +`with_no_data=True` to create it empty instead. Later changes to the source +reach the view through a refresh: + + +{MaterializedViewRefresh} + + + +These results were verified with the development SDK 0.40.0b13, built from +lancedb main. The published 0.39.0 differs: the source must be created with +stable row ids (the `new_table_enable_stable_row_ids` storage option), +`create_materialized_view` creates the view empty and has no `with_no_data`, +and `refresh()` still accepts `full`. Its first `refresh()` writes every +matching row, two in this example. + + +`select` takes column names, `(alias, expression)` pairs, or a dict of the same. +Omitting it selects every source column, expanded against the schema as it was +at creation time. `where` filters source rows; `limit` caps the view. + +## Local sources no longer need stable row ids {#stable-row-ids} + +A local refresh does not track which source rows the view came from, so a local +view's source does not need stable row ids (`new_table_enable_stable_row_ids`): +the example above creates its source with the defaults. Earlier development +builds and the published 0.39.0 required them, and a deployment may still +require them; see [Against a deployment](#availability). + +The source is still checked, when the view is created and on every refresh: one +with an LSM write spec, or with retained write-ahead-log rows not yet compacted, +is refused. + +## Every local refresh rebuilds the view {#incremental} + +Locally, `refresh()` runs the view's query against the source and replaces +everything the view holds with the result, in one commit. It does not reconcile +changes: `rows_written` is the size of the whole result — two after adding Bea, +not one — and a refresh with nothing new to read still rewrites the view and +commits a new table version. + +`source_version=N` refreshes against that source version instead of the latest, +which is how a view reproduces a known point in time. There is no `full` +argument; every local refresh is a full one. + +## Against a deployment {#availability} + +Enterprise + +`create_materialized_view` and `refresh` also work on a `db://` connection. +There the SDK sends them to the deployment as SQL -- `CREATE MATERIALIZED VIEW` +and `REFRESH MATERIALIZED VIEW` -- and the work runs on the cluster rather than +in your process. `source_version` is for local views only; the SDK refuses it +on a remote refresh. + +Materialized views are off by default on a deployment and have to be enabled +there. How a deployment refreshes a view -- rebuilding the whole view, as above, +or reconciling what changed in the source -- depends on its LanceDB Enterprise +version and configuration, and so do the options +[`REFRESH MATERIALIZED VIEW`](/sql/dml/refresh#refresh-materialized-view) +accepts and whether the source table needs stable row ids. + +Stable row ids are set up on the deployment, not by the SDK: a `db://` +connection does not send `new_table_enable_stable_row_ids`, and no SDK call adds +them to an existing table. Depending on its version and configuration, a +deployment may give new tables stable row ids, may refuse a view created through +SQL -- as the SDK creates one -- over a source without them, or may convert an +existing source when a view is created through its REST API. Check with the +deployment's administrator what it requires before relying on a source. + +`SHOW MATERIALIZED VIEWS` lists them, which is cheaper than +`list_materialized_views` -- that method finds views by reading every table's +schema, so it costs one open per table. + +## Working with the view {#working-with-the-view} + +`view.table` is the underlying table, for querying, indexing, or search. +`view.definition` is the recorded query — the select list, predicate, and +limit — which is how you check what a view actually contains without inferring +it from the rows. Both are properties; on an async view, the definition is read +with `await view.definition()`. + +An existing view is reopened with `db.open_materialized_view(name)`. + +## What to read next {#next} + + + + Derive a column instead of a whole table. + + + Every refresh commits a table version. + + diff --git a/docs/web/feature-engineering/refresh-column.mdx b/docs/web/feature-engineering/refresh-column.mdx new file mode 100644 index 000000000..d0f9f06a3 --- /dev/null +++ b/docs/web/feature-engineering/refresh-column.mdx @@ -0,0 +1,138 @@ +--- +title: "Refresh a column" +sidebarTitle: "Column Refresh" +description: "Fill the rows of a computed column that lack values — in process, or as a server-side job." +icon: arrows-rotate +keywords: ["refresh_column", "backfill", "job", "idempotent", "REFRESH COLUMN"] +--- + +A [declared column](/feature-engineering/add-computed-column) starts empty. A +refresh gives values to the rows that lack them. + + +```python Python icon="python" +table.refresh_column("doubled") +# RefreshColumnResult(rows_filled=2, version=4) + +table.to_arrow().sort_by("x").to_pandas() +# x doubled +# 0 1 2 +# 1 2 4 +``` + +```sql SQL +REFRESH COLUMN doubled ON my_table +``` + + +The refresh commits ordinary table versions -- the values, then a +metadata-only record of the inputs they were computed from -- so +[versioning](/management/table#versioning) applies to it like any other write. + + +The SQL form is [`REFRESH COLUMN`](/sql/refresh#refresh-column), with the same resource options. + + +## What a refresh recomputes {#idempotent} + +A refresh fills rows that have no value and recomputes rows whose inputs +changed since they were computed. Every other row is left alone, so a second +refresh with nothing changed does nothing. Two consequences follow, and both +are load-bearing. + +**Appending is cheap.** Rows added since the last refresh are filled by the +next one; filled rows whose inputs are unchanged are skipped. Backfilling a +column and then ingesting more data does not mean recomputing the backfill. + +**A filled value catches up with its input at the next refresh.** If `doubled` +derives from `x` and a row's `x` is later updated, `doubled` keeps its old value +until the next refresh recomputes it. + +That is what makes the call idempotent and safe to retry -- but unlike a +spreadsheet formula, nothing is recomputed until you refresh, so a derived +column is stale between an update and the next refresh. + +## Refreshing part of a table {#partial} + +Enterprise In SQL a refresh can be scoped to a +predicate, which is useful for filling a slice first and checking it before +committing to the whole table: + +```sql +REFRESH COLUMN embedding ON documents WHERE created_at > '2026-01-01' +``` + + +**Enterprise job type:** `computed_refresh` for a SQL-expression column, +`function_refresh` for one filled by a registered +[function](/management/function). + + +## As a job {#async} + +Enterprise + +Against a deployment the refresh runs on the cluster. `refresh_column` raises +there; use `refresh_column_async`, which returns a [job](/basics/jobs) you can +wait on or poll instead of holding a connection open: + +```python +job = table.refresh_column_async("doubled") +result = job.wait() +result.rows_assigned +# 2 +job.status() +# 'finished' +``` + +Poll with `status()`, resolve with `wait()`. `status()` is a point snapshot and +does not raise, which is what makes it safe in a loop; `wait()` raises +`JobFailedError`, `JobCancelledError` or `TimeoutError`. + +A long backfill can therefore be started, left, and checked on from a different +process. + +### Resourcing the job {#resources} + +SQL exposes the worker budget directly, which is the lever when a backfill is +too slow or too expensive: + +```sql +REFRESH COLUMN embedding ON documents + WITH (cpu_millis = 8000, memory_bytes = 17179869184, gpu_count = 2) +``` + +`num_workers` sets the count instead of a per-worker budget; the two are +alternatives rather than a pair. `batch_size` controls how many rows a worker +takes at a time, and `error_policy = 'skip'` continues past rows that fail +rather than failing the job. + +## When a refresh fails {#failures} + +What a failure keeps depends on where the refresh ran. `refresh_column` in your +process writes the column in one commit, so a failure leaves the column as it +was. A refresh job on a deployment commits finished batches as it goes, so some +values can remain after a failure. + +Either way, recover by fixing the cause and refreshing again. The new refresh +fills rows that are still empty or whose inputs changed, and normally skips +values that were kept. That is not guaranteed: a refresh can recompute values +that were already written -- for example when the record of the inputs they +were computed from was not saved -- so do not count on a retry costing only the +rows that did not complete. + +For a column filled by a [function](/management/function), the usual cause is +the worker environment rather than the data. See +[environment mismatches](/management/function#mismatch-symptoms) for the +symptoms, which rarely name their cause. + +## What to read next {#next} + + + + Waiting on, inspecting, and cancelling server-side work. + + + The same idea, for a whole derived table. + + diff --git a/docs/web/governance/authorization.mdx b/docs/web/governance/authorization.mdx new file mode 100644 index 000000000..044db158e --- /dev/null +++ b/docs/web/governance/authorization.mdx @@ -0,0 +1,115 @@ +--- +title: "Authorization" +sidebarTitle: "Authorization" +description: "Who may do what: privileges granted on catalog objects to principals, groups, roles, and API keys." +icon: shield-halved +keywords: ["authorization", "authz", "privileges", "rbac", "access control"] +--- + +Enterprise + +This page describes a LanceDB Enterprise capability. Embedded LanceDB has no +such layer -- access is whatever the object store enforces on the bucket or +directory, which is coarser and has no concept of a table. See +[Architecture](/architecture) for what differs between the two. + +[Authentication](/enterprise/authentication) establishes who is calling. +Authorization decides what they may do. + +An access control entry grants **one privilege** on **one object** to **one +subject**. Everything below is a consequence of that shape. + +## Subjects {#subjects} + +A grant is held by one of four kinds of subject, distinguished by a prefix: + +| Prefix | Subject | +| --- | --- | +| `p:` | a principal, by ID -- `P:` by name | +| `g:` | a group, by ID -- `G:` by name | +| `r:` | a role | +| `a:` | an API key | + +Granting to a group or role rather than a principal is what keeps the entry +count manageable: a principal's effective privileges are the union of what it +holds directly and what it inherits. + +Granting to an API key is narrower than granting to the principal that owns +it -- useful when a key is issued for one job and should not carry that +principal's full authority. + +## Objects {#objects} + +Objects are addressed by kind and path: + +``` +system +database:db +namespace:db/ns +table:db/ns/table +``` + +The hierarchy is not decorative. Reaching a table requires `USAGE` on the +namespace containing it and on the database containing that -- a grant on the +table alone is not enough to traverse to it. + +## Privileges {#privileges} + +| Privilege | Granted on | Allows | +| --- | --- | --- | +| `OWNERSHIP` | any object | ownership, conveying full authority | +| `USAGE` | database | accessing the namespaces inside | +| `USAGE` | namespace | accessing the tables inside | +| `SELECT` | table | reading rows | +| `INSERT` | table | appending rows | +| `UPDATE` | table | modifying existing rows | +| `DELETE` | table | deleting rows | +| `CREATE_DATABASE` | system | creating a database | +| `CREATE_NAMESPACE` | database, namespace | creating immediate child namespaces | +| `CREATE_TABLE` | namespace | creating a table in it | +| `CREATE_MATERIALIZED_VIEW` | namespace | creating a materialized view in it | +| `CREATE_VIEW` | namespace | creating a non-materialized view in it | +| `OPERATE` | system | reading server configuration | + +The four data privileges map onto the four +[ingestion operations](/ingestion), so a principal that may append but not +modify is expressed by granting `INSERT` without `UPDATE`. + + +`CREATE_MATERIALIZED_VIEW` and `CREATE_VIEW` are separate privileges because +[the two objects differ](/basics/catalog): one stores rows and is refreshed, +the other stores only a query. Granting the ability to create one does not +grant the other. + + +## Creation privileges resolve upward {#upward} + +A subtlety worth knowing, because it explains grants that appear to work on +objects that do not exist yet. + +When `USAGE` is checked on a database and **no entries exist for it at all**, +the database is assumed not to exist yet, and `CREATE_DATABASE` on `system` is +checked instead. The same applies to the default namespace: absent any +entries, the check falls back to `CREATE_NAMESPACE` on the containing +database. + +So a principal holding `CREATE_DATABASE` can act on a database that has not +been created -- which is what makes creation possible at all, and why an empty +ACL set is not the same as a deny. + +## Listing grants {#listing} + +Entries can be listed filtered by object, by subject, or both: + +``` +object = table:db/ns/documents +subject = G:data-science +``` + +Results paginate through a continuation token. One stored `(object, subject)` +tuple carrying several privileges expands into several entries in the +response, so the count reflects privileges rather than tuples. + +Listing by subject answers "what can this team reach"; listing by object +answers "who can reach this table". Both are worth running before changing a +grant. diff --git a/docs/web/index.mdx b/docs/web/index.mdx new file mode 100644 index 000000000..75044ad0d --- /dev/null +++ b/docs/web/index.mdx @@ -0,0 +1,124 @@ +--- +title: LanceDB +sidebarTitle: "LanceDB" +description: "Multimodal lakehouse for AI." +icon: "/static/assets/logo/lancedb-icon-gray.svg" +keywords: ["multimodal lakehouse", "training", "feature engineering", "search", "open source", "oss"] +--- + +**LanceDB** is a [multimodal lakehouse](https://lancedb.com/blog/multimodal-lakehouse/) for AI teams that need +one data layer for curation, feature engineering, search and retrieval, and model training. +It is built on top of [Lance](/basics/lance), an open-source lakehouse format designed for multimodal AI data. + +Move from data exploration to model training on one, unified platform without needing to manage a +fragmented stack of storage, feature, retrieval, and training systems. + +## Build better models, faster {#build-better-models-faster} + +Training data and experimentation slow down when raw data, metadata, embeddings, features, and governance +artifacts live in separate systems. LanceDB keeps them together in one versioned multimodal table, so AI teams spend less +time stitching infrastructure together and more time improving datasets, testing features, and keeping GPUs fed. + +![Training data lifecycle: Curation, Feature Engineering, Search and Retrieval, Training](/static/assets/images/overview/training-data-lifecycle.svg) + +Use the same table to curate training data, add derived features, retrieve examples, and feed training jobs that rely on expensive GPUs. +Training workloads can sample, shuffle, and scan projected columns from local storage or object storage, then assemble +GPU-ready batches from a tagged dataset version. + +For a deeper look at how this works in training pipelines, start with [Why LanceDB for training](/training/why-lancedb). + +## LanceDB suite {#lancedb-suite} + +The LanceDB suite includes LanceDB OSS, an open-source embedded retrieval library, and LanceDB Enterprise, +a multimodal lakehouse platform for the full AI data lifecycle. +OSS is easy to set up on a local machine for search and regular-scale workflows. LanceDB Enterprise is built +for teams that need scale without building bespoke infrastructure for curation, +feature engineering, search and retrieval, and efficient training data access. + +![LanceDB suite: OSS search and Enterprise multimodal lakehouse on Lance format](/static/assets/images/overview/lancedb-suite.svg) + +## Why teams use LanceDB {#why-teams-use-lancedb} + + + + Store images, video, audio, text, annotations, embeddings, and model-generated features together in one schema-enforced table. + The same table can support dataset curation, feature backfills, experiment splits, retrieval, and training. + + + Training workloads mix fast random access with high-throughput sequential scans. LanceDB is designed for both, so + teams can shuffle data into GPU-ready batches more efficiently, improve input throughput, and iterate on experiments faster. + + + Whether the end user is a human or an agent, LanceDB powers production retrieval workloads such as semantic search, + hybrid search, RAG, agent memory, and recommendation systems. Retrieval runs against the same LanceDB tables used + for curation, feature engineering, and training workflows. + + + +## Start with your workload {#start-with-your-workload} + + + + Learn why LanceDB works well as the data layer for training workloads. + + + Use LanceDB tables and permutations for projected, shuffled, random-access training reads. + + + Explore Lance-formatted multimodal datasets with raw bytes, metadata, embeddings, and indices. + + + Use vector search, full-text search, hybrid search, reranking, filtering, and SQL. + + + +## From local development to production scale {#from-local-development-to-production-scale} + +LanceDB OSS and LanceDB Enterprise share the same Lance format and table model. Start locally with the embedded OSS +library, then move to Enterprise when your team needs distributed scale, managed infrastructure, private deployment, +or higher-throughput curation, feature engineering, search and retrieval, and training workflows. + +### 1\. LanceDB OSS {#1-lancedb-oss} +The fastest way to get started is the open-source embedded library, with client SDKs in Python, TypeScript +and Rust. Run it locally in just a few steps, which lets you explore datasets, curate data, and run search and retrieval workloads +for agents. Start here: + + + + Get started with LanceDB in minutes. + + + Create tables, evolve schemas, version data, and modify rows in LanceDB. + + + +### 2\. LanceDB Enterprise {#2-lancedb-enterprise} + +[LanceDB Enterprise](/enterprise) is a petabyte-scale (and beyond), distributed **multimodal lakehouse** platform built for +search, curation, feature engineering, and high-throughput training data access workflows on top of the same core table +abstraction. This eliminates the need for teams to build bespoke infrastructure to manage large multimodal datasets. +To set up LanceDB Enterprise in your organization, +[reach out to us](https://www.lancedb.com/contact). + + +**Built with scale, performance, and security in mind.** + +LanceDB Enterprise is designed for very large-scale, high-performance, distributed workloads in +private deployments, and can operate under strict [security requirements](/enterprise/security). + + + + Get started with LanceDB in minutes, including Enterprise `db://` connections. + diff --git a/docs/web/indexing/fts-index.mdx b/docs/web/indexing/fts-index.mdx new file mode 100644 index 000000000..4e18d5a86 --- /dev/null +++ b/docs/web/indexing/fts-index.mdx @@ -0,0 +1,165 @@ +--- +title: "Full-Text Search (FTS) Index" +sidebarTitle: "FTS Index" +description: "Create and tune BM25-based full-text search indexes in LanceDB." +icon: "book" +--- +import { PyFtsIndexAsync as FtsIndexAsync, PyFtsIndexCreate as FtsIndexCreate, PyFtsIndexNested as FtsIndexNested, PyFtsIndexWait as FtsIndexWait } from '/snippets/indexing.mdx'; + +LanceDB provides performant full-text search based on BM25, allowing you to incorporate keyword-based search in your retrieval solutions. This page shows +examples on how to create and configure FTS indexes in LanceDB OSS and Enterprise, using the synchronous and asynchronous APIs. + + +In LanceDB Enterprise, `create_fts_index` API returns immediately, but index building happens asynchronously. + + +## Creating FTS Indexes {#creating-fts-indexes} + +### Synchronous API {#synchronous-api} + +Use `create_fts_index` with synchronous LanceDB connections: + + + + {FtsIndexCreate} + + + +Check FTS index status using the API: + + + + {FtsIndexWait} + + + +`wait_for_index(...)` waits until the named FTS index exists and `index_stats(...)` reports `num_unindexed_rows == 0`. It can time out if writes keep adding rows faster than the index catches up. If a table has multiple FTS indexes, specify the target text column when querying instead of relying on implicit selection. + +### Asynchronous API {#asynchronous-api} + +When using async connections (`connect_async`), use `create_index` with the `FTS` configuration: + + + + {FtsIndexAsync} + + + + +The `create_fts_index` method is not available on `AsyncTable`. Use `create_index` with `FTS` config instead. + + +The current FTS implementation is Lance-native. Legacy Tantivy-only options, including +`use_tantivy`, are no longer accepted by the index creation APIs. + +## Nested field paths {#nested-field-paths} + +FTS indexes can target text leaves inside struct columns by passing a dotted path (for example, `payload.text`). The same path works for [`MatchQuery`](/search/full-text-search) and [`PhraseQuery`](/search/full-text-search), and for the `columns` argument on async `nearest_to_text` queries. + +You can point an index at any string leaf nested in a struct, regardless of depth. The struct container itself isn't indexable: you have to name a specific text field. + + + + {FtsIndexNested} + + + +LanceDB rejects paths that don't resolve to a text leaf: + +- A struct container (for example, `payload`): raises `ValueError: FTS index cannot be created ...`. +- A non-text leaf such as an integer or float (for example, `payload.count`): raises the same error. +- A path that doesn't exist in the schema (for example, `payload.missing`): raises `ValueError: Field path ... not found`. + +The async API accepts the same dotted paths through `create_index`: + +```python Python icon="python" +from lancedb.index import FTS + +await async_table.create_index("payload.text", config=FTS(with_position=True)) +``` + +## Configuration Options {#configuration-options} + +### FTS Parameters {#fts-parameters} + +| Parameter | Type | Default | Description | +|:----------|:-----|:--------|:------------| +| `with_position` | bool | `False` | Store token positions (required for phrase queries) | +| `base_tokenizer` | str | `"simple"` | Text splitting method (`simple`, `whitespace`, `raw`, `ngram`, `icu`, `jieba/*`, or `lindera/*`) | +| `language` | str | `"English"` | Language for stemming and stop-word filters. Choose CJK and mixed-language segmentation with `base_tokenizer`. | +| `max_token_length` | int | `40` | Maximum token size; longer tokens are omitted | +| `lower_case` | bool | `True` | Lowercase tokens | +| `stem` | bool | `True` | Apply stemming (`running` → `run`) | +| `remove_stop_words` | bool | `True` | Drop common stop words | +| `ascii_folding` | bool | `True` | Normalize accented characters | +| `custom_stop_words` | list[str] | `None` | Extra stop words to drop in addition to the language defaults. Requires `remove_stop_words=True`. | +| `ngram_min_length` | int | `3` | Minimum n-gram length. Applies only when `base_tokenizer="ngram"`. | +| `ngram_max_length` | int | `3` | Maximum n-gram length. Applies only when `base_tokenizer="ngram"`. | +| `prefix_only` | bool | `False` | Index only prefix n-grams rather than all substrings. Applies only when `base_tokenizer="ngram"`. | +| `block_size` | int | `128` | Number of documents per compressed posting block. Supported values are `128` and `256`. Setting this to `256` opts in to the experimental FTS V3 layout. | + + +- `max_token_length` can filter out base64 blobs or long URLs. +- Disabling `with_position` reduces index size but disables phrase queries. +- `ascii_folding` helps with international text (e.g., “café” → “cafe”). + + +### Tokenizer choices {#tokenizer-choices} + +`base_tokenizer` controls segmentation before token filters run: + +- `simple`, `whitespace`, and `raw` cover common tokenization strategies for space-delimited text. +- `ngram` indexes overlapping character spans for substring-style matching. +- `icu` uses bundled ICU4X word segmentation for mixed-language text and scripts where whitespace splitting is not enough. ICU stands for [International Components for Unicode](https://icu.unicode.org/), and this tokenizer does not need external model files. +- `jieba/*` is for Chinese word segmentation with Jieba. +- `lindera/*` loads a compiled Lindera dictionary, such as `lindera/ipadic` for Japanese or `lindera/ko-dic` for Korean. + +Model-backed tokenizers such as `jieba/default`, `lindera/ipadic`, and `lindera/ko-dic` require tokenizer model files in Lance's language model home. Lance looks under the default platform data directory for `lance/language_models`, or you can set `LANCE_LANGUAGE_MODEL_HOME` to point to another model root. For example, `jieba/default` is resolved under `/jieba/default/...`. + +`language` is used by token filters, not by the base tokenizer. Stemming supports Arabic, Danish, Dutch, English, Finnish, French, German, Greek, Hungarian, Italian, Norwegian, Portuguese, Romanian, Russian, Spanish, Swedish, Tamil, and Turkish. Built-in stop-word removal supports Danish, Dutch, English, Finnish, French, German, Hungarian, Italian, Norwegian, Portuguese, Russian, Spanish, and Swedish. For other stemming languages, set `remove_stop_words=False` or pass `custom_stop_words`. + +### Posting block size {#posting-block-size} + +`block_size` controls the number of documents packed into each compressed posting block on disk. The default of `128` matches the current FTS layout and is the right choice for most workloads. Setting it to `256` opts in to the experimental FTS V3 format, which changes how postings are encoded and may introduce breaking changes in future releases. Any other value is rejected at index creation time. + +You can set the option through either the synchronous or asynchronous API. In the async Python API, pass it on the `FTS` config, and in the TypeScript API use the camelCase `blockSize` field on `FtsOptions`: + +```python Python icon="python" +from lancedb.index import FTS + +await async_table.create_index("text", config=FTS(block_size=256)) +``` + +```typescript TypeScript icon="square-js" +await table.createIndex("text", { + config: lancedb.Index.fts({ blockSize: 256 }), +}); +``` + +### Phrase Query Configuration {#phrase-query-configuration} + +Enable phrase queries by setting: + +| Parameter | Required Value | Purpose | +|:----------|:---------------|:--------| +| `with_position` | `True` | Track token positions for phrase matching | +| `remove_stop_words` | `False` | Preserve stop words for exact phrase matching | + +## Indexing nested string fields {#indexing-nested-string-fields} + +You can build an FTS index on a string field inside a struct by passing its full dotted path, like `nested.text`. The same path is used when you query the index through `fts_columns`, and the indexed column is reported back as the full path from `list_indices()`. + +```python +# Schema: pa.struct([pa.field("text", pa.string())]) stored under the `nested` column. +table.create_fts_index("nested.text") + +results = ( + table.search("puppy", query_type="fts", fts_columns="nested.text") + .limit(5) + .to_list() +) +``` + + +Use the canonical Lance path: dot-separate each struct field from root to leaf (for example, `metadata.author.name`). The same convention applies to scalar and vector indexes. + diff --git a/docs/web/indexing/index.mdx b/docs/web/indexing/index.mdx new file mode 100644 index 000000000..b40fb7918 --- /dev/null +++ b/docs/web/indexing/index.mdx @@ -0,0 +1,66 @@ +--- +title: "Indexing Data" +sidebarTitle: "Overview" +description: "Optimize search performance with LanceDB using vector indexes, full-text search, scalar indexes, and more." +icon: "list" +--- + +An **index** is a data structure that facilitates efficient scans and lookups on an embedded dataset. +LanceDB provides a comprehensive suite of indexes to optimize performance across different use cases and data types: + +- **Vector Index**: Efficiently searches for similar vectors across high-dimensional data (e.g. images, audio, or text embeddings) +- **Full-Text Search Index**: Enables fast keyword-based searches by indexing words and phrases +- **Scalar Index**: Accelerates filtering and sorting of structured numeric or categorical data + +## Supported Indexes + +| Index | Use Case | Description | +| :--------- | :------- | :---------- | +| `IVF` (Vector) | Large-scale vector search with configurable accuracy/speed trade-offs. | Inverted File Index—a partition-based approximate nearest neighbor algorithm that groups similar vectors into partitions for efficient search.
**Quantizations**: `None/Flat` `PQ` `SQ` `RQ`| +| `IVF_HNSW` (Vector) | Large-scale vector search requiring both high recall and efficient partitioning. Combines the scalability of IVF with the search quality of HNSW. | Hybrid index combining IVF partitioning with HNSW graphs in each partition. Provides improved search quality over pure IVF while maintaining scalability.
**Quantizations**: `None/Flat` `SQ` `PQ`| +| `FTS` (Full-text search) | String columns (e.g., title, description, content) requiring keyword-based search with BM25 ranking. | Full-text search index using BM25 ranking algorithm. Tokenizes text with configurable tokenization, stemming, stop word removal, and language-specific processing. | +| `BTree` (Scalar) | Numeric, temporal, and string columns with mostly distinct values. Best for selective equality, inequality, and range predicates. | Sorted index storing sorted copies of scalar columns with block headers in a btree cache. Header entries map to blocks of rows (4096 rows per block) for efficient disk reads. | +| `Bitmap` (Scalar) | Low-cardinality columns with few thousand or fewer distinct values. Accelerates equality and range filters. | Stores a bitmap for each distinct value in the column, with one bit per row indicating presence. Memory-efficient for low-cardinality data. | +| `LabelList` (Scalar) | List columns (e.g., tags, categories, keywords) requiring `array_contains_all` or `array_contains_any` filters. | Scalar index for `List` and `LargeList` columns of primitive values, using an underlying bitmap index structure to enable fast array membership lookups. | +| `FM` (Scalar) | String or binary columns that need raw substring search. | FM-Index over `Utf8`, `LargeUtf8`, `Binary`, or `LargeBinary` data for filters such as `contains(path, 'needle')`. Use FTS instead for tokenized word search and BM25 ranking. | + +## Quantization +LanceDB also supports several [quantization](/indexing/vector-index) methods, used by vector indexes to compress vectors and reduce storage requirements: + +| Quantization | Use Case | Description | +| :----------- | :------- | :---------- | +| `PQ` (Product Quantization) | Default choice for most vector search scenarios. Use when you need to balance index size and recall. | Divides vectors into subvectors and quantizes each subvector independently. Provides a good balance between compression ratio and search accuracy. | +| `RQ` (RabitQ Quantization) | Use when you need maximum compression or have specific per-dimension requirements. | Per-dimension quantization using a RabitQ codebook. Provides fine-grained control over compression per dimension. For `IVF_RQ`, vector dimensions must be divisible by `8`. | +| `SQ` (Scalar Quantization) | Use when you need faster indexing or when vector dimensions have consistent value ranges. | Quantizes each dimension independently. Simpler than PQ but typically provides less compression. | +| `None/Flat` | Use for binary vectors (with `hamming` distance) or when you need maximum recall and have sufficient storage. | No quantization—stores raw vectors. Provides the highest accuracy but requires more storage and memory. | + +## Performance {#performance} + +#### Vector indexes {#vector-indexes} + +If vector search latency climbs with table size (i.e., queries that ran in milliseconds on a small table take seconds as it grows to millions of rows), the cause is the default brute-force scan over every vector. That works fine below ~100K vectors, but past that you should build a dedicated vector index. Pick the type by your data shape: + +| Index | When to use | +|-------|-------------| +| `IVF_PQ` | General-purpose default; what Enterprise builds automatically. Higher accuracy than RQ at small dimensions (≤ 256). | +| `IVF_RQ` | Maximum compression on high-dim vectors, faster builds than PQ. | +| `IVF_HNSW_SQ` | Best recall/latency for unfiltered search; higher latency variance under selective filters. | +| `IVF_FLAT` | Required for binary vectors with `hamming`. | + +The distance metric is fixed once the index is built. Pick the distance metric based on how the embedding model was trained: `cosine` (unnormalized), `dot` (already-normalized, best performance), `l2` (general-purpose, default), `hamming` (binary). If a query explicitly requests a different metric, the index cannot be used and the query falls back to a brute-force scan; LanceDB Enterprise may reject that scan on a large table. For parameter tuning, see [Vector Indexing](/indexing/vector-index). + +#### Scalar indexes {#scalar-indexes} + +If filtered queries slow down as the table grows — even when the filter is selective — the cause is a full column scan: without a scalar index, LanceDB evaluates the `where(...)` predicate on every row, and the same applies to `merge_insert` join keys. The best practice is to build a scalar index on every column you filter or join on, picking the type by the column's shape: + +| Index | Best for | +|-------|----------| +| `BTREE` (default) | Numeric, string, temporal columns with mostly distinct values | +| `BITMAP` | Boolean and low-cardinality columns (< ~1,000 distinct values) | +| `LABEL_LIST` | `List` columns queried with `array_has_any` / `array_has_all` | + +See [Scalar Indexing](/indexing/scalar-index). + +#### Full-text search {#full-text-search} + +If your full-text index is much larger than expected, or takes longer than expected to build, the cause is usually phrase-query flags being enabled when they aren't needed: `with_position=True` and `remove_stop_words=False` both significantly inflate index size and build time. The best practice is to keep the defaults for most workloads, and only enable those flags when you actually need to search for phrases. See [FTS Indexing](/indexing/fts-index) configuration options for the full set of options. diff --git a/docs/web/indexing/reindexing.mdx b/docs/web/indexing/reindexing.mdx new file mode 100644 index 000000000..dd50f62fe --- /dev/null +++ b/docs/web/indexing/reindexing.mdx @@ -0,0 +1,135 @@ +--- +title: "Building and repairing indexes" +sidebarTitle: "Build & Repair" +description: "Learn how to keep your indexes up-to-date in LanceDB using incremental indexing, including best practices for adding new records without full reindexing." +icon: "refresh" +keywords: ["reindexing", "re-index", "incremental indexing", "reindex"] +--- + +import { + PyReindexingIncremental as ReindexingIncremental, + PyGpuIndexCuda as GpuIndexCuda, + PyGpuIndexMps as GpuIndexMps, +} from '/snippets/indexing.mdx'; + +As you add new data to your LanceDB tables, your indexes may become outdated. +Reindexing is the process of updating the index to account for new data -- this applies to either a full-text search (FTS) index or a vector index. Reindexing is an important operation to run periodically as your data grows, as it has performance implications. + +As data is being added and a reindex operation is running, LanceDB will combine results from the existing index with exhaustive/flat search on the new data. This is done to ensure that you're still retrieving results over all your data, but it does come at a performance cost. The more data that you add without reindexing, the impact on latency (due to exhaustive search) can be noticeable. + +Rather than dropping an existing index entirely and reindexing from scratch, LanceDB supports **incremental indexing**. + + +**Enterprise job types:** building an index runs as `create_ivf_pq`, `create_btree`, +`create_fts`, and one more per index algorithm -- the type names the algorithm, +not a separate feature. Folding new rows into an existing one runs as the +matching `reindex_*` type instead. + + +## Incremental Reindexing {#incremental-reindexing} + +You can manually trigger an incremental indexing operation on updated data +using the `optimize()` method on a table. + +Table optimization performs three maintenance operations: + +1. **Compaction**: merges small fragments into larger ones to improve read performance +2. **Pruning/Cleanup**: removes files from versions older than a retention window (7 days by default) +3. **Index update**: adds newly-ingested data to existing vector, scalar, and FTS indexes + + + + {ReindexingIncremental} + + + +Enterprise + +LanceDB Enterprise support incremental reindexing through an automated background process. When new data is added to a table, the system automatically triggers a new index build. As the dataset grows, indexes are asynchronously updated in the background. + +- While indexes are being rebuilt, queries use brute force methods on unindexed rows, which may temporarily increase latency. To avoid this, set `fast_search=True` to search only indexed data. +- Use `index_stats()` to view the number of unindexed rows. This will be zero when indexes are fully up-to-date. If you call `wait_for_index(...)`, it polls the same status and can time out while continuous writes keep adding unindexed rows. + +The benefit of using LanceDB Enterprise is that it automates the reindexing process +and operates continuously in the background, minimizing the impact on latency under high loads. +In OSS, you must manually manage the reindexing cadence based on your data growth and performance needs. + +## Disk utilization {#disk-utilization} + +Compaction by itself does not immediately free disk space, and can temporarily increase it because new +compacted files are written before old-version files are deleted. Disk space is reclaimed when old versions +are pruned during cleanup. Set retention only as low as your rollback and time-travel requirements allow. + +If you need to reclaim space more aggressively in OSS, use a shorter retention window: + + ```python Python icon=Python + from datetime import timedelta + + table.optimize(cleanup_older_than=timedelta(days=1)) + ``` + + +## Building on GPUs {#gpu} + +With LanceDB's GPU-powered vector indexing you can index very large datasets in far less time +than you could with the default CPU-based indexing. In our tests, LanceDB +is capable of indexing billions of rows in under four hours on a 1-8 GPU cluster. + + +**Automatic GPU indexing** +Enterprise-only + +Automatic GPU Indexing is currently only available in [LanceDB Enterprise](/enterprise/). +Please [contact us](https://www.lancedb.com/contact) to enable this feature for your deployment. + +The vector index is created when you call `create_index`. The backend will use GPU resources +to build either the IVF or HNSW indexes. The system automatically selects the optimal GPU +configuration based on your data size and available hardware. + +This process is also asynchronous by default, but you can use `wait_for_index` to convert it +into a synchronous process by waiting until the index is built. + + + +`wait_for_index(...)` waits for the index to exist and for `index_stats(...)` to report `num_unindexed_rows == 0`. It can time out if the table is receiving continuous writes while the build is trying to catch up. + +GPU acceleration changes how the vector index is built, not the lifecycle after the build. Rows +appended later still need `optimize()` before they are part of the index, and `fast_search()` only +searches indexed rows. + +### Manual GPU indexing in LanceDB OSS {#gpu-manual-gpu-indexing-in-lancedb-oss} + +You can use the Python SDK to manually create the `IVF_PQ` index on a GPU. You'll need +[PyTorch>2.0](https://pytorch.org/). Note that GPU-based indexing is currently only +supported by the synchronous SDK in LanceDB OSS. + +Specify the values `cuda` or `mps` (on Apple Silicon) for the `accelerator` parameter +to enable GPU training on your device. + +#### GPU indexing on Linux {#gpu-gpu-indexing-on-linux} + + + + {GpuIndexCuda} + + + +#### GPU indexing on macOS (Apple Silicon) {#gpu-gpu-indexing-on-macos-apple-silicon} + + + + {GpuIndexMps} + + + +### Performance considerations {#gpu-performance-considerations} + +- GPU memory usage scales with `num_partitions` and vector dimensions +- For optimal performance, ensure GPU memory exceeds dataset size +- Batch size is automatically tuned based on available GPU memory +- Indexing speed improves with larger batch sizes + +### Troubleshooting {#gpu-troubleshooting} + +If you encounter the error `AssertionError: Torch not compiled with CUDA enabled`, +you need to [install PyTorch with CUDA support](https://pytorch.org/get-started/locally/). diff --git a/docs/web/indexing/scalar-index.mdx b/docs/web/indexing/scalar-index.mdx new file mode 100644 index 000000000..e8dcf6bb7 --- /dev/null +++ b/docs/web/indexing/scalar-index.mdx @@ -0,0 +1,253 @@ +--- +title: "Scalar Indexes" +sidebarTitle: "Scalar Index" +description: "Learn how to use scalar indexes in LanceDB for efficient metadata filtering and query optimization." +icon: "tree" +--- + +import { + PyScalarIndexBuild as ScalarIndexBuild, + PyScalarIndexWait as ScalarIndexWait, + PyScalarIndexOptimize as ScalarIndexOptimize, + PyScalarIndexFilter as ScalarIndexFilter, + PyScalarIndexPrefilter as ScalarIndexPrefilter, + PyScalarIndexUuidType as ScalarIndexUuidType, + PyScalarIndexUuidData as ScalarIndexUuidData, + PyScalarIndexUuidTable as ScalarIndexUuidTable, + PyScalarIndexUuidWait as ScalarIndexUuidWait, + PyScalarIndexUuidUpsert as ScalarIndexUuidUpsert, + PyScalarIndexNestedFields as ScalarIndexNestedFields, +} from '/snippets/indexing.mdx'; + +Scalar indexes organize data by scalar attributes (e.g., numbers, categories) and enable fast filtering of vector data. They accelerate retrieval of scalar data associated with vectors, thus enhancing query performance. + +LanceDB supports four types of scalar indexes: + +- `BTREE`: Stores column data in sorted order for binary search. Best for columns with many unique values. +- `BITMAP`: Uses bitmaps to track value presence. Ideal for columns with few unique values (e.g., categories, tags). +- `LABEL_LIST`: Special index for `List` and `LargeList` columns of primitive values supporting `array_contains_all` and `array_contains_any` queries. +- `FM`: FM-Index over string or binary columns that accelerates substring search via `contains(col, 'needle')`. + + +Scalar indices serve as a foundational optimization layer, accelerating filtering across diverse search workloads. They can be combined with: + +- Vector search (prefilter results using metadata) +- Full-text search (combining keyword matching with structured filters) +- SQL scans (optimizing WHERE clauses on scalar columns) +- Key-value lookups (enabling rapid primary key-based retrievals) + + +## Choosing the Right Index Type + +| Data Type | Filter | Index Type | +|:----------------------------------------------------------------|:------------------------------------------|:-------------| +| Numeric, String, Temporal | `<`, `=`, `>`, `in`, `between`, `is null` | `BTREE` | +| Boolean, numbers or strings with fewer than 1,000 unique values | `<`, `=`, `>`, `in`, `between`, `is null` | `BITMAP` | +| List of low cardinality of numbers or strings | `array_has_any`, `array_has_all` | `LABEL_LIST` | +| String or binary (`Utf8`, `LargeUtf8`, `Binary`, `LargeBinary`) | `contains(col, 'needle')` | `FM` | + +## Scalar Index Operations + +### 1. Build the Index + +You can create multiple scalar indexes within a table. By default, the index will be `BTREE`, but you can always configure another type like `BITMAP` + + + + {ScalarIndexBuild} + + + + +If you are using LanceDB Enterprise, the `create_scalar_index` API returns immediately, but the building of the scalar index is asynchronous. To wait until all data is fully indexed, you can specify the `wait_timeout` parameter on `create_scalar_index()` or call `wait_for_index()` on the table. + + +### 2. Check Index Status + + + + {ScalarIndexWait} + + + +`wait_for_index(...)` waits until the named scalar indexes exist and `index_stats(...)` reports `num_unindexed_rows == 0`. If a table is receiving steady writes, that fully indexed state may not stabilize before the timeout. + +### 3. Update the Index + +Updating the table data (adding, deleting, or modifying records) requires that you also update the scalar index. This can be done by calling `optimize`, which will trigger an update to the existing scalar index. + + + + {ScalarIndexOptimize} + + + + +New data added after creating the scalar index will still appear in search results if optimize is not used, but with increased latency due to a flat search on the unindexed portion. LanceDB Enterprise automates the optimize process, minimizing the impact on search speed. + + +### 4. Run Indexed Searches + +The following scan will be faster if the column `book_id` has a scalar index: + + + + {ScalarIndexFilter} + + + +Scalar indexes can also speed up scans containing a vector search or full text search, and a prefilter: + + + + {ScalarIndexPrefilter} + + + +## Indexing nested fields + +Scalar indexes can target a scalar field inside a struct by passing its full dotted path. The path is preserved end to end: it's the value you pass to `create_scalar_index`, it's what `list_indices()` reports under `columns`, and it's the column reference you use in filter predicates. + +```python +# Schema: pa.struct([pa.field("user_id", pa.int32())]) stored under the `metadata` column. +table.create_scalar_index("metadata.user_id", name="metadata_user_id_idx") + +# The same dotted path works in WHERE clauses. +table.search().where("metadata.user_id = 42").limit(1).to_list() +``` + + +Nested paths follow Lance field-path semantics: dot-separate each struct field from root to leaf (for example, `metadata.author.name`). The same convention applies to FTS and vector indexes. + + +## FM-Index for substring search + +The `FM` index is a scalar index built over string or binary columns that +accelerates substring lookups expressed as `contains(col, 'needle')`. Unlike the +tokenized [FTS index](/indexing/fts-index), which matches whole words after +tokenization, the FM-Index matches arbitrary substrings of the raw bytes — so it +works well for URLs, file paths, identifiers, log lines, or any column where you +search for a fragment rather than a word. + +Use the FM-Index when: + +- Filters use `contains(col, 'needle')` (substring), not equality or word search. +- The column is `Utf8`, `LargeUtf8`, `Binary`, or `LargeBinary`. +- You want substring matches without paying for tokenization, language analysis, + or BM25 scoring. + +Pick `FTS` instead when you need word-level relevance ranking, phrase queries, +or language-aware tokenization. + +### Create an FM-Index + +Build an FM-Index with the async `create_index` API by passing the `Fm` config in +Python or `Index.fm()` in TypeScript. In Rust, use `Index::Fm(FmIndexBuilder::default())`. + + +```python Python icon="python" +from lancedb.index import Fm + +await tbl.create_index("text", config=Fm()) +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; +import { Index } from "@lancedb/lancedb"; + +await tbl.createIndex("text", { config: Index.fm() }); +``` + +```rust Rust icon="rust" +use lancedb::index::Index; +use lancedb::index::scalar::FmIndexBuilder; + +tbl.create_index(&["text"], Index::Fm(FmIndexBuilder::default())) + .execute() + .await?; +``` + + +After the index is built, substring filters use it automatically: + +```python +table.search().where("contains(text, 'needle')").limit(10).to_pandas() +``` + +`list_indices()` reports the index type as `"Fm"`. + +## Index UUID Columns + +LanceDB supports scalar indexes on UUID columns (stored as `FixedSizeBinary(16)`), enabling efficient lookups and filtering on UUID-based primary keys. + + +**To use `FixedSizeBinary`, ensure you have:** + +- Python SDK version `0.22.0` or later +- TypeScript SDK version `0.19.0` or later + + +### 1. Define UUID Type + + + + {ScalarIndexUuidType} + + + +### 2. Generate UUID Data + + + + {ScalarIndexUuidData} + + + +### 3. Create Table with UUID Column + + + + {ScalarIndexUuidTable} + + + +### 4. Create and Wait for the Index + + + + {ScalarIndexUuidWait} + + + +### 5. Perform Operations with the UUID Index + + + + {ScalarIndexUuidUpsert} + + + +## Index nested fields + +You can build a scalar index on a field inside a struct column by passing the +canonical dot-separated path to `create_index`. This is useful when filters +target attributes nested under a `metadata`-style column, for example +`metadata.user_id` or `metadata.event.type`. + +If a literal segment of the path itself contains a dot (for example a column +named `user.id` nested inside `metadata`), wrap that segment in backticks so +LanceDB can tell the dot apart from the path separator: `` metadata.`user.id` ``. + +`list_indices()` echoes the same canonical path back, so the column you pass in +round-trips through index metadata regardless of nesting depth or escaping. + + + + {ScalarIndexNestedFields} + + + + +Composite indexes that cover multiple columns aren't supported yet. Each +`create_index` call must target a single (possibly nested) field path. + diff --git a/docs/web/indexing/vector-index.mdx b/docs/web/indexing/vector-index.mdx new file mode 100644 index 000000000..3cd3a970a --- /dev/null +++ b/docs/web/indexing/vector-index.mdx @@ -0,0 +1,408 @@ +--- +title: "Vector Indexes" +sidebarTitle: "Vector Index" +description: "Build and manage LanceDB vector indexes." +icon: "arrow-up-right-dots" +--- + +import { + PyVectorIndexConfigureIvf as VectorIndexConfigureIvf, + PyVectorIndexSetup as VectorIndexSetup, + PyVectorIndexBuildIvf as VectorIndexBuildIvf, + PyVectorIndexNestedField as VectorIndexNestedField, + PyVectorIndexAsyncConfig as VectorIndexAsyncConfig, + PyVectorIndexQueryIvf as VectorIndexQueryIvf, + PyVectorIndexBuildHnsw as VectorIndexBuildHnsw, + PyVectorIndexQueryHnsw as VectorIndexQueryHnsw, + PyVectorIndexBinarySchema as VectorIndexBinarySchema, + PyVectorIndexBinaryAddData as VectorIndexBinaryAddData, + PyVectorIndexBinaryBuildIndex as VectorIndexBinaryBuildIndex, + PyVectorIndexBinarySearch as VectorIndexBinarySearch, + PyVectorIndexCheckStatus as VectorIndexCheckStatus, + PyVectorIndexNprobes as VectorIndexNprobes, + PyVectorIndexDistanceRange as VectorIndexDistanceRange, + PyVectorIndexBypassRecall as VectorIndexBypassRecall, + PyVectorIndexCustomName as VectorIndexCustomName, + PyQuantizationCustomParams as QuantizationCustomParams, +} from '/snippets/indexing.mdx'; + +Vector indexes are robust tools in facilitating fast searches across large numeric datasets. +LanceDB implements **ANN (Approximate Nearest-Neighbor)** queries with several techniques that provide benefits across a variety of use cases. + +## Do you need one? {#do-you-need-one} + +Not always. LanceDB's disk-based brute-force kNN search is fast even without an +index -- computing 100K pairs of 1000-dimension vectors takes under 20ms in +benchmarks. For datasets around 100K rows or fewer, or applications that can +accept ~100ms latency, a vector index is usually unnecessary overhead. + +Build one once a table passes roughly a million rows, dimensionality is high, +or query latency matters more than that. See +[`bypass_vector_index`](#search-time-parameters) to run an exact search +side-by-side and measure whether an index is worth it for your data. + +## Choosing the Right Index + +LanceDB vector indexes are combined with several [quantization](/indexing/vector-index) techniques to admit efficient storage. +The following table lists provided quantized vector indexes and their common use cases. You can specify index type manually in +Lance with `index_type`. + + +| If your priority is... | Use this index | Why | Approx. compression ratio | Python config class | +| :--- | :--- | :--- | :--- | :--- | +| Maximum compression | `IVF_RQ` | IVF indexing with RaBitQ quantization | Around `1/32` of raw size | `IvfRq` | +| Higher accuracy at small dimensions (`dimension <= 256`) | `IVF_PQ` | IVF indexing with product quantization | Usually `1/64` to `1/16` of raw size (depends on `num_sub_vectors`) | `IvfPq` | +| Best recall/latency trade-off | `IVF_HNSW_SQ` | IVF-HNSW indexing with scalar | Typically a little larger than `1/4` of raw size | `IvfHnswSq` | +| Highest recall / no quantization | `IVF_HNSW_FLAT` | IVF-HNSW indexing with no quantization | Around raw vector size plus HNSW graph overhead | `IvfHnswFlat` | + + +If your vector search frequently includes metadata filters (`where(...)`), use `IVF_RQ` or `IVF_PQ`. In filtered workloads, HNSW-backed IVF indexes such as `IVF_HNSW_FLAT` and `IVF_HNSW_SQ` can show higher latency variance. + + +## Understanding Vector Indexes + +LanceDB offers two vector indexes, which can be created on any numeric Lance dataset: +**Inverted File (IVF)** and **Hierarchical Navigable Small World (HNSW)**. + +### IVF + +The **Inverted File Index (IVF)** accelerates ANN searches by drastically reducing the search space. The index consists of +a small set of *centroids* corresponding to an approximate solution to the +[$k$-means clustering](https://en.wikipedia.org/wiki/K-means_clustering) problem. +Each vector remembers its nearest centroid, and each centroid remembers its associated set of vectors, +called its *partition*. + + IVF vector-space partitioning + + +At query time, we can compare the queried vector to the smaller set of *centroids* (as opposed to the entire dataset) +for a closest match, then run a brute-force comparison against its resulting partition. This technique quickly prunes a large search space, +giving an approximate ANN result. + +However, observe that a queried vector may lie near the boundary of $2$ or more partitions; thus, the true nearest neighbors are scattered across several different partitions. + To address this, LanceDB exposes the `nprobes` parameter, which specifies the number of partitions searched. +A high `nprobes` parameter will yield more accurate results at slightly higher runtime. + + + IVF vector-space partitioning + + +### HNSW +**Hierarchical Navigable Small World (HNSW)** constructs a layered graph hierarchy on the vector set, with edges representing distances. +We can visualize an HNSW index as a vertical stack of graphs, with the top layer having very few edges and +each other layer having a multiplicative factor more edges than the layer above it. + +High layers of a HNSW hierarchy represent sparse, higher-distance networks, and lower layers represent finer, lower-distance networks. +To query a vector $q$, the index proceeds iteratively through layers, first finding $q$'s nearest neighbor in the graph, then proceeding recursively +through the induced subhierarchy until the lowest layer is reached. + + + HNSW layered graph hierarchy + + +To visualize this process, imagine that you must drive your car from San Francisco to a specific house in Boston. Initially, you must first drive thousands of miles +on the interstate freeway I-90 E. Eventually, you merge onto the Massachusetts Turnpike, the center +of the greater Boston highway system. From there, you use a series of increasingly smaller, narrower roads within the city (Charles River Bridge, St. Paul St, +Thatcher St) before finally reaching the house. + +A key observation is that you must initially travel far distances through long-distance road networks +(the interstate freeway system), before proceeding to finer and finer road networks (greater Boston highway system, central Brookline neighborhood connectors) before finally reaching +your destination. This iterative series of road networks mimics the layered graph traversals performed by a HNSW query. + +**IVF + HNSW** + +In LanceDB, HNSW is not exposed as a top-level vector index. Instead, it's available as a substructure which +further indexes the selected vectors inside each IVF partition. +This combines the scalability of IVF with the high recall of HNSW. +LanceDB supports IVF-HNSW-based quantized indexes `IVF_HNSW_FLAT`, `IVF_HNSW_PQ`, and `IVF_HNSW_SQ`. + + + +## Using Vector Indexes +Learn how to configure, build, and search LanceDB vector indexes, including build and search time parameters, asynchronous objects, and several examples. + +### Configuration + +#### Build-time Parameters + +| Parameter | Description | +| :--- | :--- | +| `distance_type` | Default is `l2`, others available are `cosine` and `dot`. +| `num_partitions` | The number of IVF partitions constructed (corresponds to the $k$ in $k$-means clustering). This takes precedence over `target_partition_size` if both are set. | +| `target_partition_size` | An alternative IVF sizing knob that derives the partition count by setting the number of rows per partition. `num_partitions` takes precedence over `target_partition_size` if both are set. If neither is set the number of partitions is automatically calculated based on the number of rows and the index type. | +| `num_sub_vectors` | Applies to `IVF_PQ`; defaults to `dimension // 16` (or `dimension // 8` if not a multiple of 16). Larger values produce better recall and slower search. | +| `max_iterations` | Maximum number of k-means training iterations, for every IVF/HNSW index type. Default `50`. Increase for larger datasets or to improve training quality. | +| `sample_rate` | Number of k-means training samples per partition, for every IVF/HNSW index type. Default `256`. Higher values increase both accuracy and training time. | + +#### Search-time Parameters + +| Parameter | Description | +| :--- | :--- | +| `limit` | Number of results to return (the `k` in `k-ANN`). | +| `nprobes` | Shorthand that sets both `minimum_nprobes` and `maximum_nprobes` to the same value. LanceDB defaults this to 20 which is a good default for many cases. At very high scales you will likely need to experiment to find your preferred recall / latency balance. | +| `minimum_nprobes` | Minimum number of partitions scanned. | +| `maximum_nprobes` | Maximum number of partitions scanned. Only scans more than `minimum_nprobes` if an initial pass does not return enough results — useful for narrow filters. Set to `0` to remove the cap. | +| `ef` | HNSW search-time exploration factor. Start around `1.5 * k` and increase up to `10 * k` for higher recall. | +| `refine_factor` | Reads and reranks additional candidates in memory to recover recall lost to quantization. Start between `5` and `50` (a factor of `25` reads and reranks the top `25 * k` candidates) and measure the latency cost, typically a few milliseconds, against the recall gained. | +| `distance_range(lower_bound, upper_bound)` | Return only rows whose distance falls within `[lower_bound, upper_bound)`. Either bound is optional. Useful for near-duplicate detection or "close-enough" matching. | +| `bypass_vector_index()` | Ignore the ANN index entirely and perform an exact (flat) scan. Can be used to measure ANN `recall@k`, or to query with a metric the index was not built for (e.g., a non-cosine query on a multivector column). | + + +The `distance_type` can be specified at search time in addition to build time. If +you do this, set it to the same value used to build the vector index. If the search +metric does not match the index metric, LanceDB cannot use the index and falls +back to a costly flat search. On LanceDB Cloud, the brute-force KNN safety limit +can reject that search on a large table instead of executing it. LanceDB +Enterprise doesn't have the limit. + +When the search metric is omitted, current SDKs use the index metric if an index +is available. Some older SDK versions sent `l2` when it was omitted, so explicitly +set the matching metric or upgrade the SDK when querying a non-`l2` index with an +older client. + + +**Recommended `nprobes` behavior by index type:** + +| Index type | Guidance | +| :--- | :--- | +| `IVF_HNSW_FLAT`, `IVF_HNSW_SQ` | Keep the default `nprobes`, then tune `ef` first. Expect higher latency variance under filtered search. | +| `IVF_RQ`, `IVF_PQ` | Keep the default `nprobes` and raise when recall is insufficient. If using a selective filter you may need to increase `maximum_nprobes` if you do not received enough results. | + + + +**Filtered queries and adaptive `nprobes`.** When a `where(...)` filter is active, LanceDB initially scans `minimum_nprobes` +partitions and uses a wider scan if too few rows are found. +Set `minimum_nprobes == maximum_nprobes` or call `nprobes(n)` to instead fix the partition count. + + +Here is an example of a vector search exercising several of the above parameters. + + + + {VectorIndexNprobes} + + + + +LanceDB also supports advanced search-time controls for thresholded retrieval, recall measurement, and working around index-level metric constraints. + +**Thresholding with `distance_range`:** + + + + {VectorIndexDistanceRange} + + + +**Using `bypass_vector_index`:** + +Use `bypass_vector_index` to compute an exact **kNN** result. Note that exact queries may be prohibitively slow on production scales. + + + + {VectorIndexBypassRecall} + + + + +Multivector indexing currently requires `distance_type="cosine"`. Use `bypass_vector_index()` for non-`cosine` queries on a multivector column. See [Multivector Search](/search/vector-search) for the full rules. + + +#### Async API and Config Objects + +Create vector indexes asynchronously with `await table.create_index("vector", config=...)`. The `config` object +admits the same build-time parameters described above — pass an instance of a Python config class from the table in [Choosing the Right Index](#choosing-the-right-index): + + + + {VectorIndexAsyncConfig} + + + +### IVF Indexes + +This example creates and queries an `IVF_PQ` index for a table of vectors with respect to `cosine` similarity. +Specify `vector_column_name` if you have multiple vector columns or non-default names. + + + + {VectorIndexBuildIvf + VectorIndexQueryIvf} + + + + +TypeScript currently doesn't support `IvfSq` (IVF with Scalar Quantization). + + +For a vector field nested inside a struct, pass its full dotted path as `vector_column_name` (e.g. `image.embedding`) — the same path is used at query time and is what `list_indices()` reports under `columns`. See [Selecting the vector column](/search/vector-search#selecting-the-vector-column) for the full path syntax. + + + + {VectorIndexNestedField} + + + +### IVF-HNSW Indexes + +Beyond the general build-time parameters above, two additional parameters are specific to IVF-HNSW indexes: + +| Parameter | Description | +| :--- | :--- | +| `m` | The number of neighbors to select for each vector in the HNSW graph. | +| `ef_construction` | The number of candidates to evaluate during the construction of the HNSW graph. Start at `150`; increase for better recall, decrease for faster indexing. | + +Partition sizing follows the general `num_partitions`/`target_partition_size` guidance above. +The snippet below builds and queries an `IVF_HNSW_SQ` index. + + + + {VectorIndexBuildHnsw + VectorIndexQueryHnsw} + + + +### Managing Vector Indexes + + Enterprise-only +In LanceDB Enterprise, vector indexes are managed **automatically**. The system asynchronously updates and optimizes indexes as a background process: +- Automatically manages indexing parameters and storage +- Infers vector columns from the schema +- GPU-based indexing is available on request -- [contact support](/deployment/support) to enable it for your deployment. A few billion vectors index in under 4 hours on GPU. + + Open-Source + LanceDB OSS users can manually create vector indexes by calling `table.create_index()`. + See the above sections for guidance on manually tuning index parameters as data changes. + + +`create_index()` returns immediately, but the vector index builds asynchronously. +To wait until all data is indexed, specify the `wait_timeout` parameter, or call `wait_for_index(...)` afterward — +it waits for the named index to exist and for `index_stats(...)` to report `num_unindexed_rows == 0`. + + + +Rows appended after an initial index build remain outside the index until refreshed manually (OSS) or automatically (Enterprise). Normal +search still checks those unindexed rows with a slower fallback path; `fast_search()` skips that +fallback and searches only indexed rows. + + + +**Operational checks** + +After appends or other writes, use `optimize()` to fold new rows into existing indexes. + +#### Check Index Status + +Vector index creation runs in the background and may take some time to complete. +While it is ongoing, you can check its status through the API or the **LanceDB Enterprise UI**. + +In the LanceDB Enterprise UI, navigate to your table page - the "Index" column reflects each column's index status: it is blank when no index exists, shows an "in progress" label while the index is being built, and shows the index type once the build completes. + +To check status programmatically, use `list_indices()` and `index_stats()`. **By default**, the index name is formed by appending `_idx` to the column name (e.g., a `keywords_embeddings` column produces `keywords_embeddings_idx`). Note that `list_indices()` only returns information after the index is fully built. + +Each entry returned by `list_indices()` also carries detailed per-index metadata, so you can inspect an index without a follow-up `index_stats()` call. Node.js exposes the same fields in camelCase (`num_indexed_rows` → `numIndexedRows`): + +| Parameter | Description | +| :--- | :--- | +| `num_indexed_rows`, `num_unindexed_rows` | Index coverage over the table | +| `size_bytes` | Total size of the index files on disk | +| `num_segments`, `index_version` | On-disk layout and format version | +| `created_at` | Creation time (ms since the Unix epoch in Node.js) | +| `index_uuid`, `type_url` | Internal identifiers for the index segment | +| `index_details` | Type-specific details (e.g. IVF partition counts or quantization settings) | + + + + + {VectorIndexCheckStatus} + + + + + +These fields are populated for local and embedded tables. On LanceDB Enterprise remote tables they are returned as `None` / `undefined` until the server response surfaces them. + +#### Custom Index Names + +The `{column}_idx` suffix is the default naming convention. +Pass `name=...` to `create_index()` to override it. Once set, the custom name will be reflected in `list_indices()`, `index_stats(name)`, and `wait_for_index([name])`. + + + + {VectorIndexCustomName} + + + +## Quantization {#quantization} + +**Quantization** is used in LanceDB to efficiently compress and store vector indexes. We discuss only the quantization techniques here; +discussion of LanceDB vector indexes and quantized vector indexes can be found [here](/indexing/vector-index). + +### Quantization Techniques +LanceDB provides $3$ distinct quantization techniques: Product Quantization (PQ), RaBitQ Quantization (RQ), and Scalar Quantization (SQ). +Recall that all quantizations perform **lossy** compression, in that they irreversibly lose some degree of precision in order to +compactly store an index. + +#### Product Quantization (PQ) +To visualize PQ, assume a vector dataset has $d$ dimensions, with a **chunk** of a vector denoting a contiguous block of entries. Imagine that each vector has + $m$ disjoint chunks of $d/m$ entries each, where the first chunk represents entries $0$ through $d-1$, the second contains entries $d$ through $2d - 1$, and so on. + +Now, for each $i$, let $S_i$ represent the set containing chunk $i$ of each vector (entries $(i-1)d$ to $di - 1$). +For each $S_i$ independently, a small set of **centroids** is computed corresponding to an approximate solution to the $k$-means clustering problem. + +For each original vector in the dataset, every chunk is associated to its nearest centroid; thus, the vector itself is associated with the concatenation of $m$ centroids. +We then gather all our centroids (from all chunks) into a single lookup table, and for each vector, we store the concatenation of IDs of its corresponding centroids. +At query time, we need only to compare each chunk of the queried vector with our set of centroids. + + + IVF vector-space partitioning + + +In the above example, the original vector is split into chunks (subvectors), each of which is associated to a centroid. The stored quantization code for this vector +is the concatenation $2 || 1 || 4 || 3$. The original vector required $128$ dimensions $\times 32$-bit floats $ =4096$ bits total, +which has been compressed to $4$ chunks $\times 8$-bit integers $= 32$ bits of quantized storage. + +#### RaBitQ Quantization (RQ) + +RaBitQ is an advanced quantization technique that outperforms PQ in several ways. +It needs no codebook to train, estimates distances very quickly at query-time, +and crucially, quantizes each vector in (with some small overhead) just **one bit per dimension!** +In practice, RaBitQ compresses a $1024$-dimensional `float32` vector into just a few thousand bits, while maintaining good recall. + +The inner workings of RaBitQ quantization are rather mathematically dense. It generates a quantization codebook by +applying a uniformly random, approximately distance-preserving orthogonal transformation of the vertices of the $d$-dimensional hypercube, +where $d$ is the dimensionality of the dataset. We defer the details, and an elegant theoretical error bound, to [the original paper.](https://arxiv.org/pdf/2405.12497) + +##### Using RaBitQ +Use RaBitQ quantization by selecting quantized index types ending in the suffix `RQ`. For example, call `create_index` with `index_type="IVF_RQ"`. +Note that when using `IVF_RQ`, the dimension of the dataset must be a multiple of `8`. + +`num_bits` determines how many bits are used to quantize each dimension. +`1` is the standard RaBitQ setting. Increase to `2`, `4`, or `8` bits to achieve better recall for additional storage and query-time compute. + + +RaBitQ-quantized indexes computed with `num_bits >= 2` use a newer on-disk layout, and cannot be read by some older LanceDB versions. + + +See this [blog post](https://lancedb.com/blog/feature-rabitq-quantization/) for further discussion and benchmarking of LanceDB's RaBitQ implementation. + +#### Scalar Quantization (SQ) +Scalar quantization quantizes each entry of a vector independently, by simply replacing it with the closest of a pre-defined set of values. +In practice, it often uses $8$ bits per dimension of a vector, and supports very fast encoding and decoding. + +For example, suppose we know all vector entries across our dataset lie in the range $[-128 \times 10^5, 127 \times 10^5]$. +In this case, we could quantize a given value $v$ as an $8$-bit representation of the integer $j$, where $j \times 10^5$ is the closest value to $v$ +among all integral multiples $\{j \times 10^5: -128 \leq j \leq 127 \}$. + +### Quantization API Reference + + +| Parameter | Description | +| :--- | :--- | +| `num_bits` | Bits per dimension for quantization. Only applies to `IVF_PQ`/`IVF_HNSW_PQ` (default `8`) and `IVF_RQ` (default `1`, RaBitQ) — not used by `IVF_FLAT`/`IVF_SQ`/`IVF_HNSW_FLAT`/`IVF_HNSW_SQ`. Higher values improve accuracy at the cost of additional storage. | + +`max_iterations` and `sample_rate` also affect quantizer training, but since they apply to every IVF/HNSW index type (not just quantized ones), they're documented as general [Build-time Parameters](/indexing/vector-index#build-time-parameters) instead. All three are passed as keyword arguments to `create_index`, alongside `index_type`: + + + + {QuantizationCustomParams} + + diff --git a/docs/web/ingestion/create-table.mdx b/docs/web/ingestion/create-table.mdx new file mode 100644 index 000000000..fce23ac65 --- /dev/null +++ b/docs/web/ingestion/create-table.mdx @@ -0,0 +1,347 @@ +--- +title: "Creating a table" +sidebarTitle: "Create Table" +description: "Start here: turn the data you already have into a LanceDB table." +icon: table +keywords: ["create table", "ingest", "arrow", "pandas", "polars", "pydantic", "schema"] +--- + +import { + TsConnect, + RsConnect, +} from '/snippets/connection.mdx'; +import { + PyCreateTableFromDicts as CreateTableFromDicts, + TsCreateTableFromDicts as TsCreateTableFromDicts, + RsCreateTableFromDicts as RsCreateTableFromDicts, + PyCreateTableConflictHandling as CreateTableConflictHandling, + TsCreateTableConflictHandling as TsCreateTableConflictHandling, + RsCreateTableConflictHandling as RsCreateTableConflictHandling, + PyCreateTableFromPandas as CreateTableFromPandas, + PyCreateTableCustomSchema as CreateTableCustomSchema, + TsCreateTableCustomSchema as TsCreateTableCustomSchema, + RsCreateTableCustomSchema as RsCreateTableCustomSchema, + PyCreateTableFromPolars as CreateTableFromPolars, + PyCreateTableFromArrow as CreateTableFromArrow, + TsCreateTableFromArrow as TsCreateTableFromArrow, + RsCreateTableFromArrow as RsCreateTableFromArrow, + PyCreateTableFromPydantic as CreateTableFromPydantic, + PyCreateTableNestedSchema as CreateTableNestedSchema, + PyOpenExistingTable as OpenExistingTable, + TsOpenExistingTable as TsOpenExistingTable, + RsOpenExistingTable as RsOpenExistingTable, + PyCreateEmptyTable as CreateEmptyTable, + TsCreateEmptyTable as TsCreateEmptyTable, + RsCreateEmptyTable as RsCreateEmptyTable, + PyCreateEmptyTablePydantic as CreateEmptyTablePydantic, + PyDropTable as DropTable, + TsDropTable as TsDropTable, + RsDropTable as RsDropTable, + PyTablesBasicConnect as TablesBasicConnect, + PyTablesDocumentModel as TablesDocumentModel, + PyTablesTzValidator as TablesTzValidator, +} from '/snippets/tables.mdx'; + +A table is created from data you already have -- a list of objects, an Arrow +table, a pandas or polars frame, Pydantic models -- or from a schema alone when +the rows come later. + +## Create a table with data {#create-a-table-with-data} + +Initialize a LanceDB connection and create a table + + + + {TablesBasicConnect} + + + + {TsConnect} + + + + {RsConnect} + + + +Depending on the SDK, LanceDB can ingest arrays of records, Arrow tables or record batches, and Arrow batch iterators or readers. Let's take a look at some of the common patterns. + +### From list of objects {#from-list-of-objects} + +You can provide a list of objects to create a table. The Python and TypeScript SDKs +support lists/arrays of dictionaries, while the Rust SDK supports lists of structs. +In Python, pass a list or other batch-like object; a single bare `dict` or single +`LanceModel` is rejected. + + + + {CreateTableFromDicts} + + + + {TsCreateTableFromDicts} + + + + {RsCreateTableFromDicts} + + + +### Handle existing tables {#handle-existing-tables} + +By default, `create_table` raises an error if a table with the same name already exists. +You can change this behavior with two parameters that resolve the conflict in different ways: + +- **Idempotent open**: return the existing table without modifying it. Use when your + code may run more than once (notebooks, retries, init scripts) and you want to reuse + the table on subsequent runs. The provided data is ignored, but the schema is + validated against the existing table and a mismatch raises an error. +- **Overwrite**: drop the existing table and create a new one with the provided data. + Use this for test fixtures or when you intentionally want to replace prior contents. + This permanently discards the old table's data. + + + + {CreateTableConflictHandling} + + + + {TsCreateTableConflictHandling} + + + + {RsCreateTableConflictHandling} + + + + +`exist_ok` / `existOk` does not append the provided data to an existing table. Use +[`table.add()`](/ingestion/insert) for that. If you need to ensure a table exists *and* +contains specific rows, prefer the [empty-table-then-add pattern](#create-empty-table). + + +### From a custom schema {#from-a-custom-schema} + +You can define a custom Arrow schema for the table. This is useful when you want to have more control over the column types and metadata. + + + + {CreateTableCustomSchema} + + + + {TsCreateTableCustomSchema} + + + + {RsCreateTableCustomSchema} + + + +An explicit schema is also where you control nullability. If later writes omit a +non-nullable column, or provide actual nulls for it, ingestion fails; nullable columns can be +omitted or written with null values. Without an explicit schema, Python infers list-like vector +values as fixed-size `float32` vector fields from the observed dimension. + +For Python ingest, malformed vector values fail by default. If you expect occasional wrong-length, +null, or NaN vectors, choose an `on_bad_vectors` policy: `"drop"` removes those rows, `"fill"` writes +`fill_value`, and `"null"` writes nulls. + +### From an Arrow Table {#from-an-arrow-table} +You can also create LanceDB tables directly from Arrow tables. +Rust uses an Arrow `RecordBatchReader` for the same Arrow-native ingest flow. + + + + {CreateTableFromArrow} + + + + {TsCreateTableFromArrow} + + + + {RsCreateTableFromArrow} + + + + +### From a Pandas DataFrame {#from-a-pandas-dataframe} +Python Only + + + + {CreateTableFromPandas} + + + + +Data is converted to Arrow before being written to disk. For maximum control over how data is saved, either provide the PyArrow schema to convert to or else provide a PyArrow Table directly. + + + +The **`vector`** column needs to be a [Vector](/integrations/data/pydantic#vector-field) (defined as [pyarrow.FixedSizeList](https://arrow.apache.org/docs/python/generated/pyarrow.list_.html)) type. + + +### From a Polars DataFrame {#from-a-polars-dataframe} +Python Only + +LanceDB supports [Polars](https://pola.rs/), a modern, fast DataFrame library +written in Rust. Just like in Pandas, the Polars integration is enabled by PyArrow +under the hood. A deeper integration between LanceDB Tables and Polars DataFrames +is on the way. + + + + {CreateTableFromPolars} + + + +### From Pydantic Models {#from-pydantic-models} +Python Only + +When you create an empty table without data, you must specify the table schema. +LanceDB supports creating tables by specifying a PyArrow schema or a specialized +Pydantic model called `LanceModel`. + +For example, the following Content model specifies a table with 5 columns: +`movie_id`, `vector`, `genres`, `title`, and `imdb_id`. When you create a table, you can +pass the class as the value of the `schema` parameter to `create_table`. +The `vector` column is a `Vector` type, which is a specialized Pydantic type that +can be configured with the vector dimensions. It is also important to note that +LanceDB only understands subclasses of `lancedb.pydantic.LanceModel` +(which itself derives from `pydantic.BaseModel`). + + + + {CreateTableFromPydantic} + + + +#### Nested schemas {#nested-schemas} + +Sometimes your data model may contain nested objects. For example, you may want to store the document string and the document source name as a nested Document object: + + + + {TablesDocumentModel} + + + +This can be used as the type of a LanceDB table column: + + + + {CreateTableNestedSchema} + + + +This creates a struct column called "document" that has two subfields +called "content" and "source": + +```bash +In [28]: tbl.schema +Out[28]: +id: string not null +vector: fixed_size_list[1536] not null + child 0, item: float +document: struct not null + child 0, content: string not null + child 1, source: string not null +``` + +#### Validators {#validators} + +Because `LanceModel` inherits from Pydantic's `BaseModel`, you can combine them with Pydantic's +[field validators](https://docs.pydantic.dev/latest/concepts/validators). The example +below shows how to add a validator to ensure that only valid timezone-aware datetime objects are used +for a `created_at` field. + + + + {TablesTzValidator} + + + +When you run this code it, should raise the `ValidationError`. + + +Datasets too large to pass to `create_table` at once are covered separately -- +see [Insert data](/ingestion/insert#large-datasets), which explains why +`table.add()` parallelizes where `create_table` does not. + + +## Create empty table {#create-empty-table} +You can create an empty table for scenarios where you want to add data to the table later. +An example would be when you want to collect data from a stream/external file and then add it to a table in +batches. + +An empty table can be initialized via an Arrow schema. + + + + {CreateEmptyTable} + + + + {TsCreateEmptyTable} + + + + {RsCreateEmptyTable} + + + +Alternatively, you can also use Pydantic to specify the schema for the empty table. Note that we do not +directly import `pydantic` but instead use `lancedb.pydantic` which is a subclass of `pydantic.BaseModel` +that has been extended to support LanceDB specific types like `Vector`. + + + + {CreateEmptyTablePydantic} + + + +Once the empty table has been created, you can append to it or modify its contents, +as explained in [Ingesting data](/ingestion). + +## Open an existing table {#open-an-existing-table} + +You can open an existing table by specifying the name of the table to the `open_table` / `openTable` method. +If you forget the name of your table, you can always get a listing of all table names. + + + + {OpenExistingTable} + + + + {TsOpenExistingTable} + + + + {RsOpenExistingTable} + + + +## Drop a table {#drop-a-table} + +Use the `drop_table()` method on the database to remove a table. + + + + {DropTable} + + + + {TsDropTable} + + + + {RsDropTable} + + + +This permanently removes the table and is not recoverable, unlike deleting rows. +By default, if the table does not exist an exception is raised. To suppress this, +you can pass in `ignore_missing=True`. diff --git a/docs/web/ingestion/delete.mdx b/docs/web/ingestion/delete.mdx new file mode 100644 index 000000000..0e2a4fdfa --- /dev/null +++ b/docs/web/ingestion/delete.mdx @@ -0,0 +1,77 @@ +--- +title: "Delete rows" +sidebarTitle: "Delete" +description: "Remove rows by predicate, and reclaim their space." +icon: trash +--- + +These examples use the table set up in +[Ingesting data](/ingestion), and the connection made there. + +import { + PyDeleteOperation as DeleteOperation, + PyUpdateOptimizeCleanup as UpdateOptimizeCleanup, + TsDeleteOperation, + TsUpdateOptimizeCleanup, + RsDeleteOperation, + RsUpdateOptimizeCleanup, +} from '/snippets/tables.mdx'; + + +In SQL this is [`DELETE`](/sql/dml/delete). + + +## Delete rows {#delete-rows} + +Delete operations **soft delete** rows that match a given condition. +The underlying data is not immediately removed, but is marked +for deletion (in the [deletion files](https://lance.org/format/table/#deletion-files) at the Lance format level) and excluded from query results. + + + + {DeleteOperation} + + + + {TsDeleteOperation} + + + + {RsDeleteOperation} + + + +Expected table contents: + +| id | name | login_count | +| --- | --- | --- | +| 1 | Alice | 10 | +| 2 | Bob | 20 | + + + +**Deleting rows removes them from the index** + +When rows are deleted, those rows are also excluded from the index segments, so indexed queries will not return them either. If ALL the rows are deleted (i.e., the table is emptied), ensure that you recreate the index after ingesting new data. + + +To permanently remove deleted rows, you can optimize the table, which will run compaction and cleans up the soft-deleted rows, which frees up storage space. + +- In LanceDB OSS, compaction and cleanup are manual. Run `table.optimize()` regularly to free up disk space. +- In LanceDB Enterprise, files aren't cleaned up by default. You can configure automatic compaction and cleanup behavior at cluster setup time to suit your organization's retention policy. + +By default, table cleanup removes data up to 7 days ago. If you need to reclaim space from deleted rows more aggressively, manually call `table.optimize()` use a shorter retention window as follows: + + + + {UpdateOptimizeCleanup} + + + + {TsUpdateOptimizeCleanup} + + + + {RsUpdateOptimizeCleanup} + + diff --git a/docs/web/ingestion/index.mdx b/docs/web/ingestion/index.mdx new file mode 100644 index 000000000..2a8de6759 --- /dev/null +++ b/docs/web/ingestion/index.mdx @@ -0,0 +1,125 @@ +--- +title: "Ingesting data" +sidebarTitle: "Overview" +description: "Getting data into a table: creating one from your data, and the four write operations that follow." +icon: inbox +keywords: ["ingest", "create table", "add", "insert", "arrow", "pandas", "polars", "pydantic"] +--- + +import { + PyUpdateConnectEnterprise as UpdateConnectEnterprise, + PyUpdateConnectLocal as UpdateConnectLocal, + PyUpdateExampleTableSetup as UpdateExampleTableSetup, + TsUpdateConnectEnterprise, + TsUpdateConnectLocal, + TsUpdateExampleTableSetup, + RsUpdateConnectEnterprise, + RsUpdateConnectLocal, + RsUpdateExampleTableSetup, + RsUpdateMakeUsersReader, +} from '/snippets/tables.mdx'; + +In LanceDB, tables store records with a defined schema that specifies column +names and types. Across the SDKs, you can create tables from row-oriented data +and Apache Arrow data structures. The Python SDK additionally supports PyArrow +schemas for explicit schema control, and `LanceModel` for Pydantic-based +validation. + +Getting data in has two halves: creating a table from data you already have, and +writing to a table that exists. This page covers the first and introduces the +second. + + +Every operation here has a SQL form -- see [DML](/sql#dml). + + +Start by [creating a table](/ingestion/create-table); the rest of this +section writes to one that exists. + +## Choose a write method {#choose-a-write-method} + +| Family | Method | Use this when... | +| --------------- | --------------- | ---------------- | +| [`add`](/ingestion/insert) | `add(data)` | You are appending new rows and do not need to match against existing ones. | +| `update` | `update(where=..., values=...)` | You want to edit rows that already exist, using a SQL filter. | +| `merge_insert` | `.when_matched_update_all()` | You have incoming rows and want to update keys that already exist in the table. | +| `merge_insert` | `.when_not_matched_insert_all()` | You have incoming rows and want to insert keys that do not exist yet. | +| `merge_insert` | `.when_matched_update_all()` + `.when_not_matched_insert_all()` | You want both behaviors together (often called **upsert**: update existing keys **and** insert missing keys in the same operation). | +| `merge_insert` | `.when_not_matched_by_source_delete(...)` | You want to remove target rows that are missing from the incoming source set. | + +Write operations return status metadata. For example, `update` reports updated row count and +committed `version`, `delete` reports deleted row count and `version`, and `merge_insert` reports +inserted, updated, deleted, retry-attempt, and `version` fields. Writes require a mutable table +handle; after checking out an older version for reads, call `checkout_latest` / `checkoutLatest` +before modifying data. +The committed `version` advances even for writes that affect zero rows, such as a delete predicate +that matches nothing. + +## Connect to LanceDB {#connect-to-lancedb} + +Connect to your local LanceDB instance: + + + + {UpdateConnectLocal} + + + + {TsUpdateConnectLocal} + + + + {RsUpdateConnectLocal} + + + +Or, connect to LanceDB Enterprise: + + + + {UpdateConnectEnterprise} + + + + {TsUpdateConnectEnterprise} + + + + {RsUpdateConnectEnterprise} + + + +In the Rust snippets, a `make_users_reader` helper is used to build Arrow input data. + + + + {RsUpdateMakeUsersReader} + + + +## Create the example table {#create-the-example-table} + +We'll start by creating a simple table with `id`, `name`, and `login_count` columns. All examples below use the same table. + + + {UpdateExampleTableSetup} + + + + {TsUpdateExampleTableSetup} + + + + {RsUpdateExampleTableSetup} + + + +Expected table contents: + +| id | name | login_count | +| --- | --- | --- | +| 1 | Alice | 10 | +| 2 | Bob | 20 | + +The example above shows a PyArrow schema. You can just as well create the table using other +table creation patterns (Pandas, Polars, Pydantic, iterators, etc.) -- see [Create a table](/ingestion/create-table#create-a-table-with-data). diff --git a/docs/web/ingestion/insert.mdx b/docs/web/ingestion/insert.mdx new file mode 100644 index 000000000..2d411eb3b --- /dev/null +++ b/docs/web/ingestion/insert.mdx @@ -0,0 +1,217 @@ +--- +title: "Insert data" +sidebarTitle: "Insert" +description: "Append rows to a table, including datasets too large to hold in memory." +icon: plus +--- + +Rows are appended with `table.add()`. It accepts the same shapes +[table creation](/ingestion) does — lists of objects, Arrow tables, pandas +and polars frames, Pydantic models — and commits a new table version. + +These examples use the table set up in [Ingesting data](/ingestion). + +import { + PyAddFromDataset as AddFromDataset, + PyCreateTableFromIterator as CreateTableFromIterator, + TsCreateTableFromIterator as TsCreateTableFromIterator, + RsCreateTableFromIterator as RsCreateTableFromIterator, + TsAddProgress, +} from '/snippets/tables.mdx'; + + +In SQL this is [`INSERT`](/sql/dml/insert), including `INSERT ... SELECT` to move rows server-side. + + +## Ingesting large datasets {#large-datasets} + +When ingesting large datasets, use `table.add()` on an existing table rather than +passing all data to `create_table()`. The `add()` method auto-parallelizes large +writes, while `create_table(name, data)` does not. + + +For best performance with large datasets, create an empty table first and then call +`table.add()`. This enables automatic write parallelism for materialized data sources. + + +### From files (Parquet, CSV, etc.) {#from-files-parquet-csv-etc} +Python Only + +For file-based data, pass a `pyarrow.dataset.Dataset` to `table.add()`. This streams +data from disk without loading the entire dataset into memory. + + + + {AddFromDataset} + + + + +`pa.dataset()` input is currently Python-only. TypeScript and Rust support for +file-based dataset ingestion is tracked in +[lancedb#3173](https://github.com/lancedb/lancedb/issues/3173). + + +### From iterators (custom batch generation) {#from-iterators-custom-batch-generation} + +When you need custom batch logic — generating embeddings on the fly, transforming +rows from an external source, etc. — use an iterator of `RecordBatch` objects. + + + + {CreateTableFromIterator} + + + + {TsCreateTableFromIterator} + + + + {RsCreateTableFromIterator} + + + +Use this pattern when: + +- Your source data already arrives in Arrow batches, readers, datasets, or streams. +- Materializing the entire ingest as one giant in-memory list or array would be too expensive. +- You want to control chunk size explicitly during ingestion. + +Python can also consume iterators of other supported types like Pandas DataFrames or Python lists. + +### Write parallelism {#write-parallelism} + + +For materialized data (`pa.Table`, `pd.DataFrame`, `pa.dataset()`), LanceDB +automatically parallelizes large writes — no configuration needed. Auto-parallelism +targets approximately 1M rows or 2GB per write partition. + +For streaming sources (iterators, `RecordBatchReader`), LanceDB cannot determine +total size upfront. Python exposes `write_parallelism` on both synchronous and +asynchronous `table.add()` calls; see the [iterator example](#iterator-ingestion-for-data-you-transform-on-the-fly). +TypeScript does not expose a manual write-parallelism option. + + +### Tracking ingestion progress {#tracking-ingestion-progress} +TypeScript example + +In TypeScript, for long-running writes, pass a `progress` callback to `table.add()` to surface +per-batch progress in your UI, logs, or metrics pipeline. Progress updates are dispatched +asynchronously to the JavaScript event loop, including a final `done: true` +update when an active write finishes. + +Each invocation receives a `WriteProgress` object: + +| Field | Description | +|:------|:------------| +| `outputRows` | Rows written so far. | +| `outputBytes` | Bytes written so far. | +| `totalRows` | Expected total rows when the input source reports one. Always set on the final callback. | +| `elapsedSeconds` | Wall-clock seconds since the write started. | +| `activeTasks` | Parallel write tasks currently in flight. | +| `totalTasks` | Total parallel write tasks (the write parallelism). | +| `done` | `true` only for the final callback. | + + + + {TsAddProgress} + + + +A few things to know before you wire this up: + +- Asynchronous delivery: slow callbacks queue updates without back-pressuring the writer. Keep the callback cheap to avoid accumulating queued updates. +- Completion: an active write emits a final `done: true` update when it finishes, including execution errors or cancellation. Errors before writing starts, such as invalid input, may produce no progress callbacks. Use the `table.add()` promise to determine whether the write succeeded; it does not wait for JavaScript to process every queued callback. +- Errors swallowed: anything your callback throws is logged with `console.warn` and won't abort the write, so keep the callback side-effect-only and don't rely on it for control flow. +- Row totals: `totalRows` is only populated when the input source can report it up front (for example, a materialized `arrow.Table`). For streaming sources it stays `undefined` until the final callback, where it falls back to the actual rows written. + +## Performance {#performance} + +If ingestion is taking longer than expected on a large dataset, the cause is almost always how `add()` is called: each call commits a new version and a new fragment, so a per-row loop pays that per-call overhead at every row. The best practice is to pick the ingestion mode that matches your data shape — bulk ingestion when the data is already materialized, or iterator ingestion when it's streamed or computed on the fly. + + +**Why `merge_insert()` is significantly slower than `add()`** + +A merge has to scan existing data to find matches on the join key (or look them up via a scalar index, if one exists), and then delete-and-reinsert any updated rows in a single transaction; `add()` simply appends new fragments. + +Use `add()` for pure appends, and reach for `merge_insert()` only when you need upsert or conditional-insert logic. When you do use it, build a scalar index on the join column first — otherwise the match step falls back to a full column scan, which is the dominant cost at scale. + + +#### Bulk ingestion: for data you already have {#bulk-ingestion-for-data-you-already-have} + +For materialized inputs (Arrow Tables, DataFrames) and file-backed sources (`pyarrow.dataset(...)`), LanceDB auto-parallelizes the write across workers, estimating the partition count from the data size — more partitions means more concurrent writes and higher throughput, up to the CPU core count. + +```python Python icon="python" +table.add(arrow_table) # in-memory +table.add(df) # pandas +table.add(ds.dataset("data/", format="parquet")) # streams from disk, still parallelized +``` + +Pass `progress=True` to watch it happen: LanceDB shows a live tqdm bar with rows written, throughput in MB/s, and active worker count. + +```python Python icon="python" +table.add(ds.dataset("data/", format="parquet"), progress=True) +``` + +```text +71%|███████▏ | 710000/1000000 [00:12<00:05, 58.8kit/s, 42.3 MB/s | 8/8 workers] +``` + +For larger-than-memory data, prefer scanning a file-backed dataset (`ds.dataset(...)`) over a hand-built `pyarrow.RecordBatchReader`: a `Dataset` can be counted and rescanned, so LanceDB knows the row count upfront (better auto-parallelism) and can retry a failed write from the start — a reader can only be consumed once, so neither is possible. Reach for the iterator path below only when the data genuinely can't be backed by files, e.g. to apply custom data transformations as you ingest. + +For very large initial loads, create the table empty first; passing data directly to `create_table(name, data)` skips the auto-parallel path. + +#### Iterator ingestion: for data you transform on the fly {#iterator-ingestion-for-data-you-transform-on-the-fly} + +If each row needs work before it can be written — applying a custom transformation as you ingest, for example — the data doesn't exist as a file you can point `ds.dataset(...)` at. The best practice is to pass an iterator of `pyarrow.RecordBatch` instead; LanceDB consumes one batch at a time as you produce them. + +```python Python icon="python" +def stream(): + for raw in source: + vectors = model.encode(raw["text"]) + yield pa.RecordBatch.from_pydict({**raw, "vector": vectors}) + +table.add(stream()) +``` + +Use decently large chunks of several thousand rows or more, rather than yielding single-row batches. + + +**Set `write_parallelism` yourself for large inputs** + +A reader can't be counted or rescanned the way a `Dataset` can (see above), so LanceDB can't auto-size parallelism for it — set `write_parallelism` explicitly for large inputs: + +```python Python icon="python" +table.add(stream(), write_parallelism=4) +``` + +Each partition becomes its own fragment, so don't over-allocate on a small input — budget one unit of parallelism per ~100K rows or ~1 GB of data as a rule of thumb. + + +#### Bulk ingests into a remote table {#bulk-ingests-into-a-remote-table} + +Enterprise + +This section applies only to remote tables (`db://` connections). Embedded (local) writes are unaffected. + +When you write to a remote LanceDB Enterprise table, the client splits each write partition into one or more HTTP `insert` parts and uploads them under a single upload id. Each part is streamed rather than buffered, so peak memory stays bounded. But each part still has to finish within the client read timeout while the server writes it to object storage, so on very large ingests an oversized part can run past that timeout and surface as: + +```text +lancedb.remote.errors.HttpError: operation timed out +``` + +Two environment variables control how parts are cut. Both are picked up automatically by the Python and TypeScript clients, and are the only way to tune this from those SDKs: + +| Variable | Default | What it controls | +|----------|---------|------------------| +| `LANCE_CLIENT_MAX_BYTES_PER_REQUEST` | 8 GiB | Maximum size of a single insert part, in LZ4-compressed Arrow IPC bytes. Set to `0` to disable splitting and send one request per partition. | +| `LANCE_CLIENT_MAX_REQUEST_DURATION` | Half the read timeout | Maximum wall-clock time, in whole seconds, that any one insert part may stay open. Set to `0` to disable the time-based cut. | + +A part is cut when it hits either limit, whichever comes first. Lower the byte budget when large ingests hit the read timeout; lower the duration when uploads are slow or throttled and the byte budget isn't the limiting factor. + +```bash +export LANCE_CLIENT_MAX_BYTES_PER_REQUEST=1073741824 # 1 GiB +export LANCE_CLIENT_MAX_REQUEST_DURATION=120 # 2 minutes +``` + +Splitting into more parts does not change the final table. The server stages every part under the shared upload id and merges them atomically when the write completes. diff --git a/docs/web/ingestion/merge-insert.mdx b/docs/web/ingestion/merge-insert.mdx new file mode 100644 index 000000000..8bfb6084a --- /dev/null +++ b/docs/web/ingestion/merge-insert.mdx @@ -0,0 +1,214 @@ +--- +title: "Merge insert" +sidebarTitle: "Merge Insert" +description: "Reconcile incoming rows against a key: update what matches, insert what does not." +icon: code-merge +--- + +These examples use the table set up in +[Ingesting data](/ingestion), and the connection made there. + +import { + PyMergeMatchedUpdateOnly as MergeMatchedUpdateOnly, + PyInsertIfNotExists as InsertIfNotExists, + PyMergeUpdateInsert as MergeUpdateInsert, + PyMergeDeleteMissingBySource as MergeDeleteMissingBySource, + PyMergePartialColumns as MergePartialColumns, + TsMergeMatchedUpdateOnly, + TsInsertIfNotExists, + TsMergeUpdateInsert, + TsMergeDeleteMissingBySource, + TsMergePartialColumns, + RsMergeMatchedUpdateOnly, + RsInsertIfNotExists, + RsMergeUpdateInsert, + RsMergeDeleteMissingBySource, + RsMergePartialColumns, +} from '/snippets/tables.mdx'; + + +Merge insert has **no SQL form** -- it needs a source relation and a per-match policy that one statement cannot express. See [DML](/sql/dml/insert#next). + + +## Merge incoming rows by key {#merge-incoming-rows-by-key} + +Merging is different from updating because it involves comparing incoming rows to existing rows by key, and then choosing what to do based on whether the key exists in the target table or not. +The `merge_insert(""..."")` method lets you do this. + +In merge operations, rows are split into three groups: + +- **Matched**: key exists in both source and target. +- **Not matched**: key exists only in source. +- **Not matched by source**: key exists only in target. + +Conditional merge clauses can compare old and new values. Use the `target.` prefix for the +existing table row and `source.` for the incoming row, for example +`target.last_update < source.last_update`. + + +**Use scalar indexes to speed up merge insert** + +The merge insert command performs a join between the input data and the target table `on` the key you provide. This requires scanning that entire column, which can be expensive for large tables. To speed up this operation, create a scalar index on the join column, which will allow LanceDB to find matches without scanning the whole table. + +Read more about scalar indices in the [Scalar Index](/indexing/scalar-index/) guide. + + + +If you see this HTTP 400 error from `merge_insert`: `Bad request: Merge insert cannot be performed because the number of unindexed rows exceeds the maximum of 10000`. Verify that the scalar index on the join column is up to date before retrying. + + + +**Rust: build merge predicates with DataFusion expressions** + +In the Rust SDK, you can pass a `datafusion_expr::Expr` directly instead of a SQL string by using +`when_matched_update_all_expr` and `when_not_matched_by_source_delete_expr` on `MergeInsertBuilder`. +This is useful when you are constructing predicates programmatically and want to avoid building a SQL string. + +These methods are only supported on local tables. Calling them against a remote table returns a +`NotSupported` error — use the SQL string variants (`when_matched_update_all` / +`when_not_matched_by_source_delete`) for remote tables. + + +Like the create table and add APIs, the merge insert API will automatically compute embeddings based on the [embedding registry](/embedding/index#embedding-registry) if the table has an embedding definition in its schema. + +During `merge_insert`, if the input data doesn't contain the source column (i.e., the original field used to generate embeddings, such as text for a text embedding model or `image_uri` for an image model), or if a vector value is already provided, LanceDB skips embedding generation for that row. Embeddings are only auto-generated when that source field is present in the incoming data, **and** the vector field is empty. + +Primary keys in LanceDB are metadata used by operations such as `merge_insert`; they are not +enforced as uniqueness constraints on ordinary writes. Keep using `merge_insert` or your own +deduplication logic when you need key-based upsert semantics. + +### Update matched rows only {#update-matched-rows-only} + +This updates keys that already exist in the target table. Source rows with new keys are ignored. + + + + {MergeMatchedUpdateOnly} + + + + {TsMergeMatchedUpdateOnly} + + + + {RsMergeMatchedUpdateOnly} + + + +Expected table contents: + +| id | name | login_count | +| --- | --- | --- | +| 1 | Alice | 10 | +| 2 | Bobby | 21 | + +### Insert unmatched rows only {#insert-unmatched-rows-only} + +This inserts only brand-new keys from the source. Existing keys are left unchanged. + + + + {InsertIfNotExists} + + + + {TsInsertIfNotExists} + + + + {RsInsertIfNotExists} + + + +Expected table contents: + +| id | name | login_count | +| --- | --- | --- | +| 1 | Alice | 10 | +| 2 | Bob | 20 | +| 3 | Charlie | 5 | + +### Update matched rows and insert unmatched rows {#update-matched-rows-and-insert-unmatched-rows} + +Use both `when_matched_update_all()` and `when_not_matched_insert_all()` when you want to update existing keys and insert missing keys in one operation. + + +This is a conventional **upsert**. + + + + + {MergeUpdateInsert} + + + + {TsMergeUpdateInsert} + + + + {RsMergeUpdateInsert} + + + +Expected table contents: + +| id | name | login_count | +| --- | --- | --- | +| 1 | Alice | 10 | +| 2 | Bobby | 21 | +| 3 | Charlie | 5 | + +### Delete target rows that are missing from source {#delete-target-rows-that-are-missing-from-source} + +Use `when_not_matched_by_source_delete()` when you want to remove any target row that does not appear in the incoming source data. + + + + {MergeDeleteMissingBySource} + + + + {TsMergeDeleteMissingBySource} + + + + {RsMergeDeleteMissingBySource} + + + +Expected table contents: + +| id | name | login_count | +| --- | --- | --- | +| 2 | Bobby | 21 | +| 3 | Charlie | 5 | + +In the example above, LanceDB matches rows by `id`. Rows with `id=2` and `id=3` exist in both the table and incoming data, so they are updated. Row `id=1` exists only in the target, so it is deleted. + +### Use partial columns in merge updates {#use-partial-columns-in-merge-updates} + +Merge updates do not require you to provide values for all columns. You can provide only a subset of columns in source rows. For matched rows, only the provided columns are updated. + + + + {MergePartialColumns} + + + + {TsMergePartialColumns} + + + + {RsMergePartialColumns} + + + +Expected table contents: + +| id | name | login_count | +| --- | --- | --- | +| 1 | Alice | 10 | +| 2 | Bobby | 20 | +| 3 | Charlie | null | + +Note that in the example above, when `merge_insert` creates a new row, any missing columns are written as `null`. If a missing column is non-nullable in your schema, the insert will fail. diff --git a/docs/web/ingestion/update.mdx b/docs/web/ingestion/update.mdx new file mode 100644 index 000000000..e77587ea8 --- /dev/null +++ b/docs/web/ingestion/update.mdx @@ -0,0 +1,82 @@ +--- +title: "Update rows" +sidebarTitle: "Update" +description: "Change values in rows that already exist, by predicate or SQL expression." +icon: pen +--- + +These examples use the table set up in +[Ingesting data](/ingestion), and the connection made there. + +import { + PyUpdateOperation as UpdateOperation, + PyUpdateUsingSql as UpdateUsingSql, + TsUpdateOperation, + TsUpdateUsingSql, + RsUpdateOperation, + RsUpdateUsingSql, +} from '/snippets/tables.mdx'; + + +In SQL this is [`UPDATE`](/sql/dml/update). + + +## Update rows {#update-rows} + +Use `update` when you already know which target rows to modify and you do not need to compare against an incoming dataset. + + + + {UpdateOperation} + + + + {TsUpdateOperation} + + + + {RsUpdateOperation} + + + +Expected table contents: + +| id | name | login_count | +| --- | --- | --- | +| 1 | Alice | 10 | +| 2 | Bobby | 20 | + + +Updating nested columns is not yet supported. + + +## Update rows with SQL expressions {#update-rows-with-sql-expressions} + +Use `values_sql` when you want to use SQL-like expressions to update rows. This is useful for operations like incrementing a counter, or setting a column value based on another column. + + + + {UpdateUsingSql} + + + + {TsUpdateUsingSql} + + + + {RsUpdateUsingSql} + + + +Expected table contents: + +| id | name | login_count | +| --- | --- | --- | +| 1 | Alice | 10 | +| 2 | Bob | 21 | + + +See the [SQL queries](/sql#connecting) page for more information on the supported SQL syntax. + + +When rows are updated, they are moved out of any existing index. The row will still show up in search queries, but the query will not be as fast as it would be if the row was in the index. If you update a large proportion of rows, consider triggering an index rebuild afterwards. diff --git a/docs/web/integrations/ai/agno.mdx b/docs/web/integrations/ai/agno.mdx new file mode 100644 index 000000000..b5edef031 --- /dev/null +++ b/docs/web/integrations/ai/agno.mdx @@ -0,0 +1,151 @@ +--- +title: "Agno" +sidebarTitle: "Agno" +description: "Build a search assistant using the Agno agent framework with LanceDB as the knowledge backend." +--- + +import { + PyFrameworksAgnoAgent, + PyFrameworksAgnoCliChat, + PyFrameworksAgnoIngestYoutube, + PyFrameworksAgnoSetup, +} from '/snippets/integrations.mdx'; + +[Agno](https://docs.agno.com/introduction) is a framework for building agentic AI applications. +It supports LanceDB as a knowledge backend, allowing you to easily ingest and retrieve external content for your agents. + +When you pair Agno's `Knowledge` system with LanceDB, you get a clean Agentic RAG setup. +We'll walk through the steps below to build a YouTube transcript-aware Agno assistant that can: +- Ingest a transcript from a YouTube video via the YouTube API +- Store embeddings and metadata in LanceDB +- Retrieve context during responses with hybrid search +- Ask questions about the video content in a CLI chat loop + +## Prerequisites {#prerequisites} + +Install dependencies: + + +```bash pip icon="terminal" +pip install -U agno openai lancedb youtube-transcript-api beautifulsoup4 +``` + +```bash uv icon="terminal" +uv add agno openai lancedb youtube-transcript-api beautifulsoup4 +``` + + +## Step 1: Configure LanceDB-backed knowledge {#step-1-configure-lancedb-backed-knowledge} + +First, you can initialize the core `Knowledge` object that your agent will use for retrieval. +It configures LanceDB as the vector store, enables hybrid search with native LanceDB FTS, and sets the embedding model. + + + {PyFrameworksAgnoSetup} + + +## Step 2: Fetch and ingest the YouTube transcript {#step-2-fetch-and-ingest-the-youtube-transcript} + +Next, extract a YouTube video ID, fetch the full transcript, and flatten it into text for indexing. +The snippet shown below then inserts that transcript text into the Agno knowledge base, which writes vectors and metadata to LanceDB. + + + {PyFrameworksAgnoIngestYoutube} + + + +This path explicitly fetches the transcript first, then inserts transcript text into LanceDB through Agno. + + +## Step 3: Create an Agno agent with knowledge search {#step-3-create-an-agno-agent-with-knowledge-search} + +The next step is to construct an Agno `Agent` and attach the knowledge base you just populated. +With `search_knowledge=True`, the agent performs retrieval before answering, so responses stay grounded in transcript context. + +In Agno, retrieval is exposed as a tool call that the model can invoke at runtime. +When `search_knowledge=True`, Agno makes a knowledge-search tool (shown in output as `search_knowledge_base(...)`) available to the model; the model decides when to call it, Agno executes the tool, and the returned context is fed back into the final answer. + + + {PyFrameworksAgnoAgent} + + +## Step 4: Start a CLI chat loop {#step-4-start-a-cli-chat-loop} + +You can now ask an initial question and then start an interactive loop for follow-up queries. +Each prompt runs through the same retrieval pipeline, so you can iteratively inspect what the transcript contains. + + + {PyFrameworksAgnoCliChat} + + + +Want local-first inference? Replace OpenAI model/embedder classes with Agno's Ollama providers. See Agno's Ollama knowledge examples: [docs.agno.com/examples/models/ollama/chat/knowledge](https://docs.agno.com/examples/models/ollama/chat/knowledge). + + +### Question 1 {#question-1} + +The following question is asked in the CLI chat loop: +``` +┏━ Message ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Q: What kinds of data can LanceDB handle? ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━ Tool Calls ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ • search_knowledge_base(query=What kinds of data can LanceDB handle?) ┃ +┃ • search_knowledge_base(query=LanceDB images audio video handle kinds of data ┃ +┃ can handle 'LanceDB can handle' 'kinds of data' 'images audio video' transcript) ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━ Response (19.1s) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ ┃ +┃ • Images, audio, video — i.e., multimodal AI data and “all manners of things ┃ +┃ you don't put into traditional databases” (per the transcript). ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +``` + +We get the response based on the transcript's contents as expected. + +### Question 2 {#question-2} + +Let's ask a more specific question about the CEO of LanceDB, which is also in the transcript: + +``` +You: What is the name of the CEO of LanceDB? +INFO Found 10 documents +┏━ Message ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ What is the name of the CEO of LanceDB? ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━ Tool Calls ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ • search_knowledge_base(query=CEO of LanceDB) ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━ Response (16.7s) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ ┃ +┃ • According to the retrieved YouTube transcript/title, the CEO of LanceDB is ┃ +┃ Chang She. ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +``` + +We get the response based on the transcript's contents and title as expected. + +## Why this works well {#why-this-works-well} + +To start, LanceDB OSS can run from a local directory, so transcript data can stay on your machine when you are using the OSS stack. + +- You do not need to maintain a separate transcript parser in your application code. +- You do not need to hand-roll chunking and retrieval orchestration across multiple modules. +- One explicit Agno `Knowledge` object, backed by LanceDB, defines both ingestion and search behavior in one place. +- Fewer moving parts means the tutorial stays readable and the same pattern is easier to carry into production code. + +As your application needs grow, you can migrate to LanceDB [Enterprise](/enterprise) for +convenience features like automatic compaction and reindexing and the ability to scale to +really large datasets. diff --git a/docs/web/integrations/ai/genkit.mdx b/docs/web/integrations/ai/genkit.mdx new file mode 100644 index 000000000..6314842b8 --- /dev/null +++ b/docs/web/integrations/ai/genkit.mdx @@ -0,0 +1,73 @@ +--- +title: "GenKit" +sidebarTitle: "GenKit" + +--- + +import { + TsFrameworksGenkitCustomIndexer, + TsFrameworksGenkitCustomRetriever, + TsFrameworksGenkitUsage, +} from '/snippets/integrations.mdx'; + +### genkitx-lancedb {#genkitx-lancedb} +Genkit is an open-source framework for building end-to-end AI and RAG pipelines with a clean, TypeScript-first +developer experience. The genkitx-lancedb plugin lets you use LanceDB as a high-performance vector store +inside your Genkit flows, so you can index, search, and retrieve data efficiently as part of your AI +applications. + +### Installation {#installation} +```bash +pnpm install genkitx-lancedb +``` + +### Usage {#usage} + +Adding LanceDB plugin to your genkit instance. + + + {TsFrameworksGenkitUsage} + + +You can run this app with the following command: +```bash +genkit start -- tsx --watch src/index.ts +``` + +This'll add LanceDB as a retriever and indexer to the genkit instance. You can see it in the GUI view +Screenshot 2025-05-11 at 7 21 05 PM + +**Testing retrieval on a sample table** +Let's see the raw retrieval results + +Screenshot 2025-05-11 at 7 21 05 PM +On running this query, you'll get 5 results fetched from the lancedb table, where each result looks something like this: +Screenshot 2025-05-11 at 7 21 18 PM + + + +## Creating a custom RAG flow {#creating-a-custom-rag-flow} + +Now that we've seen how you can use LanceDB in a Genkit pipeline, let's refine the flow and create a RAG. A RAG flow will consist of an index and a retriever with its outputs postprocessed and fed into an LLM for final response + +### Creating custom indexer flows {#creating-custom-indexer-flows} +You can also create custom indexer flows, utilizing more options and features provided by LanceDB. + + + {TsFrameworksGenkitCustomIndexer} + + +Screenshot 2025-05-11 at 8 35 56 PM + +In your console, you can see the logs + +Screenshot 2025-05-11 at 7 19 14 PM + +### Creating custom retriever flows {#creating-custom-retriever-flows} +You can also create custom retriever flows, utilizing more options and features provided by LanceDB. + + {TsFrameworksGenkitCustomRetriever} + +Now using our retrieval flow, we can ask a question about the ingested PDF +Screenshot 2025-05-11 at 7 18 45 PM + diff --git a/docs/web/integrations/ai/hermes-agent.mdx b/docs/web/integrations/ai/hermes-agent.mdx new file mode 100644 index 000000000..a5521a9d8 --- /dev/null +++ b/docs/web/integrations/ai/hermes-agent.mdx @@ -0,0 +1,327 @@ +--- +title: "Hermes Agent" +sidebarTitle: "Hermes Agent" +description: "Use LanceDB as a persistent, semantic memory backend for Hermes Agent. Get durable recall across sessions with vector and hybrid search." +--- + +[Hermes Agent](https://github.com/NousResearch/hermes-agent) is a self-hosted, open-source +personal agent from [Nous Research](https://nousresearch.com). You can talk to it from a +terminal UI or reach the same agent from Telegram, Discord, and Slack, and it exposes a +dedicated slot for external *memory providers* that run alongside its built-in notes. + +The [LanceDB memory plugin](https://github.com/lancedb/hermes-agent-memory) fills that slot. +It gives Hermes durable, semantic recall across sessions: state a preference or a project +convention once, and the agent can retrieve it weeks later in a brand-new session — even when +you ask for it in completely different words. Everything runs inside Hermes' own Python +process, storing a single LanceDB table on local disk. There's no memory server to operate. + + +**The mental model is clean** + +- Hermes owns the agent loop +- LanceDB manages the durable long-term memory and offers semantic recall. + + +## Why LanceDB fits agent memory {#why-lancedb-fits-agent-memory} + +Out of the box, Hermes remembers with a small curated notes file frozen into the system +prompt, plus lexical (keyword) search over past sessions. Both are useful, but keyword search +misses paraphrases of what you originally typed — the exact thing you need when recalling a +fact you phrased differently months ago. + +LanceDB is an embedded retrieval library, which makes it a natural fit here: + +- **No server to stand up** — it reads and writes a table on local disk, so the plugin ships + as a dependency rather than a service to operate. +- **One table holds everything** — content, metadata, and embeddings live together. A memory + becomes a structured row with a category, tags, timestamps, and provenance, not just a text + blob. +- **Query it any way you need** — vector similarity for meaning, BM25 full-text for exact + names and jargon, a hybrid of the two, or plain metadata filters to keep recall scoped to + the right workspace. +- **It scales up** — the same table abstraction carries over to larger LanceDB deployments + later, so the local setup is never a dead end. + +## Install and activate {#install-and-activate} + + +Want to try this without touching your existing Hermes setup? Run everything in an isolated +profile: `hermes profile create demo`, then add `-p demo` to the commands below. When you're +done, `rm -rf ~/.hermes/profiles/demo` removes all trace. + + + + +Skip this if you already have Hermes installed. + +```bash +curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +``` + + + +This shallow-clones the plugin into `~/.hermes/plugins/lancedb/`. + +```bash +hermes plugins install lancedb/hermes-agent-memory +``` + + + +Hermes loads plugins inside its own Python interpreter, so the dependencies go *there* — not +into a separate virtualenv. (This interpreter is shared across profiles, so you only install +once.) + +```bash +uv pip install --python ~/.hermes/hermes-agent/venv/bin/python3 lancedb openai pyyaml +``` + + + +The plugin turns conversations into embeddings, so it needs an embeddings key. By default that +is OpenAI, so set `OPENAI_API_KEY` in your environment or in `~/.hermes/.env`. + + +Prefer a local or non-OpenAI model? The plugin uses an OpenAI-compatible client, so you can +point it at any compatible endpoint (OpenRouter, Ollama, vLLM, …) in your config — no code +change needed. See [Configuration](#configuration) below. + + + + +Switch memory on and pick this plugin: + +```bash +hermes memory setup # choose "lancedb" +``` + +Then confirm it's actually active before you start chatting — this is the one step worth not +skipping, because Hermes quietly falls back to its built-in notes if the provider isn't set: + +```bash +hermes memory status +``` + +```text +Memory status +──────────────────────────────────────── + Built-in: always active + Provider: lancedb + + Plugin: installed ✓ + Status: available ✓ +``` + +You want to see `Provider: lancedb` with both `installed ✓` and `available ✓`. + + + +## The memory tools {#the-memory-tools} + +Once activated, the agent has four tools for working with long-term memory: + +| Tool | What it does | +|:--|:--| +| `lancedb_recall` | Semantic (vector, the default) or hybrid search over your workspace memory. Returns matching facts with scores and provenance. | +| `lancedb_remember` | Stores a durable fact when you explicitly ask. Deduplicated by content hash, so remembering the same thing twice doesn't pile up rows. | +| `lancedb_read` | Fetches a single memory by ID, optionally with the original conversation messages it was distilled from. | +| `lancedb_forget` | Deletes safely: previews candidates first, then deletes by exact ID, so nothing disappears by accident. | + +Beyond these tools, the plugin also captures durable facts from your conversations +automatically — an auxiliary model distills them before context is compressed and again when a +session ends, so insights survive even when the raw messages are summarized away. + +## Walkthrough {#walkthrough} + +"_Teach it your project preferences_" + +Let's make this concrete with the pain we opened on: re-explaining your setup to the agent every session. +We'll save a convention once and then prove a brand-new session can recall it. This example will touch all four +tools along the way. + +### Remember {#remember} + +Ask Hermes to commit a convention to long-term memory. Saying "remember in long-term memory" +makes sure it lands in the LanceDB store, which shows up as the `⚡ lancedb_r` (`lancedb_remember`) +line below: + +```text +● Remember in long-term memory: for this project I only use uv, never pip, and I always add type hints to Python functions. + + ┊ 🧠 memory +memory: "For this project, the user only uses uv for Python package management, never pip, and always adds type hints to Python functions." + ┊ ⚡ lancedb_r 0.0s + ─ ⚕ Hermes ──────────────────────────────────────────────────────────────── + + Remembered. I've stored that project convention: use uv only, never pip, and always add type hints to Python functions. +``` + +### Recall {#recall} + +First, take Hermes' built-in notes out of the picture so recall can *only* come from LanceDB — +the two layers run side by side otherwise, and either could answer: + +```bash +hermes config set memory.memory_enabled false +``` + +Now start a fresh session (`/new`) and ask for the convention back, worded completely +differently from how you saved it: + +```text +● /new +● Look through your long-term memory for my Python project preferences. + + ─ ⚕ Hermes ──────────────────────────────────────────────────────────────── + + I found one durable memory about Python project preferences: + + - Project Python tooling preference is uv; pip is not used; type hints are required. +``` + +This is the whole point in one exchange. You asked about your *preferences* using none of the +words you stored the fact with — no *uv*, no *pip*, no *type hints* — yet a fresh session, with +none of the earlier conversation in context, pulls it straight back. Semantic search matches on +meaning rather than exact wording, which is precisely where keyword search falls short. + + +Turn the built-in layer back on for everyday use with `hermes config set memory.memory_enabled true` +— the two memory layers happily coexist. We only disabled it here to prove the recall came from +LanceDB. + + +### Read {#read} + +You can also ask where a fact came from. Hermes attributes the answer to its stored memory +rather than guessing from a file in the repo (under the hood, `lancedb_read` can also return +the exact source messages a fact was distilled from): + +```text +● Where did that uv preference come from? + + ─ ⚕ Hermes ──────────────────────────────────────────────────────────────── + + It came from the project's persistent memory, not from a codebase file I inspected. + + Specifically, the recalled memory says: + - "For this project, the user only uses uv for Python package management, never pip, and always adds type hints to Python functions." +``` + +### Forget {#forget} + +When a preference changes, ask Hermes to drop the old fact. The tool calls tell the whole +story: the two `⚡ lancedb_f` (`lancedb_forget`) lines are it previewing matches and then +deleting, and the trailing `⚡ lancedb_r` is it saving the replacement in the same breath: + +```text +● I've switched from uv to pixi. Forget the uv preference. + + ┊ ⚡ lancedb_f uv preference python package management never pip type hints 0.3s + ┊ ⚡ lancedb_f 0.0s + ┊ ⚡ lancedb_r 0.2s + ─ ⚕ Hermes ──────────────────────────────────────────────────────────────── + + Done — I removed the uv preference from memory and replaced it with: + + - This project uses pixi for Python package management + - Type hints on Python functions are still preferred +``` + +Remember, recall, read, forget: four small operations that between them cover the entire +lifecycle of a durable memory. + +## Retrieval modes {#retrieval-modes} + +Recall ships in `vector` mode by default — pure semantic search, which is what survives the +paraphrasing you saw above. If you also need exact name or jargon matching, switch to `hybrid` +(vector + BM25) and choose how the two legs are fused: RRF, a vector-biased linear blend, or a +cross-encoder reranker. Mode is set per call; fusion is a config setting. + +```yaml +# ~/.hermes/config.yaml +plugins: + lancedb: + retrieval: + mode: hybrid # vector (default) | hybrid + reranker: + type: rrf # how the vector + BM25 legs are fused + # Swap RRF for a reranking pass (pulls in sentence-transformers + torch): + # type: cross-encoder + # model: cross-encoder/ettin-reranker-17m-v1 + # rerank_top_n: 50 +``` + +The cross-encoder is the one path that pulls in a local ML stack, so it stays opt-in. It +defaults to the compact 17M-parameter [ettin reranker](https://huggingface.co/cross-encoder/ettin-reranker-17m-v1). + +## Inspect the store {#inspect-the-store} + +Everything lives in one table named `memories` at `~/.hermes/lancedb/memories.lance`. Because +it's a plain LanceDB table, you can open it directly and see exactly what the agent has stored +— a `kind` column separates extracted `fact` rows from the raw `turn` rows they were drawn +from: + +```python +import lancedb + +db = lancedb.connect("~/.hermes/lancedb") +tbl = db.open_table("memories") +print(tbl.to_pandas()[["kind", "category", "content"]].head()) +``` + +## Configuration {#configuration} + +The plugin runs on sensible defaults once activated — you don't have to configure anything. +`~/.hermes/config.yaml` is purely for overrides. Two common ones: + +Use a cheaper model for the auxiliary fact-extraction calls: + +```yaml +# ~/.hermes/config.yaml +auxiliary: + lancedb_extraction: + provider: openrouter + model: google/gemini-3-flash +``` + +Point embeddings at a fully local endpoint (for example, Ollama) so nothing leaves your +machine: + +```yaml +# ~/.hermes/config.yaml +plugins: + lancedb: + embedding: + model: nomic-embed-text + base_url: http://localhost:11434/v1 + api_key_env: OLLAMA_API_KEY # any value works for local Ollama +``` + + +Changing the embedding model (or its dimension) against an existing store requires recreating +the table — the plugin fails loudly on a dimension mismatch rather than silently returning +nothing. Every option is documented in the plugin's [`default_config.yaml`](https://github.com/lancedb/hermes-agent-memory/blob/main/src/default_config.yaml). + + +## Benchmark {#benchmark} + +On [LongMemEval-S](https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned), a +long-conversation QA benchmark, LanceDB's semantic recall clearly beat Hermes' built-in lexical +search (0.66 vs. 0.53 answer accuracy) by finding the right messages even when the question was +worded differently from the original conversation. For the full methodology, the +per-question-type breakdown, and a reproducible harness, see the +[blog post](https://www.lancedb.com/blog/semantic-memory-for-hermes-agent-with-lancedb) and the +[benchmark harness](https://github.com/lancedb/hermes-agent-memory/tree/main/benchmarks). + +## Why this works well {#why-this-works-well} + +- **It's local-first and embedded.** The LanceDB memory table lives on your disk with no server to run; + the plugin installs as a dependency of Hermes' own environment. +- **Recall survives paraphrasing.** Semantic search matches meaning, not spelling, which is the + failure mode that sinks keyword-only session search. +- **Memories are structured and traceable.** Each fact is a row with metadata and a link back + to the messages it came from, and `forget` always previews before it deletes. +- **Nothing about it is a dead end.** As your needs grow, the same table abstraction carries + over to LanceDB [Enterprise](/enterprise) for automatic compaction, reindexing, and scale. + +To try it, install the plugin, enable it with `hermes memory setup`, and run the kind of +workflow we walked through above. diff --git a/docs/web/integrations/ai/huggingface.mdx b/docs/web/integrations/ai/huggingface.mdx new file mode 100644 index 000000000..62ac7e8cd --- /dev/null +++ b/docs/web/integrations/ai/huggingface.mdx @@ -0,0 +1,341 @@ +--- +title: "Hugging Face Hub" +sidebarTitle: "Hugging Face" +description: "Use LanceDB directly on Lance datasets hosted on the Hugging Face Hub for multimodal search and retrieval." +--- + +[Hugging Face Hub](https://huggingface.co/datasets?format=format:lance&sort=trending) is a popular platform for sharing machine learning datasets, models, and other resources. + +LanceDB can directly scan Lance datasets hosted on the [Hugging Face Hub](https://huggingface.co/datasets?format=format:lance) with `hf://` URIs. +This is enabled under the hood by the [lance-huggingface](https://lance.org/integrations/huggingface/) +integration that allows users to stream Lance datasets directly from Hugging Face without needing to +download them first. + +For ML and AI engineers working in LanceDB, this capability is incredibly useful for quickly exploring +multimodal datasets and reusing Lance datasets shared by others, without writing custom data loaders +or preprocessing pipelines. + +The snippets below use the [`lance-format/laion-1m`](https://huggingface.co/datasets/lance-format/laion-1m) +dataset published in Lance format. The dataset includes a million image-caption pairs, and the +Lance dataset can package image embeddings alongside the metadata. This makes it useful for +demonstrating LanceDB's multimodal search capabilities in combination with easy sharing via the +Hugging Face Hub. + +The LAION table includes multimodal columns such as: + +- `image` (inline JPEG bytes) +- `caption` (text) +- `img_emb` (image embedding vector) +- metadata fields such as `url` and `similarity` + +## Install dependencies {#install-dependencies} + +```bash +pip install lancedb pillow +``` + +## Open the dataset with LanceDB {#open-the-dataset-with-lancedb} + +LanceDB can open the dataset directly from the Hub, without needing to download it first. +Note that in LanceDB, you need to specify the table name when opening a Lance dataset, +and the Hugging Face convention is to use `train` and `test` splits for datasets. +The LAION dataset is uploaded as a single split named `train`, so we specify the table name +that contains the `*.lance` files when opening the dataset. + +```python +import lancedb + +db = lancedb.connect("hf://datasets/lance-format/laion-1m/data") +table = db.open_table("train") + +print(f"Opened table: {table.name}") +print(f"Rows: {len(table)}") +``` + +## Inspect schema and available indexes {#inspect-schema-and-available-indexes} + +```python +print(table.schema) +``` +This prints the schema of the LAION table. Note that there's an image embedding column that's +a fixed-size list of 768-dimensional floats, and a binary column containing the raw JPEG bytes of the image. +``` +image_path: string +caption: string +NSFW: string +similarity: double +LICENSE: string +url: string +key: string +status: string +error_message: null +width: int64 +height: int64 +original_width: int64 +original_height: int64 +exif: string +md5: string +img_emb: fixed_size_list[768] + child 0, item: float +image: binary +``` + +When inspecting Lance datasets from Hugging Face, it's also a good idea to check whether the dataset author included +any pre-built indexes that you can use for search. You can check the available indexes with: + +```python +print(table.list_indices()) +``` +``` +[ + Index(IvfPq, columns=["img_emb"], name="img_emb_idx"), + Index(FTS, columns=["caption"], name="caption_idx") +] +``` + +In this case, we see that we have an IVF_PQ vector index on the `img_emb` column, and an FTS index on the `caption` +column, which means we can directly do vector search on the image embeddings and keyword search on the captions +without needing to build the indexes ourselves! + + +If you see an empty list, it may be because the dataset author did not include the index files when uploading +to Hugging Face. You can download the dataset locally, and build the indexes yourself. See the [indexing guide](/indexing/) +for instructions on building different types of indexes with LanceDB. + + +## Projection scan {#projection-scan} + +Run a simple scan by projecting relevant columns to get a feel for the dataset. For example, we +can run a search without any filters or input parameters to get a small subset of the data: + +```python +rows = ( + table.search() + .select(["caption", "url", "similarity"]) + .limit(3) + .to_list() +) + +for i, row in enumerate(rows, start=1): + print(f"{i}. {row['caption']}") + print(f" url={row['url']}") + print(f" similarity={row['similarity']}") +``` + +We get the first three rows and their metadata printed out, which look like this: +``` +1. Cordelia and Dudley on their wedding day last year + url=https://i.dailymail.co.uk/i/pix/2012/01/05/article-2082728-0EF8956600000578-53_233x315.jpg + similarity=0.2926466464996338 +2. Statistics on challenges for automation in 2021 + url=https://verloop.io/wp-content/uploads/2021/02/Challenges.jpg + similarity=0.30174341797828674 +3. Teacher Gifts / Great gifts for your child's teacher. Don't know what to get? Take a look at these gifts that the teacher in your life will love! + url=https://i.pinimg.com/custom_covers/216x146/550494823141083777_1487893945.jpg + similarity=0.3362061381340027 +``` + +## Scan and filter data {#scan-and-filter-data} + +Filtered search is a common pattern to narrow down interesting subsets of the data during early +exploration. Here's an example: + +```python +filtered = ( + table.search() + .where("height > 600") + .select(["caption", "url", "width", "height"]) + .limit(3) + .to_list() +) + +for row in filtered: + print(row["caption"], row["url"], row["width"], row["height"]) +``` + +This prints out the metadata for large images with height greater than 600 pixels: +``` +Luca Trousers, mustard stripe https://cdn.shopify.com/s/files/1/0151/5333/products/IMG_0791_1024x1024.jpg?v=1585142190 384 766 +Baby Blue Fitted Short Sleeve T Shirt 3 https://cdn-img.prettylittlething.com/a/d/d/1/add198cab3ec30a61102437275573f4963642528_cmf6022_3.jpg 384 612 +pattern cutting made easy pdf https://i.pinimg.com/736x/7c/6c/a7/7c6ca7361815a8929b3dd6ad34a03ab9.jpg 384 1045 +``` + +## Export image bytes to local files {#export-image-bytes-to-local-files} + +To work with a subset of the data locally, you can export the image bytes from the table and save them as JPEG files. +```python +from pathlib import Path + +sample = ( + table.search() + .select(["image", "caption"]) + .limit(3) + .to_list() +) + +out_dir = Path("samples") +out_dir.mkdir(exist_ok=True) + +for i, row in enumerate(sample): + out_path = out_dir / f"laion_{i}.jpg" + with open(out_path, "wb") as f: + f.write(row["image"]) + print(f"Saved {out_path} | caption={row['caption']}") +``` + +You can now preview the images you just exported on your local machine to get a better sense of the data. + +## Vector search {#vector-search} + +You can use LanceDB to run vector search directly on the data on the Hub, **without needing to download the dataset +or build your own vector index**. This makes it incredibly easy to explore the dataset and iterate on your search queries +before you decide to download a local copy for further experimentation on your end. + +```python +# Pick an arbitrary image embedding from the dataset +query_embedding = ( + table.search() + .select(["img_emb"]) + .limit(1) + .to_list()[0]["img_emb"] +) + +results = ( + table.search(query_embedding, vector_column_name="img_emb") + .select(["caption", "url", "_distance"]) + .limit(3) + .to_list() +) + +for row in results: + print(row["_distance"], row["caption"]) +``` + +| distance | caption | +| --- | --- | +| 0.17765313386917114 | Cordelia and Dudley on their wedding day last year | +| 0.17765313386917114 | Cordelia and Dudley on their wedding day last year | +| 0.17765313386917114 | Cordelia and Dudley on their wedding day last year | + + +Note that the LAION dataset is known to contain a lot of duplicate images, so you may see the same image +showing up multiple times in the search results. + + +## Full-text search {#full-text-search} + +Run an FTS search query that uses BM25 ranking on the `caption` column (on which we already have an FTS index): + +```python +fts_results = ( + table.search("dog running on beach", query_type="fts") + .select(["caption", "url", "_score"]) + .limit(3) + .to_list() +) +``` + +| caption | url | _score | +| --- | --- | --- | +| running with dog | https://www.doggytastic.com/wp… | 15.73168 | +| Dog Running in Water | https://static.wixstatic.com/m… | 14.756516 | +| Dogs on the run by heidiannemo… | http://ih2.redbubble.net/image… | 14.756516 | + +## Download the full dataset {#download-the-full-dataset} + + +You may hit Hugging Face rate limits when streaming large samples from `hf://`, despite using a Hugging Face token. +For repeated queries or queries that operate on the full dataset, it's recommended to +download the dataset locally and query from disk. + + +Here's how to download the entire dataset via the [Hugging Face CLI](https://huggingface.co/docs/huggingface_hub/en/guides/cli): + +```bash +huggingface-cli download lance-format/laion-1m --repo-type dataset --local-dir ./laion-1m +``` + +## Upload your own datasets to Hugging Face in Lance format {#upload-your-own-datasets-to-hugging-face-in-lance-format} + +This section shows how you can upload your own Lance datasets to the Hugging Face Hub to share with the community. + +First, install the [Hugging Face CLI](https://huggingface.co/docs/huggingface_hub/en/guides/cli) and export both `OPENAI_API_KEY` and `HF_TOKEN`. +Then, create a Lance dataset using LanceDB on a local machine, and then proceed to upload it to the Hub via a CLI command. + +```bash +export OPENAI_API_KEY=... +export HF_TOKEN=hf_... +hf auth login --token "$HF_TOKEN" +``` + +A typical sequence of steps is given below. + +### 1\. Upload your local directory to the Hub {#1-upload-your-local-directory-to-the-hub} + +Upload the full local directory to a specified repository on the Hugging Face Hub. The command below uploads the contents of your local LanceDB directory at `/path/to/your_local_dir` to a new repository named `your_hf_org/repo_name` under your Hugging Face account. + +```bash bash icon="code" +hf upload-large-folder /path/to/your_local_dir your_hf_org/repo_name \ + --repo-type dataset \ + --revision main +``` + + +The `upload-large-folder` command is designed for [uploading large datasets](https://huggingface.co/docs/huggingface_hub/en/guides/upload) (potentially terabytes in size) and will handle multipart uploads, retries, and resuming interrupted uploads. + + +### 2\. Inspect dataset versions {#2-inspect-dataset-versions} + +Because you can query your remote dataset directly from Hugging Face with `hf://` URIs in LanceDB, you can easily inspect the dataset versions and updates on the Hub without needing to download the data locally. This is very useful to keep track of changes to the dataset and iterate on your data collection and curation process. + +```python Python icon="python" +import lancedb + +db = lancedb.connect("hf://datasets/your_hf_org/repo_name") +table = db.open_table("table_name") + +versions = table.list_versions() +print(versions) +``` +This will print out the list of versions available for the dataset on the Hub, along with their metadata such as creation date and description. + +### 3\. Add a dataset card {#3-add-a-dataset-card} + +The Hub dataset card allows you to communicate the schema and usage of the dataset to other developers. It sits at the repo's root in a file named `README.md` on the Hub. +This project keeps the source card text in `HF_DATASET_CARD.md`, so you can publish updates +to the dataset there and upload it as `README.md` using the following command on the HF CLI: +this requires a regular `hf upload` because it is a single-file upload to a specific target path (a custom commit message can be added if you wish). + +```bash +hf upload lancedb/magical_kingdom HF_DATASET_CARD.md README.md \ + --repo-type dataset \ + --commit-message "Update dataset card" +``` + +### 4\. Update the dataset {#4-update-the-dataset} + +Over time, you may want to add new rows (append) or columns (backfill) to your dataset as your needs evolve. You can make the necessary updates to your local dataset using LanceDB, and then upload the updated version back to the Hub with the same `hf upload-large-folder` command. + +```bash bash icon="code" +hf upload-large-folder /path/to/your_local_dir your_hf_org/repo_name \ + --repo-type dataset \ + --revision main +``` +The CLI will only upload the new data that has changed since the last upload, avoiding wasted I/O while making it easy to keep your dataset up-to-date on the Hub. + +That's it! Your dataset is now updated on the Hub with the new data and schema changes, and other users can query the latest version of the dataset directly from Hugging Face with `hf://` URIs in LanceDB. + +## Explore more Lance datasets on Hugging Face {#explore-more-lance-datasets-on-hugging-face} + +The LanceDB team is actively uploading useful and interesting datasets in Lance format to the Hugging Face Hub +under the [lance-format](https://huggingface.co/lance-format) organization. We actively encourage the Hugging Face +and LanceDB communities to upload their own Lance datasets to the Hub to share with others! + +In the meantime, feel free to check out the Hugging Face Hub to discover more Lance datasets uploaded by the community. + + +Click here to explore the latest trending Lance datasets on 🤗 Hugging Face! + diff --git a/docs/web/integrations/ai/kiln.mdx b/docs/web/integrations/ai/kiln.mdx new file mode 100644 index 000000000..c93f31e17 --- /dev/null +++ b/docs/web/integrations/ai/kiln.mdx @@ -0,0 +1,48 @@ +--- +title: "Kiln AI" +sidebarTitle: "Kiln" + +--- + +[**Kiln**](https://kiln.tech) is a free tool for building production-ready AI systems, combining an intuitive desktop application and an open-source Python library. It supports RAG pipelines, evaluations, agents, MCP tool-calling, synthetic data generation, and fine-tuning. Kiln provides deep integration with LanceDB for vector search, full-text search (BM25), and hybrid search. + +## Quick Start: Build a RAG Pipeline in 5 Minutes with Kiln and LanceDB {#quick-start-build-a-rag-pipeline-in-5-minutes-with-kiln-and-lancedb} + +Watch the [quick start overview on Vimeo](https://vimeo.com/1119945690). + +Kiln's [app](https://kiln.tech/download) makes it easy to: + + - Build a RAG pipeline with a simple drag-and-drop interface + - [Compare](#find-the-best-rag-pipeline-for-your-use-case) search index options (powered by LanceDB), document extractors, embedding models, and chunking strategies + - Create end-to-end [evaluations](https://docs.kiln.tech/docs/evaluations) to determine which search configuration works best for your use case + - Load your data from Kiln into [LanceDB Enterprise](/enterprise) for production use + - Iterate with confidence by evaluating new content, prompts, models, and embeddings in minutes instead of weeks + +## Find the Best RAG Pipeline for Your Use Case {#find-the-best-rag-pipeline-for-your-use-case} + +There is no universal best RAG solution—only the best solution for your specific use case. Kiln makes it easy to compare state-of-the-art configurations and find which works best for you. + +Start with pre-configured templates for state-of-the-art RAG at various performance/quality/cost levels, or experiment with any combination of options: + +|Area|Technologies|Description| +|:----|:----|:----| +|Search Index|LanceDB|Compare LanceDB's vector search, full-text search (BM25), and hybrid search to find the best approach for your use case.| +|Content|Kiln Document Library|Collaborate on a document library with your team to find the best content for your RAG. Track every revision and tag document sets.| +|Document Extraction|Gemini, OpenAI GPT, Qwen VL, and more|Find the most accurate document extraction models for converting PDFs, images, audio, video, and other formats into textual data for RAG.| +|Embeddings|Embedding models from Gemini, OpenAI, Nomic, Qwen, and more|Find the embedding model best suited to your use case.| +|Chunking|LlamaIndex|Find the ideal chunk size and method.| + +## Get Started {#get-started} + +To get started, download the [Kiln App](https://kiln.tech/download), create a project, and navigate to "Docs & Search". + +See the [Kiln documentation for creating a RAG system](https://docs.kiln.tech/docs/documents-and-search-rag) for details on each step of the process. + +## More Information {#more-information} + + - [Kiln Homepage](https://kiln.tech) + - [Download the Kiln App](https://kiln.tech/download) + - [Kiln GitHub Repository](https://github.com/Kiln-AI/Kiln) + - [Building RAG Systems - Kiln Documentation](https://docs.kiln.tech/docs/documents-and-search-rag) + - [Python Library](https://pypi.org/project/kiln-ai/) or `pip install kiln_ai` + diff --git a/docs/web/integrations/ai/langchain.mdx b/docs/web/integrations/ai/langchain.mdx new file mode 100644 index 000000000..363331093 --- /dev/null +++ b/docs/web/integrations/ai/langchain.mdx @@ -0,0 +1,226 @@ +--- +title: "LangChain" +sidebarTitle: "LangChain" + +--- + +import { + PyFrameworksLangchainAddImages, + PyFrameworksLangchainAddTexts, + PyFrameworksLangchainCreateIndex, + PyFrameworksLangchainMaxMarginalRelevance, + PyFrameworksLangchainQuickStart, + PyFrameworksLangchainSimilaritySearch, + PyFrameworksLangchainSimilaritySearchByVector, + PyFrameworksLangchainSimilaritySearchByVectorWithScores, + PyFrameworksLangchainSimilaritySearchWithScores, + PyFrameworksLangchainVectorStoreConfig, +} from '/snippets/integrations.mdx'; + +**LangChain** is a framework designed for building applications with large language models (LLMs) by chaining together various components. It supports a range of functionalities including memory, agents, and chat models, enabling developers to create context-aware applications. + +![Illustration](https://raw.githubusercontent.com/lancedb/assets/refs/heads/main/docs/assets/integration/langchain_rag.png) + +LangChain streamlines these stages (in figure above) by providing pre-built components and tools for integration, memory management, and deployment, allowing developers to focus on application logic rather than underlying complexities. + +Integration of **Langchain** with **LanceDB** enables applications to retrieve the most relevant data by comparing query vectors against stored vectors, facilitating effective information retrieval. It results in better and context aware replies and actions by the LLMs. + +## Quick Start {#quick-start} +You can load your document data using langchain's loaders, for this example we are using `TextLoader` and `OpenAIEmbeddings` as the embedding model. + + + {PyFrameworksLangchainQuickStart} + + +## Documentation {#documentation} +In the above example `LanceDB` vector store class object is created using `from_documents()` method which is a `classmethod` and returns the initialized class object. + +You can also use `LanceDB.from_texts(texts: List[str],embedding: Embeddings)` class method. + +The exhaustive list of parameters for `LanceDB` vector store are : + +|Name|type|Purpose|default| +|:----|:----|:----|:----| +|`connection`| (Optional) `Any` |`lancedb.db.LanceDBConnection` connection object to use. If not provided, a new connection will be created.|`None`| +|`embedding`| (Optional) `Embeddings` | Langchain embedding model.|Provided by user.| +|`uri`| (Optional) `str` |It specifies the directory location of **LanceDB database** and establishes a connection that can be used to interact with the database. |`/tmp/lancedb`| +|`vector_key` |(Optional) `str`| Column name to use for vector's in the table.|`'vector'`| +|`id_key` |(Optional) `str`| Column name to use for id's in the table.|`'id'`| +|`text_key` |(Optional) `str` |Column name to use for text in the table.|`'text'`| +|`table_name` |(Optional) `str`| Name of your table in the database.|`'vectorstore'`| +|`api_key` |(Optional `str`) |API key to use for LanceDB Enterprise deployment.|`None`| +|`region` |(Optional) `str`| Region to use for LanceDB Enterprise deployment.| Only for LanceDB Enterprise : `None`.| +|`mode` |(Optional) `str` | Mode to use for adding data to the table. Valid values are "append" and "overwrite".|`'overwrite'`| +|`table`| (Optional) `Any`| You can connect to an existing table of LanceDB, created outside of langchain, and utilize it.|`None`| +|`distance`|(Optional) `str`| The choice of distance metric used to calculate the similarity between vectors.|`'l2'`| +|`reranker` |(Optional) `Any`| The reranker to use for LanceDB.|`None`| +|`relevance_score_fn` |(Optional) `Callable[[float], float]` | Langchain relevance score function to be used.|`None`| +|`limit`|`int`|Set the maximum number of results to return.| `DEFAULT_K` (it is 4)| + + + {PyFrameworksLangchainVectorStoreConfig} + + +### Methods {#methods} + +##### `add_texts()` + +This method turn texts into embedding and add it to the database. + +|Name|Purpose|defaults| +|:---|:---|:---| +|`texts`|`Iterable` of strings to add to the vectorstore.|Provided by user| +|`metadatas`|Optional `list[dict()]` of metadatas associated with the texts.|`None`| +|`ids`|Optional `list` of ids to associate with the texts.|`None`| +|`kwargs`| Other keyworded arguments provided by the user. |-| + +It returns list of ids of the added texts. + + + {PyFrameworksLangchainAddTexts} + + +------ + + +##### create_index() + +This method creates a scalar(for non-vector cols) or a vector index on a table. + +|Name|type|Purpose|defaults| +|:---|:---|:---|:---| +|`vector_col`|`Optional[str]`| Provide if you want to create index on a vector column. |`None`| +|`col_name`|`Optional[str]`| Provide if you want to create index on a non-vector column. |`None`| +|`metric`|`Optional[str]` |Provide the metric to use for vector index. choice of metrics: 'l2', 'dot', 'cosine'. |`l2`| +|`num_partitions`|`Optional[int]`|Number of partitions to use for the index.|`256`| +|`num_sub_vectors`|`Optional[int]` |Number of sub-vectors to use for the index.|`96`| +|`index_cache_size`|`Optional[int]` |Size of the index cache.|`None`| +|`name`|`Optional[str]` |Name of the table to create index on.|`None`| + +For index creation make sure your table has enough data in it. An ANN index is usually not needed for datasets ~100K vectors. For large-scale (>1M) or higher dimension vectors, it is beneficial to create an ANN index. + + + {PyFrameworksLangchainCreateIndex} + + +------ + +##### similarity_search() + +This method performs similarity search based on **text query**. + +| Name | Type | Purpose | Default | +|---------|----------------------|---------|---------| +| `query` | `str` | A `str` representing the text query that you want to search for in the vector store. | N/A | +| `k` | `Optional[int]` | It specifies the number of documents to return. | `None` | +| `filter` | `Optional[Dict[str, str]]`| It is used to filter the search results by specific metadata criteria. | `None` | +| `fts` | `Optional[bool]` | It indicates whether to perform a full-text search (FTS). | `False` | +| `name` | `Optional[str]` | It is used for specifying the name of the table to query. If not provided, it uses the default table set during the initialization of the LanceDB instance. | `None` | +| `kwargs` | `Any` | Other keyworded arguments provided by the user. | N/A | + +Return documents most similar to the query **without relevance scores**. + + + {PyFrameworksLangchainSimilaritySearch} + + +------ + +##### similarity_search_by_vector() + +The method returns documents that are most similar to the specified **embedding (query) vector**. + +| Name | Type | Purpose | Default | +|-------------|---------------------------|---------|---------| +| `embedding` | `List[float]` | The embedding vector you want to use to search for similar documents in the vector store. | N/A | +| `k` | `Optional[int]` | It specifies the number of documents to return. | `None` | +| `filter` | `Optional[Dict[str, str]]`| It is used to filter the search results by specific metadata criteria. | `None` | +| `name` | `Optional[str]` | It is used for specifying the name of the table to query. If not provided, it uses the default table set during the initialization of the LanceDB instance. | `None` | +| `kwargs` | `Any` | Other keyworded arguments provided by the user. | N/A | + +**It does not provide relevance scores.** + + + {PyFrameworksLangchainSimilaritySearchByVector} + + +------ + +##### similarity_search_with_score() + +Returns documents most similar to the **query string** along with their relevance scores. + +| Name | Type | Purpose | Default | +|----------|---------------------------|---------|---------| +| `query` | `str` |A `str` representing the text query you want to search for in the vector store. This query will be converted into an embedding using the specified embedding function. | N/A | +| `k` | `Optional[int]` | It specifies the number of documents to return. | `None` | +| `filter` | `Optional[Dict[str, str]]`| It is used to filter the search results by specific metadata criteria. This allows you to narrow down the search results based on certain metadata attributes associated with the documents. | `None` | +| `kwargs` | `Any` | Other keyworded arguments provided by the user. | N/A | + +It gets called by base class's `similarity_search_with_relevance_scores` which selects relevance score based on our `_select_relevance_score_fn`. + + + {PyFrameworksLangchainSimilaritySearchWithScores} + + +------ + +##### similarity_search_by_vector_with_relevance_scores() + +Similarity search using **query vector**. + +| Name | Type | Purpose | Default | +|-------------|---------------------------|---------|---------| +| `embedding` | `List[float]` | The embedding vector you want to use to search for similar documents in the vector store. | N/A | +| `k` | `Optional[int]` | It specifies the number of documents to return. | `None` | +| `filter` | `Optional[Dict[str, str]]`| It is used to filter the search results by specific metadata criteria. | `None` | +| `name` | `Optional[str]` | It is used for specifying the name of the table to query. | `None` | +| `kwargs` | `Any` | Other keyworded arguments provided by the user. | N/A | + +The method returns documents most similar to the specified embedding (query) vector, along with their relevance scores. + + + {PyFrameworksLangchainSimilaritySearchByVectorWithScores} + + +------ + +##### max_marginal_relevance_search() + +This method returns docs selected using the maximal marginal relevance(MMR). +Maximal marginal relevance optimizes for similarity to query AND diversity among selected documents. + +| Name | Type | Purpose | Default | +|---------------|-----------------|-----------|---------| +| `query` | `str` | Text to look up documents similar to. | N/A | +| `k` | `Optional[int]` | Number of Documents to return.| `4` | +| `fetch_k`| `Optional[int]`| Number of Documents to fetch to pass to MMR algorithm.| `None` | +| `lambda_mult` | `float` | Number between 0 and 1 that determines the degree of diversity among the results with 0 corresponding to maximum diversity and 1 to minimum diversity. | `0.5` | +| `filter`| `Optional[Dict[str, str]]`| Filter by metadata. | `None` | +|`kwargs`| Other keyworded arguments provided by the user. |-| + +Similarly, `max_marginal_relevance_search_by_vector()` function returns docs most similar to the embedding passed to the function using MMR. instead of a string query you need to pass the embedding to be searched for. + + + {PyFrameworksLangchainMaxMarginalRelevance} + + +------ + +##### add_images() + +This method adds images by automatically creating their embeddings and adds them to the vectorstore. + +| Name | Type | Purpose | Default | +|------------|-------------------------------|--------------------------------|---------| +| `uris` | `List[str]` | File path to the image | N/A | +| `metadatas`| `Optional[List[dict]]` | Optional list of metadatas | `None` | +| `ids` | `Optional[List[str]]` | Optional list of IDs | `None` | + +It returns list of IDs of the added images. + + + {PyFrameworksLangchainAddImages} + + + diff --git a/docs/web/integrations/ai/llamaIndex.mdx b/docs/web/integrations/ai/llamaIndex.mdx new file mode 100644 index 000000000..a38e2c61e --- /dev/null +++ b/docs/web/integrations/ai/llamaIndex.mdx @@ -0,0 +1,55 @@ +--- +title: "LlamaIndex" +sidebarTitle: "LlamaIndex" + +--- + +import { + PyFrameworksLlamaindexAddReranker, + PyFrameworksLlamaindexFiltering, + PyFrameworksLlamaindexHybridSearch, + PyFrameworksLlamaindexQuickStart, +} from '/snippets/integrations.mdx'; + +## Quickstart {#quickstart} + +LlamaIndex is a well-known framework for building LLM-powered agents over your data with LLMs and workflows. +You can build your LlamaIndex pipeline and persist your metadata and embeddings in LanceDB via the `LanceDBVectorStore` class. + +First, install the LlamaIndex-LanceDB integration. + + +pip install llama-index-vector-stores-LanceDB + + +Run the below script as an example. + + + {PyFrameworksLlamaindexQuickStart} + + +The vector store connector will open an existing LanceDB directory or create the directory if it does not exist. + +### Filtering {#filtering} +For metadata filtering, you can use a Lance SQL-like string filter as demonstrated in the example above. Additionally, you can also filter using the `MetadataFilters` class from LlamaIndex: + + {PyFrameworksLlamaindexFiltering} + + +### Hybrid Search {#hybrid-search} +For complete documentation, refer [here](https://docs.lancedb.com/search/hybrid-search). This example uses the `colbert` reranker. Make sure to install necessary dependencies for the reranker you choose. + + {PyFrameworksLlamaindexHybridSearch} + + +In the snippet above, you can change/specify `query_type` when creating the engine/retriever +to use different search strategies, such as vector search or FTS. + +## API reference {#api-reference} + + +See the official LlamaIndex Vector Stores API reference for more details. + \ No newline at end of file diff --git a/docs/web/integrations/ai/prompttools.mdx b/docs/web/integrations/ai/prompttools.mdx new file mode 100644 index 000000000..b0edd575a --- /dev/null +++ b/docs/web/integrations/ai/prompttools.mdx @@ -0,0 +1,15 @@ +--- +title: "PromptTools" +sidebarTitle: "PromptTools" + +--- + +[PromptTools](https://github.com/hegelai/prompttools) offers a set of free, open-source tools for testing and experimenting with models, prompts, and configurations. The core idea is to enable developers to evaluate prompts using familiar interfaces like code and notebooks. You can use it to experiment with different configurations of LanceDB, and test how LanceDB integrates with the LLM of your choice. +{/* +[Evaluating Prompts with PromptTools](./examples/prompttools-eval-prompts/) | Open In Colab +*/} + +Open In Colab + +![Alt text](https://prompttools.readthedocs.io/en/latest/_images/demo.gif "a title") + diff --git a/docs/web/integrations/ai/synthetic-data-kit.mdx b/docs/web/integrations/ai/synthetic-data-kit.mdx new file mode 100644 index 000000000..18b90674a --- /dev/null +++ b/docs/web/integrations/ai/synthetic-data-kit.mdx @@ -0,0 +1,81 @@ +--- +title: "Meta Llama Synthetic Data Kit" +sidebarTitle: "Synthetic Data Kit" +description: "Use Meta Llama's Synthetic Data Kit with LanceDB to generate high-quality synthetic datasets for LLM fine-tuning and training." + +--- + + + +[Synthetic Data Kit](https://github.com/meta-llama/synthetic-data-kit) is a tool from Meta LLAMA that helps you generate high-quality synthetic datasets for fine-tuning large language models (LLMs). It simplifies the process of preparing data for fine-tuning by providing a command-line interface (CLI) with a modular four-command flow. + +One of the key features of the `synthetic-data-kit` is its use of the Lance format for storing and ingesting datasets. This allows for efficient storage and retrieval of data, which is crucial when working with large datasets. + +### Key Features: {#key-features} + +* **Data Ingestion:** The toolkit can ingest various file formats, including PDF, HTML, YouTube transcripts, DOCX, PPT, and TXT. +* **Fine-tuning Format Creation:** It can create different fine-tuning formats, such as question-answer (QA) pairs, QA pairs with Chain-of-Thought (CoT), and summarization formats. +* **Data Curation:** The tool uses Llama as a judge to curate high-quality examples, ensuring the quality of the generated dataset. +* **Flexible Saving Options:** You can save the generated datasets in various formats compatible with your fine-tuning workflow, including Hugging Face, JSONL, and JSON. + +### How it Works: {#how-it-works} + +The synthetic-data-kit follows a simple four-step process: + +1. **Ingest:** Import your input files into the toolkit. The data is stored in the Lance format for efficient processing. +2. **Create:** Generate diverse fine-tuning datasets, such as reasoning, summarization, and QA pairs, from the ingested documents. +3. **Curate:** Use Llama to filter and select high-quality examples from the generated dataset. +4. **Save-as:** Export the curated dataset in your preferred format. + +### Usage {#usage} + +The `synthetic-data-kit` uses Lance format to store and manage the data that you ingest. The workflow is a series of commands that build on each other, starting with the `ingest` command. + +Here is an example of the end-to-end workflow: + +1. **Ingest Data into a LanceDB dataset** + + This command takes a directory of source files and creates a LanceDB dataset from them. + + ```bash + synthetic-data-kit ingest docs/report.pdf --multimodal + # This will create a Lance dataset at data/parsed/report.lance + # with 'text' and 'image' columns. + + #Generate multimodal-qa pairs from the ingested data + synthetic-data-kit create data/parsed/report.lance --type multimodal-qa + ``` + +2. **Create fine-tuning data** + + This command uses the LanceDB dataset created in the previous step to generate synthetic data in the desired format. + + ```bash + synthetic_data create data/parsed/report.lance + ``` + +3. **Curate the data** + + This step uses a language model to curate the generated data and ensure its quality. + + ```bash + synthetic_data curate report.json + ``` + +4. **Save the final dataset** + + Finally, save the curated data to a file in the desired format. + + ```bash + synthetic_data save-as report.json --save_path ./my_finetuning_data.jsonl + ``` + +This workflow allows you to go from a collection of documents to a high-quality, fine-tuning dataset with just a few commands. The use of LanceDB in the background makes the process efficient and scalable. + +### Getting Started: {#getting-started} + +To get started with the synthetic-data-kit, you can clone the [GitHub Repository](https://github.com/meta-llama/synthetic-data-kit) and install the necessary dependencies. + +> **Note:** You will also need access to a Llama model, either running locally or via a hosted API. + + diff --git a/docs/web/integrations/data/dlt.mdx b/docs/web/integrations/data/dlt.mdx new file mode 100644 index 000000000..f15a4b776 --- /dev/null +++ b/docs/web/integrations/data/dlt.mdx @@ -0,0 +1,97 @@ +--- +title: "dlt" +sidebarTitle: "DLT" + +--- + +import { + PyPlatformsDltAdapterImport, + PyPlatformsDltAdapterUsage, + PyPlatformsDltPipeline, +} from '/snippets/integrations.mdx'; + +[dlt](https://dlthub.com/docs/intro) is an open-source library that you can add to your Python scripts to load data from various and often messy data sources into well-structured, live datasets. dlt's [integration with LanceDB](https://dlthub.com/docs/dlt-ecosystem/destinations/lancedb) lets you ingest data from any source (databases, APIs, CSVs, dataframes, JSONs, and more) into LanceDB with a few lines of simple python code. The integration enables automatic normalization of nested data, schema inference, incremental loading and embedding the data. dlt also has integrations with several other tools like dbt, airflow, dagster etc. that can be inserted into your LanceDB workflow. + +## How to ingest data into LanceDB {#how-to-ingest-data-into-lancedb} + +In this example, we will be fetching movie information from the [Open Movie Database (OMDb) API](https://www.omdbapi.com/) and loading it into a local LanceDB instance. To implement it, you will need an API key for the OMDb API (which can be created freely [here](https://www.omdbapi.com/apikey.aspx)). + +1. **Install `dlt` with LanceDB extras:** + ```sh + pip install dlt[lancedb] + ``` + +2. **Inside an empty directory, initialize a `dlt` project with:** + ```sh + dlt init rest_api lancedb + ``` + This will add all the files necessary to create a `dlt` pipeline that can ingest data from any REST API (ex: OMDb API) and load into LanceDB. + ```text + ├── .dlt + │ ├── config.toml + │ └── secrets.toml + ├── rest_api + ├── rest_api_pipeline.py + └── requirements.txt + ``` + + dlt has a list of pre-built [sources](https://dlthub.com/docs/dlt-ecosystem/verified-sources/) like [SQL databases](https://dlthub.com/docs/dlt-ecosystem/verified-sources/sql_database), [REST APIs](https://dlthub.com/docs/dlt-ecosystem/verified-sources/rest_api), [Google Sheets](https://dlthub.com/docs/dlt-ecosystem/verified-sources/google_sheets), [Notion](https://dlthub.com/docs/dlt-ecosystem/verified-sources/notion) etc., that can be used out-of-the-box by running `dlt init lancedb`. Since dlt is a python library, it is also very easy to modify these pre-built sources or to write your own custom source from scratch. + + +3. **Specify necessary credentials and/or embedding model details:** + + In order to fetch data from the OMDb API, you will need to pass a valid API key into your pipeline. Depending on whether you're using LanceDB OSS or LanceDB Enterprise, you also may need to provide the necessary credentials to connect to the LanceDB instance. These can be pasted inside `.dlt/secrets.toml`. + + dlt's LanceDB integration also allows you to automatically embed the data during ingestion. Depending on the embedding model chosen, you may need to paste the necessary credentials inside `.dlt/secrets.toml`: + ```toml + [sources.rest_api] + api_key = "api_key" # Enter the API key for the OMDb API + + [destination.lancedb] + embedding_model_provider = "sentence-transformers" + embedding_model = "all-MiniLM-L6-v2" + [destination.lancedb.credentials] + uri = ".lancedb" + api_key = "api_key" # API key to connect to LanceDB Enterprise. Leave out if you are using LanceDB OSS. + embedding_model_provider_api_key = "embedding_model_provider_api_key" # Not needed for providers that don't need authentication (ollama, sentence-transformers). + ``` + See [here](https://dlthub.com/docs/dlt-ecosystem/destinations/lancedb#configure-the-destination) for more information and for a list of available models and model providers. + + +4. **Write the pipeline code inside `rest_api_pipeline.py`:** + + The following code shows how you can configure dlt's REST API source to connect to the [OMDb API](https://www.omdbapi.com/), fetch all movies with the word "godzilla" in the title, and load it into a LanceDB table. The REST API source allows you to pull data from any API with minimal code, to learn more read the [dlt docs](https://dlthub.com/docs/dlt-ecosystem/verified-sources/rest_api). + + + {PyPlatformsDltPipeline} + + + The script above will ingest the data into LanceDB as it is, i.e. without creating any embeddings. If we want to embed one of the fields (for example, `"Title"` that contains the movie titles), then we will use dlt's `lancedb_adapter` and modify the script as follows: + + - Add the following import statement: + + {PyPlatformsDltAdapterImport} + + - Modify the pipeline run like this: + + {PyPlatformsDltAdapterUsage} + + This will use the embedding model specified inside `.dlt/secrets.toml` to embed the field `"Title"`. + +5. **Install necessary dependencies:** + ```sh + pip install -r requirements.txt + ``` + + Note: You may need to install the dependencies for your embedding models separately. + ```sh + pip install sentence-transformers + ``` + +6. **Run the pipeline:** + Finally, running the following command will ingest the data into your LanceDB instance. + ```sh + python custom_source.py + ``` + +For more information and advanced usage of dlt's LanceDB integration, read [the dlt documentation](https://dlthub.com/docs/dlt-ecosystem/destinations/lancedb). diff --git a/docs/web/integrations/data/duckdb.mdx b/docs/web/integrations/data/duckdb.mdx new file mode 100644 index 000000000..704a47c60 --- /dev/null +++ b/docs/web/integrations/data/duckdb.mdx @@ -0,0 +1,119 @@ +--- +title: "DuckDB" +sidebarTitle: "DuckDB" +description: "Learn how to use the DuckDB-Lance extension to query Lance tables with SQL." +--- + +LanceDB integrates with [DuckDB](https://duckdb.org/) through the [Lance extension](https://github.com/lance-format/lance-duckdb) for DuckDB. In this page, we'll show how LanceDB manages table lifecycle, and DuckDB provides SQL analytics (including joins) and search over those tables. + +Note that earlier versions of LanceDB used to recommend converting Lance tables to Arrow tables via `table.to_arrow()`. Although this method is still available (because DuckDB [natively scans Arrow tables](https://duckdb.org/2021/12/03/duck-arrow)), it is no longer the recommended workflow for working with Lance tables in DuckDB. This page shows how to use the Lance extension with namespace-attached LanceDB tables, allowing you to pushdown SQL queries directly to the Lance layer. + + +## Install {#install} + +Install the DuckDB CLI as per [their docs](https://duckdb.org/install) and alternatively, their Python package with `pip install duckdb`. + +Then, open the DuckDB CLI and install and load the Lance extension as follows: + +```sql SQL icon="database" +INSTALL lance; +LOAD lance; +``` + +## Attach the directory namespace in DuckDB {#attach-the-directory-namespace-in-duckdb} + +Attach the LanceDB root directory as a Lance namespace: + +```sql SQL icon="database" +ATTACH './local_lancedb' AS lance_ns (TYPE LANCE); +``` + +In this page, tables are referenced using `lance_ns.main.`, so the table path is `lance_ns.main.lance_duck`. + +## Write Lance table {#write-lance-table} + +Create the `lance_duck` table using SQL and populate it with sample data: + +```sql SQL icon="database" +CREATE OR REPLACE TABLE lance_ns.main.lance_duck AS +SELECT * +FROM ( + VALUES + ('duck', 'quack', [0.9, 0.7, 0.1]::FLOAT[]), + ('horse', 'neigh', [0.3, 0.1, 0.5]::FLOAT[]), + ('dragon', 'roar', [0.5, 0.2, 0.7]::FLOAT[]) +) AS t(animal, noise, vector); +``` + +This table is the source of truth for all DuckDB queries below. + + +The examples below show SQL entered in the DuckDB CLI. You can run the same SQL from +Python as well, using LanceDB and DuckDB's Python clients in your application code. + + +## Query the table with SQL {#query-the-table-with-sql} + +```sql SQL icon="database" +SELECT * + FROM lance_ns.main.lance_duck + LIMIT 5; +``` + +## Vector search {#vector-search} + +```sql SQL icon="database" +SELECT animal, noise, vector, _distance + FROM lance_vector_search( + 'lance_ns.main.lance_duck', + 'vector', + [0.8, 0.7, 0.2]::FLOAT[], + k = 1, + prefilter = true + ) + ORDER BY _distance ASC; +``` + +## Full-text search {#full-text-search} + +```sql SQL icon="database" +SELECT animal, noise, vector, _score + FROM lance_fts( + 'lance_ns.main.lance_duck', + 'animal', + 'the brave knight faced the dragon', + k = 1, + prefilter = true + ) + ORDER BY _score DESC; +``` + +## Hybrid search {#hybrid-search} + +```sql SQL icon="database" +SELECT animal, noise, vector, _hybrid_score, _distance, _score + FROM lance_hybrid_search( + 'lance_ns.main.lance_duck', + 'vector', + [0.8, 0.7, 0.2]::FLOAT[], + 'animal', + 'the duck surprised the dragon', + k = 2, + prefilter = false, + alpha = 0.5, + oversample_factor = 4 + ) + ORDER BY _hybrid_score DESC; +``` + +## Directory namespace model {#directory-namespace-model} + +A directory namespace maps a LanceDB catalog root to namespace-qualified table identifiers in DuckDB. This keeps table discovery and table naming stable as your project grows. + +To learn more about the catalog and namespace model, see [Namespaces and the Catalog Model](/management/namespace). + + +## Advanced usage {#advanced-usage} + +See the [docs](https://github.com/lance-format/lance-duckdb) directory in the Lance-DuckDB extension repo +for more advanced usage on SQL and REST API clients. diff --git a/docs/web/integrations/data/pandas_and_pyarrow.mdx b/docs/web/integrations/data/pandas_and_pyarrow.mdx new file mode 100644 index 000000000..170ccf772 --- /dev/null +++ b/docs/web/integrations/data/pandas_and_pyarrow.mdx @@ -0,0 +1,46 @@ +--- +title: "Pandas and PyArrow" +sidebarTitle: "Pandas & PyArrow" + +--- + +import { + PyPlatformsPandasAsyncExample, + PyPlatformsPandasCreateTable, + PyPlatformsPandasImports, + PyPlatformsPandasVectorSearch, +} from '/snippets/integrations.mdx'; + +Because Lance is built on top of [Apache Arrow](https://arrow.apache.org/), +LanceDB fits naturally into Pandas-first workflows. You can ingest a `DataFrame`, +query it with LanceDB's vector operators, and keep working in Pandas without any glue code. + +## Create a dataset {#create-a-dataset} + +Start by importing LanceDB alongside your usual Pandas utilities and connect to a temporary database. + + + {PyPlatformsPandasImports} + + +Use the familiar `pd.DataFrame` API to prepare your rows, then pass the entire frame to `db.create_table`. + + + {PyPlatformsPandasCreateTable} + + +## Vector search {#vector-search} + +Queries can return Pandas frames as well, so you can immediately inspect the results or pipe them into downstream analytics. + + + {PyPlatformsPandasVectorSearch} + + +## Async API {#async-api} + +For web services or background jobs that already rely on `asyncio`, use the asynchronous helpers to keep everything non-blocking. + + + {PyPlatformsPandasAsyncExample} + diff --git a/docs/web/integrations/data/polars_arrow.mdx b/docs/web/integrations/data/polars_arrow.mdx new file mode 100644 index 000000000..4cb85737e --- /dev/null +++ b/docs/web/integrations/data/polars_arrow.mdx @@ -0,0 +1,51 @@ +--- +title: "Polars" +sidebarTitle: "Polars & Arrow" + +--- + +import { + PyPlatformsPolarsCreateTable, + PyPlatformsPolarsImports, + PyPlatformsPolarsLazyframe, + PyPlatformsPolarsPydantic, + PyPlatformsPolarsVectorSearch, +} from '/snippets/integrations.mdx'; + +LanceDB supports [Polars](https://github.com/pola-rs/polars), a blazingly fast DataFrame library for Python written in Rust. Under the hood, both Lance and Polars speak Arrow, so passing data back and forth stays zero-copy and ergonomic. + +## Create and Query a Table {#create-and-query-a-table} + +Import the required libraries, including the optional Pydantic helpers if you plan to define schemas. + + + {PyPlatformsPolarsImports} + + +Build a Polars `DataFrame`, convert it to Arrow, and use it directly when creating a LanceDB table. + + + {PyPlatformsPolarsCreateTable} + + +Run vector search and keep the results as a Polars `DataFrame` for further processing or visualization. + + + {PyPlatformsPolarsVectorSearch} + + +## Work with LazyFrames {#work-with-lazyframes} + +When you want to operate on the entire table (potentially larger than RAM), convert to a Polars `LazyFrame` so you can chain transformations without loading everything at once. + + + {PyPlatformsPolarsLazyframe} + + +## Define Schemas with Pydantic {#define-schemas-with-pydantic} + +You can also describe your table via `LanceModel` and continue ingesting data from Polars. This is useful when multiple teams share a schema or when you want validation. + + + {PyPlatformsPolarsPydantic} + diff --git a/docs/web/integrations/data/pydantic.mdx b/docs/web/integrations/data/pydantic.mdx new file mode 100644 index 000000000..b6f80aca3 --- /dev/null +++ b/docs/web/integrations/data/pydantic.mdx @@ -0,0 +1,90 @@ +--- +title: "Pydantic" +sidebarTitle: "Pydantic" + +--- + +import { + PyFrameworksPydanticBaseExample, + PyFrameworksPydanticBaseModel, + PyFrameworksPydanticImports, + PyFrameworksPydanticSetUrl, + PyFrameworksPydanticTypeConversion, + PyFrameworksPydanticVectorField, +} from '/snippets/integrations.mdx'; + +[Pydantic](https://docs.pydantic.dev/latest/) is a data validation library in Python. +LanceDB integrates with Pydantic for schema inference, data ingestion, and query result casting. +Using `lancedb.pydantic.LanceModel`, users can seamlessly +integrate Pydantic with the rest of the LanceDB APIs. + +First, import the necessary LanceDB and Pydantic modules: + + + {PyFrameworksPydanticImports} + + +Next, define your Pydantic model by inheriting from `LanceModel` and specifying your fields including a vector field: + + + {PyFrameworksPydanticBaseModel} + + +Set the database connection URL: + + + {PyFrameworksPydanticSetUrl} + + +Now you can create a table, add data, and perform vector search operations: + + + {PyFrameworksPydanticBaseExample} + + + +## Vector Field {#vector-field} + +LanceDB provides a `lancedb.pydantic.Vector` method to define a +vector Field in a Pydantic Model. + + + {PyFrameworksPydanticVectorField} + + +This example demonstrates how LanceDB automatically converts Pydantic field types to their corresponding Apache Arrow data types. The `pydantic_to_schema()` function takes a Pydantic model and generates an Arrow schema where: +- `int` fields become `pa.int64()` (64-bit integers) +- `str` fields become `pa.utf8()` (UTF-8 encoded strings) +- `Vector(768)` becomes `pa.list_(pa.float32(), 768)` (fixed-size list of 768 float32 values) +- The `False` parameter indicates that the fields are not nullable + +## Type Conversion {#type-conversion} + +LanceDB automatically convert Pydantic fields to +[Apache Arrow DataType](https://arrow.apache.org/docs/python/generated/pyarrow.DataType.html#pyarrow.DataType). + +Current supported type conversions: + +| Pydantic Field Type | PyArrow Data Type | +| ------------------- | ----------------- | +| `int` | `pyarrow.int64` | +| `float` | `pyarrow.float64` | +| `bool` | `pyarrow.bool` | +| `str` | `pyarrow.utf8()` | +| `list` | `pyarrow.List` | +| `BaseModel` | `pyarrow.Struct` | +| `Vector(n)` | `pyarrow.FixedSizeList(float32, n)` | + +LanceDB supports to create Apache Arrow Schema from a +`pydantic.BaseModel` +via `lancedb.pydantic.pydantic_to_schema` method. + + + {PyFrameworksPydanticTypeConversion} + + +This example shows a more complex Pydantic model with various field types and demonstrates how LanceDB handles: +- Basic types: `int` and `str` fields +- Vector fields: `Vector(1536)` creates a fixed-size list of 1536 float32 values +- List fields: `List[int]` becomes a variable-length list of int64 values +- Schema generation: The `pydantic_to_schema()` function automatically converts all these types to their Arrow equivalents \ No newline at end of file diff --git a/docs/web/integrations/data/voxel51.mdx b/docs/web/integrations/data/voxel51.mdx new file mode 100644 index 000000000..1ee782353 --- /dev/null +++ b/docs/web/integrations/data/voxel51.mdx @@ -0,0 +1,212 @@ +--- +title: "Voxel51" +sidebarTitle: "Voxel51" +--- + +import { + PyPlatformsVoxel51BackendFlag, + PyPlatformsVoxel51BackendParams, + PyPlatformsVoxel51BrainConfig, + PyPlatformsVoxel51Cleanup, + PyPlatformsVoxel51ComputeSimilarity, + PyPlatformsVoxel51LoadDataset, + PyPlatformsVoxel51SortBySimilarity, +} from '/snippets/integrations.mdx'; + +# FiftyOne + +[FiftyOne](https://docs.voxel51.com/) is an open source toolkit that enables users to curate better data and build better models. It includes tools for data exploration, visualization, and management, as well as features for collaboration and sharing. + +Any developers, data scientists, and researchers who work with computer vision and machine learning can use FiftyOne to improve the quality of their datasets and deliver insights about their models. + +![example](/static/assets/images/integrations/voxel.gif) + +**FiftyOne** provides an API to create LanceDB tables and run similarity queries, both **programmatically in Python** and via **point-and-click in the App**. + +Let's get started and see how to use **LanceDB** to create a **similarity index** on your FiftyOne datasets. + +## Overview {#overview} + +[Embeddings](/embedding/) are foundational to all of the **vector search** features. In FiftyOne, embeddings are managed by the [**FiftyOne Brain**](https://docs.voxel51.com/user_guide/brain.html) that provides powerful machine learning techniques designed to transform how you curate your data from an art into a measurable science. + +> _Have you ever wanted to find the images most similar to an image in your dataset?_ + +The **FiftyOne Brain** makes computing **visual similarity** really easy. You can compute the similarity of samples in your dataset using an embedding model and store the results in the **brain key**. +You can then sort your samples by similarity or use this information to find potential duplicate images. + + +We'll be doing the following : + +1. **Create Index** - In order to run similarity queries against our media, we need to **index** the data. We can do this via the `compute_similarity()` function. + - In the function, specify the **model** you want to use to generate the embedding vectors, and what **vector search engine** you want to use on the **backend** (here LanceDB). + + You can also give the similarity index a name(`brain_key`), which is useful if you want to run vector searches against multiple indexes. + + +2. **Query** - Once you have generated your similarity index, you can query your dataset with `sort_by_similarity()`. The query can be any of the following: + + - An ID (sample or patch) + - A query vector of same dimension as the index + - A list of IDs (samples or patches) + - A text prompt (search semantically) + +## Prerequisites: install necessary dependencies {#prerequisites-install-necessary-dependencies} + +1. **Create and activate a virtual environment** + +Install virtualenv package and run the following command in your project directory. + +python -m venv fiftyone_ + +From inside the project directory run the following to activate the virtual environment. + + + + source fiftyone_/Scripts/activate + + + fiftyone_/Scripts/activate + + + +2. **Install the following packages in the virtual environment** + + To install FiftyOne, ensure you have activated any virtual environment that you are using, then run + +pip install fiftyone + + + +## Understand basic workflow {#understand-basic-workflow} + +The basic workflow shown below uses LanceDB to create a similarity index on your FiftyOne datasets: + +1. Load a dataset into FiftyOne. + +2. Compute embedding vectors for samples or patches in your dataset, or select a model to use to generate embeddings. + +3. Use the `compute_similarity()` method to generate a LanceDB table for the samples or object patches embeddings in a dataset by setting the parameter `backend="lancedb"` and specifying a `brain_key` of your choice. + +4. Use this LanceDB table to query your data with `sort_by_similarity()`. + +5. If desired, delete the table. + +## Quick Example {#quick-example} + +Let's jump on a quick example that demonstrates this workflow. + + + + {PyPlatformsVoxel51LoadDataset} + +Make sure you install torch ([guide here](https://pytorch.org/get-started/locally/)) before proceeding. + + + {PyPlatformsVoxel51ComputeSimilarity} + + +!!! note + Running the code above will download the clip model (2.6Gb) + +Once the similarity index has been generated, we can query our data in FiftyOne by specifying the `brain_key`: + + + {PyPlatformsVoxel51SortBySimilarity} + + +The returned result are of type - `DatasetView`. + + +`DatasetView` does not hold its contents in-memory. Views simply store the rule(s) that are applied to extract the content of interest from the underlying Dataset when the view is iterated/aggregated on. + +This means, for example, that the contents of a `DatasetView` may change as the underlying Dataset is modified. + + +> _Can you query a view instead of dataset?_ + +Yes, you can also query a view. + +Performing a similarity search on a `DatasetView` will only return results from the view; if the view contains samples that were not included in the index, they will never be included in the result. + +This means that you can index an entire Dataset once and then perform searches on subsets of the dataset by constructing views that contain the images of interest. + + + {PyPlatformsVoxel51Cleanup} + + + +## Using LanceDB backend {#using-lancedb-backend} +By default, calling `compute_similarity()` or `sort_by_similarity()` will use an sklearn backend. + +To use the LanceDB backend, simply set the optional `backend` parameter of `compute_similarity()` to `"lancedb"`: + + + {PyPlatformsVoxel51BackendFlag} + + +Alternatively, you can configure FiftyOne to use the LanceDB backend by setting the following environment variable. + +In your terminal, set the environment variable using: + + + + export FIFTYONE_BRAIN_DEFAULT_SIMILARITY_BACKEND=lancedb + + + + $Env:FIFTYONE_BRAIN_DEFAULT_SIMILARITY_BACKEND="lancedb" //powershell + + set FIFTYONE_BRAIN_DEFAULT_SIMILARITY_BACKEND=lancedb //cmd + + + + +This will only run during the terminal session. Once terminal is closed, environment variable is deleted. + + +Alternatively, you can **permanently** configure FiftyOne to use the LanceDB backend creating a `brain_config.json` at `~/.fiftyone/brain_config.json`. The JSON file may contain any desired subset of config fields that you wish to customize. + +```json +{ + "default_similarity_backend": "lancedb" +} +``` +This will override the default `brain_config` and will set it according to your customization. You can check the configuration by running the following code : + + + {PyPlatformsVoxel51BrainConfig} + + +## LanceDB config parameters {#lancedb-config-parameters} + +The LanceDB backend supports query parameters that can be used to customize your similarity queries. These parameters include: + +| Name | Purpose | Default | +| :------------- | :--------------------------------------------------------------------------------------------------------------- | :--------------- | +| **table_name** | The name of the LanceDB table to use. If none is provided, a new table will be created | `None` | +| **metric** | The embedding distance metric to use when creating a new table. The supported values are ("cosine", "euclidean") | `"cosine"` | +| **uri** | The database URI to use. In this Database URI, tables will be created. | `"/tmp/lancedb"` | + +There are two ways to specify/customize the parameters: + +1. **Using `brain_config.json` file** + +```json +{ + "similarity_backends": { + "lancedb": { + "table_name": "your-table", + "metric": "euclidean", + "uri": "/tmp/lancedb" + } + } +} +``` + +2. **Directly passing to `compute_similarity()` to configure a specific new index** : + + + {PyPlatformsVoxel51BackendParams} + + +For a much more in depth walkthrough of the integration, visit the LanceDB x Voxel51 [docs page](https://docs.voxel51.com/integrations/lancedb.html). diff --git a/docs/web/integrations/embedding/aws.mdx b/docs/web/integrations/embedding/aws.mdx new file mode 100644 index 000000000..4e1c4fb90 --- /dev/null +++ b/docs/web/integrations/embedding/aws.mdx @@ -0,0 +1,42 @@ +--- +title: AWS Bedrock +sidebarTitle: AWS +--- + +import { PyEmbeddingAwsUsage } from '/snippets/integrations.mdx'; + +AWS Bedrock supports multiple base models for generating text embeddings. You need to setup the AWS credentials to use this embedding function. +You can do so by using `awscli` and also add your session_token: +```shell +aws configure +aws configure set aws_session_token "" +``` +to ensure that the credentials are set up correctly, you can run the following command: +```shell +aws sts get-caller-identity +``` + +Supported Embedding modelIDs are: +* `amazon.titan-embed-text-v1` +* `cohere.embed-english-v3` +* `cohere.embed-multilingual-v3` + +Supported parameters (to be passed in `create` method) are: + +| Parameter | Type | Default Value | Description | +|---|---|---|---| +| **name** | str | "amazon.titan-embed-text-v1" | The model ID of the bedrock model to use. Supported base models for Text Embeddings: amazon.titan-embed-text-v1, cohere.embed-english-v3, cohere.embed-multilingual-v3 | +| **region** | str | "us-east-1" | Optional name of the AWS Region in which the service should be called (e.g., "us-east-1"). | +| **profile_name** | str | None | Optional name of the AWS profile to use for calling the Bedrock service. If not specified, the default profile will be used. | +| **assumed_role** | str | None | Optional ARN of an AWS IAM role to assume for calling the Bedrock service. If not specified, the current active credentials will be used. | +| **role_session_name** | str | "lancedb-embeddings" | Optional name of the AWS IAM role session to use for calling the Bedrock service. If not specified, a "lancedb-embeddings" name will be used. | +| **runtime** | bool | True | Optional choice of getting different client to perform operations with the Amazon Bedrock service. | +| **max_retries** | int | 7 | Optional number of retries to perform when a request fails. | + +Usage Example: + + + + {PyEmbeddingAwsUsage} + + diff --git a/docs/web/integrations/embedding/cohere.mdx b/docs/web/integrations/embedding/cohere.mdx new file mode 100644 index 000000000..8051462a9 --- /dev/null +++ b/docs/web/integrations/embedding/cohere.mdx @@ -0,0 +1,51 @@ +--- +title: Cohere +sidebarTitle: Cohere +--- + +import { PyEmbeddingCohereUsage } from '/snippets/integrations.mdx'; + +Using cohere API requires cohere package, which can be installed using `pip install cohere`. Cohere embeddings are used to generate embeddings for text data. The embeddings can be used for various tasks like semantic search, clustering, and classification. +You also need to set the `COHERE_API_KEY` environment variable to use the Cohere API. + +Supported models are: + +- embed-english-v3.0 +- embed-multilingual-v3.0 +- embed-english-light-v3.0 +- embed-multilingual-light-v3.0 +- embed-english-v2.0 +- embed-english-light-v2.0 +- embed-multilingual-v2.0 + + +Supported parameters (to be passed in `create` method) are: + +| Parameter | Type | Default Value | Description | +|---|---|--------|---------| +| `name` | `str` | `"embed-english-v2.0"` | The model ID of the cohere model to use. Supported base models for Text Embeddings: embed-english-v3.0, embed-multilingual-v3.0, embed-english-light-v3.0, embed-multilingual-light-v3.0, embed-english-v2.0, embed-english-light-v2.0, embed-multilingual-v2.0 | +| `source_input_type` | `str` | `"search_document"` | The type of input data to be used for the source column. | +| `query_input_type` | `str` | `"search_query"` | The type of input data to be used for the query. | + +Cohere supports following input types: + +| Input Type | Description | +|-------------------------|---------------------------------------| +| "`search_document`" | Used for embeddings stored in a vector| +| | database for search use-cases. | +| "`search_query`" | Used for embeddings of search queries | +| | run against a vector DB | +| "`semantic_similarity`" | Specifies the given text will be used | +| | for Semantic Textual Similarity (STS) | +| "`classification`" | Used for embeddings passed through a | +| | text classifier. | +| "`clustering`" | Used for the embeddings run through a | +| | clustering algorithm | + +Usage Example: + + + + {PyEmbeddingCohereUsage} + + \ No newline at end of file diff --git a/docs/web/integrations/embedding/colpali.mdx b/docs/web/integrations/embedding/colpali.mdx new file mode 100644 index 000000000..dcd65a017 --- /dev/null +++ b/docs/web/integrations/embedding/colpali.mdx @@ -0,0 +1,53 @@ +--- +title: ColPali +sidebarTitle: ColPali +--- + +import { + PyEmbeddingColpaliSetup, + PyEmbeddingColpaliTextSearch, +} from '/snippets/integrations.mdx'; + +We support [ColPali](https://github.com/illuin-tech/colpali) model embeddings for multimodal multi-vector retrieval. ColPali produces multiple embedding vectors per input (multi-vector), enabling more nuanced similarity matching between text queries and image documents. + +Using ColPali requires the colpali-engine package, which can be installed using `pip install colpali-engine`. + + +ColPali produces **multi-vector** embeddings, meaning each input generates multiple embedding vectors rather than a single vector. Use `MultiVector(func.ndims())` instead of `Vector(func.ndims())` when defining your schema. + + +Supported models are: + +- Metric-AI/ColQwen2.5-3b-multilingual-v1.0 (default) +- vidore/colpali-v1.3 +- vidore/colqwen2-v1.0 +- vidore/colSmol-256M + +Supported parameters (to be passed in `create` method) are: + +| Parameter | Type | Default Value | Description | +|---|---|---|---| +| `model_name` | `str` | `"Metric-AI/ColQwen2.5-3b-multilingual-v1.0"` | The name of the model to use. | +| `device` | `str` | `"auto"` | The device for inference. Can be `"auto"`, `"cpu"`, `"cuda"`, or `"mps"`. | +| `dtype` | `str` | `"bfloat16"` | Data type for model weights (bfloat16, float16, float32, float64). | +| `pooling_strategy` | `str` | `"hierarchical"` | Token pooling strategy: `"hierarchical"`, `"lambda"`, or `None`. | +| `pool_factor` | `int` | `2` | Factor to reduce sequence length when pooling is enabled. | +| `batch_size` | `int` | `2` | Batch size for processing inputs. | +| `quantization_config` | `Optional[BitsAndBytesConfig]` | `None` | Quantization configuration for the model (requires bitsandbytes). | + +This embedding function supports ingesting images as both bytes and URLs. You can query them using text. + + + + {PyEmbeddingColpaliSetup} + + + +Now we can search using text queries: + + + + {PyEmbeddingColpaliTextSearch} + + + diff --git a/docs/web/integrations/embedding/gemini.mdx b/docs/web/integrations/embedding/gemini.mdx new file mode 100644 index 000000000..e3359ec0d --- /dev/null +++ b/docs/web/integrations/embedding/gemini.mdx @@ -0,0 +1,26 @@ +--- +title: Gemini +sidebarTitle: Gemini +--- + +import { PyEmbeddingGeminiUsage } from '/snippets/integrations.mdx'; + +With Google's Gemini, you can represent text (words, sentences, and blocks of text) in a vectorized form, making it easier to compare and contrast embeddings. For example, two texts that share a similar subject matter or sentiment should have similar embeddings, which can be identified through mathematical comparison techniques such as cosine similarity. For more on how and why you should use embeddings, refer to the Embeddings guide. +The Gemini Embedding Model API supports various task types: + +| Task Type | Description | +|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| +| "`retrieval_query`" | Specifies the given text is a query in a search/retrieval setting. | +| "`retrieval_document`" | Specifies the given text is a document in a search/retrieval setting. Using this task type requires a title but is automatically provided by Embeddings API | +| "`semantic_similarity`" | Specifies the given text will be used for Semantic Textual Similarity (STS). | +| "`classification`" | Specifies that the embeddings will be used for classification. | +| "`clustering`" | Specifies that the embeddings will be used for clustering. | + + +Usage Example: + + + + {PyEmbeddingGeminiUsage} + + \ No newline at end of file diff --git a/docs/web/integrations/embedding/huggingface.mdx b/docs/web/integrations/embedding/huggingface.mdx new file mode 100644 index 000000000..69cf85599 --- /dev/null +++ b/docs/web/integrations/embedding/huggingface.mdx @@ -0,0 +1,16 @@ +--- +title: Hugging Face +sidebarTitle: Hugging Face +--- + +import { PyEmbeddingHuggingfaceUsage } from '/snippets/integrations.mdx'; + +We offer support for all Hugging Face models (which can be loaded via [transformers](https://huggingface.co/docs/transformers/en/index) library). The default model is `colbert-ir/colbertv2.0` which also has its own special callout - `registry.get("colbert")`. Some Hugging Face models might require custom models defined on the HuggingFace Hub in their own modeling files. You may enable this by setting `trust_remote_code=True`. This option should only be set to True for repositories you trust and in which you have read the code, as it will execute code present on the Hub on your local machine. + +Example usage: + + + + {PyEmbeddingHuggingfaceUsage} + + \ No newline at end of file diff --git a/docs/web/integrations/embedding/ibm.mdx b/docs/web/integrations/embedding/ibm.mdx new file mode 100644 index 000000000..cec6c32bf --- /dev/null +++ b/docs/web/integrations/embedding/ibm.mdx @@ -0,0 +1,64 @@ +--- +title: IBM watsonx +sidebarTitle: IBM WatsonX +--- + +import { PyEmbeddingIbmUsage } from '/snippets/integrations.mdx'; + +Generate text embeddings using IBM's watsonx.ai platform. + +## Supported Models {#supported-models} + +You can find a list of supported models at [IBM watsonx.ai Documentation](https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models-embed.html?context=wx). The currently supported model names are: + +| Model ID | Dimensions | +|----------------------------------------------|------------| +| `ibm/granite-embedding-278m-multilingual` | 768 | +| `ibm/slate-125m-english-rtrvr-v2` | 768 | +| `ibm/slate-30m-english-rtrvr-v2` | 384 | +| `intfloat/multilingual-e5-large` | 1024 | +| `sentence-transformers/all-minilm-l6-v2` | 384 | + + +For new tables, `ibm/granite-embedding-278m-multilingual` is the recommended default. Older model IDs (such as `ibm/slate-125m-english-rtrvr` and `sentence-transformers/all-minilm-l12-v2`) remain resolvable for tables whose stored metadata references them, but they are no longer advertised for new use. + + +## Parameters {#parameters} + +The following parameters can be passed to the `create` method: + +| Parameter | Type | Default Value | Description | +|------------|----------|----------------------------------|-----------------------------------------------------------| +| name | str | `"ibm/slate-125m-english-rtrvr"` | The model ID of the watsonx.ai model to use. Pass one of the current supported IDs above (e.g. `"ibm/granite-embedding-278m-multilingual"`) when creating new tables. | +| api_key | str | None | Optional IBM Cloud API key (or set `WATSONX_API_KEY`) | +| project_id | str | None | Optional watsonx project ID (or set `WATSONX_PROJECT_ID`). Mutually exclusive with `space_id`. | +| space_id | str | None | Optional watsonx deployment space ID (or set `WATSONX_SPACE_ID`). Mutually exclusive with `project_id`. | +| url | str | None | Optional custom URL for the watsonx.ai instance | +| params | dict | None | Optional additional parameters for the embedding model (e.g. `{"truncate_input_tokens": 512}`) | + + +You must supply exactly one of `project_id` or `space_id` (either as an argument or via its environment variable). Setting both, or neither, raises a `ValueError`. + + +## Usage Example {#usage-example} + +First, the watsonx.ai library is an optional dependency, so must be installed separately: + +``` +pip install ibm-watsonx-ai +``` + +Optionally set environment variables (if not passing credentials to `create` directly): + +```sh +export WATSONX_API_KEY="YOUR_WATSONX_API_KEY" +# Provide exactly one of the following: +export WATSONX_PROJECT_ID="YOUR_WATSONX_PROJECT_ID" +export WATSONX_SPACE_ID="YOUR_WATSONX_SPACE_ID" +``` + + + + {PyEmbeddingIbmUsage} + + diff --git a/docs/web/integrations/embedding/imagebind.mdx b/docs/web/integrations/embedding/imagebind.mdx new file mode 100644 index 000000000..4010de634 --- /dev/null +++ b/docs/web/integrations/embedding/imagebind.mdx @@ -0,0 +1,54 @@ +--- +title: ImageBind +sidebarTitle: ImageBind +--- + +import { + PyEmbeddingImagebindSetup, + PyEmbeddingImagebindImageSearch, + PyEmbeddingImagebindAudioSearch, + PyEmbeddingImagebindTextSearch, +} from '/snippets/integrations.mdx'; + +We have support for [imagebind](https://github.com/facebookresearch/ImageBind) model embeddings. You can download our version of the packaged model via - `pip install imagebind-packaged==0.1.2`. + +This function is registered as `imagebind` and supports Audio, Video and Text modalities(extending to Thermal,Depth,IMU data): + +| Parameter | Type | Default Value | Description | +|---|---|---|---| +| `name` | `str` | `"imagebind_huge"` | Name of the model. | +| `device` | `str` | `"cpu"` | The device to run the model on. Can be `"cpu"` or `"gpu"`. | +| `normalize` | `bool` | `False` | set to `True` to normalize your inputs before model ingestion. | + +Below is an example demonstrating how the API works: + + + + {PyEmbeddingImagebindSetup} + + + +Now, we can search using any modality: + +#### image search {#image-search} + + + {PyEmbeddingImagebindImageSearch} + + +#### audio search {#audio-search} + + + + {PyEmbeddingImagebindAudioSearch} + + +#### Text search {#text-search} +You can add any input query and fetch the result as follows: + + + {PyEmbeddingImagebindTextSearch} + + + +If you have any questions about the embeddings API, supported models, or see a relevant model missing, please raise an issue [on GitHub](https://github.com/lancedb/lancedb/issues). diff --git a/docs/web/integrations/embedding/instructor.mdx b/docs/web/integrations/embedding/instructor.mdx new file mode 100644 index 000000000..aea4d443e --- /dev/null +++ b/docs/web/integrations/embedding/instructor.mdx @@ -0,0 +1,39 @@ +--- +title: Instructor +sidebarTitle: Instructor +--- + +import { PyEmbeddingInstructorUsage } from '/snippets/integrations.mdx'; + +[Instructor](https://instructor-embedding.github.io/) is an instruction-finetuned text embedding model that can generate text embeddings tailored to any task (e.g. classification, retrieval, clustering, text evaluation, etc.) and domains (e.g. science, finance, etc.) by simply providing the task instruction, without any finetuning. + +If you want to calculate customized embeddings for specific sentences, you can follow the unified template to write instructions. + + +Represent the `domain` `text_type` for `task_objective`: + +- `domain` is optional, and it specifies the domain of the text, e.g. science, finance, medicine, etc. +- `text_type` is required, and it specifies the encoding unit, e.g. sentence, document, paragraph, etc. +- `task_objective` is optional, and it specifies the objective of embedding, e.g. retrieve a document, classify the sentence, etc. + + +More information about the model can be found at the [source URL](https://github.com/xlang-ai/instructor-embedding). + +| Argument | Type | Default | Description | +|---|---|---|---| +| `name` | `str` | "hkunlp/instructor-base" | The name of the model to use | +| `batch_size` | `int` | `32` | The batch size to use when generating embeddings | +| `device` | `str` | `"cpu"` | The device to use when generating embeddings | +| `show_progress_bar` | `bool` | `True` | Whether to show a progress bar when generating embeddings | +| `normalize_embeddings` | `bool` | `True` | Whether to normalize the embeddings | +| `quantize` | `bool` | `False` | Whether to quantize the model | +| `source_instruction` | `str` | `"represent the document for retrieval"` | The instruction for the source column | +| `query_instruction` | `str` | `"represent the document for retrieving the most similar documents"` | The instruction for the query | + + + + + + {PyEmbeddingInstructorUsage} + + \ No newline at end of file diff --git a/docs/web/integrations/embedding/jina.mdx b/docs/web/integrations/embedding/jina.mdx new file mode 100644 index 000000000..70457180d --- /dev/null +++ b/docs/web/integrations/embedding/jina.mdx @@ -0,0 +1,49 @@ +--- +title: Jina +sidebarTitle: Jina +--- + +import { + PyEmbeddingJinaText, + PyEmbeddingJinaMultimodal, +} from '/snippets/integrations.mdx'; + +## Text Embedding Models {#text-embedding-models} + +Jina embeddings are used to generate embeddings for text and image data. +You also need to set the `JINA_API_KEY` environment variable to use the Jina API. + +You can find a list of supported models under [https://jina.ai/embeddings/](https://jina.ai/embeddings/) + +Supported parameters (to be passed in `create` method) are: + +| Parameter | Type | Default Value | Description | +|---|---|---|---| +| `name` | `str` | `"jina-clip-v1"` | The model ID of the jina model to use | + +Usage Example: + + + + {PyEmbeddingJinaText} + + + +## Multimodal Embedding Models {#multimodal-embedding-models} + +Jina embeddings can also be used to embed both text and image data, only some of the models support image data and you can check the list +under [https://jina.ai/embeddings/](https://jina.ai/embeddings/) + +Supported parameters (to be passed in `create` method) are: + +| Parameter | Type | Default Value | Description | +|---|---|---|---| +| `name` | `str` | `"jina-clip-v1"` | The model ID of the jina model to use | + +Usage Example: + + + + {PyEmbeddingJinaMultimodal} + + diff --git a/docs/web/integrations/embedding/ollama.mdx b/docs/web/integrations/embedding/ollama.mdx new file mode 100644 index 000000000..120cde8f2 --- /dev/null +++ b/docs/web/integrations/embedding/ollama.mdx @@ -0,0 +1,25 @@ +--- +title: Ollama +sidebarTitle: Ollama +--- + +import { PyEmbeddingOllamaUsage } from '/snippets/integrations.mdx'; + +Generate embeddings via the [ollama](https://github.com/ollama/ollama-python) python library. More details: + +- [Ollama docs on embeddings](https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings) +- [Ollama blog on embeddings](https://ollama.com/blog/embedding-models) + +| Parameter | Type | Default Value | Description | +|------------------------|----------------------------|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------| +| `name` | `str` | `nomic-embed-text` | The name of the model. | +| `host` | `str` | `http://localhost:11434` | The Ollama host to connect to. | +| `options` | `ollama.Options` or `dict` | `None` | Additional model parameters listed in the documentation for the Modelfile such as `temperature`. | +| `keep_alive` | `float` or `str` | `"5m"` | Controls how long the model will stay loaded into memory following the request. | +| `ollama_client_kwargs` | `dict` | `{}` | kwargs that can be past to the `ollama.Client`. | + + + + {PyEmbeddingOllamaUsage} + + diff --git a/docs/web/integrations/embedding/openai.mdx b/docs/web/integrations/embedding/openai.mdx new file mode 100644 index 000000000..cdd60d7cd --- /dev/null +++ b/docs/web/integrations/embedding/openai.mdx @@ -0,0 +1,20 @@ +--- +title: OpenAI +sidebarTitle: OpenAI +--- + +import { PyEmbeddingOpenaiBasic } from '/snippets/integrations.mdx'; + +LanceDB registers the OpenAI embeddings function in the registry by default, as `openai`. Below are the parameters that you can customize when creating the instances: + +| Parameter | Type | Default Value | Description | +|---|---|---|---| +| `name` | `str` | `"text-embedding-ada-002"` | The name of the model. | +| `dim` | `int` | Model default | For OpenAI's newer text-embedding-3 model, we can specify a dimensionality that is smaller than the 1536 size. This feature supports it | +| `use_azure` | bool | `False` | Set true to use Azure OpenAI SDK | + + + + {PyEmbeddingOpenaiBasic} + + \ No newline at end of file diff --git a/docs/web/integrations/embedding/openclip.mdx b/docs/web/integrations/embedding/openclip.mdx new file mode 100644 index 000000000..118bb26c1 --- /dev/null +++ b/docs/web/integrations/embedding/openclip.mdx @@ -0,0 +1,46 @@ +--- +title: OpenCLIP +sidebarTitle: OpenCLIP +--- + +import { + PyEmbeddingOpenclipSetup, + PyEmbeddingOpenclipTextSearch, + PyEmbeddingOpenclipImageSearch, +} from '/snippets/integrations.mdx'; + +We support CLIP model embeddings using the open source alternative, [open-clip](https://github.com/mlfoundations/open_clip) which supports various customizations. It is registered as `open-clip` and supports the following customizations: + +| Parameter | Type | Default Value | Description | +|---|---|---|---| +| `name` | `str` | `"ViT-B-32"` | The name of the model. | +| `pretrained` | `str` | `"laion2b_s34b_b79k"` | The name of the pretrained model to load. | +| `device` | `str` | `"cpu"` | The device to run the model on. Can be `"cpu"` or `"gpu"`. | +| `batch_size` | `int` | `64` | The number of images to process in a batch. | +| `normalize` | `bool` | `True` | Whether to normalize the input images before feeding them to the model. | + +This embedding function supports ingesting images as both bytes and urls. You can query them using both text and other images. + + +LanceDB supports ingesting images directly from accessible links. + + + + + {PyEmbeddingOpenclipSetup} + + +Now we can search using text from both the default vector column and the custom vector column + + + {PyEmbeddingOpenclipTextSearch} + + + +Because we're using a multimodal embedding function, we can also search using images + + + + {PyEmbeddingOpenclipImageSearch} + + diff --git a/docs/web/integrations/embedding/sentence-transformers.mdx b/docs/web/integrations/embedding/sentence-transformers.mdx new file mode 100644 index 000000000..aa0c9d1ca --- /dev/null +++ b/docs/web/integrations/embedding/sentence-transformers.mdx @@ -0,0 +1,169 @@ +--- +title: Sentence Transformers +sidebarTitle: Sentence Transformers +--- + +import { PyEmbeddingSentenceTransformersBaai } from '/snippets/integrations.mdx'; + +Allows you to set parameters when registering a `sentence-transformers` object. + + +Sentence transformer embeddings are normalized by default. It is recommended to use normalized embeddings for similarity search. + + + +The `trust_remote_code` parameter defaults to `True`, which allows models to execute arbitrary code from their Hugging Face repository during loading. If you are loading untrusted models, set `trust_remote_code=False` to prevent remote code execution. + + +| Parameter | Type | Default Value | Description | +|---|---|---|---| +| `name` | `str` | `all-MiniLM-L6-v2` | The name of the model | +| `device` | `str` | `cpu` | The device to run the model on (can be `cpu` or `gpu`) | +| `normalize` | `bool` | `True` | Whether to normalize the input text before feeding it to the model | +| `trust_remote_code` | `bool` | `True` | Whether to trust and execute remote code from the model's Huggingface repository | + + +
+```markdown + - sentence-transformers/all-MiniLM-L12-v2 + - sentence-transformers/paraphrase-mpnet-base-v2 + - sentence-transformers/gtr-t5-base + - sentence-transformers/LaBSE + - sentence-transformers/all-MiniLM-L6-v2 + - sentence-transformers/bert-base-nli-max-tokens + - sentence-transformers/bert-base-nli-mean-tokens + - sentence-transformers/bert-base-nli-stsb-mean-tokens + - sentence-transformers/bert-base-wikipedia-sections-mean-tokens + - sentence-transformers/bert-large-nli-cls-token + - sentence-transformers/bert-large-nli-max-tokens + - sentence-transformers/bert-large-nli-mean-tokens + - sentence-transformers/bert-large-nli-stsb-mean-tokens + - sentence-transformers/distilbert-base-nli-max-tokens + - sentence-transformers/distilbert-base-nli-mean-tokens + - sentence-transformers/distilbert-base-nli-stsb-mean-tokens + - sentence-transformers/distilroberta-base-msmarco-v1 + - sentence-transformers/distilroberta-base-msmarco-v2 + - sentence-transformers/nli-bert-base-cls-pooling + - sentence-transformers/nli-bert-base-max-pooling + - sentence-transformers/nli-bert-base + - sentence-transformers/nli-bert-large-cls-pooling + - sentence-transformers/nli-bert-large-max-pooling + - sentence-transformers/nli-bert-large + - sentence-transformers/nli-distilbert-base-max-pooling + - sentence-transformers/nli-distilbert-base + - sentence-transformers/nli-roberta-base + - sentence-transformers/nli-roberta-large + - sentence-transformers/roberta-base-nli-mean-tokens + - sentence-transformers/roberta-base-nli-stsb-mean-tokens + - sentence-transformers/roberta-large-nli-mean-tokens + - sentence-transformers/roberta-large-nli-stsb-mean-tokens + - sentence-transformers/stsb-bert-base + - sentence-transformers/stsb-bert-large + - sentence-transformers/stsb-distilbert-base + - sentence-transformers/stsb-roberta-base + - sentence-transformers/stsb-roberta-large + - sentence-transformers/xlm-r-100langs-bert-base-nli-mean-tokens + - sentence-transformers/xlm-r-100langs-bert-base-nli-stsb-mean-tokens + - sentence-transformers/xlm-r-base-en-ko-nli-ststb + - sentence-transformers/xlm-r-bert-base-nli-mean-tokens + - sentence-transformers/xlm-r-bert-base-nli-stsb-mean-tokens + - sentence-transformers/xlm-r-large-en-ko-nli-ststb + - sentence-transformers/bert-base-nli-cls-token + - sentence-transformers/all-distilroberta-v1 + - sentence-transformers/multi-qa-MiniLM-L6-dot-v1 + - sentence-transformers/multi-qa-distilbert-cos-v1 + - sentence-transformers/multi-qa-distilbert-dot-v1 + - sentence-transformers/multi-qa-mpnet-base-cos-v1 + - sentence-transformers/multi-qa-mpnet-base-dot-v1 + - sentence-transformers/nli-distilroberta-base-v2 + - sentence-transformers/all-MiniLM-L6-v1 + - sentence-transformers/all-mpnet-base-v1 + - sentence-transformers/all-mpnet-base-v2 + - sentence-transformers/all-roberta-large-v1 + - sentence-transformers/allenai-specter + - sentence-transformers/average_word_embeddings_glove.6B.300d + - sentence-transformers/average_word_embeddings_glove.840B.300d + - sentence-transformers/average_word_embeddings_komninos + - sentence-transformers/average_word_embeddings_levy_dependency + - sentence-transformers/clip-ViT-B-32-multilingual-v1 + - sentence-transformers/clip-ViT-B-32 + - sentence-transformers/distilbert-base-nli-stsb-quora-ranking + - sentence-transformers/distilbert-multilingual-nli-stsb-quora-ranking + - sentence-transformers/distilroberta-base-paraphrase-v1 + - sentence-transformers/distiluse-base-multilingual-cased-v1 + - sentence-transformers/distiluse-base-multilingual-cased-v2 + - sentence-transformers/distiluse-base-multilingual-cased + - sentence-transformers/facebook-dpr-ctx_encoder-multiset-base + - sentence-transformers/facebook-dpr-ctx_encoder-single-nq-base + - sentence-transformers/facebook-dpr-question_encoder-multiset-base + - sentence-transformers/facebook-dpr-question_encoder-single-nq-base + - sentence-transformers/gtr-t5-large + - sentence-transformers/gtr-t5-xl + - sentence-transformers/gtr-t5-xxl + - sentence-transformers/msmarco-MiniLM-L-12-v3 + - sentence-transformers/msmarco-MiniLM-L-6-v3 + - sentence-transformers/msmarco-MiniLM-L12-cos-v5 + - sentence-transformers/msmarco-MiniLM-L6-cos-v5 + - sentence-transformers/msmarco-bert-base-dot-v5 + - sentence-transformers/msmarco-bert-co-condensor + - sentence-transformers/msmarco-distilbert-base-dot-prod-v3 + - sentence-transformers/msmarco-distilbert-base-tas-b + - sentence-transformers/msmarco-distilbert-base-v2 + - sentence-transformers/msmarco-distilbert-base-v3 + - sentence-transformers/msmarco-distilbert-base-v4 + - sentence-transformers/msmarco-distilbert-cos-v5 + - sentence-transformers/msmarco-distilbert-dot-v5 + - sentence-transformers/msmarco-distilbert-multilingual-en-de-v2-tmp-lng-aligned + - sentence-transformers/msmarco-distilbert-multilingual-en-de-v2-tmp-trained-scratch + - sentence-transformers/msmarco-distilroberta-base-v2 + - sentence-transformers/msmarco-roberta-base-ance-firstp + - sentence-transformers/msmarco-roberta-base-v2 + - sentence-transformers/msmarco-roberta-base-v3 + - sentence-transformers/multi-qa-MiniLM-L6-cos-v1 + - sentence-transformers/nli-mpnet-base-v2 + - sentence-transformers/nli-roberta-base-v2 + - sentence-transformers/nq-distilbert-base-v1 + - sentence-transformers/paraphrase-MiniLM-L12-v2 + - sentence-transformers/paraphrase-MiniLM-L3-v2 + - sentence-transformers/paraphrase-MiniLM-L6-v2 + - sentence-transformers/paraphrase-TinyBERT-L6-v2 + - sentence-transformers/paraphrase-albert-base-v2 + - sentence-transformers/paraphrase-albert-small-v2 + - sentence-transformers/paraphrase-distilroberta-base-v1 + - sentence-transformers/paraphrase-distilroberta-base-v2 + - sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 + - sentence-transformers/paraphrase-multilingual-mpnet-base-v2 + - sentence-transformers/paraphrase-xlm-r-multilingual-v1 + - sentence-transformers/quora-distilbert-base + - sentence-transformers/quora-distilbert-multilingual + - sentence-transformers/sentence-t5-base + - sentence-transformers/sentence-t5-large + - sentence-transformers/sentence-t5-xxl + - sentence-transformers/sentence-t5-xl + - sentence-transformers/stsb-distilroberta-base-v2 + - sentence-transformers/stsb-mpnet-base-v2 + - sentence-transformers/stsb-roberta-base-v2 + - sentence-transformers/stsb-xlm-r-multilingual + - sentence-transformers/xlm-r-distilroberta-base-paraphrase-v1 + - sentence-transformers/clip-ViT-L-14 + - sentence-transformers/clip-ViT-B-16 + - sentence-transformers/use-cmlm-multilingual + - sentence-transformers/all-MiniLM-L12-v1 +``` +
+ + +You can also load many other model architectures from the library. For example models from sources such as BAAI, Nomic, Salesforce Research, etc. See this HF hub page for all [supported models](https://huggingface.co/models?library=sentence-transformers). + + + +Here is an example that uses the BAAI embedding model from the Hugging Face Hub [supported models](https://huggingface.co/models?library=sentence-transformers). + + + + {PyEmbeddingSentenceTransformersBaai} + + + +Visit sentence-transformers [HuggingFace HUB](https://huggingface.co/sentence-transformers) page for more information on the available models. + diff --git a/docs/web/integrations/embedding/superlinked.mdx b/docs/web/integrations/embedding/superlinked.mdx new file mode 100644 index 000000000..f35796230 --- /dev/null +++ b/docs/web/integrations/embedding/superlinked.mdx @@ -0,0 +1,140 @@ +--- +title: Superlinked +sidebarTitle: Superlinked +--- + +[Superlinked](https://superlinked.com) is a self-hosted inference engine (SIE) for embedding, reranking, and extraction. The `sie-lancedb` package registers SIE as a first-class embedding function in LanceDB's embeddings registry, so embeddings are computed automatically on insert and search. You need a running SIE instance - see the [Superlinked quickstart](https://superlinked.com/docs) for deployment options. + +## Installation {#installation} + + +```bash Python icon=Python +pip install sie-lancedb +``` + +```bash TypeScript icon=js +npm install @superlinked/sie-lancedb @lancedb/lancedb +``` + + +## Registered functions {#registered-functions} + +Importing `sie_lancedb` registers two embedding functions in LanceDB's registry: + +| Name | Purpose | +|---|---| +| `"sie"` | Dense text embeddings | +| `"sie-multivector"` | ColBERT-style late interaction with MaxSim scoring | + +Supported parameters on `.create()`: + +| Parameter | Type | Description | +|---|---|---| +| `model` | `str` | Any of 85+ SIE-supported models (e.g. `BAAI/bge-m3`, `NovaSearch/stella_en_400M_v5`, `jinaai/jina-colbert-v2`) | +| `base_url` | `str` | URL of the SIE endpoint (e.g. `http://localhost:8080`) | + +## Usage {#usage} + +```py Python icon=Python +import lancedb +from lancedb.embeddings import get_registry +from lancedb.pydantic import LanceModel, Vector +import sie_lancedb # registers "sie" and "sie-multivector" + +sie = get_registry().get("sie").create( + model="BAAI/bge-m3", + base_url="http://localhost:8080", +) + +class Documents(LanceModel): + text: str = sie.SourceField() + vector: Vector(sie.ndims()) = sie.VectorField() + +db = lancedb.connect("~/.lancedb") +table = db.create_table("docs", schema=Documents, mode="overwrite") + +table.add([ + {"text": "Machine learning is a subset of AI."}, + {"text": "Neural networks use multiple layers."}, + {"text": "Python is popular for ML development."}, +]) + +results = table.search("What is deep learning?").limit(3).to_list() +``` + +LanceDB handles embedding generation for both inserts and queries automatically, based on the `SourceField` / `VectorField` declarations on the schema. + +## Hybrid search with reranker {#hybrid-search-with-reranker} + +`SIEReranker` plugs into LanceDB's hybrid search pipeline. It uses SIE's cross-encoder `score()` to rerank combined vector + full-text search results. You need a full-text search index on the column first: + +```py Python icon=Python +from sie_lancedb import SIEReranker + +# Create FTS index for hybrid search +table.create_fts_index("text", replace=True) + +results = ( + table.search("What is deep learning?", query_type="hybrid") + .rerank(SIEReranker(model="jinaai/jina-reranker-v2-base-multilingual")) + .limit(5) + .to_list() +) + +for r in results: + print(f"{r['_relevance_score']:.3f} {r['text']}") +``` + +The reranker also works with pure vector or pure FTS search via `.rerank()`. + +## ColBERT and multivector {#colbert-and-multivector} +`SIEMultiVectorEmbeddingFunction` (registered as `"sie-multivector"`) works with LanceDB's native `MultiVector` type and MaxSim scoring for ColBERT and ColPali models: + +```py Python icon=Python +from lancedb.pydantic import MultiVector + +sie_colbert = get_registry().get("sie-multivector").create( + model="jinaai/jina-colbert-v2", + base_url="http://localhost:8080", +) + +class ColBERTDocs(LanceModel): + text: str = sie_colbert.SourceField() + vector: MultiVector(sie_colbert.ndims()) = sie_colbert.VectorField() + +table = db.create_table("colbert_docs", schema=ColBERTDocs, mode="overwrite") +table.add([{"text": "Machine learning is a subset of AI."}]) + +# MaxSim search - query and document multivectors compared token-by-token +results = table.search("What is ML?").limit(5).to_list() +``` + +## Entity extraction {#entity-extraction} + +`SIEExtractor` adds entity extraction to LanceDB's data-enrichment workflows. Extract entities from a text column and merge the results back as a structured Arrow column - enabling filtered search on extracted entities: + +```py Python icon=Python +from sie_lancedb import SIEExtractor + +extractor = SIEExtractor( + base_url="http://localhost:8080", + model="urchade/gliner_multi-v2.1", +) + +extractor.enrich_table( + table, + source_column="text", + target_column="entities", + labels=["person", "technology", "organization"], + id_column="id", +) +``` + +The `entities` column stores structured Arrow data (`list>`), so you can filter on extracted entities in queries. + +## Links {#links} + +- [`sie-lancedb` on PyPI](https://pypi.org/project/sie-lancedb/) +- [`@superlinked/sie-lancedb` on npm](https://www.npmjs.com/package/@superlinked/sie-lancedb) +- [Superlinked on GitHub](https://github.com/superlinked/sie) +- [Superlinked docs](https://superlinked.com/docs) diff --git a/docs/web/integrations/embedding/voyageai.mdx b/docs/web/integrations/embedding/voyageai.mdx new file mode 100644 index 000000000..54d155d1b --- /dev/null +++ b/docs/web/integrations/embedding/voyageai.mdx @@ -0,0 +1,60 @@ +--- +title: VoyageAI +sidebarTitle: VoyageAI +--- + +import { PyEmbeddingVoyageaiUsage, PyEmbeddingVoyageaiMultimodal } from '/snippets/integrations.mdx'; + +Voyage AI provides cutting-edge embedding and rerankers. + + +Using voyageai API requires voyageai package, which can be installed using `pip install voyageai`. Voyage AI embeddings are used to generate embeddings for text data. The embeddings can be used for various tasks like semantic search, clustering, and classification. +You also need to set the `VOYAGE_API_KEY` environment variable to use the VoyageAI API. + +Supported models are: + +- voyage-4-large (best retrieval quality, 1024 default dimensions, supports 256/512/1024/2048) +- voyage-4 (balanced general-purpose, 1024 default dimensions, supports 256/512/1024/2048) +- voyage-4-lite (optimized for latency/cost, 1024 default dimensions, supports 256/512/1024/2048) +- voyage-context-3 +- voyage-3.5 +- voyage-3.5-lite +- voyage-3 +- voyage-3-lite +- voyage-finance-2 +- voyage-multilingual-2 +- voyage-law-2 +- voyage-code-2 +- voyage-multimodal-3.5 (multimodal - supports text, images, and video) + + +**Multimodal Model:** `voyage-multimodal-3.5` supports text, images, and video inputs. It outputs 1024-dimensional embeddings by default, configurable via the `output_dimension` parameter (256, 512, 1024, 2048). See the [VoyageAI multimodal embeddings documentation](https://docs.voyageai.com/docs/multimodal-embeddings) for more details. + + +Supported parameters (to be passed in `create` method) are: + +| Parameter | Type | Default Value | Description | +|---|---|--------|---------| +| `name` | `str` | `None` | The model ID of the model to use. Supported models: voyage-4-large, voyage-4, voyage-4-lite, voyage-3, voyage-3-lite, voyage-3.5, voyage-3.5-lite, voyage-context-3, voyage-finance-2, voyage-multilingual-2, voyage-law-2, voyage-code-2, voyage-multimodal-3.5 | +| `input_type` | `str` | `None` | Type of the input text. Default to None. Other options: query, document. | +| `truncation` | `bool` | `True` | Whether to truncate the input texts to fit within the context length. | +| `output_dimension` | `int` | `None` | Output embedding dimension. Only supported by `voyage-multimodal-3.5`. Valid options: 256, 512, 1024 (default), 2048. | + + +Usage Example: + + + + {PyEmbeddingVoyageaiUsage} + + + +### Multimodal Example {#multimodal-example} + +The `voyage-multimodal-3.5` model can embed text alongside images. You can use image URLs, file paths, or PIL Image objects: + + + + {PyEmbeddingVoyageaiMultimodal} + + \ No newline at end of file diff --git a/docs/web/integrations/index.mdx b/docs/web/integrations/index.mdx new file mode 100644 index 000000000..640628684 --- /dev/null +++ b/docs/web/integrations/index.mdx @@ -0,0 +1,17 @@ +--- +title: Integrations +sidebarTitle: Overview +description: Connect LanceDB with popular AI providers, frameworks, and data platforms +--- + +LanceDB seamlessly plugs into the rest of your AI and data engineering stack. Use the sections +below to jump straight into the guides that matter for your workflow. + + +| Group | Description | +|:----------------|:-------------| +| [Embedding models](/integrations/embedding/) | Connect with popular embedding model providers including OpenAI, Cohere, Hugging Face, and more | +| [Reranking models](/integrations/reranking/) | Enhance search results with advanced reranking models and techniques | +| [AI platforms & frameworks](/integrations/ai/) | Integrate with LangChain, LlamaIndex, Kiln, and other AI development frameworks | +| [Data platforms & frameworks](/integrations/data/) | Integrate LanceDB with popular data tools and platforms like DuckDB, Pydantic and dlt | + diff --git a/docs/web/integrations/lerobotdataset.mdx b/docs/web/integrations/lerobotdataset.mdx new file mode 100644 index 000000000..08cf8bd15 --- /dev/null +++ b/docs/web/integrations/lerobotdataset.mdx @@ -0,0 +1,161 @@ +--- +title: "LeRobotDataset" +sidebarTitle: "LeRobotDataset" +description: "Train LeRobot policies straight from object storage, and curate, search and visualize robotics datasets as Lance tables." +keywords: ["lerobot", "robotics", "droid", "training", "dataloader"] +--- + +import { + PyFrameworksLerobotFilterFrames, + PyFrameworksLerobotLoad, + PyFrameworksLerobotOpenLanceTables, + PyFrameworksLerobotTrainReady, +} from '/snippets/integrations.mdx'; + +[LeRobot](https://huggingface.co/docs/lerobot/index) is Hugging Face's open-source robotics stack. Since [lerobot#4363](https://github.com/huggingface/lerobot/pull/4363) it reads Lance datasets natively: same `LeRobotDataset` class, same items, same training pipeline. The dataset's `"storage_format": "lance"` picks the reader. No subclasses, no wrapper library. + +What you get: + +- **Train from object storage while keeping your GPUs fed.** Lance reads random frames straight from S3, GCS, the HF Hub or HF Storage Buckets, fetching only the bytes each batch needs, so every batch is a global shuffle: any frame, from anywhere in the dataset. In the table below (samples/s, 8 workers, batch 64), Lance and upstream local both draw every batch from the whole dataset. Upstream streaming does a reservoir shuffle, reading the dataset in order and drawing each batch from a pool of recently seen frames, so those two columns are not the same job: + +| dataset | window shuffle, Lance from object store | window shuffle, upstream streaming from the Hub | Lance (object store) vs upstream streaming | RAM, Lance | RAM, streaming | global shuffle, Lance from object store | global shuffle, upstream from local NVMe | Lance (object store) vs upstream (local NVMe) | +|---|---|---|---|---|---|---|---|---| +| DROID | 904 | 478 | 1.9x | 8 GB | 122 GB | 257 | 224 | 1.1x | +| roboturk | 778 | 152 | 5.1x | 6 GB | 9 GB | 1,074 | 983 | 1.1x | +| aloha_mobile_cabinet | 772 | 51 | 15.2x | 9 GB | 30 GB | 693 | 573 | 1.2x | +| koch_pick_place_5_lego | 701 | 54 | 13.0x | 8 GB | 15 GB | 876 | 526 | 1.7x | +| pusht | 5,340 | 648 | 8.2x | 3 GB | 7 GB | 5,508 | 4,224 | 1.3x | +| aloha_static_cups_open | 597 | 45 | 13.2x | 10 GB | 38 GB | 575 | 483 | 1.2x | + +The shuffle window is 0.05% of the dataset, at least 5,000 frames: 5,000 on the small sets, 13,815 on DROID. Streaming DROID at that window in upstream default needs 122 GB, so those two runs used a 256 GB machine with the same decode speed. The DROID pair is noisy: Lance 684 and 1,124, streaming 303 and 653. + +Same batches: Lance from an object store is 1.9x faster than upstream streaming on DROID and 5x to 15x on the other datasets, with 8 GB of RAM against 122 GB on DROID. Global shuffle: Lance with nothing downloaded is 1.1x to 1.7x faster than upstream reading local NVMe after the download. berkeley_rpt, toto and soarm101 are left out because their metadata lists frames the videos do not have; `lerobot-lance-doctor` reports that before you train. + +- **Faster iteration, end to end.** 10,000 steps of SmolVLA on DROID, 8xH100, lerobot's default `num_workers=4`, same seed: + +| | Lance, S3, nothing downloaded | upstream, local NVMe after a 384 GB download | +|---|---|---| +| wall clock | **1 h 27 m** | 2 h 00 m | +| steady rate | 495 samples/s | 361 samples/s | +| of each step spent waiting on data | 1.7% | 37.4% | +| final loss | 0.2380 | 0.2380 | + +Same model, same loss, 33 minutes sooner, and no 384 GB download first. The difference is time the GPUs spend waiting on data, so it grows with the run: at 100k steps it is over five hours, and every re-run and sweep point pays it again. + +- **The training table is also the index.** Parquet has no secondary indexes, so finding frames instead of scanning them usually means another system per question: a vector database for embeddings, a search service for instructions, a feature store for scores. With Lance those are columns and indexes on the same table the DataLoader reads. One query spans all of them, at a version the trainer can reopen. + +## Install + +```bash +pip install "lerobot[lancedb]" # the reader, inside lerobot itself +pip install lerobot-lancedb # the converter +``` + +## Convert a dataset + +Any LeRobot v3.0 dataset, from the Hub or a local directory: + +```bash +lerobot-lance-convert --repo-id lerobot/pusht --out ./pusht-lance +``` + +**Migration costs about as much as a file copy.** Videos are stored as blobs without re-encoding, so items are bit-identical to the source: 392,578 frames convert in 10 seconds, DROID's 27.6M in 34 minutes. Nothing about your recording or training code changes. The output keeps `meta/` as is and adds three Lance tables (`frames`, `videos`, `meta`). `lerobot-lance-doctor` audits the result and often finds problems in the source: DROID carries 44% orphaned rows, and three of eight public datasets we converted have frame-count defects that only random access exposes. + +Host the result anywhere: an HF dataset repo (`hf upload`, tag it `v3.0`), an HF Storage Bucket (`hf buckets sync`), or any object store (`aws s3 sync`). + +## Load and train + + + {PyFrameworksLerobotLoad} + + +An explicit `root` works for any object store: `LeRobotDataset("lerobot/pusht", root="s3://my-bucket/pusht-lance")`. Remote roots download only `meta/`. Data is fetched per batch. Temporal windows, `DataLoader` and the rest of the training stack work unchanged: + + + {PyFrameworksLerobotTrainReady} + + +Or from the CLI. Held-out evaluation on a remote dataset needs random access, so it only works on the Lance path: + +```bash +lerobot-train \ + --dataset.repo_id=my-org/my-bucket/pusht-lance \ + --dataset.repo_type=bucket \ + --dataset.eval_split=0.02 \ + --policy.type=act +``` + +## Visualize TB-scale datasets in Foxglove + +LeRobot's dataset viewer can serve an episode to [Foxglove](https://foxglove.dev) over its WebSocket protocol ([lerobot#4542](https://github.com/huggingface/lerobot/pull/4542)), and with a Lance dataset it works wherever the data lives: + +```bash +lerobot-dataset-viz --repo-id lance-format/pusht-lance --episode-index 3 --display-mode foxglove +``` + +Open the Foxglove app, connect to `ws://localhost:8765`, and scrub. Cameras, joint state and actions show up as topics you can lay out however you like: + +![Foxglove showing episode 7 of DROID served from S3: three camera panels and the joint state, scrubbed to 19.4 s](/static/assets/images/integrations/lerobot-foxglove-droid.png) + +Frames are fetched as you scrub, so looking at one episode of the 373 GB DROID from S3 moves a few MB, not the dataset. No MCAP export, no download, and nothing new to install on the dataset side: this is the same reader the trainer uses. + +## Query, curate, enrich + +The frames table is a LanceDB table like any other: + + + {PyFrameworksLerobotOpenLanceTables} + + +Filter with SQL over any feature, using the indexes built at conversion: + + + {PyFrameworksLerobotFilterFrames} + + +From here it is standard LanceDB. The examples below are from our DROID walkthrough, all on the table the trainer reads. + +**Feature engineering costs nothing until you use it.** With [Geneva](/geneva/udfs/udfs), `add_columns` registers a `@udf` function and its input columns as a new column and computes nothing; `backfill` computes and stores the values later, in parallel. No copy of the dataset, and the videos are never touched: + +```python +@udf(data_type=pa.float32(), input_columns=["action_joint_velocity"]) +def jerk_score(v): # per-frame motion roughness + ... + +tbl.add_columns({"jerk_score": jerk_score}) # nothing computed yet +tbl.backfill("jerk_score", concurrency=8) # 610,403 rows in 16.7 s +``` + +Embeddings work the same way, reading frames straight from the video blobs in the same table: + +```python +@udf(data_type=pa.list_(pa.float32(), 768), num_gpus=1, + input_columns=["episode_index", "frame_index"]) +class EmbedFromBlob: # SigLIP2 loads once per worker + def __call__(self, episode_index, frame_index): + blob = self.videos.take_blobs("video_bytes", indices=[vrow])[0] + frame = VideoDecoder(blob).get_frames_at([pos]).data[0] + ... +``` + +**Search** is an index on those columns: a vector index over the embeddings built in 29.7 s across 27.6M rows, a full-text index over the instructions in 7.0 s, queries in 10 to 54 ms. + +**Curation** is one query across all of it. Semantic search with a predicate on the derived score, no join, no second system: + +```python +tbl.search(vec("a gripper closing on an object"), vector_column_name="emb_siglip2") \ + .where("jerk_score > 1.2875") # the roughest 1% of frames + .limit(4) +``` + +**Mining** is the same query pointed at the rare cases. `tbl.search().where("jerk_score > 1.2875 AND success = false")` pulls the rough, failed frames out of 27.6M rows in milliseconds, ready to become a fine-tuning set or a labeling queue. + +**EDA** is a scan of the tabular columns: a pass over all 27.6M rows reads 2.52 GB in 190 s and decodes no video. + +Then drop what you found from training with `episodes=[...]` instead of rewriting files. The reader ignores columns it does not know, and the table is versioned, so curation and training share one copy of the data at a version you can reopen. + + +Lance datasets are read-only in LeRobot: record and edit in the default format, then convert. + + +Public examples to try everything above: [`lance-format/pusht-lance`](https://huggingface.co/datasets/lance-format/pusht-lance) and friends under the [`lance-format`](https://huggingface.co/lance-format) org. diff --git a/docs/web/integrations/reranking/answerdotai.mdx b/docs/web/integrations/reranking/answerdotai.mdx new file mode 100644 index 000000000..e3f500d49 --- /dev/null +++ b/docs/web/integrations/reranking/answerdotai.mdx @@ -0,0 +1,52 @@ +--- +title: Answer.AI Rerankers +sidebarTitle: "Answer.AI" +description: Use AnswerDotAI's lightweight reranking library with LanceDB. Features unified API for common reranking models, configurable model selection, and comprehensive scoring options. + +--- + +import { PyRerankingAnswerdotaiUsage } from '/snippets/integrations.mdx'; + +# Answer.AI Rerankers + +This integration uses [AnswersDotAI's rerankers](https://github.com/AnswerDotAI/rerankers) to rerank the search results, providing a lightweight, low-dependency, unified API to use all common reranking and cross-encoder models. + +> **Note:** Supported query types – Hybrid, Vector, and FTS. + + + + + {PyRerankingAnswerdotaiUsage} + + + +## Accepted Arguments {#accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `model_type` | `str` | `"colbert"` | The type of model to use. Supported model types can be found here: https://github.com/AnswerDotAI/rerankers. | +| `model_name` | `str` | `"answerdotai/answerai-colbert-small-v1"` | The name of the reranker model to use. | +| `column` | `str` | `"text"` | The name of the column to use as input to the cross encoder model. | +| `return_score` | `str` | `"relevance"` | Options are "relevance" or "all". The type of score to return. If "relevance", will return only the `_relevance_score. If "all" is supported, will return relevance score along with the vector and/or fts scores depending on query type. | + + + +## Supported Scores for each query type {#supported-scores-for-each-query-type} +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +### Hybrid Search {#hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ❌ Not Supported | Results have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_relevance_score`). | + +### Vector Search {#vector-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have vector(`_distance`) along with Hybrid Search score(`_relevance_score`). | + +### FTS Search {#fts-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have FTS(`score`) along with Hybrid Search score(`_relevance_score`). | diff --git a/docs/web/integrations/reranking/cohere.mdx b/docs/web/integrations/reranking/cohere.mdx new file mode 100644 index 000000000..e484f3ee0 --- /dev/null +++ b/docs/web/integrations/reranking/cohere.mdx @@ -0,0 +1,57 @@ +--- +title: Cohere Reranker +sidebarTitle: "Cohere" +description: Integrate Cohere's powerful reranking API with LanceDB for enhanced search results. Supports English and multilingual models with configurable scoring options for vector, FTS, and hybrid search. + +--- + +import { PyRerankingCohereUsage } from '/snippets/integrations.mdx'; + +# Cohere Reranker + +This reranker uses the [Cohere](https://cohere.ai/) API to rerank the search results. You can use this reranker by passing `CohereReranker()` to the `rerank()` method. Note that you'll either need to set the `COHERE_API_KEY` environment variable or pass the `api_key` argument to use this reranker. + + +> **Note:** Supported query types – Hybrid, Vector, and FTS. + +```shell +pip install cohere +``` + + + + {PyRerankingCohereUsage} + + + +## Accepted Arguments {#accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `model_name` | `str` | `"rerank-english-v2.0"` | The name of the reranker model to use. Available cohere models are: rerank-english-v2.0, rerank-multilingual-v2.0 | +| `column` | `str` | `"text"` | The name of the column to use as input to the cross encoder model. | +| `top_n` | `str` | `None` | The number of results to return. If None, will return all results. | +| `api_key` | `str` | `None` | The API key for the Cohere API. If not provided, the `COHERE_API_KEY` environment variable is used. | +| `return_score` | `str` | `"relevance"` | Options are "relevance" or "all". The type of score to return. If "relevance", will return only the `_relevance_score. If "all" is supported, will return relevance score along with the vector and/or fts scores depending on query type | + + + +## Supported Scores for each query type {#supported-scores-for-each-query-type} +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +### Hybrid Search {#hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column | +| `all` | ❌ Not Supported | Results have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_relevance_score`) | + +### Vector Search {#vector-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column | +| `all` | ✅ Supported | Results have vector(`_distance`) along with Hybrid Search score(`_relevance_score`) | + +### FTS Search {#fts-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column | +| `all` | ✅ Supported | Results have FTS(`score`) along with Hybrid Search score(`_relevance_score`) | diff --git a/docs/web/integrations/reranking/colbert.mdx b/docs/web/integrations/reranking/colbert.mdx new file mode 100644 index 000000000..463b3d78d --- /dev/null +++ b/docs/web/integrations/reranking/colbert.mdx @@ -0,0 +1,50 @@ +--- +title: ColBERT Reranker +sidebarTitle: "ColBERT" +description: Enhance search results with ColBERT's contextual reranking in LanceDB. Features efficient model deployment, device optimization, and flexible scoring options for vector, FTS, and hybrid search. + +--- + +import { PyRerankingColbertUsage } from '/snippets/integrations.mdx'; + +# ColBERT Reranker + +This reranker uses ColBERT model to rerank the search results. You can use this reranker by passing `ColbertReranker()` to the `rerank()` method. +> **Note:** Supported query types – Hybrid, Vector, and FTS. + + + + + {PyRerankingColbertUsage} + + + +## Accepted Arguments {#accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `model_name` | `str` | `"colbert-ir/colbertv2.0"` | The name of the reranker model to use.| +| `column` | `str` | `"text"` | The name of the column to use as input to the cross encoder model. | +| `device` | `str` | `None` | The device to use for the cross encoder model. If None, will use "cuda" if available, otherwise "cpu". | +| `return_score` | `str` | `"relevance"` | Options are "relevance" or "all". The type of score to return. If "relevance", will return only the `_relevance_score. If "all" is supported, will return relevance score along with the vector and/or fts scores depending on query type. | + + +## Supported Scores for each query type {#supported-scores-for-each-query-type} +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +### Hybrid Search {#hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ❌ Not Supported | Results have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_relevance_score`). | + +### Vector Search {#vector-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have vector(`_distance`) along with Hybrid Search score(`_relevance_score`). | + +### FTS Search {#fts-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have FTS(`score`) along with Hybrid Search score(`_relevance_score`). | diff --git a/docs/web/integrations/reranking/jina.mdx b/docs/web/integrations/reranking/jina.mdx new file mode 100644 index 000000000..4624149d2 --- /dev/null +++ b/docs/web/integrations/reranking/jina.mdx @@ -0,0 +1,54 @@ +--- +title: Jina Reranker +sidebarTitle: "Jina AI" +description: Integrate Jina's multilingual reranking API with LanceDB for improved search results. Features model selection, API key management, and flexible scoring options for all search types. + +--- + +import { PyRerankingJinaUsage } from '/snippets/integrations.mdx'; + +# Jina Reranker + +This reranker uses the [Jina](https://jina.ai/reranker/) API to rerank the search results. You can use this reranker by passing `JinaReranker()` to the `rerank()` method. Note that you'll either need to set the `JINA_API_KEY` environment variable or pass the `api_key` argument to use this reranker. + + +> **Note:** Supported query types – Hybrid, Vector, and FTS. + + + + + {PyRerankingJinaUsage} + + + +## Accepted Arguments {#accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `model_name` | `str` | `"jina-reranker-v2-base-multilingual"` | The name of the reranker model to use. You can find the list of available models in https://jina.ai/reranker. | +| `column` | `str` | `"text"` | The name of the column to use as input to the cross encoder model. | +| `top_n` | `str` | `None` | The number of results to return. If None, will return all results. | +| `api_key` | `str` | `None` | The API key for the Jina API. If not provided, the `JINA_API_KEY` environment variable is used. | +| `return_score` | `str` | `"relevance"` | Options are "relevance" or "all". The type of score to return. If "relevance", will return only the `_relevance_score. If "all" is supported, will return relevance score along with the vector and/or fts scores depending on query type. | + + + +## Supported Scores for each query type {#supported-scores-for-each-query-type} +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +### Hybrid Search {#hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ❌ Not Supported | Results have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_relevance_score`). | + +### Vector Search {#vector-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have vector(`_distance`) along with Hybrid Search score(`_relevance_score`). | + +### FTS Search {#fts-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have FTS(`score`) along with Hybrid Search score(`_relevance_score`). | diff --git a/docs/web/integrations/reranking/openai.mdx b/docs/web/integrations/reranking/openai.mdx new file mode 100644 index 000000000..413f95e67 --- /dev/null +++ b/docs/web/integrations/reranking/openai.mdx @@ -0,0 +1,51 @@ +--- +title: OpenAI Reranker (Experimental) +sidebarTitle: "OpenAI" +description: Explore experimental search reranking using OpenAI's GPT models in LanceDB. Features configurable model selection, API key management, and comprehensive scoring options for all search types. + +--- + +import { PyRerankingOpenaiUsage } from '/snippets/integrations.mdx'; + +# OpenAI Reranker (Experimental) + +This reranker uses OpenAI chat model to rerank the search results. You can use this reranker by passing `OpenAI()` to the `rerank()` method. +> **Note:** Supported query types – Hybrid, Vector, and FTS. + +> **Warning:** This reranker is experimental. OpenAI does not have a dedicated reranking model, so it uses a chat model under the hood. + + + + {PyRerankingOpenaiUsage} + + + +## Accepted Arguments {#accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `model_name` | `str` | `"gpt-4-turbo-preview"` | The name of the reranker model to use.| +| `column` | `str` | `"text"` | The name of the column to use as input to the cross encoder model. | +| `return_score` | `str` | `"relevance"` | Options are "relevance" or "all". The type of score to return. If "relevance", will return only the `_relevance_score. If "all" is supported, will return relevance score along with the vector and/or fts scores depending on query type. | +| `api_key` | `str` | `None` | The API key to use. If None, will use the OPENAI_API_KEY environment variable. + + +## Supported Scores for each query type {#supported-scores-for-each-query-type} +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +### Hybrid Search {#hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ❌ Not Supported | Results have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_relevance_score`). | + +### Vector Search {#vector-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have vector(`_distance`) along with Hybrid Search score(`_relevance_score`). | + +### FTS Search {#fts-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have FTS(`score`) along with Hybrid Search score(`_relevance_score`). | diff --git a/docs/web/integrations/reranking/voyageai.mdx b/docs/web/integrations/reranking/voyageai.mdx new file mode 100644 index 000000000..62e9a16da --- /dev/null +++ b/docs/web/integrations/reranking/voyageai.mdx @@ -0,0 +1,56 @@ +--- +title: VoyageAI Reranker +sidebarTitle: "Voyage AI" +description: Integrate VoyageAI's cutting-edge reranking models with LanceDB. Features model selection, API key management, and comprehensive scoring options for all search types. + +--- + +import { PyRerankingVoyageaiUsage } from '/snippets/integrations.mdx'; + +# VoyageAI Reranker + +Voyage AI provides cutting-edge embedding and rerankers. + +This reranker uses the [VoyageAI](https://docs.voyageai.com/docs/) API to rerank the search results. You can use this reranker by passing `VoyageAIReranker()` to the `rerank()` method. Note that you'll either need to set the `VOYAGE_API_KEY` environment variable or pass the `api_key` argument to use this reranker. + + +> **Note:** Supported query types – Hybrid, Vector, and FTS. + + + + + {PyRerankingVoyageaiUsage} + + + +## Accepted Arguments {#accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `model_name` | `str` | `None` | The name of the reranker model to use. Available models are: rerank-2, rerank-2-lite | +| `column` | `str` | `"text"` | The name of the column to use as input to the cross encoder model. | +| `top_n` | `str` | `None` | The number of results to return. If None, will return all results. | +| `api_key` | `str` | `None` | The API key for the Voyage AI API. If not provided, the `VOYAGE_API_KEY` environment variable is used. | +| `return_score` | `str` | `"relevance"` | Options are "relevance" or "all". The type of score to return. If "relevance", will return only the `_relevance_score. If "all" is supported, will return relevance score along with the vector and/or fts scores depending on query type | +| `truncation` | `bool` | `None` | Whether to truncate the input to satisfy the "context length limit" on the query and the documents. | + + +## Supported Scores for each query type {#supported-scores-for-each-query-type} +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +### Hybrid Search {#hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Returns only have the `_relevance_score` column | +| `all` | ❌ Not Supported | Returns have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_relevance_score`) | + +### Vector Search {#vector-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Returns only have the `_relevance_score` column | +| `all` | ✅ Supported | Returns have vector(`_distance`) along with Hybrid Search score(`_relevance_score`) | + +### FTS Search {#fts-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Returns only have the `_relevance_score` column | +| `all` | ✅ Supported | Returns have FTS(`score`) along with Hybrid Search score(`_relevance_score`) | diff --git a/docs/web/integrations/reranking/watsonx.mdx b/docs/web/integrations/reranking/watsonx.mdx new file mode 100644 index 000000000..775af4f15 --- /dev/null +++ b/docs/web/integrations/reranking/watsonx.mdx @@ -0,0 +1,63 @@ +--- +title: Watsonx Reranker +sidebarTitle: "IBM watsonx.ai" +description: Rerank LanceDB search results with the IBM watsonx.ai text rerank API. Supports vector, FTS, and hybrid search with configurable models, projects, and spaces. + +--- + +import { PyRerankingWatsonxUsage } from '/snippets/integrations.mdx'; + +# Watsonx Reranker + +This reranker uses the [IBM watsonx.ai](https://cloud.ibm.com/docs/apis/watsonx-ai#text-rerank) text rerank API to reorder search results. Pass `WatsonxReranker()` to the `rerank()` method on a query. Credentials come from the `WATSONX_API_KEY` and `WATSONX_PROJECT_ID` (or `WATSONX_SPACE_ID`) environment variables, or can be passed explicitly as arguments. + +> **Note:** Supported query types – Hybrid, Vector, and FTS. + +```shell +pip install ibm-watsonx-ai +``` + + + + {PyRerankingWatsonxUsage} + + + +## Accepted Arguments {#accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `model_name` | `str` | `"cross-encoder/ms-marco-minilm-l-12-v2"` | The rerank model ID. See [supported rerank models](https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models-embed.html?context=wx#rerank). | +| `column` | `str` | `"text"` | The name of the table column to use as document input. | +| `top_n` | `int` | `None` | The number of results to return. If `None`, all results are returned. | +| `return_score` | `str` | `"relevance"` | Options are `"relevance"` or `"all"`. Controls which score columns are kept on the result. | +| `api_key` | `str` | `None` | IBM Cloud API key. Falls back to the `WATSONX_API_KEY` environment variable. | +| `project_id` | `str` | `None` | watsonx.ai project ID. Falls back to `WATSONX_PROJECT_ID`. Mutually exclusive with `space_id`. | +| `space_id` | `str` | `None` | watsonx.ai deployment space ID. Falls back to `WATSONX_SPACE_ID`. Mutually exclusive with `project_id`. | +| `url` | `str` | `"https://us-south.ml.cloud.ibm.com"` | watsonx.ai service URL. | +| `truncate_input_tokens` | `int` | `None` | Truncate each document to this many tokens before scoring. | + + +You must supply exactly one of `project_id` or `space_id` (either as an argument or via its environment variable). Setting both, or neither, raises a `ValueError`. + + +## Supported scores for each query type {#supported-scores-for-each-query-type} + +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +### Hybrid Search {#hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ❌ Not Supported | Results have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_relevance_score`). | + +### Vector Search {#vector-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have vector(`_distance`) along with Hybrid Search score(`_relevance_score`). | + +### FTS Search {#fts-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have FTS(`score`) along with Hybrid Search score(`_relevance_score`). | diff --git a/docs/web/integrations/stable-worldmodel.mdx b/docs/web/integrations/stable-worldmodel.mdx new file mode 100644 index 000000000..ee35e2159 --- /dev/null +++ b/docs/web/integrations/stable-worldmodel.mdx @@ -0,0 +1,107 @@ +--- +title: "Stable World Model" +sidebarTitle: "Stable World Model" +description: "Use Stable World Model with LanceDB-backed datasets for reproducible world model research, fast data loading, and compact storage." +--- + +import { + PyFrameworksStableWorldmodelCollectLance, + PyFrameworksStableWorldmodelConvert, + PyFrameworksStableWorldmodelEvaluate, + PyFrameworksStableWorldmodelLoadLance, +} from '/snippets/integrations.mdx'; + +[Stable World Model](https://github.com/galilai-group/stable-worldmodel) is a research platform for collecting data, training world models, and evaluating policies with model-predictive control across standardized environments. + +The LanceDB integration is built into Stable World Model's data format registry. Lance is the default backend for collected datasets, so a path ending in `.lance` gives you an append-friendly LanceDB table with episode-contiguous rows and fast indexed reads. + +Random access speed is the bottleneck for world model training, since the loop repeatedly samples temporal windows from high-dimensional observations, actions, and rewards. The faster those windows arrive, the more GPU time goes into training rather than waiting on the data loader. + +## Install {#install} + +```bash +pip install stable-worldmodel +``` + +Datasets and checkpoints are stored under `$STABLEWM_HOME`, which defaults to `~/.stable_worldmodel/`. + +## Collect data into Lance {#collect-data-into-lance} + +Stable World Model uses Lance by default when you collect to a `.lance` path. +Replace `your_expert_policy` with the expert or scripted policy you use to collect demonstrations. + + + {PyFrameworksStableWorldmodelCollectLance} + + +Every writer accepts a `mode` argument such as `append`, `overwrite`, or `error`. The default is append, so re-running collection extends the existing dataset. + +## Load a Lance dataset for training {#load-a-lance-dataset-for-training} + +The dataset loader autodetects the Lance format from the path. + + + {PyFrameworksStableWorldmodelLoadLance} + + +Your model code stays focused on the world model objective while LanceDB handles the storage layout and read path. + +## Evaluate with model-predictive control {#evaluate-with-model-predictive-control} + +After training a world model on the Lance-backed dataset, Stable World Model can evaluate it with planning solvers such as CEM. +Replace `world_model` with the trained model object from your training loop. + + + {PyFrameworksStableWorldmodelEvaluate} + + +## Convert between formats {#convert-between-formats} + +Stable World Model can convert between registered dataset formats. A common workflow is to collect in Lance for fast training reads, then export to the video layout for compact inspection artifacts. + + + {PyFrameworksStableWorldmodelConvert} + + +## Throughput {#throughput} + +The Stable World Model README reports the following PushT benchmark results from `scripts/benchmark/compare_h5_lance.py`: + +| Format | Source | Cache | samples/s | ms/step | +|:---|:---|:---|---:|---:| +| HDF5 | local | no-cache | 1,416.1 | 45.2 | +| HDF5 | local | cached | 1,474.0 | 43.4 | +| LanceDB | local | no-cache | 4,814.8 | 13.3 | +| LanceDB | local | cached | 4,431.3 | 14.4 | +| Video | local | - | 1,330.6 | 48.1 | +| LanceDB | s3 | no-cache | 3,183.7 | 20.1 | +| LanceDB | s3 | cached | 3,253.2 | 19.7 | +| HDF5 | s3 | no-cache | 9.1 | 7,032.5 | +| HDF5 | s3 | cached | 756.5 | 84.6 | + +In that benchmark, local LanceDB reached about **3.4x** the no-cache throughput of local HDF5, while S3-backed LanceDB reached about **350x** the no-cache throughput of S3-backed HDF5. Even with cache enabled, S3-backed LanceDB was about **4.3x** faster than S3-backed HDF5. + +These numbers come from the Stable World Model project's own benchmark setup, so they're best read as a reproducible directional baseline that may shift across environments, models, and storage configurations. + +## Storage {#storage} + +The same README reports these local storage sizes for the benchmark dataset: + +| Format | Local size | +|:---|---:| +| HDF5 | 43.12 GB | +| LanceDB | 13.31 GB | +| Video | 496.29 MB | + +LanceDB used about **69% less local storage than HDF5** in the reported benchmark, while preserving a table interface built for fast training reads and append-heavy collection. + +## More resources {#more-resources} + + + + Installation, quick start, supported formats, benchmarks, environments, solvers, and citation. + + + Full upstream documentation with tutorials, API references, and guides. + + diff --git a/docs/web/maintenance/background-jobs.mdx b/docs/web/maintenance/background-jobs.mdx new file mode 100644 index 000000000..ba88b7137 --- /dev/null +++ b/docs/web/maintenance/background-jobs.mdx @@ -0,0 +1,65 @@ +--- +title: "Background jobs" +sidebarTitle: "Background Jobs" +description: "Jobs a deployment runs on its own — visible in SHOW JOBS, not started by a call you make." +icon: gears +keywords: ["background jobs", "job type", "wal", "spfresh", "prewarm", "archive"] +--- + +Enterprise + +Every job documented elsewhere in these docs -- [Build & Repair](/indexing/reindexing), +[Compaction](/maintenance/compaction), [Cleanup](/maintenance/cleanup), +[Column Refresh](/feature-engineering/refresh-column) -- is one you cause, +directly or by a schedule you set. This page is the rest: jobs the deployment +runs to keep itself correct and fast, which you may see named in +[`SHOW JOBS`](/sql/ddl/job#show) without having asked for any of them. + +There is no action to take on these beyond recognizing the name. None of them +has a parameter you set or a call you make. + +## Write-ahead log maintenance {#wal} + +For a table with a [write-ahead log](/sql/ddl/table#properties) enabled, two jobs keep the +log from growing without bound: + +| Job type | What it does | +| --- | --- | +| `sstable_compaction` | Folds a WAL bucket's SSTables into the base table by primary key. Distinct from `compact`, which merges base-table fragments -- the two are unrelated. | +| `wal_index_catchup` | Extends a base-table index over the rows `sstable_compaction` just folded in, and records how far the index has caught up. Nothing else advances that record. | + +## Index maintenance {#index} + +| Job type | What it does | +| --- | --- | +| `index_remap` | Reassigns index entries after row ids shift underneath an index -- the kind of remap a delta compaction can require. | +| `spfresh_merge` | Merges SPFresh-managed segments for indexes that use it. | + +## Cache warming {#cache} + +| Job type | What it does | +| --- | --- | +| `prewarm_page_cache` | Loads table data into a query node's page cache ahead of a request needing it. | +| `prewarm_index_cache` | The same, for index structures. | +| `startup_prewarm_idx` | Runs cache prewarming as part of a query node's own startup, rather than on a schedule. | + +## Registry housekeeping {#registry} + +| Job type | What it does | +| --- | --- | +| `job_registry_archive` | Sweeps terminal jobs out of the active registry and into archival storage, on a per-database schedule. | +| `job_events_export` | Copies terminal jobs' event histories into a curated system table for metering. Registry-scoped, like the archive sweep. | + +These two are why an old job you completed weeks ago no longer shows in +`SHOW JOBS` -- it was archived, not deleted; its events survive in the exported +table. + +## Object teardown {#teardown} + +| Job type | What it does | +| --- | --- | +| `drop_object` | Deletes a dropped catalog object's content, then clears it from the database's cleanup backlog. Covers namespaces, views, and functions -- everything [`drop_table`](/management/table#drop) does not, since that one fences a directory with its own marker instead. | + +You trigger this indirectly, by dropping a [namespace](/management/namespace), +[view](/sql/ddl/view#drop), or [function](/management/function#drop) -- the teardown +itself runs as this job rather than inline with the call. diff --git a/docs/web/maintenance/cleanup.mdx b/docs/web/maintenance/cleanup.mdx new file mode 100644 index 000000000..71447a7f7 --- /dev/null +++ b/docs/web/maintenance/cleanup.mdx @@ -0,0 +1,82 @@ +--- +title: "Cleanup" +sidebarTitle: "Cleanup" +description: "Pruning old versions and the files only they referenced." +icon: broom +keywords: ["cleanup", "retention", "prune", "delete_unverified", "vacuum"] +--- + +Every write keeps the version before it, which is what makes +[time travel](/management/table#versioning) possible. Cleanup is what ends +that -- pruning versions older than a retention window, and the files that only +those versions referenced. + + +**Enterprise job type:** `cleanup`. Distinct from `compact` -- compaction +merges files, cleanup removes them. `optimize()` in OSS runs both in one call; +Enterprise schedules them as separate jobs. + + +## Retention {#retention} + +```python +from datetime import timedelta + +table.optimize(cleanup_older_than=timedelta(days=30)) +``` + +`cleanup_older_than` defaults to **7 days**. Versions older than that are +removed, along with the files only they referenced. + +The latest version is never removed, whatever you pass -- `timedelta(0)` prunes +everything except it. + + +Pruning is what makes [time travel](/management/table#versioning) finite. A +version outside the retention window is gone, and a tag or branch pointing at +it no longer resolves. + +If you rely on reading old versions, set the window to cover the range you +need before running this, not after. + + +## Disk usage does not drop immediately {#disk} + +Compaction writes new, merged files before the old ones are removed, so disk +usage can rise before it falls. Space is reclaimed only when cleanup prunes the +versions that referenced the superseded files. + +If you need to reclaim space aggressively, shorten the retention window rather +than waiting for the default: + +```python +table.optimize(cleanup_older_than=timedelta(days=1)) +``` + +## Files from failed transactions {#unverified} + +A write that fails partway can leave files that look like an operation still +in progress. Cleanup will not remove them until they are at least seven days +old, because it cannot tell them apart from a live write. + +```python +table.optimize(delete_unverified=True) +``` + + +`delete_unverified=True` removes them regardless of age. Only set it when you +can guarantee **no other process is writing to the table** -- otherwise it can +delete files belonging to a write that is genuinely in progress, and corrupt +the table. + + +## What to read next {#next} + + + + Merging small files -- the other half of `optimize()`. + + + The Enterprise jobs that run without a call from you. + + diff --git a/docs/web/maintenance/compaction.mdx b/docs/web/maintenance/compaction.mdx new file mode 100644 index 000000000..b6697921c --- /dev/null +++ b/docs/web/maintenance/compaction.mdx @@ -0,0 +1,95 @@ +--- +title: "Compaction and cleanup" +sidebarTitle: "Compaction" +description: "Merge small files, prune old versions, and fold new rows into existing indexes." +icon: broom +--- + +import { PyStorageRebuildVectorIndex } from '/snippets/doc_storage_examples.mdx'; + +Every write commits a new table version, and versions accumulate: small files +from frequent appends, superseded files from updates and deletes, and rows added +since an index was built. `optimize()` addresses all three. + +It is modelled after `VACUUM` in PostgreSQL — routine housekeeping that a table +needs periodically rather than a repair for something broken. + +## What it does {#what-it-does} + +```python +table.optimize() +``` + +Three operations, in one call: + +| | | +| --- | --- | +| **Compaction** | merges small files into larger ones | +| **Prune** | removes versions older than the retention window -- see [Cleanup](/maintenance/cleanup) | +| **Index** | folds newly added rows into existing indexes -- see [Build & Repair](/indexing/reindexing) | + +Compaction itself is only the first of the three: merging small fragments back +into larger ones, which is what keeps a scan from opening more files than it +needs to. + + +**Enterprise job type:** `compact`. Runs automatically as data accumulates, +separately from [`cleanup`](/maintenance/cleanup) -- the two are distinct jobs +even though `optimize()` bundles both in one OSS call. + + +## Retraining indexes {#retrain} + +Folding new rows into an index preserves the structure the index was trained on. +When the data distribution has shifted enough that the index's clusters are a +poor fit, recreate the index with `replace=True` to train it on the current data. +The deprecated `retrain` argument to `optimize()` does not rebuild indexes. + +This example assumes a populated table whose `vector` column has an IVF_FLAT +index configured for L2 distance and four partitions: + + + + {PyStorageRebuildVectorIndex} + + + +Use your index's column, type, distance metric, and training settings when +rebuilding it. Recreating an index is more expensive than a normal optimize, +and can help after a large ingest that changed the data distribution. + +## When to run it {#when} + +There is no background compactor: `optimize()` runs when you call it. + +Frequent small appends are what make it necessary, because each one commits +files. A table written in a few large batches needs it rarely; one taking +continuous small writes needs it on a schedule. + + +Compaction rewrites files, which conflicts with a concurrent +[computed-column refresh](/feature-engineering/add-computed-column) or +[view refresh](/feature-engineering/materialized-views) planned against the +version it replaces. Sequence them rather than running both at once. + + +See [Cleanup](/maintenance/cleanup) for pruning old versions, and +[Build & Repair](/indexing/reindexing) for the index-folding half of +`optimize()`. + +## Performance {#performance} + +Two things accumulate on a long-lived table as more and more data gets added to it: + +- **Many small fragments build up as you write**, slowing down queries that have to scan across more files. **Compaction** merges them back into larger fragments. +- **Old versions build up as the table changes**, growing disk usage beyond the live data size (LanceDB retains them for time-travel and rollback). **Cleanup** prunes versions older than a retention window. + +In OSS, you run them yourself via `optimize()`, which bundles both into a single call: + +```python Python icon="python" +from datetime import timedelta +table.optimize() # 7-day default retention +table.optimize(cleanup_older_than=timedelta(days=1)) # reclaim space sooner +``` + +The best practice is to run `optimize()` after large writes or on a schedule. It also bundles incremental index updates — see [Build & Repair](/indexing/reindexing) for the breakdown. Updates also move rows out of the vector index, so they remain searchable but unindexed — rebuild the index after large update batches. diff --git a/docs/web/management/database.mdx b/docs/web/management/database.mdx new file mode 100644 index 000000000..1f77e5b10 --- /dev/null +++ b/docs/web/management/database.mdx @@ -0,0 +1,193 @@ +--- +title: "Database" +sidebarTitle: "Database" +description: "Connecting to LanceDB: the URI forms, their options, and what each one changes." +icon: database +keywords: ["connect", "database", "uri", "api key", "storage options", "consistency"] +--- + +A database is the root of a [catalog](/basics/catalog). A connection opens +exactly one, and every other object is reached through it. + +Connecting is the one decision that reaches furthest: the URI you pass decides +where queries execute, which features exist, and what a table handle actually +is. + +## The three URI forms {#uris} + + +```python Python icon="python" +import lancedb + +db = lancedb.connect("./.lancedb") # local directory +db = lancedb.connect("s3://bucket/path") # object storage +db = lancedb.connect("db://my-database", api_key="...") # a deployment +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; + +const db = await lancedb.connect("./.lancedb"); +const db = await lancedb.connect("s3://bucket/path"); +const db = await lancedb.connect("db://my-database", { apiKey: "..." }); +``` + + +The first two are **embedded**: your process opens Lance tables directly from +storage and does the work itself. The third talks to a **deployment**. + +| | Embedded | Deployment | +| --- | --- | --- | +| Where queries run | in your process | on the server | +| Table handle | `LanceTable` | `RemoteTable` | +| [SQL](/basics/sql) | -- | yes | +| Server-side [jobs](/basics/jobs) | -- | yes | +| [Functions](/management/function) | -- | yes | +| [Authorization](/governance/authorization) | storage permissions | granted per object | + +![](/static/assets/images/overview/understanding-tables.png) + +Both handles expose the same table API -- create, open, mutate, query -- so +application code moves between them by changing the URI. What differs is where +resolution and execution happen, and therefore which operations exist at all. + +## Connecting to a deployment {#deployment} + +Enterprise + + +```python Python icon="python" +db = lancedb.connect( + "db://my-database", + api_key="...", + region="us-east-1", +) +``` + +```typescript TypeScript icon="square-js" +const db = await lancedb.connect("db://my-database", { + apiKey: "...", + region: "us-east-1", +}); +``` + + +| Option | Meaning | +| --- | --- | +| `api_key` / `apiKey` | authenticates the connection -- see [Authentication](/enterprise/authentication) | +| `region` | which region hosts the database. Defaults to `us-east-1` | +| `host_override` / `hostOverride` | point at a specific endpoint instead of the regional one | +| `sql_host_override` | the same, for the [SQL endpoint](/sql#connecting). Python only | +| `client_config` / `clientConfig` | retries, timeouts, and connection pooling | + +An API key belongs in the environment, not in source. `region` is easy to get +wrong silently -- the default is `us-east-1` whether or not that is where your +database lives, and a wrong region fails as a connection error rather than a +helpful one. + +## Storage options {#storage-options} + +`storage_options` configures the underlying object store -- credentials, +endpoints, and defaults for tables created through this connection: + + +```python Python icon="python" +db = lancedb.connect( + "s3://bucket/path", + storage_options={"new_table_enable_stable_row_ids": "true"}, +) +``` + +```typescript TypeScript icon="square-js" +const db = await lancedb.connect("s3://bucket/path", { + storageOptions: { new_table_enable_stable_row_ids: "true" }, +}); +``` + + + +`new_table_enable_stable_row_ids` is worth setting deliberately: it gives stable +row ids to the tables this connection creates, and no SDK call adds them to an +existing table. A `db://` connection does not send it; a deployment decides +stable row ids itself (see +[Against a deployment](/feature-engineering/materialized-views#availability)). + + +See [Storage](/deployment/storage) for the full set, including per-cloud credentials. + +## Read consistency {#consistency} + +`read_consistency_interval` controls how often a connection checks for versions +committed by other writers. + + +```python Python icon="python" +from datetime import timedelta + +db = lancedb.connect(uri, read_consistency_interval=timedelta(seconds=5)) +``` + +```typescript TypeScript icon="square-js" +const db = await lancedb.connect(uri, { readConsistencyInterval: 5 }); +``` + + +The default reads a snapshot taken when the table was opened, so a long-lived +connection **will not see other processes' writes** until it reopens the table. +That is the right default for a batch job and the wrong one for a service. + +Set it when several processes write the same table and readers must observe +those writes. The cost is a metadata check on that interval. See +[Consistency](/tables/consistency). + +## Database-level operations {#operations} + +| | Python | TypeScript | SQL | +| --- | --- | --- | --- | +| list tables | `table_names`, `list_tables` | `tableNames`, `listTables` | -- | +| the URI it opened | `uri` | `display()` | -- | +| drop every table | `drop_all_tables` | `dropAllTables` | -- | +| drop the database | `drop_database` | -- | -- | +| close | -- | `close`, `isOpen` | -- | + + +```python Python icon="python" +db.table_names() +tbl = db.open_table("documents") +db.drop_table("documents") +``` + +```typescript TypeScript icon="square-js" +await db.tableNames(); +const tbl = await db.openTable("documents"); +await db.dropTable("documents"); +``` + +```sql SQL +DROP TABLE documents; +``` + + +Each takes a `namespace_path` to act inside a [namespace](/management/namespace) rather +than at the root. + + +`drop_database()` removes every table in the database. There is no confirmation +and no undo -- table [versioning](/management/table#versioning) does not survive the table +being dropped. + + +## Async connections {#async} + +`connect_async` returns an `AsyncConnection` with the same surface as +coroutines. The TypeScript SDK is async throughout, so its `connect` already is +one. + +```python +db = await lancedb.connect_async("db://my-database", api_key="...") +tbl = await db.open_table("documents") +``` + +This is a different axis from the `_async` operations that return a +[job](/basics/jobs): `connect_async` is about not blocking your event loop, +while `create_index_async` is about work that outlives the call. diff --git a/docs/web/management/function.mdx b/docs/web/management/function.mdx new file mode 100644 index 000000000..40e5bcd00 --- /dev/null +++ b/docs/web/management/function.mdx @@ -0,0 +1,224 @@ +--- +title: "Functions" +sidebarTitle: "Function" +description: "Register a Python function once and apply it as a column, for work SQL cannot express." +icon: code +keywords: ["udf", "functions", "create_function", "embedding", "feature engineering"] +--- + +import { + PyFunctionDefine as FunctionDefine, + PyFunctionLocalCall as FunctionLocalCall, + PyFunctionConnect as FunctionConnect, + PyFunctionCreateTable as FunctionCreateTable, + PyFunctionRegister as FunctionRegister, + PyFunctionApply as FunctionApply, + PyFunctionRefresh as FunctionRefresh, + PyFunctionInspect as FunctionInspect, +} from '/snippets/function.mdx'; + +Enterprise + +Some columns cannot be written as a SQL expression: an embedding, a model call, +an image decode. A function is a Python callable registered with the database, +versioned, and then applied as a column — the code runs where the data is +rather than pulling rows to your process. + +Registering and running a function needs a LanceDB Enterprise connection. On a +local connection `create_function` raises `NotImplementedError`; to derive a +column locally, declare a +[SQL computed column](/feature-engineering/add-computed-column) instead. + + +In SQL, a function is registered with [`CREATE FUNCTION`](/sql/ddl/function#create) and listed with [`SHOW FUNCTIONS`](/sql/ddl/function#show). + + +The steps below take a column of numbers to a filled column of their doubles. + +## Define a function {#define} + +`@udf` packages a scalar Python callable. Input and output types are inferred +from annotations, so `double` takes one non-nullable `float64` parameter, +`value`, and returns a non-nullable `float64`: + + +{FunctionDefine} + + +The decorated object stays callable. Calling it runs the original function in +your process, without the database, so unit tests stay ordinary tests: + + +{FunctionLocalCall} + + +Where annotations cannot express the Arrow type precisely, pass `input_schema` +and `output_schema` together — one without the other is rejected. A scalar +output must be non-nullable, because a physical NULL is what marks a +computed-column row as unfilled. A named-struct output may declare nullable +fields: whether a row has a result at all is the struct's own validity, which +Enterprise keeps in a separate internal column when it expands the result into +sibling columns, so a null field is a value rather than a missing row. + +## Connect and create a table {#table} + +The remaining steps run against your Enterprise deployment. This connection +reads its settings from environment variables, so no credential appears in the +code: + + +{FunctionConnect} + + +For an ordinary stored input column, the Arrow type must match the parameter. +A nullable column requires a nullable parameter, such as `Optional[float]`; +a non-nullable column can bind to either. This example uses `value: float`, +so the table declares `value` non-nullable. + +Computed-column inputs depend on the deployment version. Where supported, +they bind to non-nullable parameters even though the physical column is +nullable: refresh skips rows whose computed input is still null. Check your +deployment's support before using another computed column as an input. + + +{FunctionCreateTable} + + +## Register it {#register} + +Registration returns a job; the immutable version exists once the job succeeds. + + +{FunctionRegister} + + +`create_function` is the blocking equivalent. A registered version is fetched +with `db.get_function(name, version=...)`. + + +**Enterprise job type:** `create_function`. Registration returns a job because +the function is baked into an image before the version becomes usable. + + +## Apply it as a column {#apply} + +Calling the registered version binds each parameter, by name, to a column with +`col(...)`. `add_columns` declares the result as a new column: + + +{FunctionApply} + + +The column is declared empty; the function has not run yet. A mapping with one +function application keeps its scalar or named-struct result in the named +column. A bare named-struct application expands its result fields as one atomic +binding, with aliases from `rename(columns=...)`. + +## Fill the column {#refresh} + +A refresh runs the function on the cluster as a [job](/basics/jobs). `wait()` +blocks until the job finishes and returns its result, including how many rows +it filled: + + +{FunctionRefresh} + + +The filled values are read like any other column: + + +{FunctionInspect} + + +Filling then works as it does for a SQL computed column: a later refresh fills +rows added since the last one and recomputes rows whose inputs changed, and +leaves filled rows with unchanged inputs alone. See +[Refresh a column](/feature-engineering/refresh-column). + +## Declare the environment {#environment} + +The function runs remotely, so its dependencies are part of its definition: + +| | | +| --- | --- | +| `pip` | requirements for the remote environment | +| `conda`, `conda_channels` | conda packages instead of pip; channels in priority order | +| `env` | environment variables | +| `python_version` | remote Python major/minor, defaulting to yours | +| `gpu` | whether every execution requires a GPU | + +These are part of the **immutable function version**. Changing a dependency +produces a new version rather than altering the one already registered, so a +column filled by version 1 is unaffected by version 2. + +Declaring them is not optional bookkeeping. If the worker environment differs +from the one the function was written against, the failures are among the +hardest to diagnose in the system, because the error rarely names the cause. + +### Symptoms of an environment mismatch {#mismatch-symptoms} + +| Symptom | Likely cause | +| --- | --- | +| `TypeError: Enum.__new__() missing 1 required positional argument` | `attrs` version mismatch | +| `TypeError: Can't instantiate abstract class` | package structure differs between versions | +| `ArrowInvalid: cannot cast`, or serialization errors | NumPy 1.x against 2.x | +| `ModuleNotFoundError` on the worker | package installed locally but not declared | +| Model loading failures | PyTorch version mismatch | +| Permission denied | an API key present locally but absent from `env` | + +The packages worth pinning explicitly, because a mismatch in them produces the +errors above rather than a clear one: **numpy, torch, pyarrow, attrs, +pydantic**. + + +The client version defaults `python_version`, so a function registered from a +3.11 environment runs on 3.11 unless you say otherwise. That default is usually +right and occasionally surprising -- pin it when the function is sensitive to it. + + +## Failure handling {#failures} + +A function that raises fails the job. There is no per-row retry or skip policy +in the function definition: `@udf` takes no error-handling parameter, so a +function that calls a flaky external service should handle that itself, inside +the function body. + +Letting the job fail is the other option: fix the cause and refresh again. The +next refresh [skips rows that are filled and current](/feature-engineering/refresh-column#idempotent), +but how much of the failed job's work was kept is not guaranteed, so do not +count on the retry costing only the rows that did not complete. + +## What gets packaged {#packaging} + + +The artifact is a **snapshot**: the function source, plus exactly the +module-level names it references — modules as imports, importable classes and +functions as imports, literals inlined. + +Code that reaches the module namespace another way is rejected where it can be +seen and unsupported where it cannot: `globals()`, `eval`, `sys.modules`, +`builtins`. Closures and a non-standard `__builtins__` are rejected outright. + + +In practice this means a function should be self-contained: take its inputs as +arguments, import what it needs at module level, and avoid capturing state from +the surrounding scope. + +## Drop it {#drop} + +`db.drop_function(name, version=...)` drops the function's name and the object +it is bound to; `version` must be a version that exists. It returns `False` if +the name was already gone. Deleting the object's content can finish after the +call returns, as a [background job](/maintenance/background-jobs). +`drop_function_async` returns that job with the result, so you can wait for it. + +## What to read next {#next} + + + + Refresh semantics, and the SQL alternative. + + + Built-in embedding providers, which need no registration. + + diff --git a/docs/web/management/index.mdx b/docs/web/management/index.mdx new file mode 100644 index 000000000..1d4d4eeba --- /dev/null +++ b/docs/web/management/index.mdx @@ -0,0 +1,154 @@ +--- +title: "Management" +sidebarTitle: "Overview" +description: "Create, list, and drop the objects in a catalog, from Python, TypeScript, or SQL." +icon: folder-tree +keywords: ["ddl", "create", "drop", "list", "catalog", "management"] +--- + +Every [catalog object](/basics/catalog) is created, listed, and dropped the same +few ways. This section is the reference for those operations; the pages linked +from it cover what each object is for. + +Operations that can take a while have an `_async` form returning a +[job](/basics/jobs). + +## The surface at a glance {#surface} + + +SQL requires a deployment -- a local database has no engine to plan a statement. +See [SQL](/basics/sql#sql-is-remote). + + +### Namespaces {#namespaces} + +| | Python | TypeScript | SQL | +| --- | --- | --- | --- | +| create | `create_namespace` | `createNamespace` | -- | +| list | `list_namespaces` | `listNamespaces` | -- | +| describe | `describe_namespace` | `describeNamespace` | -- | +| exists | `namespace_exists` | -- | -- | +| drop | `drop_namespace` | `dropNamespace` | -- | + +See [Namespaces](/management/namespace). + +### Tables {#tables} + +| | Python | TypeScript | SQL | +| --- | --- | --- | --- | +| create | `create_table` | `createTable` | `CREATE TABLE` | +| create empty | `create_table(schema=)` | `createEmptyTable` | `CREATE TABLE` | +| open | `open_table` | `openTable` | -- | +| list | `table_names`, `list_tables` | `tableNames`, `listTables` | -- | +| exists | `table_exists` | -- | -- | +| rename | `rename_table` | `renameTable` | -- | +| clone | `clone_table` | `cloneTable` | -- | +| drop | `drop_table`, `drop_table_async` | `dropTable`, `dropTableAsync` | `DROP TABLE` | +| drop all | `drop_all_tables` | `dropAllTables` | -- | + +See [Tables](/management/table) for creating one and shaping its schema. + +### Materialized views {#materialized-views} + +| | Python | TypeScript | SQL | +| --- | --- | --- | --- | +| create | `create_materialized_view` | `createMaterializedView` | `CREATE MATERIALIZED VIEW` | +| open | `open_materialized_view` | `openMaterializedView` | -- | +| list | `list_materialized_views` | `listMaterializedViews` | `SHOW MATERIALIZED VIEWS` | +| refresh | `refresh` | `refresh` | `REFRESH MATERIALIZED VIEW` | +| drop | `drop_table` | `dropTable` | `DROP TABLE` | + + +`list_materialized_views` reads every table's schema to find them, so it costs +one open per table. `SHOW MATERIALIZED VIEWS` asks the server instead. + + +See [Materialized views](/management/materialized-view) for creating one, and +[Refresh a materialized view](/feature-engineering/materialized-views) for keeping +it current. + +### Views {#views} + +| | Python | TypeScript | SQL | +| --- | --- | --- | --- | +| create | -- | -- | `CREATE VIEW` | +| drop | -- | -- | `DROP VIEW` | + +Enterprise Views exist only in SQL -- there is no SDK +method for them, because the thing a view stores *is* a SQL statement. See +[Views](/management/view). + +### Functions {#functions} + +| | Python | TypeScript | SQL | +| --- | --- | --- | --- | +| create | `create_function`, `create_function_async` | -- | `CREATE FUNCTION` | +| get | `get_function` | -- | -- | +| list | `list_functions` | -- | `SHOW FUNCTIONS` | +| drop | `drop_function` | -- | -- | + +Enterprise A function runs on the server, so there is +no local form. The TypeScript SDK has no function methods; use Python or SQL. +See [Functions](/management/function). + +### Jobs {#jobs} + +| | Python | TypeScript | SQL | +| --- | --- | --- | --- | +| list | `list_jobs` | `listJobs` | `SHOW JOBS` | +| open | `open_job` | `openJob` | -- | +| cancel | `cancel_job` | `cancelJob` | -- | +| errors | `job.events` | `job.events` | `SHOW ERRORS` | + +See [Jobs](/basics/jobs). + +### Indexes {#indexes} + +| | Python | TypeScript | SQL | +| --- | --- | --- | --- | +| create | `create_index`, `create_index_async` | `createIndex`, `createIndexAsync` | `CREATE INDEX` | +| list | `list_indices` | `listIndices` | -- | +| stats | `index_stats` | `indexStats` | -- | +| drop | `drop_index` | `dropIndex` | `DROP INDEX` | + + +SQL `CREATE INDEX` covers one column and takes no user-chosen name -- the server +names it. For a multi-column or named index, use the SDK. + + +Indexes are a performance decision rather than a structural one, so they are +covered under [Maintenance](/indexing). + +## Namespaces qualify everything {#namespace-paths} + +Most connection-level operations take a `namespace_path` (`namespacePath`) to +act inside a namespace rather than at the database root: + + +```python Python icon="python" +db.table_names(namespace_path=["prod", "events"]) +tbl = db.open_table("clicks", namespace_path=["prod", "events"]) +``` + +```typescript TypeScript icon="square-js" +await db.tableNames({ namespacePath: ["prod", "events"] }); +const tbl = await db.openTable("clicks", { namespacePath: ["prod", "events"] }); +``` + +```sql SQL +SELECT * FROM prod.events.clicks +``` + + +In SQL the path is written as dotted identifiers instead. + +## Dropping is not undoable {#dropping} + + +`drop_table` removes the table and every version of it. Table +[versioning](/management/table#versioning) does not survive the table being dropped, and +neither `drop_database` nor `drop_all_tables` asks for confirmation. + + +Dropping a **view** removes only the saved query. Dropping a **materialized +view** removes stored rows, because a materialized view is a table. diff --git a/docs/web/management/materialized-view.mdx b/docs/web/management/materialized-view.mdx new file mode 100644 index 000000000..cbdb703b1 --- /dev/null +++ b/docs/web/management/materialized-view.mdx @@ -0,0 +1,95 @@ +--- +title: "Materialized view" +sidebarTitle: "Materialized View" +description: "Create, open, list and drop a table whose contents are defined by a query." +icon: table +keywords: ["materialized view", "create_materialized_view", "catalog", "ddl"] +--- + +A materialized view is a table whose contents are defined by a query over +another table. The query is recorded in the view's schema, and a refresh +brings its stored rows up to date with the source. + +The result is an ordinary table -- queryable, indexable, searchable, and present +in `table_names()`. Nothing about reading it is special. + +This page covers creating and managing one. Keeping it current is +[Refresh a materialized view](/feature-engineering/materialized-views). + + +In SQL, see [`REFRESH MATERIALIZED VIEW`](/sql/dml/refresh#refresh-materialized-view) and [`SHOW MATERIALIZED VIEWS`](/sql/ddl/materialized-view#show). + + +## Creating one {#create} + + +```python Python icon="python" +view = db.create_materialized_view( + "recent_docs", + source="documents", + select=["id", "title", "embedding"], + where="created_at > '2026-01-01'", +) +``` + +```typescript TypeScript icon="square-js" +const view = await db.createMaterializedView("recent_docs", "documents", { + select: ["id", "title", "embedding"], + where: "created_at > '2026-01-01'", +}); +``` + + +`source` names the table it derives from. A local source does not need stable +row ids or any other storage option; a deployment may require stable row ids +(see [Against a deployment](#deployment)). `select`, `where` and `limit` record +the query -- there is no separate definition step. + +## Opening, listing and dropping {#manage} + +| | Python | TypeScript | SQL | +| --- | --- | --- | --- | +| create | `create_materialized_view` | `createMaterializedView` | `CREATE MATERIALIZED VIEW` | +| open | `open_materialized_view` | `openMaterializedView` | -- | +| list | `list_materialized_views` | `listMaterializedViews` | `SHOW MATERIALIZED VIEWS` | +| refresh | `refresh` | `refresh` | `REFRESH MATERIALIZED VIEW` | +| drop | `drop_table` | `dropTable` | `DROP TABLE` | + + +`list_materialized_views` finds views by reading every table's schema, so it +costs one open per table. `SHOW MATERIALIZED VIEWS` asks the server instead and +is cheaper on a catalog with many tables. + + +A view is dropped with `drop_table`, because a materialized view **is** a table. +That also means dropping one removes stored rows -- unlike dropping a +[view](/management/view), which removes only a saved query. + +## Working with the view {#working} + +`view.table` is the underlying table, for querying, indexing, or search. +`view.definition` is the recorded query -- select list, predicate, limit +-- which is how you check what a view contains without inferring it from the +rows. Both are properties; on an async view, the definition is read with +`await view.definition()`. + +## Against a deployment {#deployment} + +Enterprise `create_materialized_view` works on a +`db://` connection as well as a local one. There the SDK creates and refreshes +the view through SQL, and the work runs on the cluster rather than in your +process. Materialized views are off by default on a deployment and have to be +enabled there, and a deployment may require a view's source to have stable row +ids; see +[Against a deployment](/feature-engineering/materialized-views#availability). + +## What to read next {#next} + + + + How a refresh works, and what it writes. + + + The non-materialized counterpart, defined in SQL. + + diff --git a/docs/web/management/namespace.mdx b/docs/web/management/namespace.mdx new file mode 100644 index 000000000..f1310b6da --- /dev/null +++ b/docs/web/management/namespace.mdx @@ -0,0 +1,363 @@ +--- +title: "Namespaces" +sidebarTitle: "Namespace" +description: "Grouping tables into a hierarchy, and addressing them through it." +icon: folder-tree +keywords: ["namespace", "catalog", "hierarchy", "directory namespace", "rest namespace"] +--- + +import { + PyNamespaceTableOps, + PyNamespaceAdminOps, + TsNamespaceTableOps, + TsNamespaceAdminOps, + RsNamespaceAdminOps, + RsNamespaceTableOps, +} from '/snippets/connection.mdx'; + +Despite its name, LanceDB is not a "database" in the traditional sense -- it is a **Multimodal Lakehouse** that builds on the table abstraction, +similar to many other lakehouse projects. LanceDB exposes a catalog-level abstraction over the Lance table format, via a *namespace spec*. +If you're coming from traditional databases or lakehouses, you can think of a namespace as the catalog path that says where a table name lives. + +Lance provides the **file** and **table** formats to store and manage your data and indexes. +LanceDB operates at the catalog layer (used to organize, discover, and operate on many Lance tables) +and provides a compute engine on top of the Lance format. + +This is why many SDK methods in LanceDB, like `create_table`, `open_table`, `drop_table`, and +`rename_table`, accept namespace input. The SDK methods expose that input in the idiom of each +language: Python uses `namespace_path`, Rust uses builder methods like `.namespace(...)`, and +TypeScript uses `namespacePath` arguments. + + +In SQL a namespaced table is addressed as a [dotted path](/sql/dql/select#namespaces). + + +## Namespace hierarchy {#namespace-hierarchy} + +Namespaces are generalizations of catalog specs that give platform developers a clean way to present Lance tables in the structures users expect. The diagram below shows how the hierarchy can go beyond a single level. +A namespace can contain a collection of tables, and it can also contain namespaces recursively. + +![](/static/assets/images/namespaces/lance-namespace.png) + +Before diving into examples, it helps to keep two terms in mind: the **namespace client** is the abstraction that presents a consistent namespace API, while the **namespace implementation** is the concrete backend that resolves namespaces and table locations (for example, a local directory or an external catalog). +If you want to go deeper, see the Lance format [namespace documentation](https://lance.org/format/namespace/). + +## Namespace paths and names {#namespace-paths-and-names} + +A namespace path is a list of components. For example, `["prod", "search"]` means the `search` +namespace inside the `prod` namespace. The empty path, `[]`, means the root namespace. + +Each component is a name, not a filesystem path segment. Namespace names can't be empty, and each +component can contain only letters, numbers, underscores, hyphens, and periods. That keeps the same +identifier usable across local directory namespaces and REST namespace identifiers. + +## Directory namespaces {#directory-namespaces} + +The simplest namespace model in LanceDB is a single root namespace, often represented by one +directory: + +```bash +./local_lancedb (root) +└── prod + └── search + └── user (table) + └── data (table) +``` + +As a user of LanceDB OSS, you might never notice namespaces at first, because LanceDB exposes the single-level hierarchy shown above, with the data stored in the `data/` directory, where the root namespace is implicit. Connecting to this namespace is as simple as connecting to the catalog root: + + +```python Python icon="python" +import lancedb + +# Connect to the directory namespace root +db = lancedb.connect("./local_lancedb") +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; + +// Connect to the directory namespace root +const db = await lancedb.connect("./local_lancedb"); +``` + +```rust Rust icon="rust" +use lancedb::connect; + +// Connect to the directory namespace root +let db = connect("./local_lancedb").execute().await?; +``` + + +This creates the default namespace directory (`data/`) under the specified root path. + +You can also explicitly connect to a namespace using `lancedb.connect_namespace(...)` with the directory namespace implementation: + + +```python Python icon="python" +import lancedb + +# Local namespace-backed catalog root (DirectoryNamespace) +# See https://lance.org/format/namespace/dir/catalog-spec/ +db = lancedb.connect_namespace("dir", {"root": "./local_lancedb"}) + +table_name = "user" +data = [{"id": 1, "vector": [0.1, 0.2], "name": "alice"}] + +table = db.create_table(table_name, data=data, mode="create") +print(f"Created table: {table.name}") +# Created table: user +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; + +// Local namespace-backed catalog root (DirectoryNamespace) +// See https://lance.org/format/namespace/dir/catalog-spec/ +const db = await lancedb.connectNamespace("dir", { root: "./local_lancedb" }); + +const table = await db.createTable( + "user", + [{ id: 1, vector: [0.1, 0.2], name: "alice" }], + { mode: "create" }, +); +console.log(`Created table: ${table.name}`); +// Created table: user +``` + +```rust Rust icon="rust" +use std::collections::HashMap; +use std::sync::Arc; +use arrow_schema::{DataType, Field, Schema}; + +// Local namespace-backed catalog root (DirectoryNamespace) +// See https://lance.org/format/namespace/dir/catalog-spec/ +let mut properties = HashMap::new(); +properties.insert("root".to_string(), "./local_lancedb".to_string()); +let db = lancedb::connect_namespace("dir", properties) + .execute() + .await?; + +let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), +])); +let table = db + .create_empty_table("user", schema) + .execute() + .await?; +println!("Created table: {}", table.name()); +// Created table: user +``` + + + +- For simple use cases in LanceDB OSS, you don't need to go too deep into namespaces. +- To integrate LanceDB with external catalogs and to use it as a true **multimodal lakehouse**, it's useful to understand the different namespace implementations and how to use them in your organization's setup. + + +## Remote or external catalog namespaces {#remote-or-external-catalog-namespaces} + +The example above showed local directory-based namespaces. LanceDB also supports namespaces backed by remote object stores and external catalogs, via the REST namespace implementation. + +For remote object stores with central metadata or catalog services (either commercial or open source), +use the REST namespace implementation. It is backed by REST routes +(for example `POST /v1/namespace/{id}/create` and `GET /v1/namespace/{id}/list`) and server-provided table locations. + +For authentication, any property prefixed with `headers` is forwarded as an HTTP header +(for example `headers.Authorization` becomes `Authorization`, and `headers.X-API-Key` becomes `X-API-Key`). +LanceDB Enterprise REST requests use the `x-api-key` header for API-key authentication. Deployments +that route multiple databases through the same endpoint can also use headers such as +`x-lancedb-database` or `x-lancedb-database-prefix` for database context. + + +```python Python icon="python" +import os +import lancedb + +# Remote namespace-backed catalog root (RestNamespace) +# See https://lance.org/format/namespace/rest/catalog-spec/ +db = lancedb.connect_namespace( + "rest", + { + "uri": "https://.internal..com", + "headers.x-api-key": os.environ["API_KEY"], + # or: + # "headers.Authorization": f"Bearer {os.environ['REST_AUTH_TOKEN']}", + }, +) +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; + +// Remote namespace-backed catalog root (RestNamespace) +// See https://lance.org/format/namespace/rest/catalog-spec/ +const db = await lancedb.connectNamespace("rest", { + uri: "https://.internal..com", + headers: { + "x-api-key": process.env.API_KEY ?? "", + // or: + // Authorization: `Bearer ${process.env.REST_AUTH_TOKEN}`, + }, +}); +``` + +```rust Rust icon="rust" +use std::collections::HashMap; + +// Remote namespace-backed catalog root (RestNamespace) +// See https://lance.org/format/namespace/rest/catalog-spec/ +let mut properties = HashMap::new(); +properties.insert( + "uri".to_string(), + "https://.internal..com".to_string(), +); +properties.insert( + "headers.x-api-key".to_string(), + std::env::var("API_KEY")?, +); +// or: +// properties.insert( +// "headers.Authorization".to_string(), +// format!("Bearer {}", std::env::var("REST_AUTH_TOKEN")?), +// ); +let db = lancedb::connect_namespace("rest", properties) + .execute() + .await?; +``` + +[LanceDB Enterprise](/enterprise) operates a REST namespace server on top of the Lance format, so any REST client that can speak the REST namespace API +contract can be used to interact with it. For authentication examples in LanceDB Enterprise, visit +the [Namespaces in SDKs](#namespaces-in-lancedb-enterprise) page. + +## Best practices {#best-practices} + +Below, we list some best practices for working with namespaces: +- For simple use cases and single, stand-alone applications, the directory-based root namespace is sufficient and requires no special configuration. +- For remote storage locations, introduce explicit namespaces when multiple teams, environments, or domains share the same catalog. +- Treat namespace paths as stable identifiers (for example `"prod/search"`, `"staging/recs"`). +- For maintainability reasons, avoid hard-coding object-store table paths in application code -- instead, prefer catalog identifiers + namespaces. + + +## Table operations with namespace paths {#table-operations-with-namespace-paths} + +Let's imagine a scenario where your table management needs have evolved, and you now have the following multi-level structure to organize your tables outside the root namespace. +``` +./local_lancedb (root) +└── prod + └── search + └── user (table) + └── data (table) + └── recommendations + └── user (table) + └── data (data) +``` + +Below, we show how you would express table operations within that namespace. Each item in the namespace +list (`["prod", "search"]`) represents a level in the namespace hierarchy, and the table name is +specified when you create, open, list, or drop it. + +The SDK methods expose the namespace path in the idiom of each language: + +- Python: pass `namespace_path=["prod", "search"]` to table operations. +- Rust: call builder methods such as `.namespace(vec!["prod".to_string(), "search".to_string()])`. +- TypeScript: pass a `namespacePath` array, for example `await db.openTable("user", ["prod", "search"])`. + + + + {PyNamespaceTableOps} + + + + {TsNamespaceTableOps} + + + + {RsNamespaceTableOps} + + + + +Using namespaces is **optional** in LanceDB, and most basic use cases do not require to work with them. +An empty namespace (`[]`), which is the default, means "root namespace", and the data will be stored in +the `data/` directory under the specified root path. + + +## Namespace management APIs {#namespace-management-apis} + +You can open/create/drop tables inside a namespace path (like `["prod", "search"]`). +All three SDKs expose namespace lifecycle operations directly. +In Python, use `lancedb.connect_namespace(...)` when calling namespace lifecycle methods such as +`create_namespace`, `list_namespaces`, `describe_namespace`, and `drop_namespace`. +In TypeScript, use `lancedb.connectNamespace(...)` and call `createNamespace`, `listNamespaces`, +`describeNamespace`, and `dropNamespace` on the returned `Connection`. +In Rust, use `lancedb::connect_namespace(...)` and call `create_namespace`, `list_namespaces`, +and `drop_namespace`. + + + + {PyNamespaceAdminOps} + + + + {TsNamespaceAdminOps} + + + + {RsNamespaceAdminOps} + + + +Namespace creation and deletion have modes that control what happens when the target already exists, +doesn't exist, or contains data: + +- Create mode: `create` fails if the namespace already exists, `exist_ok` keeps the existing namespace, and `overwrite` replaces it. +- Drop mode: `fail` reports an error when the namespace doesn't exist, and `skip` treats a missing namespace as a successful no-op. +- Drop behavior: `restrict` keeps non-empty namespaces from being dropped, and `cascade` drops child namespaces and tables first. + +Namespace path components can't be empty. Each component can contain only letters, numbers, +underscores, hyphens, and periods. + +Listing APIs return the immediate children of the requested namespace path. Use `limit` with the +returned `page_token` to page through large catalogs; pass an empty namespace path (`[]`) when you +want to list from the root namespace. + +## Namespaces in LanceDB Enterprise {#namespaces-in-lancedb-enterprise} + +In LanceDB Enterprise deployments, configure namespace-backed federated databases in a TOML file under your deployment's `config` directory. +LanceDB Enterprise supports both directory-based (`ns_impl = "dir"`) and REST-based (`ns_impl = "rest"`) namespace implementations. +The example below shows how to configure a directory-based namespace implementation in LanceDB Enterprise. +```toml +# Federated database configuration for DirectoryNamespace +# This example uses minio storage +[federated_dbs.federated_dir_test] +ns_impl = "dir" +root = "s3:///" +"storage.region" = "us-east-1" +"storage.endpoint" = "http://localhost:9000" +"storage.access_key_id" = "minioadmin" +"storage.secret_access_key" = "minioadmin" +"storage.allow_http" = "true" +# Far future expiration (year 2100) +"storage.expires_at_millis" = "4102444800000" +``` + +The example above uses MinIO, but the same approach applies to other cloud object storage platforms based on your deployment. + +For REST-based namespace servers, you can specify the namespace implementation as `"rest"` with forwarding prefixed headers +for authentication and context propagation. + +```toml +[federated_dbs.federated_rest_test] +ns_impl = "rest" +uri = "http://.internal.catalog.com" +forward_header_prefixes = ["X-forward"] +``` + +With `forward_header_prefixes = ["X-forward"]`, any incoming header starting with `X-forward` is forwarded to +`http://.internal.catalog.com`. This is useful for auth propagation, for example sending +`X-forward-authorization: Bearer xxxx`. + +For the LanceDB REST API itself, requests use `x-api-key` for API-key authentication. If your endpoint +serves more than one database, LanceDB can also use headers such as `x-lancedb-database` or +`x-lancedb-database-prefix` to route the request to the right database context. diff --git a/docs/web/management/table.mdx b/docs/web/management/table.mdx new file mode 100644 index 000000000..7f84b9131 --- /dev/null +++ b/docs/web/management/table.mdx @@ -0,0 +1,1146 @@ +--- +title: "Tables" +sidebarTitle: "Table" +description: "The object most operations act on: creating, opening, renaming, cloning and dropping one." +icon: table +keywords: ["table", "create_table", "open_table", "clone", "rename", "drop"] +--- + +import { + PySchemaAddSetup, + TsSchemaAddSetup as TsSchemaAddSetup, + RsSchemaAddSetup as RsSchemaAddSetup, + PyAddColumnsDefaultValues as AddColumnsDefaultValues, + TsAddColumnsDefaultValues as TsAddColumnsDefaultValues, + RsAddColumnsDefaultValues as RsAddColumnsDefaultValues, + PyAddColumnsNullable as AddColumnsNullable, + TsAddColumnsNullable as TsAddColumnsNullable, + RsAddColumnsNullable as RsAddColumnsNullable, + PySchemaAlterSetup as SchemaAlterSetup, + TsSchemaAlterSetup as TsSchemaAlterSetup, + RsSchemaAlterSetup as RsSchemaAlterSetup, + PyAlterColumnsRename as AlterColumnsRename, + TsAlterColumnsRename as TsAlterColumnsRename, + RsAlterColumnsRename as RsAlterColumnsRename, + PyAlterColumnsDataType as AlterColumnsDataType, + TsAlterColumnsDataType as TsAlterColumnsDataType, + RsAlterColumnsDataType as RsAlterColumnsDataType, + PyAlterColumnsNullable as AlterColumnsNullable, + TsAlterColumnsNullable as TsAlterColumnsNullable, + RsAlterColumnsNullable as RsAlterColumnsNullable, + PyAlterColumnsMultiple as AlterColumnsMultiple, + TsAlterColumnsMultiple as TsAlterColumnsMultiple, + PyAlterColumnsWithExpression as AlterColumnsWithExpression, + TsAlterColumnsWithExpression as TsAlterColumnsWithExpression, + RsAlterColumnsMultiple as RsAlterColumnsMultiple, + RsAlterColumnsWithExpression as RsAlterColumnsWithExpression, + PySchemaDropSetup as SchemaDropSetup, + TsSchemaDropSetup as TsSchemaDropSetup, + RsSchemaDropSetup as RsSchemaDropSetup, + PyDropColumnsSingle as DropColumnsSingle, + TsDropColumnsSingle as TsDropColumnsSingle, + RsDropColumnsSingle as RsDropColumnsSingle, + PyDropColumnsMultiple as DropColumnsMultiple, + TsDropColumnsMultiple as TsDropColumnsMultiple, + RsDropColumnsMultiple as RsDropColumnsMultiple, + PyAlterVectorColumn as AlterVectorColumn, + TsAlterVectorColumn as TsAlterVectorColumn, + RsAlterVectorColumn as RsAlterVectorColumn, + PySchemaFieldMetadataMerge as SchemaFieldMetadataMerge, + TsSchemaFieldMetadataMerge as TsSchemaFieldMetadataMerge, + RsSchemaFieldMetadataMerge as RsSchemaFieldMetadataMerge, + PySchemaFieldMetadataReplace as SchemaFieldMetadataReplace, + TsSchemaFieldMetadataReplace as TsSchemaFieldMetadataReplace, + RsSchemaFieldMetadataReplace as RsSchemaFieldMetadataReplace, + PyVersioningBasicSetup as VersioningBasicSetup, + TsVersioningBasicSetup as TsVersioningBasicSetup, + RsVersioningBasicSetup as RsVersioningBasicSetup, + PyVersioningCheckInitialVersion as VersioningCheckInitialVersion, + TsVersioningCheckInitialVersion as TsVersioningCheckInitialVersion, + RsVersioningCheckInitialVersion as RsVersioningCheckInitialVersion, + PyVersioningUpdateData as VersioningUpdateData, + TsVersioningUpdateData as TsVersioningUpdateData, + RsVersioningUpdateData as RsVersioningUpdateData, + PyVersioningAddData as VersioningAddData, + TsVersioningAddData as TsVersioningAddData, + RsVersioningAddData as RsVersioningAddData, + PyVersioningCheckVersionsAfterMod as VersioningCheckVersionsAfterMod, + TsVersioningCheckVersionsAfterMod as TsVersioningCheckVersionsAfterMod, + RsVersioningCheckVersionsAfterMod as RsVersioningCheckVersionsAfterMod, + PyVersioningListAllVersions as VersioningListAllVersions, + TsVersioningListAllVersions as TsVersioningListAllVersions, + RsVersioningListAllVersions as RsVersioningListAllVersions, + PyVersioningRollback as VersioningRollback, + TsVersioningRollback as TsVersioningRollback, + RsVersioningRollback as RsVersioningRollback, + PyVersioningCheckoutLatest as VersioningCheckoutLatest, + TsVersioningCheckoutLatest as TsVersioningCheckoutLatest, + RsVersioningCheckoutLatest as RsVersioningCheckoutLatest, + PyVersioningDeleteData as VersioningDeleteData, + TsVersioningDeleteData as TsVersioningDeleteData, + RsVersioningDeleteData as RsVersioningDeleteData, + PyVersioningTags as VersioningTags, + TsVersioningTags as TsVersioningTags, + RsVersioningTags as RsVersioningTags, + RsVersioningMakeQuotesReader as RsVersioningMakeQuotesReader, +} from '/snippets/tables.mdx'; +import { + PyMultimodalImports as MultimodalImports, + TsMultimodalImports as TsMultimodalImports, + RsMultimodalImports as RsMultimodalImports, + PyCreateDummyData as CreateDummyData, + TsCreateDummyData as TsCreateDummyData, + RsCreateDummyData as RsCreateDummyData, + PyDefineSchema as DefineSchema, + TsDefineSchema as TsDefineSchema, + RsDefineSchema as RsDefineSchema, + PyIngestData as IngestData, + TsIngestData as TsIngestData, + RsIngestData as RsIngestData, + PySearchData as SearchData, + TsSearchData as TsSearchData, + RsSearchData as RsSearchData, + PyProcessResults as ProcessResults, + TsProcessResults as TsProcessResults, + RsProcessResults as RsProcessResults, + PyBlobApiSchema as BlobApiSchema, + TsBlobApiSchema as TsBlobApiSchema, + RsBlobApiSchema as RsBlobApiSchema, + PyBlobApiIngest as BlobApiIngest, + TsBlobApiIngest as TsBlobApiIngest, + RsBlobApiIngest as RsBlobApiIngest, + PyBlobApiToPandas as BlobApiToPandas, + PyQueryToPandasKwargs as QueryToPandasKwargs, +} from '/snippets/multimodal.mdx'; + +A table is rows and columns, versioned. It is what searches read, what writes +commit to, and what indexes and branches belong to. + +This page covers the table as a catalog object. Getting data into one is +[Ingest](/ingestion); changing its shape is +[Schema evolution](/management/table#schema). + + +Tables are created and dropped in SQL with [`CREATE TABLE` and `DROP TABLE`](/sql/ddl/table). + + +## What a table carries {#carries} + +| | | +| --- | --- | +| **Columns** | including vectors, blobs, and [computed](/feature-engineering/add-computed-column) columns | +| **Versions** | every write commits one, so reads can [travel back](/management/table#versioning) | +| **Indexes** | vector, full-text and scalar, built over its columns | +| **Branches** | isolated lines of change you can [merge or discard](/tables/branching) | + +## Creating {#create} + + +```python Python icon="python" +tbl = db.create_table("documents", data) +tbl = db.create_table("documents", schema=schema) # empty, typed +``` + +```typescript TypeScript icon="square-js" +const tbl = await db.createTable("documents", data); +const tbl = await db.createEmptyTable("documents", schema); +``` + +```sql SQL +CREATE TABLE documents (id BIGINT, title VARCHAR) +``` + + +`data` can be a list of objects, an Arrow table or reader, a pandas or polars +frame, or Pydantic models. [Ingesting data](/ingestion) covers each, plus +`exist_ok` and `mode="overwrite"` for re-runnable scripts. + + +An explicit schema is where nullability is decided. Creation is also the only +point at which the SDK can give a table **stable row ids**, which a deployment +may require on a [materialized view](/management/materialized-view) source. See +[Limits](/basics/limits#schema). + + +## Opening and listing {#open} + + +```python Python icon="python" +tbl = db.open_table("documents") +db.table_names() +db.table_exists("documents") +``` + +```typescript TypeScript icon="square-js" +const tbl = await db.openTable("documents"); +await db.tableNames(); +``` + + + +Each accepts `namespace_path` to act inside a [namespace](/management/namespace) +rather than at the database root. TypeScript has no `tableExists`. + +## Renaming and cloning {#rename-clone} + +```python +db.rename_table("documents", "docs") + +db.clone_table( + "docs_snapshot", + source_uri="s3://bucket/db/documents.lance", + source_version=42, # or source_tag="release-1" + is_shallow=True, +) +``` + +A **shallow** clone -- the default -- references the source's data files rather +than copying them, so it is fast and cheap regardless of table size. It is the +practical way to branch an experiment off a known version, or to pin a training +set to the exact version a model saw. + + +A shallow clone depends on the source's files continuing to exist. If the source +is [compacted](/maintenance/compaction) past the retention window, the version +the clone points at can be pruned. Local OSS connections support shallow clones +only; `is_shallow=False` is not implemented. If the copy must outlive the +source's history, copy its data into an independent table instead. For remote +connections, confirm which clone modes your deployment supports. + + + +**Enterprise job type:** `drop_table`. Fences the table's directory with a +marker and deletes its content in the background -- see +[Background jobs](/maintenance/background-jobs#teardown) for the namespace, +view, and function equivalent. + + +## Dropping {#drop} + + +```python Python icon="python" +db.drop_table("documents") +job = db.drop_table_async("documents") +db.drop_all_tables() +``` + +```typescript TypeScript icon="square-js" +await db.dropTable("documents"); +const job = await db.dropTableAsync("documents"); +await db.dropAllTables(); +``` + +```sql SQL +DROP TABLE documents +``` + + + +Dropping removes the table and **every version of it**. +[Versioning](/management/table#versioning) does not survive a drop, and neither +`drop_all_tables` nor `drop_database` asks for confirmation. + + +On a large table the drop can take a while, so `drop_table_async` returns a +[job](/basics/jobs) rather than blocking. + +## The full surface {#surface} + +| | Python | TypeScript | SQL | +| --- | --- | --- | --- | +| create | `create_table` | `createTable` | `CREATE TABLE` | +| create empty | `create_table(schema=)` | `createEmptyTable` | `CREATE TABLE` | +| open | `open_table` | `openTable` | -- | +| list | `table_names`, `list_tables` | `tableNames`, `listTables` | -- | +| exists | `table_exists` | -- | -- | +| rename | `rename_table` | `renameTable` | -- | +| clone | `clone_table` | `cloneTable` | -- | +| drop | `drop_table`, `drop_table_async` | `dropTable`, `dropTableAsync` | `DROP TABLE` | +| drop all | `drop_all_tables` | `dropAllTables` | -- | + +## Data types {#data-types} + +A table's schema is an **Arrow schema** -- there is no separate LanceDB type +system mapped onto storage, so a column is the same object to the query engine +as it is to the format. + +| Category | Types | +| --- | --- | +| Numeric | `int8`-`int64` and unsigned, `float16`, `float32`, `float64` | +| String and binary | `utf8`, `large_utf8`, `binary`, `large_binary`, `fixed_size_binary(n)` | +| Date and time | `date32`, `date64`, `time32`, `time64`, `timestamp`, `duration` | +| Nested | `struct`, `list`, `large_list`, `fixed_size_list`, `map` | +| Lance extensions | vectors, [JSON](/sql/data-types#json), [blobs](#multimodal), bfloat16 | + +A vector column is a `fixed_size_list` -- an ordinary Arrow type, +which is why it is selectable and joinable but why arithmetic over it is list +arithmetic rather than scalar. + +See [SQL data types](/sql/data-types) for the full reference, including the +extension types and pseudo-columns. + +## Multimodal data {#multimodal} + +Binary data -- images, audio, video, model artifacts -- lives in the table +beside the structured columns and embeddings that describe it, and is governed +by the same versioning and search. + +Small values go in an ordinary `binary` column. Large ones go in a **blob** +column, stored out of line so a scan over the other columns stays fast. See +[Data types](/sql/data-types#blobs) for how the two differ to a reader. + +### Store binary data {#multimodal-store-binary-data} + +To store binary data, define a binary Arrow field in your schema (`pa.binary()` in Python, `Binary` in TypeScript, and `DataType::Binary` in Rust). + +#### 1\. Setup and imports {#multimodal-1-setup-and-imports} + +First, import the necessary libraries for LanceDB and Arrow in your SDK. + + + + {MultimodalImports} + + + + {TsMultimodalImports} + + + + {RsMultimodalImports} + + + +#### 2\. Prepare data {#multimodal-2-prepare-data} + +For this example, we'll create some dummy in-memory images. In a real application, you would read these from files or an API. The key is to convert your data (image, audio, etc.) into a raw `bytes` object. + + + + {CreateDummyData} + + + + {TsCreateDummyData} + + + + {RsCreateDummyData} + + + +#### 3\. Define the schema {#multimodal-3-define-the-schema} + +When creating the table, it is **highly recommended** to define the schema explicitly. This ensures that your binary data is correctly interpreted as a `binary` type by Arrow/LanceDB and not as a generic string or list. + + + + {DefineSchema} + + + + {TsDefineSchema} + + + + {RsDefineSchema} + + + +#### 4\. Ingest data {#multimodal-4-ingest-data} + +Now, create the table using the data and the defined schema. + + + + {IngestData} + + + + {TsIngestData} + + + + {RsIngestData} + + + +### Retrieve and use blobs {#multimodal-retrieve-and-use-blobs} + +When you search your LanceDB table, you can retrieve the binary column just like any other metadata. + + + + {SearchData} + + + + {TsSearchData} + + + + {RsSearchData} + + + +#### Convert bytes back to objects {#multimodal-convert-bytes-back-to-objects} + +Once you have the bytes back from the search result, you can decode them into the original format (for example, an image object or audio buffer). + + + + {ProcessResults} + + + + {TsProcessResults} + + + + {RsProcessResults} + + + +### Large Blobs (Blob API) {#multimodal-large-blobs-blob-api} + +For larger files like high-resolution images or videos, Lance provides a specialized **Blob API**. By using a large-binary Arrow type (`pa.large_binary()` in Python, `LargeBinary` in TypeScript, and `DataType::LargeBinary` in Rust) and specific metadata, you enable **lazy loading** and optimized encoding. This allows you to work with massive datasets without loading all binary data into memory upfront. + +#### 1\. Define a blob schema {#multimodal-1-define-a-blob-schema} + +To use the Blob API, you must mark the column with `{"lance-encoding:blob": "true"}` metadata. + + + + {BlobApiSchema} + + + + {TsBlobApiSchema} + + + + {RsBlobApiSchema} + + + +#### 2\. Ingest large blobs {#multimodal-2-ingest-large-blobs} + +You can then ingest data normally, and Lance will handle the optimized storage. + + + + {BlobApiIngest} + + + + {TsBlobApiIngest} + + + + {RsBlobApiIngest} + + + + +For more advanced usage, including random access and file-like reading of blobs, see the +Lance format's [blob API documentation](https://lance.org/guide/blob/). + + +#### 3\. Convert blob tables to pandas {#multimodal-3-convert-blob-tables-to-pandas} + +When you call `to_pandas()` on a local LanceDB table that contains Blob API columns, the `blob_mode` argument controls how those columns materialize. This is available in the Python SDK on local tables; remote tables raise `NotImplementedError`. + +`blob_mode` accepts: + +- `"lazy"` (default): returns blob columns as lazy `BlobFile` objects without eagerly materializing their payloads. Use this when you want to stream blob bytes on demand or only inspect a subset of rows. Namespace-backed local tables also use the Lance native blob-aware pandas conversion for lazy blobs; in-memory datasets fall back to the standard PyArrow `to_pandas()` path. +- `"bytes"`: eagerly materializes each blob as `bytes`. Use this when you need the raw payload in the DataFrame, for example to decode an image or audio clip in-process. +- `"descriptions"`: returns blob descriptors (offsets, sizes, and positions) instead of the data itself. Use this when you want to plan I/O without paying the cost of loading every blob. + +`"bytes"` and `"descriptions"` require a filesystem-backed Lance dataset and are not supported on in-memory tables. + +Extra keyword arguments are forwarded to the underlying PyArrow / Lance pandas conversion, so you can also pass options like `split_blocks` or `self_destruct`: + + + + {BlobApiToPandas} + + + +Query builders also accept `blob_mode` on their `to_pandas()` method: + +- Plain scans support `"lazy"`, `"bytes"`, and `"descriptions"` with filters, projections, aliases, `limit`, and `offset`. +- Vector, FTS, hybrid, and ordered queries can't materialize blob columns through `to_pandas()`; omit blob columns from the projection for those query shapes. +- This works on both sync and async query builders. Extra PyArrow kwargs like `split_blocks` and `self_destruct` are still forwarded. + + + + {QueryToPandasKwargs} + + + +### Other modalities {#multimodal-other-modalities} + +The `pa.binary()` and `pa.large_binary()` types are universal. You can use this same pattern for other types of multimodal data: + +- **Audio:** Read `.wav` or `.mp3` files as bytes. +- **Video:** Store video transitions or full clips using the Blob API. +- **PDFs/Documents:** Store the raw file content for document search. + +### bfloat16 {#multimodal-bfloat16} + +Sixteen-bit floats in the bfloat16 layout, stored as `fixed_size_binary(2)`. +Useful as a vector element type when halving the memory matters more than the +precision does. + +## Schema evolution {#schema} + +Schema evolution enables non-breaking modifications to a database table's structure — such as adding columns, altering data types, or dropping fields — to adapt to evolving data requirements without service interruptions. +LanceDB supports ACID-compliant schema evolution through granular operations (add/alter/drop columns), allowing you to: + +* Iterate Safely: Modify schemas in production with versioned datasets and backward compatibility +* Scale Seamlessly: Handle ML model iterations, regulatory changes, or feature additions +* Optimize Continuously: Remove unused fields or enforce new constraints without downtime + + +In SQL, schema changes are [`ALTER TABLE`](/sql/ddl/table#alter). + + +### Schema evolution operations {#schema-schema-evolution-operations} + +LanceDB supports four primary schema evolution operations: + +1. **Adding new columns**: Extend your table with additional attributes +2. **Altering existing columns**: Change column names, data types, or nullability +3. **Updating field metadata**: Attach or change per-column Arrow metadata +4. **Dropping columns**: Remove unnecessary columns from your schema + + + +Schema evolution operations are applied immediately but do not typically require rewriting all data. However, data type changes may involve more substantial operations. + + +Each schema evolution operation commits a new table version and returns status metadata such as +the committed `version`. Run these operations from a mutable table handle; if you checked out an +older version for reads, call `checkout_latest` / `checkoutLatest` before modifying the schema. + +### Add new columns {#schema-add-new-columns} + +You can add new columns to a table with the [`add_columns`](https://lancedb.github.io/lancedb/python/python/#lancedb.table.Table.add_columns) +method in Python, [`addColumns`](https://lancedb.github.io/lancedb/js/classes/Table/#addcolumns) in TypeScript/JavaScript, or `add_columns` in Rust. +New columns are populated based on SQL expressions you provide. + + +#### Set up the example table {#schema-set-up-the-example-table} + +First, let's create a sample table with product data to demonstrate schema evolution: + + + + {PySchemaAddSetup} + + + + {TsSchemaAddSetup} + + + + {RsSchemaAddSetup} + + + + +Columns derived from existing data -- SQL expressions over other columns -- are +covered in [Data evolution](/feature-engineering/data-evolution), because +deriving values is a feature-engineering job rather than a schema one. + + +#### Add columns with default values {#schema-add-columns-with-default-values} + +Add boolean columns with default values for status tracking: + + + + {AddColumnsDefaultValues} + + + + {TsAddColumnsDefaultValues} + + + + {RsAddColumnsDefaultValues} + + + +#### Add nullable columns {#schema-add-nullable-columns} + +Add timestamp columns that can contain NULL values: + + + + {AddColumnsNullable} + + + + {TsAddColumnsNullable} + + + + {RsAddColumnsNullable} + + + + +When adding columns that should contain NULL values, be sure to cast the NULL to the appropriate type, e.g., `cast(NULL as timestamp)`. + + +### Alter existing columns {#schema-alter-existing-columns} + +You can alter columns using the [`alter_columns`](https://lancedb.github.io/lancedb/python/python/#lancedb.table.Table.alter_columns) +method in Python, [`alterColumns`](https://lancedb.github.io/lancedb/js/classes/Table/#altercolumns) in TypeScript/JavaScript, or `alter_columns` in Rust. This allows you to: + +- Rename a column +- Change a column's data type +- Modify nullability (whether a column can contain NULL values) + + +#### Set up the example table {#schema-set-up-the-example-table-2} + +Create a table with a custom schema to demonstrate column alterations: + + + + {SchemaAlterSetup} + + + + {TsSchemaAlterSetup} + + + + {RsSchemaAlterSetup} + + + +#### Rename columns {#schema-rename-columns} + +Change column names to better reflect their purpose: + + + + {AlterColumnsRename} + + + + {TsAlterColumnsRename} + + + + {RsAlterColumnsRename} + + + +#### Change data types {#schema-change-data-types} + +Convert column data types for better performance or compatibility: + + + + {AlterColumnsDataType} + + + + {TsAlterColumnsDataType} + + + + {RsAlterColumnsDataType} + + + +#### Make columns nullable {#schema-make-columns-nullable} + +You can alter columns to contain NULL values: + + + + {AlterColumnsNullable} + + + + {TsAlterColumnsNullable} + + + + {RsAlterColumnsNullable} + + + +Changing a column to nullable affects future writes and merges too: missing values are accepted +only when the target column is nullable. + +#### Multiple changes at once {#schema-multiple-changes-at-once} + +Apply several alterations in a single operation: + + + + {AlterColumnsMultiple} + + + + {TsAlterColumnsMultiple} + + + + {RsAlterColumnsMultiple} + + + +#### Expression-based type changes {#schema-expression-based-type-changes} + +For transformations that are not simple casts (for example, converting `"$100"` to an integer), use a SQL-expression column add, then drop and rename: + + + + {AlterColumnsWithExpression} + + + + {TsAlterColumnsWithExpression} + + + + {RsAlterColumnsWithExpression} + + + +#### Alter embedding types and dimensions {#schema-alter-embedding-types-and-dimensions} + +It's quite common to need to change an embedding column's schema, in case a new model becomes available with a different embedding dimension. +- In Python, the example shows an in-place type update when the cast is compatible. +- In TypeScript and Rust, the example shows a dimension change (`384 -> 1024`), which cannot be cast in-place. + +For dimension changes, use this 3-step pattern: add a new column with the target type, drop the old column, then rename the new column to the original name. + + + + {AlterVectorColumn} + + + + {TsAlterVectorColumn} + + + + {RsAlterVectorColumn} + + + + +**`FixedSizeList` Dimension Changes in TypeScript and Rust** + +`alterColumns` / `alter_columns` can cast between compatible types, but changing `FixedSizeList` dimensions (for example `384 -> 1024`) is not a compatible cast. +For such cases, use `addColumns` / `add_columns` (with `arrow_cast`), then `dropColumns` / `drop_columns`, then rename the replacement column. + + + +Changing data types requires rewriting the column data and may be resource-intensive for large tables. Renaming columns or changing nullability is more efficient as it only updates metadata. + + +### Update field metadata {#schema-update-field-metadata} + +Each column in a LanceDB table can carry a small key/value map of Arrow field metadata — useful +for annotating columns with units, provenance, PII flags, embedding model versions, or any other +schema-level context your application needs. + +Use [`update_field_metadata`](https://lancedb.github.io/lancedb/python/python/#lancedb.table.Table.update_field_metadata) +in Python, [`updateFieldMetadata`](https://lancedb.github.io/lancedb/js/classes/Table/#updatefieldmetadata) +in TypeScript/JavaScript, or `update_field_metadata` in Rust to add, change, or remove these +key/value pairs without rewriting the column data. Each call commits a new table version and returns +the new `version`. + +Each update targets one field by **dot-path**: top-level columns are addressed by name (for +example `"embedding"`), and nested fields by their full path (for example `"address.zip"`). By +default, the keys you pass are **merged** into the field's existing metadata — keys you do not +mention are preserved, and passing `None` (Python) or `null` (TypeScript) deletes a key. Set +`replace: true` to swap the field's entire metadata map instead of merging. + + + + {SchemaFieldMetadataMerge} + + + + {TsSchemaFieldMetadataMerge} + + + + {RsSchemaFieldMetadataMerge} + + + +To overwrite a field's metadata entirely instead of merging, set `replace` to `true`: + + + + {SchemaFieldMetadataReplace} + + + + {TsSchemaFieldMetadataReplace} + + + + {RsSchemaFieldMetadataReplace} + + + + +You can pass multiple updates in a single call to change metadata on several fields at once — +each call commits a single new table version. + + +#### Canonical metadata keys {#schema-canonical-metadata-keys} + +You can use any string as a metadata key. By convention, LanceDB treats keys prefixed with +`lancedb:` as canonical. LanceDB Enterprise displays these keys in the UI, and agents can rely +on finding this information in a consistent place. Use them when they fit your use case: + +| Key | Purpose | Example | +| --- | --- | --- | +| `lancedb:description` | Human-readable description of the field. | `"CLIP embedding of the product image"` | +| `lancedb:tag:` | User-defined key/value tag, where the suffix names the tag category. | `"lancedb:tag:model": "clip"` | +| `lancedb:logical-column` | Groups related columns into one logical column. For example, `feature_v1` and `feature_v2` might belong to the same logical column. | `"lancedb:logical-column": "feature"` | +| `lancedb:status` | Life cycle state of the column: `production`, `candidate`, `deprecated`, or `archived`. | `"lancedb:status": "production"` | + + +These keys are conventions, not enforced constraints. LanceDB does not validate their values, +but following the conventions keeps your tables consistent with Enterprise tooling. + + +### Drop columns {#schema-drop-columns} + +You can remove columns using the [`drop_columns`](https://lancedb.github.io/lancedb/python/python/#lancedb.table.Table.drop_columns) + method in Python, [`dropColumns`](https://lancedb.github.io/lancedb/js/classes/Table/#dropcolumns) in TypeScript/JavaScript, or `drop_columns` in Rust. + + +#### Set Up the example table {#schema-set-up-the-example-table-3} + +Create a table with temporary columns that we'll remove: + + + + {SchemaDropSetup} + + + + {TsSchemaDropSetup} + + + + {RsSchemaDropSetup} + + + +#### Drop single columns {#schema-drop-single-columns} + +Remove individual columns that are no longer needed: + + + + {DropColumnsSingle} + + + + {TsDropColumnsSingle} + + + + {RsDropColumnsSingle} + + + +#### Drop multiple columns {#schema-drop-multiple-columns} + +Remove several columns at once for efficiency: + + + + {DropColumnsMultiple} + + + + {TsDropColumnsMultiple} + + + + {RsDropColumnsMultiple} + + + + +Dropping columns cannot be undone. Make sure you have backups or are certain before removing columns. + + +## Versioning {#versioning} + +This page shows the core table-versioning APIs used in the code snippets for Python, TypeScript, and Rust. +Each operation below maps directly to methods shown in the examples. + +### Basic Versioning Example {#versioning-basic-versioning-example} + +Let's create a table with sample data to demonstrate LanceDB's versioning capabilities: + +#### Set Up the Table {#versioning-set-up-the-table} + +First, let's create a table with some sample data: + + + + {VersioningBasicSetup} + + + + {TsVersioningBasicSetup} + + + + {RsVersioningBasicSetup} + + + + + + + {RsVersioningMakeQuotesReader} + + + + +#### Check Initial Version {#versioning-check-initial-version} + +After creating the table, let's check the initial version information: + + + + {VersioningCheckInitialVersion} + + + + {TsVersioningCheckInitialVersion} + + + + {RsVersioningCheckInitialVersion} + + + +### Modify Data {#versioning-modify-data} + +When you modify data through operations like update or delete, LanceDB automatically creates new versions. + +#### Update Existing Data {#versioning-update-existing-data} + +Let's update some existing records to see versioning in action: + + + + {VersioningUpdateData} + + + + {TsVersioningUpdateData} + + + + {RsVersioningUpdateData} + + + +#### Add New Data {#versioning-add-new-data} + +Now let's add more records to the table: + + + + {VersioningAddData} + + + + {TsVersioningAddData} + + + + {RsVersioningAddData} + + + +#### Check Version Changes {#versioning-check-version-changes} + +Let's see how the versions have changed after our modifications: + + + + {VersioningCheckVersionsAfterMod} + + + + {TsVersioningCheckVersionsAfterMod} + + + + {RsVersioningCheckVersionsAfterMod} + + + +### Rollback to Previous Versions {#versioning-rollback-to-previous-versions} + +LanceDB supports fast rollbacks to any previous version without data duplication. + +#### View All Versions {#versioning-view-all-versions} + +First, let's see all the versions we've created: + + + + {VersioningListAllVersions} + + + + {TsVersioningListAllVersions} + + + + {RsVersioningListAllVersions} + + + +#### Restore a Version Snapshot {#versioning-restore-a-version-snapshot} + +Now let's restore a captured version snapshot: + + + + {VersioningRollback} + + + + {TsVersioningRollback} + + + + {RsVersioningRollback} + + + +### Tag-Based Versioning {#versioning-tag-based-versioning} + +Numeric table versions like `v3` or `v17` are precise but hard to remember. Tags +let you attach human-readable labels (e.g., `"prod"`, `"baseline"`, +`"q3-evaluation"`) to specific versions and check those out by name. They are +conceptually similar to git tags, and unlike numeric versions, **tagged versions +are preserved when old versions are pruned** (see the cleanup note at the bottom +of this page). + +The tags API supports the standard CRUD operations — create, list, update, delete — +plus checking out by tag name. + + + + {VersioningTags} + + + + {TsVersioningTags} + + + + {RsVersioningTags} + + + + +Deleting a tag only removes the label, not the version it points to. After +deletion, the underlying table version becomes eligible for cleanup again. + + +### Branches {#versioning-branches} + +Beyond linear history, LanceDB also supports **branches** — isolated, writable +lines of history forked from `main` (or a specific version). Whereas tags and +`checkout` give you read-only views of existing versions, a branch has its own +writable history, making it ideal for experiments, backfills, and migrations +that you want to keep separate from production reads on `main`. + +Branches are covered in their own guide: see [Branches](/tables/branching). + +### Delete Data From the Table {#versioning-delete-data-from-the-table} + +Let's demonstrate how deletions also create new versions: + +#### Go Back to Latest Version {#versioning-go-back-to-latest-version} + +First, let's return to the latest version: + + + + {VersioningCheckoutLatest} + + + + {TsVersioningCheckoutLatest} + + + + {RsVersioningCheckoutLatest} + + + +#### Delete Data {#versioning-delete-data} + +Now let's delete some data to see how it affects versioning: + + + + {VersioningDeleteData} + + + + {TsVersioningDeleteData} + + + + {RsVersioningDeleteData} + + + +#### Version History and Operations {#versioning-version-history-and-operations} + +On a fresh table, the snippets in this guide produce this version sequence: + +1. `v1`: create table (`create_table` / `createTable` / `create_table`) +2. `v2`: update rows (`update`) +3. `v3`: add rows (`add`) +4. `v4`: restore snapshot (`restore`) from `version_after_mod`/`versionAfterMod` +5. `v5`: delete rows (`delete`) + +Read-only and checkout operations shown here (`list_versions`/`listVersions`, `version`, `checkout`, `checkout_latest`/`checkoutLatest`) do not create new versions. + +The version metadata fields can differ by backend. Direct table-backed version listing exposes a +timestamp, while namespace-backed listing may expose fields such as `manifest_path`, +`manifest_size`, `e_tag`, and `timestamp_millis`. In deployments that use managed versioning, +prefer the table version APIs exposed by LanceDB Enterprise or the namespace service instead of +mixing in lower-level Lance file operations. + + +**System Operations** + +System operations like `optimize()`, index updates, and table compaction also increment table version numbers. +In LanceDB OSS and Enterprise, `optimize()` can prune older versions based on its retention setting (`cleanup_older_than`, 7 days by default), +which is when old-version files are removed and disk space is reclaimed. + +**Tagged versions are exempt from cleanup.** A version with a tag pointing at it is +retained regardless of age, and its files are not removed by `optimize()`. To make +a tagged version eligible for pruning, [delete the tag](#versioning-tag-based-versioning) first. + diff --git a/docs/web/management/view.mdx b/docs/web/management/view.mdx new file mode 100644 index 000000000..bfb3a0543 --- /dev/null +++ b/docs/web/management/view.mdx @@ -0,0 +1,32 @@ +--- +title: "Views" +sidebarTitle: "View" +description: "A saved query, evaluated when read. The non-materialized counterpart to a materialized view." +icon: eye +keywords: ["view", "create view", "sql", "catalog"] +--- + +Enterprise + +This page describes a LanceDB Enterprise capability. The embedded form +of LanceDB has no server to provide it -- see +[Architecture](/architecture) for what differs between the two. + +A view stores a **query**, not rows. Reading it runs the query against current +data, so a view is never stale -- and never faster than the query it wraps. + +It is the counterpart to a +[materialized view](/management/materialized-view), which stores rows and is +brought up to date by a refresh. Both are [catalog objects](/basics/catalog) +defined by a query; the difference is entirely when that query runs. + +| | View | Materialized view | +| --- | --- | --- | +| Stores | the query | the rows | +| Freshness | always current | as of the last refresh | +| Read cost | the full query, every read | a table scan | +| Write cost | none | a refresh | + +Views are created in SQL, over the [Flight SQL endpoint](/sql#connecting) -- +there is no SDK method, because the thing a view stores *is* a SQL statement. +See [View](/sql/ddl/view) for `CREATE VIEW`, `DROP VIEW`, and listing. diff --git a/docs/web/quickstart.mdx b/docs/web/quickstart.mdx new file mode 100644 index 000000000..36001c97e --- /dev/null +++ b/docs/web/quickstart.mdx @@ -0,0 +1,540 @@ +--- +title: Quickstart +sidebarTitle: "Quickstart" +description: "Get started with LanceDB in minutes." +icon: rocket +--- +import { + PyConnect, + PyConnectAsync, + PyConnectEnterpriseQuickstart, + PyConnectObjectStorage, + PyConnectObjectStorageAsync, + RsConnect, + RsConnectEnterpriseQuickstart, + RsConnectObjectStorage, + TsConnect, + TsConnectEnterpriseQuickstart, + TsConnectObjectStorage, +} from '/snippets/connection.mdx'; +import { + PyQuickstartData, + PyQuickstartComputedFeature, + PyQuickstartTraining, + PyQuickstartTrainingProjection, + PyQuickstartCreateTable, + PyQuickstartCreateTableAsync, + PyQuickstartAddFeature, + PyQuickstartCurateWithMetadata, + PyQuickstartMultimodalBytes, + PyQuickstartQueryFeature, + PyQuickstartVectorSearch1, + PyQuickstartVectorSearch1Async, + PyQuickstartOutputPandas, + RsQuickstartAddFeature, + RsQuickstartCurateWithMetadata, + RsQuickstartCreateTable, + RsQuickstartData, + RsQuickstartDefineStruct, + RsQuickstartMultimodalBytes, + RsQuickstartQueryFeature, + RsQuickstartVectorSearch1, + TsQuickstartAddFeature, + TsQuickstartCurateWithMetadata, + TsQuickstartCreateTable, + TsQuickstartData, + TsQuickstartMultimodalBytes, + TsQuickstartQueryFeature, + TsQuickstartVectorSearch1, +} from '/snippets/quickstart.mdx'; + +As described in [the landing page](/), LanceDB provides one data layer for +curation, feature engineering, search and retrieval, and model training. Whether you are preparing +training data, building a RAG or agentic retrieval system, reviewing examples, or adding model-generated +features, you'll work with the same underlying table and search primitives. + +Let's get started in just a few steps! + +## 1\. Install LanceDB {#1-install-lancedb} + +Install LanceDB in your client SDK. + + +```bash pip icon="terminal" +pip install lancedb +``` + +```bash uv icon="terminal" +uv add lancedb + +# Or, in an existing virtual environment: +uv pip install lancedb +``` + +```bash TypeScript icon=js +npm install @lancedb/lancedb +``` + +```bash Rust icon=Rust +cargo add lancedb +``` + + +### Python pre-release builds {#python-pre-release-builds} + +To pick up the latest features and bug fixes +before the next stable release, install a pre-release from LanceDB's Fury index. + + +```bash pip icon="terminal" +pip install --pre --extra-index-url https://pypi.fury.io/lancedb/ lancedb +``` + +```bash uv icon="terminal" +uv venv +uv pip install --prerelease allow --index https://pypi.fury.io/lancedb/ lancedb + +# To add to pyproject.toml, use: +uv add --prerelease allow --index https://pypi.fury.io/lancedb/ lancedb +``` + + + +Pre-release builds receive the same level of testing as stable releases, but their availability is not guaranteed +for more than 6 months after release. For real-world workloads, we recommend you use the latest stable release +as far as possible. + + + +The default `lancedb` wheel targets `x86-64-haswell` (AVX2 + FMA + F16C). On older x86_64 CPUs without AVX2 +(Intel Sandy Bridge, Ivy Bridge, Westmere; AMD Bulldozer, Piledriver, Steamroller), `import lancedb` crashes +with `Illegal instruction`. + +Install `lancedb-compat` instead. It exposes the same API (`import lancedb` still works), is built at the +`x86-64-v2` baseline, and uses runtime SIMD dispatch to still leverage AVX2, FMA, or AVX-512 when available: + +```bash pip icon="terminal" +pip install lancedb-compat +``` + +The two packages share the same `lancedb/` namespace and conflict at install time, so pick one. To switch, +uninstall the other first (`pip uninstall lancedb && pip install lancedb-compat`). + + +## 2\. Connect to a LanceDB database {#2-connect-to-a-lancedb-database} + +LanceDB supports several URI patterns to connect to a database. + +- A local filesystem path (when using it as an embedded library) +- A `db://...` URI (when using LanceDB Enterprise) +- An object storage URI: `s3://...`, `gs://...`, or `az://...` (when connecting directly from the client SDK) + +### Connect via local directory path {#connect-via-local-directory-path} + +The simplest way to begin is to use LanceDB as an embedded library. Import LanceDB in your +client SDK of choice and point to a local directory path. + + + + {PyConnect} + + + + {PyConnectAsync} + + + + {TsConnect} + + + + { "use lancedb::connect;\n" } + { "\n" } + {RsConnect} + + + + +### Connect via object storage URIs {#connect-via-object-storage-uris} + +You can also connect directly to object storage from the client SDK: + + + + {PyConnectObjectStorage} + + + + {PyConnectObjectStorageAsync} + + + + {TsConnectObjectStorage} + + + + {RsConnectObjectStorage} + + + +For credentials, endpoints, and provider-specific options, see +[Configuring storage](/deployment/storage). + +### Connect to LanceDB Enterprise {#connect-to-lancedb-enterprise} + +If you're using LanceDB Enterprise, you can connect to the remote database using the +`db://` URI along with the API key, region, and cluster endpoint you received from the +LanceDB team. Pass the cluster endpoint via `host_override` so the client routes +requests to your deployment. + + + + { "import lancedb\n\n" } + {PyConnectEnterpriseQuickstart} + + + + { "import * as lancedb from \"@lancedb/lancedb\";\n\n" } + {TsConnectEnterpriseQuickstart} + + + + { "use lancedb::connect;\n\n" } + {RsConnectEnterpriseQuickstart} + + + + +`host_override` is the full URL of your cluster endpoint, including the scheme +(`https://`) and a port if your deployment listens on a non-default one +(e.g. `https://your-enterprise-endpoint.com:443`). If you don't have the +endpoint, [contact the LanceDB team](https://www.lancedb.com/contact). + + +To learn more about `RemoteTable` semantics and how Enterprise differs operationally from +embedded LanceDB, see the [Enterprise overview](/enterprise). + +## 3\. Create a new table {#3-create-a-new-table} + +Let's create a small table of characters from the kingdom of Camelot. Each row stores source text, +metadata, structured fields, and a vector embedding in the same LanceDB table. + + +The embeddings we use in this example are synthetic and for demonstration purposes only. In a real AI +data workflow, you would generate them from text, images, audio, or video using an embedding model of choice. + + +Each row has source text, metadata, structured fields, and a vector: + +```json +{ + "id": "2", + "name": "Merlin", + "role": "Wizard", + "description": "Advisor and prophet with deep magical knowledge.", + "stats": { "strength": 2, "magic": 5, "leadership": 4, "wisdom": 5 }, + "vector": [0.2, 0.9, 0.4, 0.9] +} +``` + +The full raw records are included below: + + + + + {PyQuickstartData} + + + + {TsQuickstartData} + + + + {RsQuickstartDefineStruct} + {RsQuickstartData} + + + + +You can now create a LanceDB table from those records. The code below creates a LanceDB table +with the appropriate schema and ingests the data. + + + + {PyQuickstartCreateTable} + + + + {PyQuickstartCreateTableAsync} + + + + {TsQuickstartCreateTable} + + + + {RsQuickstartCreateTable} + + + +## 4\. Semantic search {#4-semantic-search} + +Search is a useful capability for all kinds of AI data pipelines. Below, we do a vector similarity +search for samples similar to a "_wise magical advisor_" (transforming the natural language query to +an embedding), and project only the columns needed by the next step. + +Search (which requires random access) is a ubiquitous access pattern that appears in many workloads: +whether you're building a RAG or recommendation system, serving agent memory, or curating a training +dataset. + + + + {PyQuickstartVectorSearch1} + + + + {PyQuickstartVectorSearch1Async} + + + + {TsQuickstartVectorSearch1} + + + + {RsQuickstartVectorSearch1} + + + +The example for Python above shows how to convert results to a Polars DataFrame. +Depending on your language, you can collect query results as a list/array of objects or DataFrames +to be used downstream in your application. + + + Use the `to_pandas()` method to convert query results into a Pandas DataFrame. + + {PyQuickstartOutputPandas} + + + +## 5\. Curation {#5-curation} + +Searching for relevant results can be more useful when combined with metadata filters. +In this tiny example, we filter to examples with high `magic` stats. + + + + {PyQuickstartCurateWithMetadata} + + + + {TsQuickstartCurateWithMetadata} + + + + {RsQuickstartCurateWithMetadata} + + + +When working with large datasets, it's common to use the same pattern to filter on quality labels, +train/eval splits, numeric fields, categorical values, timestamp windows, or generated tags and labels. + +## 6\. Add a derived feature {#6-add-a-derived-feature} + +Feature engineering is the process of cleaning up your data and creating new signals that +help your model learn, make better predictions, or your agent retrieve more useful information. +In the example below, we add a `power_score` column from the structured `stats` fields. +Lance supports data evolution, so you can add new columns without rewriting the entire table. + + + + {PyQuickstartAddFeature} + + + + {TsQuickstartAddFeature} + + + + { "use lancedb::table::NewColumnTransform;\n\n" } + {RsQuickstartAddFeature} + + + +Next, you can query a compact view of the new feature: + + + + {PyQuickstartQueryFeature} + + + + {TsQuickstartQueryFeature} + + + + {RsQuickstartQueryFeature} + + + +| name | role | power_score | +| --- | --- | --- | +| King Arthur | King | 3.5 | +| Merlin | Wizard | 4.0 | +| Sir Lancelot | Knight | 3.0 | + +The same workflow is used for data preparation tasks when adding derived features, cached model signals, review scores, or dataset +quality indicators. + +### Declare now, compute later {#computed-columns} + +The call above computes every row as part of the call, which is fine for three +rows. On a table large enough that you would rather choose *when* the work +happens, declare a column instead and fill it separately. The stored `power_score` +column stays available; declare a separate +`combat_score` column from magic and strength, then refresh it: + + +{PyQuickstartComputedFeature} + + +A refresh gives values to rows that have none and recomputes only rows whose +inputs changed, so appending more characters and refreshing again fills just +the new ones -- it does not recompute the rows that are already current. That +is what makes a backfill safe to retry and cheap to repeat as data arrives. + +On a deployment the same refresh runs server-side, on a cluster, as a +[job](/basics/jobs) you can walk away from: + +```python +job = table.refresh_column_async("combat_score") +job.wait() +``` + +See [Computed columns](/feature-engineering/add-computed-column) for expressions, +and [Functions](/management/function) for filling a column with Python +-- an embedding model, a captioner, anything you can register. + +## 7\. Store multimodal data {#7-store-multimodal-data} + +Multimodal data is a first-class citizen in LanceDB. Binary data (image, audio, video, etc.) is +stored as blobs or inline Arrow binary types in a LanceDB column, and they benefit from the same +table operations and data versioning semantics as other data types. All the data is governed +in the same table, so you can search, filter, and retrieve multimodal records together with structured +fields, metadata, and embeddings. + +In this example, the +[`lancedb/magical_kingdom`](https://huggingface.co/datasets/lancedb/magical_kingdom) dataset stores +character images, descriptions, structured stats, image embeddings, and text embeddings together. + +Say we downloaded the image for Sir Lancelot from that dataset locally. You can read the image bytes +in your client SDK and store them in a LanceDB column. The image bytes can be used for downstream tasks +like retrieval, evaluation, or training. + +
+ Sir Lancelot from the lancedb/magical_kingdom dataset +
+ +These snippets load the local image file and store the bytes in an `image` column: + + + + {PyQuickstartMultimodalBytes} + + + + {TsQuickstartMultimodalBytes} + + + + {RsQuickstartMultimodalBytes} + + + +For more examples, see [Data types](/management/table#multimodal). + +## 8\. Train on the result {#8-train} + +The point of the previous steps is this one. The table you searched, filtered, +and added features to is directly usable as a PyTorch +[Dataset](https://docs.pytorch.org/docs/stable/data.html#torch.utils.data.Dataset) +-- there is no export step, and no second copy of the data to keep in sync. + +Install PyTorch with `pip install torch`. Select the numeric feature columns +before constructing the loader; the full table also contains strings, +structured stats, and vectors. + + +{PyQuickstartTraining} + + +```python +for batch in dataloader: + ... # train with batch["power_score"] and batch["combat_score"] +``` + +Because the loader reads from the table, the feature columns you just +materialized are read straight off storage rather than recomputed every epoch. +That is the whole argument for doing feature engineering in the lakehouse: an +expensive transform runs once, is versioned with the data, and every subsequent +epoch is a read. + +Projection matters here. If a training step only needs `power_score`, select +that column so the descriptions, stats, and vectors are excluded: + + +{PyQuickstartTrainingProjection} + + +See [Training](/training/data-loading) for shuffling, multi-worker loading, and reading from +a remote table. + +## Code {#code} + +See the full code for these examples (including helper functions) in the +`quickstart` file for the appropriate client language in the +[files provided in the repo](https://github.com/lancedb/docs/tree/main/tests). + + +## Next steps {#next-steps} +You've gone from a table, through search and curation, to derived features and a +training loop -- in one table, with no export step between any two stages. That +arc is the argument for a multimodal lakehouse, and every stage of it scales +past this toy example without changing shape. + +Continue to the guides to build on it with schema options, versioning, indexing, +full-text and hybrid search, reranking, and server-side feature engineering. + + + + Build on this quickstart with table creation, updates, and schema tips. + + + Learn how to build Retrieval-Augmented Generation (RAG) applications using LanceDB. + + + Create vector, full-text, and scalar indexes to speed up queries on larger datasets. + + + Use LanceDB for projected, shuffled, random-access reads in training workflows. + + diff --git a/docs/web/reranking/index.mdx b/docs/web/reranking/index.mdx new file mode 100644 index 000000000..e17b43552 --- /dev/null +++ b/docs/web/reranking/index.mdx @@ -0,0 +1,544 @@ +--- +title: "Reranking Search Results" +sidebarTitle: "Reranking" +description: "Use a reranker to improve search relevance." +icon: "sort-amount-down" +keywords: ["re-ranking", "reranker", "rerank"] +--- + +import { + PyRerankingLinearCombinationUsage, + PyRerankingRrfUsage, + PyRerankingCohereUsage, + PyRerankingMrrUsage, + PyRerankingCrossEncoderUsage, +} from '/snippets/integrations.mdx'; + +Reranking is the process of re-ordering search results to improve relevance, often using a +different model than the one used for the initial search. LanceDB has built-in support for reranking +with models from Cohere, Sentence-Transformers, and more. + +### Quickstart {#quickstart} + +To use a reranker, you run a search and pass the results to the `rerank()` method. The examples below +move from the simplest, model-free rerankers to a model-based one. Each is a complete, runnable script. + +**1. Linear combination (simplest).** `LinearCombinationReranker` normalizes the vector and full-text +scores and blends them with a single `weight` (default `0.7`, favoring the vector score). It runs no +model, and reranks [hybrid search](/search/hybrid-search) results. + + + + {PyRerankingLinearCombinationUsage} + + + +**2. Reciprocal Rank Fusion.** `RRFReranker` fuses results by rank position instead of raw score, so it +sidesteps having to make vector and full-text scores comparable. It loads no model either, and it's the +default reranker for hybrid search. + + + + {PyRerankingRrfUsage} + + + +**3. Cohere (model-based).** For higher relevance, a model-based reranker scores each result against the +query with a trained model. `CohereReranker` works with vector, full-text, or hybrid search, and needs +the `cohere` package plus either `COHERE_API_KEY` in the environment or an `api_key` argument. + + + + {PyRerankingCohereUsage} + + + +Reach for the model-free rerankers (`LinearCombinationReranker`, `RRFReranker`) when cost and latency +matter most; reach for a model-based one like `CohereReranker` or `CrossEncoderReranker` when you need +higher relevance and can afford to score every query and document pair with a model. + +### Supported Rerankers {#supported-rerankers} + +LanceDB supports the following rerankers out of the box. The first three are score-based and run no +model; the rest are model-based. The built-in rerankers are documented in this section; the hosted +providers that need an API key live under [integrations](/integrations/reranking). + +| Reranker | Default model | +| --------------------------- | -------------------------------------- | +| `RRFReranker` | None (reciprocal rank fusion) | +| `LinearCombinationReranker` | None (weighted score blend) | +| `MRRReranker` | None (weighted reciprocal rank) | +| `CohereReranker` | `rerank-english-v3.0` | +| `CrossEncoderReranker` | `cross-encoder/ms-marco-TinyBERT-L-6` | +| `ColbertReranker` | `colbert-ir/colbertv2.0` | +| `AnswerdotaiRerankers` | `answerdotai/answerai-colbert-small-v1`| +| `JinaReranker` | `jina-reranker-v2-base-multilingual` | +| `OpenaiReranker` | `gpt-4-turbo-preview` | +| `VoyageAIReranker` | No default (model name required) | +| `WatsonxReranker` | `cross-encoder/ms-marco-minilm-l-12-v2`| + +The model-based rerankers need their provider package installed, and the hosted ones +(`CohereReranker`, `JinaReranker`, `OpenaiReranker`, `VoyageAIReranker`, `WatsonxReranker`) also need an API key, passed +as an `api_key` argument or set in the provider-specific environment variable. + +Rerankers add `_relevance_score` and return rows ordered by descending relevance. Python rerankers +accept `return_score="relevance"` or `return_score="all"`; use `"all"` when you want to keep the +original vector distance or FTS score columns for debugging. Model-based rerankers read from +`column="text"` by default, so either return that column in the search results or pass a different +column. + +Use `refine_factor` on vector or hybrid queries when you're reranking approximate IVF-PQ results and +want a larger candidate pool before the final ranking step. A value of `3` asks LanceDB to fetch +`limit * 3` candidates, refine them with the full vectors, and keep the requested `limit`. Higher +values can improve recall, but they also increase query latency. + + +**SDK coverage differs across languages** + +The provider-specific rerankers in the table above +(`CohereReranker`, `CrossEncoderReranker`, `ColbertReranker`, and others under `lancedb.rerankers`) +are currently **Python-only**. The TypeScript and Rust SDKs currently expose hybrid reranking through +the generic `Reranker` interface (`rerankHybrid` / `rerank_hybrid`) and the built-in `RRFReranker`. +In TypeScript, create the built-in RRF reranker with `await RRFReranker.create(k)`. To use a +model-based reranker from TypeScript or Rust, you must implement the hybrid reranker interface +yourself. + + + +### Multi-vector reranking {#multi-vector-reranking} +Most rerankers support reranking based on multiple vectors. To rerank based on multiple vectors, you can pass a list of vectors to the `rerank` method. Here's an example of how to rerank based on multiple vector columns using the `CrossEncoderReranker`: + + +```python Python icon="python" +from lancedb.rerankers import CrossEncoderReranker + +reranker = CrossEncoderReranker() + +query = "hello" + +# `deduplicate=True` requires `_rowid` on every input result set, +# so call `.with_row_id(True)` on each search before passing it in. +res1 = table.search(query, vector_column_name="vector").limit(3).with_row_id(True) +res2 = table.search(query, vector_column_name="text_vector").limit(3).with_row_id(True) +res3 = table.search(query, vector_column_name="meta_vector").limit(3).with_row_id(True) + +reranked = reranker.rerank_multivector([res1, res2, res3], deduplicate=True) +``` + + +- Passing `deduplicate=True` to `rerank_multivector(...)` raises a `ValueError` if any of the +input result sets is missing the `_rowid` column. Therefore, it's recommended to add `.with_row_id(True)` to every +`table.search(...)` call before reranking, or omit `deduplicate=True` if you don't need it. +- `RRFReranker.rerank_multivector(...)` always requires `_rowid` on its inputs, regardless of +the `deduplicate` flag. + +## Creating Custom Rerankers {#creating-custom-rerankers} + +LanceDB also allows you to create custom rerankers by extending the base `Reranker` class. The custom reranker +should implement the `rerank` method that takes a list of search results and returns a reranked list of +search results. This is covered in more detail in the [creating custom rerankers](/reranking) section. + +## Reciprocal rank fusion {#rrf} + +## Reciprocal Rank Fusion Reranker + +**Reciprocal Rank Fusion (RRF)** is a model-free way to merge several ranked result lists into a +single ordering. Rather than comparing raw similarity scores (which aren't directly comparable +across, say, a vector search and a full-text search), RRF looks only at each document's *rank +position* in each list. It scores every document with the formula `1 / (rank + K)`, sums those +contributions across the lists, and re-sorts by the total. Documents that rank highly in more than +one search rise to the top. Because there's no model to load or call, it's fast and cheap, which is +why it's the default reranker for LanceDB hybrid search. The implementation follows the +[Cormack et al. paper](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf). + +> **Supported query types:** hybrid search and [multi-vector reranking](/reranking#multi-vector-reranking). +> Because RRF fuses two or more ranked lists, it can't rerank a single vector or full-text result set +> on its own. Calling `rerank_vector` or `rerank_fts` on an `RRFReranker` raises `NotImplementedError`. + + + + + {PyRerankingRrfUsage} + + + +### Accepted Arguments {#rrf-accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `K` | `int` | `60` | A constant used in the RRF formula (default is 60). Experiments indicate that k = 60 was near-optimal, but that the choice is not critical. | +| `return_score` | `str` | `"relevance"` | Options are "relevance" or "all". The type of score to return. If "relevance", will return only the `_relevance_score`. If "all", will return all scores from the vector and FTS search along with the relevance score. | + +`K` must be greater than `0`. In TypeScript, construct the built-in reranker with +`await RRFReranker.create(k)` before passing it to `.rerank(...)`. + +### Multi-vector reranking {#rrf-multi-vector-reranking} + +`RRFReranker` can also fuse the results of several vector searches with `rerank_multivector`, applying +the same rank-fusion algorithm across more than two lists. Every input result set must include the +`_rowid` column, so add `.with_row_id(True)` to each `table.search(...)` call before reranking, +otherwise the call raises a `ValueError`. See [multi-vector reranking](/reranking#multi-vector-reranking) +for a full example. + +### Supported Scores for each query type {#rrf-supported-scores-for-each-query-type} +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +#### Hybrid Search {#rrf-hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Returned rows only have the `_relevance_score` column. | +| `all` | ✅ Supported | Returned rows have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_relevance_score`). | + +## Linear combination {#linear-combination} + +## Linear Combination Reranker + +> **Note:** This reranker is deprecated. Use the `RRFReranker` if you need a score-based reranker. + +The Linear Combination Reranker combines the results of semantic and full-text search using a linear combination of the scores. The weights for the linear combination can be specified, and defaults to 0.7, i.e, 70% weight for semantic search and 30% weight for full-text search. + +> **Note:** Supported query type – Hybrid search only. + + + + + {PyRerankingLinearCombinationUsage} + + + +### Accepted Arguments {#linear-combination-accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `weight` | `float` | `0.7` | The weight to use for the semantic search score. The weight for the full-text search score is `1 - weight`. | +| `fill` | `float` | `1.0` | Score used when a result is missing from one side of the hybrid query. The default strongly penalizes missing vector or FTS matches. | +| `return_score` | `str` | `"relevance"` | Options are "relevance" or "all". The type of score to return. If "relevance", returns only `_relevance_score`. If "all", returns all scores from the vector and FTS search along with the relevance score. | + +`weight` must be between `0` and `1`. If either the vector or FTS side returns no rows, the reranker +returns the non-empty side with `_relevance_score` attached rather than failing. + + +### Supported Scores for each query type {#linear-combination-supported-scores-for-each-query-type} +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +#### Hybrid Search {#linear-combination-hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column | +| `all` | ✅ Supported | Results have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_distance`) | + +## MRR {#mrr} + +## MRR Reranker + +This reranker uses the Mean Reciprocal Rank (MRR) algorithm to combine and rerank search results from vector and full-text search. You can use this reranker by passing `MRRReranker()` to the `rerank()` method. The MRR algorithm calculates the average of reciprocal ranks across different search results, providing a balanced way to merge results from multiple ranking systems. + +> **Note:** Supported query types – Hybrid and Multivector search. + + + + {PyRerankingMrrUsage} + + + +### Accepted Arguments {#mrr-accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `weight_vector` | `float` | `0.5` | Weight for vector search results (0.0 to 1.0). | +| `weight_fts` | `float` | `0.5` | Weight for FTS search results (0.0 to 1.0). | +| `return_score` | `str` | `"relevance"` | Options are "relevance" or "all". The type of score to return. If "relevance", will return only the `_relevance_score`. If "all", will return all scores from the vector and FTS search along with the relevance score. | + +**Note:** `weight_vector` + `weight_fts` must equal 1.0. + +For multivector reranking, input result sets need `_rowid` so LanceDB can identify the same row +across the ranked lists. Add `.with_row_id(True)` to each vector search before passing the results +to the reranker. + +### Supported Scores for each query type {#mrr-supported-scores-for-each-query-type} +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +#### Hybrid Search {#mrr-hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_relevance_score`). | + +#### Multivector Search {#mrr-multivector-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have vector distances from all searches along with `_relevance_score`. | + +## Cross encoder {#cross-encoder} + +## Cross Encoder Reranker + +This reranker uses Cross Encoder models from sentence-transformers to rerank the search results. You can use this reranker by passing `CrossEncoderReranker()` to the `rerank()` method. +> **Note:** Supported query types – Hybrid, Vector, and FTS. + + + + + {PyRerankingCrossEncoderUsage} + + + +### Accepted Arguments {#cross-encoder-accepted-arguments} +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `model_name` | `str` | `"cross-encoder/ms-marco-TinyBERT-L-6"` | The name of the reranker model to use.| +| `column` | `str` | `"text"` | The name of the column to use as input to the cross encoder model. | +| `device` | `str` | `None` | The device to use for the cross encoder model. If None, will use "cuda" if available, otherwise "cpu". | +| `trust_remote_code` | `bool` | `True` | Passed to Sentence Transformers model loading. Set this to `False` when you only want to load models that do not require custom repository code. | +| `return_score` | `str` | `"relevance"` | Options are "relevance" or "all". The type of score to return. If "relevance", returns only `_relevance_score`. If "all" is supported, returns relevance score along with the vector and/or FTS scores depending on query type. | + +The reranker loads the model locally through `sentence-transformers`, so install the local model +runtime dependencies you need, such as PyTorch and any device-specific acceleration packages. + +### Supported Scores for each query type {#cross-encoder-supported-scores-for-each-query-type} +You can specify the type of scores you want the reranker to return. The following are the supported scores for each query type: + +#### Hybrid Search {#cross-encoder-hybrid-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ❌ Not Supported | Results have vector(`_distance`) and FTS(`score`) along with Hybrid Search score(`_relevance_score`). | + +#### Vector Search {#cross-encoder-vector-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have vector(`_distance`) along with Hybrid Search score(`_relevance_score`). | + +#### FTS Search {#cross-encoder-fts-search} +|`return_score`| Status | Description | +| --- | --- | --- | +| `relevance` | ✅ Supported | Results only have the `_relevance_score` column. | +| `all` | ✅ Supported | Results have FTS(`score`) along with Hybrid Search score(`_relevance_score`). | + +## Building a custom reranker {#custom} + +You can build your own custom reranker in LanceDB by subclassing the base `Reranker` class. At a +minimum, you need to implement `rerank_hybrid()`, which is the logic that combines vector and +full-text search results. Beyond that, you can optionally implement `rerank_vector()` and +`rerank_fts()` if you want your reranker to also handle pure vector or pure full-text searches. + +Decide up front which surfaces — hybrid, pure vector, or pure full-text — your reranker should +cover, and only override the ones you need. The base class leaves `rerank_vector()` and +`rerank_fts()` unimplemented, so calling `.rerank(...)` on a single-modality search you haven't +overridden raises `NotImplementedError` rather than silently returning unsorted results. That's a +useful guard, but worth knowing about before you wire up a query path you didn't plan for. + +The Python base class exposes hybrid, vector-only, and FTS-only rerank hooks. TypeScript and Rust +currently expose the custom reranker interface for hybrid reranking. In Rust, a custom reranker must +also satisfy the trait bounds `Debug + Send + Sync`. + +### Interface {#custom-interface} + +The `Reranker` base interface comes with a `merge_results()` method that can be used to combine the +results of semantic and full-text search. This is a vanilla merging algorithm that simply concatenates +the results and removes the duplicates without taking the scores into consideration. It only keeps the +first copy of the row encountered. This works well in cases that don't require the scores of semantic +and full-text search to combine the results. If you want to use the scores or want to support +`return_score="all"`, you'll need to implement your own merging algorithm. The base +`return_score` option accepts only `"relevance"` and `"all"`. + +Whichever methods you override, your reranker has one job on the way out: attach a +`_relevance_score` column with the most relevant rows at the top. LanceDB will reject the result +if that column is missing, and downstream `.limit(...)` calls trust the order you return, so +sort descending before handing the table back. + +For vector-only reranking in Python, pass a text query to `.rerank(..., query_string="...")`. +The vector query itself may be numeric, but `rerank_vector(query, vector_results)` still receives +a string query so your reranker can score each candidate against the user's text intent. + +Below, we show the pseudocode of a custom reranker that combines the results of semantic and full-text +search using a linear combination of the scores: + + +```python Python icon="python" +from lancedb.rerankers import Reranker +import pyarrow as pa + +class MyReranker(Reranker): + def __init__(self, param1, param2, ..., return_score="relevance"): + super().__init__(return_score) + self.param1 = param1 + self.param2 = param2 + + def rerank_hybrid(self, query: str, vector_results: pa.Table, fts_results: pa.Table): + # Use the built-in merging function + combined_result = self.merge_results(vector_results, fts_results) + + # Do something with the combined results + # ... + + # Return the combined results + return combined_result + + def rerank_vector(self, query: str, vector_results: pa.Table): + # Do something with the vector results + # ... + + # Return the vector results + return vector_results + + def rerank_fts(self, query: str, fts_results: pa.Table): + # Do something with the FTS results + # ... + + # Return the FTS results + return fts_results +``` + + +### Example {#custom-example} + +As an example, let's build custom reranker that enhances the Cohere Reranker by accepting a filter +query, and accepts any other `CohereReranker` params as `kwargs`. + + +```python Python icon="python" +from typing import List, Union +import pandas as pd +from lancedb.rerankers import CohereReranker + +class ModifiedCohereReranker(CohereReranker): + def __init__(self, filters: Union[str, List[str]], **kwargs): + super().__init__(**kwargs) + filters = filters if isinstance(filters, list) else [filters] + self.filters = filters + + def rerank_hybrid(self, query: str, vector_results: pa.Table, fts_results: pa.Table)-> pa.Table: + combined_result = super().rerank_hybrid(query, vector_results, fts_results) + df = combined_result.to_pandas() + for filter in self.filters: + df = df.query("not text.str.contains(@filter)") + + return pa.Table.from_pandas(df) + + def rerank_vector(self, query: str, vector_results: pa.Table)-> pa.Table: + vector_results = super().rerank_vector(query, vector_results) + df = vector_results.to_pandas() + for filter in self.filters: + df = df.query("not text.str.contains(@filter)") + + return pa.Table.from_pandas(df) + + def rerank_fts(self, query: str, fts_results: pa.Table)-> pa.Table: + fts_results = super().rerank_fts(query, fts_results) + df = fts_results.to_pandas() + for filter in self.filters: + df = df.query("not text.str.contains(@filter)") + + return pa.Table.from_pandas(df) +``` + + + +Under the hood, `vector_results` and `fts_results` are PyArrow tables. You can learn more about +PyArrow tables [here](https://arrow.apache.org/docs/python). The advantage of PyArrow tables is their +interoperability -- you can easily convert them to Pandas/Polars DataFrames, `PyDict`, `PyList`, etc. + +The benefits are also bidirectional -- just as you can easily convert PyArrow tables _to_ Pandas +DataFrames using the `to_pandas()` method -- you can perform DataFrame transformations +and just as easily convert the DataFrame back to PyArrow tables using `pa.Table.from_pandas()` method +as shown in the example above. + + +## Evaluating hybrid search {#eval} + +Hybrid search is an often misused and/or misunderstood term. In this section, we're using +the definition of "hybrid search" to mean using a combination of keyword-based and vector search. +Because the vector search operates in a dense embedding space and keyword-based search operate +in a sparse embedding space, their relevance scores cannot be directly compared. +Combining results from multiple searches thus requires a reranking step. + +Before evaluating hybrid search, build an FTS index on the text column and use a table with embedding +metadata or explicit query vectors for the vector side. Otherwise the evaluation is measuring setup +fallbacks or errors rather than the reranker. + +### Reranking strategies {#eval-reranking-strategies} + +There are two common approaches for reranking search results from multiple sources. + +- **Score-based**: Calculate final relevance scores from the individual search algorithm scores. Examples: Reciprocal Rank Fusion (the default in LanceDB), mean reciprocal rank fusion, and weighted linear combination of semantic and keyword-based search scores. + +- **Relevance-based**: Discards the existing scores and calculates the relevance of each search result-query pair. Example: Cross Encoder models + + +If you call `.rerank()` on a hybrid query without passing a reranker, LanceDB defaults to +`RRFReranker()` — a score-based reranker that uses Reciprocal Rank Fusion. This is the +score-based path most readers encounter first; `LinearCombinationReranker` is an alternative +score-based strategy you opt into explicitly. + + +By default, rerankers return `_relevance_score`. Pass `return_score="all"` when a reranker +supports it, and you also need the original vector or FTS scores for debugging. +Evaluation code can rely on returned rows being ordered by descending `_relevance_score`. Empty +reranked result sets still include the `_relevance_score` column. + +The hybrid `rerank(...)` method also accepts a `normalize` argument that controls how the raw +vector and FTS scores are made comparable before reranking: + +- `normalize="score"` (the default) — normalizes the raw vector and FTS scores directly. +- `normalize="rank"` — converts each result list to ranks first, then normalizes. + +This choice materially affects score-based rerankers (such as `LinearCombinationReranker`), so +when you evaluate score-based strategies, treat `normalize` as a tunable hyperparameter +alongside the reranker itself. + +For score-based evaluation, the main built-in knobs are: + +- `RRFReranker(K=60)`: rank fusion with a positive smoothing constant. +- `MRRReranker(weight_vector=0.5, weight_fts=0.5)`: weighted reciprocal rank fusion where the weights must sum to `1.0`. +- `LinearCombinationReranker(weight=0.7, fill=1.0)`: blends vector and FTS scores directly; `fill` controls how strongly to penalize a result missing from one side. + +Even though there are many more strategies for reranking, there are no "universally best" +ones that work well for all cases, because reranking quality is dataset and application specific. +Evaluating whether a reranking strategy is a good fit is also a challenge. In the next +section, we discuss an example evaluation of different reranking strategies on a sample dataset. + +### Example evaluation {#eval-example-evaluation} + +The table below shows our evaluation results from an experiment comparing multiple rerankers on +~800 hybrid search queries. This is a modified version of an evaluation script by +[LlamaIndex](https://github.com/run-llama/finetune-embedding/blob/main/evaluate.ipynb) that measures +hit-rate \@ top-k. + +#### Using OpenAI `text-embedding-ada-002` {#eval-using-openai-text-embedding-ada-002} + +Vector Search baseline: **0.64** + +| Reranker | Top-3 | Top-5 | Top-10 | +| --- | --- | --- | --- | +| Linear Combination | `0.73` | `0.74` | `0.85` | +| Cross Encoder | `0.71` | `0.70` | `0.77` | +| Cohere | `0.81` | `0.81` | `0.85` | +| ColBERT | `0.68` | `0.68` | `0.73` | + + + + +#### Using OpenAI `text-embedding-3-small` {#eval-using-openai-text-embedding-3-small} + +Vector Search baseline: **0.59** + +| Reranker | Top-3 | Top-5 | Top-10 | +| --- | --- | --- | --- | +| Linear Combination | `0.68` | `0.70` | `0.84` | +| Cross Encoder | `0.72` | `0.72` | `0.79` | +| Cohere | `0.79` | `0.79` | `0.84` | +| ColBERT | `0.70` | `0.70` | `0.76` | + + + +### Conclusion {#eval-conclusion} + +The results show that the reranking methods can significantly improve the search relevance. However, +the improvement we saw was not consistent across all rerankers. In reality, the choice of reranker +likely depends on the dataset and the application. + +It's also important to note that the reranking methods are not a +replacement for the search methods they supplement. They are complementary and it's likely that you'd +have to tune them together to get the best results. The latency vs. recall tradeoff is also an +important factor to consider when choosing the reranker. Hopefully this evaluation +gives you a starting point for your own experiments with hybrid search in LanceDB! diff --git a/docs/web/search/explain.mdx b/docs/web/search/explain.mdx new file mode 100644 index 000000000..b2fc12c04 --- /dev/null +++ b/docs/web/search/explain.mdx @@ -0,0 +1,504 @@ +--- +title: "Explain a query plan" +sidebarTitle: "Query Plans" +description: "Read the plan LanceDB resolved for a query, and measure it with analyze." +icon: "map" +--- + +`explain_plan` prints the plan LanceDB resolved for a query without executing +it. It answers structural questions -- did the filter push into the scan, is the +vector index being used, which columns are actually read -- and costs nothing to +run, so it is the first thing to reach for when a query behaves unexpectedly. + +To measure a query rather than read it, use +[`analyze_plan`](/search/explain), which executes it and annotates every +operator with runtime metrics. + + +The SQL equivalent is [`EXPLAIN`](/sql/dql/explain). + + +## What it shows {#inspect-a-query-plan} + +Reveals the logical query plan before execution, helping you identify potential issues with query structure and index usage. This tool is useful for: + +- Verifying query optimization strategies +- Validating index selection +- Understanding query execution order +- Detecting missing indices + +Metadata filters are prefiltered by default, which usually shows the filter pushed into the +`LanceScan` or index scan. If you set `prefilter=False`, expect a separate `FilterExec` after +search instead; that can be useful for some expensive filters, but it changes both latency and +the number of rows available after filtering. + +## Reading the Execution Plan {#reading-the-execution-plan} + +To demonstrate query performance analysis, we'll use a table containing 1.2M rows sampled from the [Wikipedia dataset](https://huggingface.co/datasets/wikimedia/wikipedia). Initially, the table has no indices, allowing us to observe the impact of optimization. + +Let's examine a vector search query that: + +- Filters rows where `identifier` is between 0 and 1,000,000 +- Returns the top 100 matches +- Projects specific columns: `chunk_index`, `title`, and `identifier` + + +```python Python icon="python" +# explain_plan +query_explain_plan = ( + table.search(query_embed) + .where("identifier > 0 AND identifier < 1000000") + .select(["chunk_index", "title", "identifier"]) + .limit(100) + .explain_plan(True) +) +``` + +```typescript TypeScript icon="square-js" +// explain_plan +const explainPlan = await table + .search(queryEmbed) + .where("identifier > 0 AND identifier < 1000000") + .select(["chunk_index", "title", "identifier"]) + .limit(100) + .explainPlan(true); +``` + + +### Execution Plan Components {#execution-plan-components} + +The execution plan reveals the sequence of operations performed to execute your query. Let's examine each component: + +``` +ProjectionExec: expr=[chunk_index@4 as chunk_index, title@5 as title, identifier@1 as identifier, _distance@3 as _distance] + RemoteTake: columns="vector, identifier, _rowid, _distance, chunk_index, title" + CoalesceBatchesExec: target_batch_size=1024 + GlobalLimitExec: skip=0, fetch=100 + FilterExec: _distance@3 IS NOT NULL + SortExec: TopK(fetch=100), expr=[_distance@3 ASC NULLS LAST], preserve_partitioning=[false] + KNNVectorDistance: metric=l2 + FilterExec: identifier@1 > 0 AND identifier@1 < 1000000 + LanceScan: uri=***, projection=[vector, identifier], row_id=true, row_addr=false, ordered=false +``` + +#### 1\. Base Layer (LanceScan) {#1-base-layer-lancescan} + +- Initial data scan loading only specified columns to minimize I/O +- Unordered scan enabling parallel processing + +``` +LanceScan: +- projection=[vector, identifier] +- row_id=true, row_addr=false, ordered=false +``` + +#### 2\. First Filter {#2-first-filter} + +- Apply requested filter on `identifier` column +- Reduces the number of vectors that need KNN computation + +``` +FilterExec: identifier@1 > 0 AND identifier@1 < 1000000 +``` + +#### 3\. Vector Search {#3-vector-search} + +- Computes L2 (Euclidean) distances between query vector and all vectors that passed the filter + +``` +KNNVectorDistance: metric=l2 +``` + +#### 4\. Results Processing {#4-results-processing} + +- Filters out null distance results +- Sorts by distance and takes top 100 results +- Processes in batches of 1024 for optimal memory usage + +``` +SortExec: TopK(fetch=100) +- expr=[_distance@3 ASC NULLS LAST] +- preserve_partitioning=[false] +FilterExec: _distance@3 IS NOT NULL +GlobalLimitExec: skip=0, fetch=100 +CoalesceBatchesExec: target_batch_size=1024 +``` + +#### 5\. Data Retrieval {#5-data-retrieval} + +- `RemoteTake` is a key component of Lance's I/O cache +- Handles efficient data retrieval from remote storage locations +- Fetches specific rows and columns needed for the final output +- Optimizes network bandwidth by only retrieving required data + +``` +RemoteTake: columns="vector, identifier, _rowid, _distance, chunk_index, title" +``` + +#### 6\. Final Output {#6-final-output} + +- Returns only requested columns and maintains column ordering + +```python +ProjectionExec: expr=[chunk_index@4 as chunk_index, title@5 as title, identifier@1 as identifier, _distance@3 as _distance] +``` + +This plan demonstrates a basic search without index optimizations: it performs a full scan and filter before vector search. + +## Non-vector queries {#analyzing-non-vector-queries} + +`explain_plan` is not vector-specific -- it is available on every query builder, +including FTS and hybrid. The most common reason to read the plan for a +non-vector query is to confirm whether your `where` clause pushed into the scan +(good) or ran as a separate `FilterExec` on top of the search results (often +slower, and a hint that the filter column needs a scalar index). + +### FTS queries {#fts-queries} + + +```python Python icon="python" +plan = ( + table.search("puppy", query_type="fts") + .where("category = 'animals'", prefilter=True) + .limit(10) + .explain_plan(True) +) +print(plan) +``` + +```typescript TypeScript icon="square-js" +const plan = await table + .query() + .fullTextSearch("puppy") + .where("category = 'animals'") + .limit(10) + .explainPlan(true); +``` + + +In an indexed FTS plan you should see a `MatchQuery` (or other FTS execution node) reading from the inverted index, with the metadata filter pushed down. If the plan shows a `LanceScan` followed by `FilterExec` over the entire text column, the FTS index either isn't covering the column or the filter isn't using a scalar index — both worth investigating. + +### Hybrid queries {#hybrid-queries} + +For hybrid queries, `explain_plan` returns the reranker label followed by the vector and FTS sub-plans, indented for readability: + + +```python Python icon="python" +plan = ( + table.search("flower moon", query_type="hybrid") + .where("category = 'film'", prefilter=True) + .limit(10) + .explain_plan(True) +) +print(plan) +# RRFReranker(...) +# +# +``` + + +Reading the plan tells you the shape. `analyze_plan` executes both sub-queries +and labels them `Vector Search Plan:` and `FTS Search Plan:`, which is how you +see whether the filter pushed into both halves uniformly and which half +dominates latency. + +`analyze_plan` executes the query and returns the physical plan annotated with +runtime metrics -- elapsed time, rows produced, bytes read, index comparisons. +Where [`explain_plan`](#inspect-a-query-plan) tells you what a query intends to do, +this tells you what it cost. + + +The SQL equivalent is [`EXPLAIN ANALYZE`](/sql/dql/explain). + + +## What it measures {#analyze-a-query-plan} + +Executes the query and provides detailed runtime metrics, including: + +- Operation duration (`_elapsed_compute_`) +- Data processing statistics (`_output_rows_`, `_bytes_read_`) +- Index effectiveness (`_index_comparisons_`, `_indices_loaded_`) +- Resource utilization (`_iops_`, `_requests_`) + +## Performance Analysis {#performance-analysis} + +Let's use `analyze_plan` to run the query and analyze the query performance, which will help us identify potential bottlenecks: + + +```python Python icon="python" +# analyze_plan +query_analyze_plan = ( + table.search(query_embed) + .where("identifier > 0 AND identifier < 1000000") + .select(["chunk_index", "title", "identifier"]) + .limit(100) + .analyze_plan() +) +``` + +```typescript TypeScript icon="square-js" +// analyze_plan +const analyzePlan = await table + .search(queryEmbed) + .where("identifier > 0 AND identifier < 1000000") + .select(["chunk_index", "title", "identifier"]) + .limit(100) + .analyzePlan(); +``` + + +### Performance Metrics Analysis {#performance-metrics-analysis} + +``` +ProjectionExec: expr=[chunk_index@4 as chunk_index, title@5 as title, identifier@1 as identifier, _distance@3 as _distance], metrics=[output_rows=100, elapsed_compute=1.424µs] + RemoteTake: columns="vector, identifier, _rowid, _distance, chunk_index, title", metrics=[output_rows=100, elapsed_compute=175.53097ms, output_batches=1, remote_takes=100] + CoalesceBatchesExec: target_batch_size=1024, metrics=[output_rows=100, elapsed_compute=2.748µs] + GlobalLimitExec: skip=0, fetch=100, metrics=[output_rows=100, elapsed_compute=1.819µs] + FilterExec: _distance@3 IS NOT NULL, metrics=[output_rows=100, elapsed_compute=10.275µs] + SortExec: TopK(fetch=100), expr=[_distance@3 ASC NULLS LAST], preserve_partitioning=[false], metrics=[output_rows=100, elapsed_compute=39.259451ms, row_replacements=546] + KNNVectorDistance: metric=l2, metrics=[output_rows=1099508, elapsed_compute=56.783526ms, output_batches=1076] + FilterExec: identifier@1 > 0 AND identifier@1 < 1000000, metrics=[output_rows=1099508, elapsed_compute=17.136819ms] + LanceScan: uri=***, projection=[vector, identifier], row_id=true, row_addr=false, ordered=false, metrics=[output_rows=1200000, elapsed_compute=21.348178ms, bytes_read=1852931072, iops=78, requests=78] +``` + +#### 1\. Data Loading (LanceScan) {#1-data-loading-lancescan} + +- Scanned 1,200,000 rows from the LanceDB table +- Read 1.86GB of data in 78 I/O operations +- Only loaded necessary columns (`vector` and `identifier`) +- Unordered scan for parallel processing + +#### 2\. Filtering and Search {#2-filtering-and-search} + +- Applied prefilter condition (`identifier > 0 AND identifier < 1000000`) +- Reduced dataset from 1.2M to 1,099,508 rows +- KNN search used L2 (Euclidean) distance metric +- Vector comparisons processed in 1076 batches + +#### 3\. Results Processing {#3-results-processing} + +- KNN results sorted by distance (TopK with fetch=100) +- Null distances filtered out +- Batches coalesced to target size of 1024 rows +- Additional columns fetched for final results +- Remote take operation for 100 results +- Final projection of required columns + +### Distributed metrics on remote tables {#distributed-metrics-on-remote-tables} + +Enterprise + +When you call `analyze_plan` against a LanceDB Enterprise table, the query runs across a pool of workers. By default the returned plan aggregates each operator's metrics into a single value, matching the local single-node output. Pass a `distributed_metrics` mode when you need to see how work was split across workers: + +| Mode | What it shows | When to use it | +| :------------ | :---------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------ | +| `aggregate` | One summary value per operator (default). Same shape as local `analyze_plan`. | Everyday performance checks and comparisons against a local plan. | +| `per_worker` | Metrics reported separately for each worker that participated in the query. | Diagnosing stragglers or uneven work distribution across the cluster. | +| `full` | Both the aggregate summary and the per-worker breakdown. | Deep investigations where you need the totals *and* the per-worker detail in a single output. | + +The parameter only affects remote plans — local queries always return the aggregate output regardless of what you pass. + + +```python Python icon="python" +# Show a per-worker breakdown for a remote query +plan = ( + table.search(query_embed) + .where("identifier > 0 AND identifier < 1000000") + .limit(100) + .analyze_plan(distributed_metrics="per_worker") +) +print(plan) +``` + +```typescript TypeScript icon="square-js" +// Show a per-worker breakdown for a remote query +const plan = await table + .search(queryEmbed) + .where("identifier > 0 AND identifier < 1000000") + .limit(100) + .analyzePlan("per_worker"); +``` + + + +Use `per_worker` or `full` sparingly — the plan output grows with the size of your worker pool. Stick with the default `aggregate` mode for routine tuning. + + +### Key Observations {#key-observations} + +- Vector search is the primary bottleneck (1,099,508 vector comparisons) +- Significant I/O overhead (1.86GB data read) +- Full table scan due to lack of indices +- Substantial optimization potential through proper index implementation + +## Optimized Query Execution {#optimized-query-execution} + +After creating vector and scalar indices, the execution plan shows: + +``` +ProjectionExec: expr=[chunk_index@3 as chunk_index, title@4 as title, identifier@2 as identifier, _distance@0 as _distance] + RemoteTake: columns="_distance, _rowid, identifier, chunk_index, title" + CoalesceBatchesExec: target_batch_size=1024 + GlobalLimitExec: skip=0, fetch=100 + SortExec: TopK(fetch=100), expr=[_distance@0 ASC NULLS LAST], preserve_partitioning=[false] + ANNSubIndex: name=vector_idx, k=100, deltas=1 + ANNIvfPartition: uuid=83916fd5-fc45-4977-bad9-1f0737539bb9, nprobes=20, deltas=1 + ScalarIndexQuery: query=AND(identifier > 0,identifier < 1000000) +``` + +### Optimized Plan Analysis {#optimized-plan-analysis} + +#### 1\. Scalar Index Query {#1-scalar-index-query} + +``` +ScalarIndexQuery: query=AND(identifier > 0,identifier < 1000000) +metrics=[ + output_rows=2 + index_comparisons=2,301,824 + indices_loaded=2 + output_batches=1 + parts_loaded=562 + elapsed_compute=86.979354ms +] +``` + +- Range filter using scalar index +- Only 2 index files and 562 scalar index parts loaded +- 2.3M index comparisons for matches + +#### 2\. Vector Search {#2-vector-search} + +``` +ANNSubIndex: name=vector_idx, k=100, deltas=1 +metrics=[ + output_rows=2,000 + index_comparisons=25,893 + indices_loaded=0 + output_batches=20 + parts_loaded=20 + elapsed_compute=111.849043ms +] +``` + +- IVF index with 20 probes +- Only 20 index parts loaded +- 25,893 vector comparisons +- 2,000 matching vectors + +#### 3\. Results Processing {#3-results-processing-2} + +``` +SortExec: TopK(fetch=100), expr=[_distance@0 ASC NULLS LAST], preserve_partitioning=[false] +GlobalLimitExec: skip=0, fetch=100 +CoalesceBatchesExec: target_batch_size=1024 +``` + +- Sorts by distance +- Limits to top 100 results +- Batches into groups of 1024 + +#### 4\. Data Fetching {#4-data-fetching} + +``` +RemoteTake: columns="_distance, _rowid, identifier, chunk_index, title" +metrics=[output_rows=100, elapsed_compute=113.491859ms, output_batches=1, remote_takes=100] +``` + +- Single output batch +- One remote take per row + +#### 5\. Final Projection {#5-final-projection} + +``` +ProjectionExec: expr=[chunk_index@3 as chunk_index, title@4 as title, identifier@2 as identifier, _distance@0 as _distance] +``` + +- Returns specified columns: chunk_index, title, identifier, and distance + +### Performance Improvements {#performance-improvements} + +#### 1\. Initial Data Access {#1-initial-data-access} + +``` +ScalarIndexQuery metrics: +- indices_loaded=2 +- parts_loaded=562 +- output_batches=1 +``` + +- Before: Full table scan of 1.2M rows, 1.86GB data +- After: Only 2 indices and 562 scalar index parts loaded +- Benefit: Eliminated table scans for prefilter + +#### 2\. Vector Search Efficiency {#2-vector-search-efficiency} + +``` +ANNSubIndex: +- index_comparisons=25,893 +- indices_loaded=0 +- parts_loaded=20 +- output_batches=20 +``` + +- Before: L2 calculations on 1,099,508 vectors +- After: + - 99.8% reduction in vector comparisons + - Decreased output batches from 1,076 to 20 + +#### 3\. Data Retrieval Optimization {#3-data-retrieval-optimization} + +``` +RemoteTake: +- remote_takes=100 +- output_batches=1 +``` + +- RemoteTake operation remains consistent + +## Performance Optimization Guide {#performance-optimization-guide} + +### 1\. Index Implementation {#1-index-implementation} + +#### When to Create Indices {#when-to-create-indices} + +- Columns used in WHERE clauses +- Vector columns for similarity searches +- Join columns used in `merge_insert` + +#### Index Type Selection {#index-type-selection} + +| Data Type | Recommended Index | Use Case | +| ----------- | ------------------ | ---------------------------------------- | +| Vector | IVF_PQ/IVF_HNSW_SQ/IVF_HNSW_FLAT | Approximate nearest neighbor search | +| Scalar | B-Tree | Range queries and sorting | +| Categorical | Bitmap | Multi-value filters and set operations | +| `List` | Label_list | Multi-label classification and filtering | + + +Use `table.index_stats()` to monitor index coverage. +A well-optimized table should have `num_unindexed_rows ~ 0`. + + +### 2\. Query Plan Optimization {#2-query-plan-optimization} + +#### Common Patterns and Fixes {#common-patterns-and-fixes} + +| Plan Pattern | Optimization | +| ------------------------------------------ | -------------------------------------------- | +| LanceScan with high _bytes_read_ or _iops_ | Add missing index | +| | Use `select()` to limit returned columns | +| | Check whether the dataset has been compacted | +| Multiple sequential filters | Reorder filter conditions | + + +Regularly analyze your query plans to identify and address performance +bottlenecks. The `analyze_plan` output provides detailed metrics to guide +optimization efforts. + + +### 3\. Getting Started with Optimization {#3-getting-started-with-optimization} + +For vector search performance: + +- Create ANN index on your vector column(s) as described in the [index guide](/indexing/vector-index/) +- If you often filter by metadata, create [scalar indices](/indexing/scalar-index/) on those columns diff --git a/docs/web/search/filtering.mdx b/docs/web/search/filtering.mdx new file mode 100644 index 000000000..d5bce618b --- /dev/null +++ b/docs/web/search/filtering.mdx @@ -0,0 +1,270 @@ +--- +title: "Metadata Filtering in LanceDB" +sidebarTitle: Filtering +description: Filter search results in LanceDB based on metadata fields. +icon: "filter" +--- + +LanceDB supports filtering features of query results based on metadata fields. +While joint vector and metadata search at scale presents a significant challenge, +LanceDB achieves sub-100ms latency at thousands of QPS, enabling efficient vector search +with filtering capabilities even on datasets containing billions of records. + +**Pre-filtering** means LanceDB applies the metadata `where(...)` condition before running vector search, so the search only considers rows that already match the filter. **Post-filtering** means LanceDB runs vector search first and only then filters the returned candidates. Pre-filtering is enabled by default. In practice, pre-filtering is better when the filter is part of the result contract; post-filtering can be lower-latency for expensive or non-indexable filters, but it can return fewer than `limit` rows, or even zero, if the nearest neighbors do not pass the filter. + +On hybrid queries, the same `where(...)` filter is applied to both the vector and full-text halves of the query. The prefilter or postfilter choice controls whether that happens before each subquery scores candidates or after the subquery top-k is produced. + + +The same predicates work in SQL -- see [SELECT](/sql/dql/select#filtering) and [Operators](/sql/operators). + + +## Chaining `where` clauses {#chaining-where-clauses} + +In more recent LanceDB SDK versions (see the callout box below for the exact version numbers), you can call `where(...)` (Python and TypeScript) or `only_if(...)` (Rust) more than once on the same query builder. Each additional filter is combined with the previous one using logical `AND`, so `where("a > 0").where("b < 10")` is equivalent to `where("(a > 0) AND (b < 10)")`. + +For a fixed predicate, writing one `where(...)` clause with `AND` is just as valid and often clearer. Chaining is mainly useful when code composes filters incrementally, such as applying a shared base predicate in a helper and then adding a per-call predicate at the query site. + + +```python Python icon="python" +# Both filters apply: item is in the list AND price is above 15. +result = ( + table.search([100, 102]) + .where("item IN ('foo', 'bar', 'baz')") + .where("price > 15.0") + .limit(3) + .to_arrow() +) +``` + +```typescript TypeScript icon="square-js" +// Both filters apply: item is in the list AND price is above 15. +const result = await table + .search([100, 102]) + .where("item IN ('foo', 'bar', 'baz')") + .where("price > 15.0") + .limit(3) + .toArray(); +``` + + + +In Python SDK versions before `0.34.0` and Rust/TypeScript SDK versions before `0.31.0`, a second `where(...)` or `only_if(...)` call replaced the first filter, so only the last predicate was applied. If your code needs to run on those older versions, write a single predicate with `AND` instead of chaining calls. When upgrading to the latest SDKs, review any existing chained filters and drop earlier calls you no longer want to apply. + + +## Example: Metadata Filtering {#example-metadata-filtering} + +To illustrate filtering capabilities, let's try four data points with combinations of vectors and metadata: + + +```python Python icon="python" +data = [ + {"vector": [3.1, 4.1], "item": "foo", "price": 10.0}, + {"vector": [5.9, 26.5], "item": "bar", "price": 20.0}, + {"vector": [10.2, 100.8], "item": "baz", "price": 30.0}, + {"vector": [1.4, 9.5], "item": "fred", "price": 40.0}, +] +table = db.create_table("metadata_filter_example", data=data, mode="overwrite") +``` + +```typescript TypeScript icon="square-js" +const data = [ + { vector: [3.1, 4.1], item: "foo", price: 10.0 }, + { vector: [5.9, 26.5], item: "bar", price: 20.0 }, + { vector: [10.2, 100.8], item: "baz", price: 30.0 }, + { vector: [1.4, 9.5], item: "fred", price: 40.0 }, +]; + +const tableName = "metadata_filter_example"; +const table = await db.createTable(tableName, data, { + mode: "overwrite", +}); +``` + + +### Filtering Without Vector Search {#filtering-without-vector-search} + +You can always filter your data without search. This is useful when you need to query based on metadata: + + +```python Python icon="python" +filtered_no_search_result = ( + table.search() + .where("(item IN ('foo', 'bar', 'baz')) AND (price > 15.0)") + .limit(3) + .to_arrow() +) +``` + +```typescript TypeScript icon="square-js" +const filteredResult = await table + .query() + .where("(item IN ('foo', 'bar', 'baz')) AND (price > 15.0)") + .limit(3) + .toArray(); +``` + + + +If your table is large, this could potentially return a very large amount of data. Please be sure to use a `limit` clause unless you're sure you want to return the whole result set. + + +### Pre-Filtering with Vector Search {#pre-filtering-with-vector-search} + + +```python Python icon="python" +filtered_result = ( + table.search([100, 102]) + .where("(item IN ('foo', 'bar')) AND (price > 15.0)") + .limit(3) + .to_arrow() +) +``` + +```typescript TypeScript icon="square-js" +const results = await table + .search([100, 102]) + .where("(item IN ('foo', 'bar')) AND (price > 15.0)") + .toArray(); +``` + + +### Post-Filtering with Vector Search {#post-filtering-with-vector-search} + + +```python Python icon="python" +post_filtered_result = ( + table.search([100, 102]) + .where("(item IN ('foo', 'bar')) AND (price > 15.0)", prefilter=False) + .limit(3) + .to_arrow() +) +``` + +```typescript TypeScript icon="square-js" +const postFilteredResult = await (table.search([100, 102]) as VectorQuery) + .where("(item IN ('foo', 'bar')) AND (price > 15.0)") + .postfilter() + .limit(3) + .toArray(); +``` + + + +When querying large tables, omitting a `limit` clause may overwhelm resources and return excessive data. It's always recommended +to be mindful of the potential impact on performance and costs when working with really large tables. + + +## Filtering with SQL {#filtering-with-sql} + +Because it's built on top of DataFusion, LanceDB embraces the utilization of standard SQL expressions as predicates for filtering operations. SQL can be used during vector search, update, and deletion operations. + +LanceDB supports a growing list of SQL expressions: + +| SQL Expression | Description | +|:---------------|:-------------| +| `>, >=, <, <=, =` | Comparison operators | +| `AND`, `OR`, `NOT` | Logical operators | +| `IS NULL`, `IS NOT NULL` | Null checks | +| `IS TRUE`, `IS NOT TRUE`, `IS FALSE`, `IS NOT FALSE` | Boolean checks | +| `IN` | Value matching from a set | +| `LIKE`, `NOT LIKE` | Pattern matching | +| `CAST` | Type conversion | +| `regexp_match(column, pattern)` | Regular expression matching | +| [DataFusion Functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html) | Additional SQL functions | + +### Simple SQL Filters {#simple-sql-filters} + +For example, the following filter string is acceptable: + + +```python Python icon="python" +tbl.search([100, 102]).where( + "(item IN ('foo', 'baz')) AND (price > 20.0)" +).to_arrow() +``` + +```typescript TypeScript icon="square-js" +await table + .search([100, 102]) + .where("(item IN ('foo', 'baz')) AND (price > 20.0)") + .toArray(); +``` + + +### Advanced SQL Filters {#advanced-sql-filters} + +If your column name contains special characters, upper-case characters, or is a [SQL Keyword](https://docs.rs/sqlparser/latest/sqlparser/keywords/index.html), +you can use backtick (`` ` ``) to escape it. For nested fields, each segment of the +path must be wrapped in backticks. + +```sql +`CUBE` = 10 AND `UpperCaseName` = '3' AND `column name with space` IS NOT NULL +AND `nested with space`.`inner with space` < 2 +``` + + +Field names containing periods (.) are NOT supported. + + +### Dates, Timestamps, Decimals {#dates-timestamps-decimals} + +Literals for dates, timestamps, and decimals can be written by writing the string +value after the type name. For example: + + +```sql SQL icon="SQL" +date_col = date '2021-01-01' +and timestamp_col = timestamp '2021-01-01 00:00:00' +and decimal_col = decimal(8,3) '1.000' +``` + + +For timestamp columns, the precision can be specified as a number in the type +parameter. Microsecond precision (6) is the default. + +| SQL | Time unit | +|:-------------- |:------------ | +| `timestamp(0)` | Seconds | +| `timestamp(3)` | Milliseconds | +| `timestamp(6)` | Microseconds | +| `timestamp(9)` | Nanoseconds | + +## Apache Arrow Mapping {#apache-arrow-mapping} + +LanceDB internally stores data in [Apache Arrow](https://arrow.apache.org/) format. +The mapping from SQL types to Arrow types is: + +| SQL type | Arrow type | +|:--------------------------------------------------------- |:------------------ | +| `boolean` | `Boolean` | +| `tinyint` / `tinyint unsigned` | `Int8` / `UInt8` | +| `smallint` / `smallint unsigned` | `Int16` / `UInt16` | +| `int` or `integer` / `int unsigned` or `integer unsigned` | `Int32` / `UInt32` | +| `bigint` / `bigint unsigned` | `Int64` / `UInt64` | +| `float` | `Float32` | +| `double` | `Float64` | +| `decimal(precision, scale)` | `Decimal128` | +| `date` | `Date32` | +| `timestamp` | `Timestamp` [^1] | +| `string` | `Utf8` | +| `binary` | `Binary` | + + +## Best Practices {#best-practices} + +**Scalar Indexes**: We strongly recommend creating scalar indices on columns used for filtering, whether combined with a search operation or applied independently (e.g., for updates or deletions). + +For best performance with large tables or high query volumes: + +- Build a scalar index on frequently filtered columns +- Use exact column names in filters (e.g., `user_id` instead of `USER_ID`) +- Avoid complex transformations in filter expressions (keep them simple) +- When running concurrent queries, use connection pooling for better throughput + +For a column of type LIST(T), you can use `LABEL_LIST` to create a scalar index. Then you should leverage DataFusion's [array functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#array-functions) like `array_has_any` or `array_has_all` for optimized filtering. + +## Limitations {#limitations} + +Both **pre-filtering** and **post-filtering** can yield false positives. For pre-filtering, if the filter is too selective, it might eliminate relevant items that the vector search would have otherwise identified as a good match. In this case, increasing `nprobes` parameter will help reduce such false positives. It is recommended to call `bypass_vector_index()` if you know that the filter is highly selective. + +Similarly, a highly selective post-filter can lead to false positives. Increasing both `nprobes` and `refine_factor` can mitigate this issue. When deciding between pre-filtering and post-filtering, pre-filtering is generally the safer choice if you're uncertain. diff --git a/docs/web/search/full-text-search.mdx b/docs/web/search/full-text-search.mdx new file mode 100644 index 000000000..0008183dc --- /dev/null +++ b/docs/web/search/full-text-search.mdx @@ -0,0 +1,1123 @@ +--- +title: Full-Text Search (FTS) +sidebarTitle: Full-Text Search (FTS) +description: Learn how to implement full-text search in LanceDB using BM25 for keyword-based retrieval. +icon: "book" +--- + +import { + PyFtsPrefiltering, + PyFtsPostfiltering, + PyFtsIncrementalIndex, +} from '/snippets/search.mdx' +import { PyFtsBooleanQueries } from '/snippets/doc_function_examples.mdx' + +LanceDB provides support for Full-Text Search via Lance, allowing you to incorporate keyword-based search (based on BM25) in your retrieval solutions. + + +In SQL, full-text search is the [`fts()`](/sql/functions#fts) table function, or the [`contains_tokens`](/sql/scalar-functions#contains-tokens) predicate when you do not need a score. + + +## Basic Usage {#basic-usage} + +Consider that we have a LanceDB table named `my_table`, whose string column `text` we want to index and query via keyword search, the FTS index must be created before you can search via keywords. + +### Table Setup {#table-setup} + +First, open or create the table you want to search: + + +```python Python icon="python" +import lancedb +from lancedb.index import FTS + +uri = "data/sample-lancedb" +db = lancedb.connect(uri) + +table = db.create_table( + "my_table_fts", + data=[ + {"vector": [3.1, 4.1], "text": "Frodo was a happy puppy"}, + {"vector": [5.9, 26.5], "text": "There are several kittens playing"}, + ], +) +``` + +```ts TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; +const uri = "data/sample-lancedb" +const db = await lancedb.connect(uri); + +const data = [ + { vector: [3.1, 4.1], text: "Frodo was a happy puppy" }, + { vector: [5.9, 26.5], text: "There are several kittens playing" }, +]; +const tbl = await db.createTable("my_table", data, { mode: "overwrite" }); +``` + +```rust Rust icon="rust" +let uri = "data/sample-lancedb"; +let db = connect(uri).execute().await?; +let initial_data: Box = create_some_records()?; +let tbl = db + .create_table("my_table", initial_data) + .execute() + .await?; +``` + + +### Construct FTS Index {#construct-fts-index} + +Create a full-text search index on your text column: + + +In Python, this page shows the synchronous `create_fts_index(...)` form. For the +asynchronous equivalent (`await table.create_index("text", config=FTS(...))`), see +[FTS index](/indexing/fts-index). + + + +```python Python icon="python" +table.create_fts_index("text") +``` + +```typescript TypeScript icon="square-js" +await tbl.createIndex("text", { + config: lancedb.Index.fts(), +}); +``` + +```rust Rust icon="rust" +tbl + .create_index(&["text"], Index::FTS(FtsIndexBuilder::default())) + .execute() + .await?; +``` + + +### Full-text Search {#full-text-search} + +Perform full-text search and retrieve results: + + +```python Python icon="python" +results = table.search("puppy") + .limit(10) + .select(["text"]) + .to_list() +# [{'text': 'Frodo was a happy puppy', '_score': 0.6931471824645996}] +``` + +```typescript TypeScript icon="square-js" +const results = await tbl + .search("puppy", "fts") + .select(["text"]) + .limit(10) + .toArray(); +``` + +```rust Rust icon="Rust" +let results = tbl + .query() + .full_text_search(FullTextSearchQuery::new("puppy".to_owned())) + .select(lancedb::query::Select::Columns(vec!["text".to_owned()])) + .limit(10) + .execute() + .await?; +``` + + +The search is conducted on all indexed columns by default, so it's useful when there are multiple indexed columns. + +If you want to specify which columns to search use `fts_columns="text"` + + +LanceDB automatically searches on the existing FTS index if the input to the search is of type `str`. If you provide a vector as input, LanceDB will search the ANN index instead. + + +If a table has more than one FTS index, specify the indexed text column in the query. In Python you can use `fts_columns` or the query builder's `nearest_to_text(..., columns=...)`; in TypeScript, use `query().nearestToText(..., columns)`. The newer Lance-native FTS does not accept legacy Tantivy-only index parameters. + +### Keeping the index up to date {#keeping-the-index-up-to-date} + +Rows you add after building an FTS index aren't part of the index until you optimize the table. Until then, queries fall back to a flat scan over the unindexed fragments to keep results complete, which slows them down as the unindexed tail grows. Call `table.optimize()` to fold new rows into the existing index — it's the same operation used for vector indexes: + + + +{PyFtsIncrementalIndex} + + +```typescript TypeScript icon="square-js" +await tbl.add([{ vector: [3.1, 4.1], text: "Frodo was a happy puppy" }]); +await tbl.optimize(); +``` + +```rust Rust icon="rust" +tbl.add(new_data).execute().await?; +tbl.optimize(OptimizeAction::All).await?; +``` + + +A useful rule of thumb is to call `optimize()` after roughly 100,000 row changes or 20 data-modification operations, whichever comes first. For tables with continuous ingest, schedule it on a cadence that keeps `num_unindexed_rows` (from `table.index_stats(...)`) close to zero. If you want to skip the flat scan over unindexed rows entirely — for example, on a hot read path where stale results are acceptable — call `.fast_search()` on the query so the search returns only indexed results. + +## Advanced Usage {#advanced-usage} + +### Tokenize Table Data {#tokenize-table-data} + +By default, the text is tokenized by splitting on punctuation and whitespaces, and would filter out words that are longer than 40 characters. All words are converted to lowercase. + +Stemming is useful for improving search results by reducing words to their root form, e.g. "running" to "run". LanceDB supports stemming for Arabic, Danish, Dutch, English, Finnish, French, German, Greek, Hungarian, Italian, Norwegian, Portuguese, Romanian, Russian, Spanish, Swedish, Tamil, and Turkish. You should set the `base_tokenizer` parameter rather than `tokenizer_name` because you cannot customize the tokenizer if `tokenizer_name` is specified. + +Tokenization and language filters are separate settings. `base_tokenizer` controls how text is split into searchable tokens. `language` controls stemming and stop-word removal when `stem=True` or `remove_stop_words=True`; choose the tokenizer for CJK or mixed-language segmentation. + +For example, to enable stemming for English: + + +```python Python icon="python" +table.create_fts_index("text", language="English", replace=True) +``` + + +The tokenizer is customizable, you can specify how the tokenizer splits the text, and how it filters out words, etc. + +**Default index parameters:** +- `base_tokenizer`: `"simple"` +- `language`: English +- `with_position`: false +- `max_token_length`: 40 +- `lower_case`: true +- `stem`: true +- `remove_stop_words`: true +- `ascii_folding`: true +- `custom_stop_words`: `None` — pass a `list[str]` to drop additional words beyond the language defaults. Requires `remove_stop_words=True`. + +For multilingual use cases, use `base_tokenizer="icu"` for unicode-aware word segmentation on mixed-language text. ICU stands for [International Components for Unicode](https://icu.unicode.org/). The ICU tokenizer uses bundled ICU4X segmenter data, so it does not require external tokenizer model files. It is a good default when documents mix languages or include scripts where the simple tokenizer would keep an unspaced span as one large token. + +The Python API also supports tokenizer implementations that load language model files. Use `base_tokenizer="jieba/default"` for Jieba tokenization, which segments Chinese text into searchable word tokens when the text is written without spaces between words. Use Lindera-backed tokenizers for dictionary-based East Asian morphological segmentation, such as `base_tokenizer="lindera/ipadic"` for Japanese or `base_tokenizer="lindera/ko-dic"` for Korean when you have installed and compiled that Lindera model. These are language-specific tokenizers; ICU is the broader mixed-language option. + + +```python Python icon="python" +table.create_fts_index( + "text", + base_tokenizer="jieba/default", + stem=False, + remove_stop_words=False, + ascii_folding=False, + replace=True, +) +``` + + +Model-backed tokenizers require tokenizer model files in Lance's language model home. Lance looks under the default platform data directory for `lance/language_models`, or you can set `LANCE_LANGUAGE_MODEL_HOME` to point to a different model root: + +```bash +export LANCE_LANGUAGE_MODEL_HOME=/path/to/lance/language_models +``` + +For example, `jieba/default` is resolved under `/jieba/default/...`, `lindera/ipadic` under `/lindera/ipadic/...`, and `lindera/ko-dic` under `/lindera/ko-dic/...`. + + +Built-in stop-word removal supports Danish, Dutch, English, Finnish, French, German, Hungarian, Italian, Norwegian, Portuguese, Russian, Spanish, and Swedish. If you use another stemming language, such as Arabic, Greek, Romanian, Tamil, or Turkish, set `remove_stop_words=False` or pass `custom_stop_words`. + + +For example, for language with accents, you can specify the tokenizer to use `ascii_folding` to remove accents, e.g. 'é' to 'e': + + +```python Python icon="python" +table.create_fts_index( + "text", + language="French", + stem=True, + ascii_folding=True, + replace=True, + ) +``` + + +### Filtering Options {#filtering-options} + +LanceDB full text search supports to filter the search results by a condition, both pre-filtering and post-filtering are supported. + +This can be invoked via the familiar `where` syntax. + +With pre-filtering: + + + + +{PyFtsPrefiltering} + + +```typescript TypeScript icon="square-js" +await tbl +.search("puppy") +.select(["id", "doc"]) +.limit(10) +.where("meta='foo'") +.prefilter(true) +.toArray(); +``` + +```rust Rust icon="Rust" +table + .query() + .full_text_search(FullTextSearchQuery::new("puppy".to_owned())) + .select(lancedb::query::Select::Columns(vec!["doc".to_owned()])) + .limit(10) + .only_if("meta='foo'") + .execute() + .await?; +``` + + +With post-filtering: + + + +{PyFtsPostfiltering} + + +```typescript TypeScript icon="square-js" +await tbl +.search("apple") +.select(["id", "doc"]) +.limit(10) +.where("meta='foo'") +.prefilter(false) +.toArray(); +``` + +```rust Rust icon="Rust" +table + .query() + .full_text_search(FullTextSearchQuery::new(words[0].to_owned())) + .select(lancedb::query::Select::Columns(vec!["doc".to_owned()])) + .postfilter() + .limit(10) + .only_if("meta='foo'") + .execute() + .await?; +``` + + +### Phrase vs. Terms Queries {#phrase-vs-terms-queries} + + +Lance-based FTS doesn't support queries using boolean operators `OR`, `AND` in the search string. + + +For full-text search you can specify either a **phrase** query like `"the old man and the sea"`, +or a **terms** search query like `old man sea`. + +To search for a phrase, the index must be created with `with_position=True` and `remove_stop_words=False`: + + +```python Python icon="python" +table.create_fts_index("text", with_position=True, replace=True) +``` + + +This will allow you to search for phrases, but it will also significantly increase the index size and indexing time. + +### Fuzzy Search {#fuzzy-search} + +Fuzzy search allows you to find matches even when the search terms contain typos or slight variations. +LanceDB uses the classic [Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance) +to find similar terms within a specified edit distance. + +| Parameter | Type | Default | Description | +| -------------- | ---- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| fuzziness | int | 0 | Maximum edit distance allowed for each term. If not specified, automatically set based on term length: 0 for length ≤ 2, 1 for length ≤ 5, 2 for length > 5 | +| max_expansions | int | 50 | Maximum number of terms to consider for fuzzy matching. Higher values may improve recall but increase search time | + +For a complete walkthrough that creates a sample table and demonstrates fuzzy search and relevance boosting, see [the worked example below](#fuzzy-search-and-boosting-example). + +### Search for Substring {#search-for-substring} + +LanceDB supports searching for substrings in the text column, you can set the `base_tokenizer` parameter to `"ngram"` to enable this feature, and use the parameters `ngram_min_length` and `ngram_max_length` to control the length of the substrings: + +| Parameter | Type | Default | Description | +| ---------------- | ---- | ------- | -------------------------------------------------- | +| ngram_min_length | int | 3 | Minimum length of the n-grams to search for | +| ngram_max_length | int | 3 | Maximum length of the n-grams to search for | +| prefix_only | bool | false | Whether to only search for prefixes of the n-grams | + + +## Fuzzy Search and Boosting Example {#fuzzy-search-and-boosting-example} + +### Generate Data {#generate-data} + +First, let's create a table with sample text data for testing fuzzy search: + + +```python Python icon="python" +import lancedb +import numpy as np +import pandas as pd +import random + +# Connect to LanceDB +db = lancedb.connect( + uri="db://your-project-slug", + api_key="your-api-key", + region="us-east-1" +) + +# Generate 100 rows of random " " text +table_name = "fts-fuzzy-boosting-test" +vectors = [np.random.randn(128) for _ in range(100)] +verbs = ("runs", "hits", "jumps", "drives", "barfs") +adv = ("crazily.", "dutifully.", "foolishly.", "merrily.", "occasionally.") +adj = ("adorable", "clueless", "dirty", "odd", "stupid") + +def sentence(nouns): + return " ".join(random.choice(words) for words in (nouns, verbs, adv, adj)) + +text = [sentence(("puppy", "car")) for _ in range(100)] +text2 = [sentence(("rabbit", "girl", "monkey")) for _ in range(100)] +count = [random.randint(1, 10000) for _ in range(100)] +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb" + +const db = await lancedb.connect({ + uri: "db://your-project-slug", + apiKey: "your-api-key", + region: "us-east-1" +}); + +// Generate 100 rows of random " " text +const tableName = "fts-fuzzy-boosting-test-ts"; +const n = 100; +const verbs = ["runs", "hits", "jumps", "drives", "barfs"]; +const adverbs = ["crazily", "dutifully", "foolishly", "merrily", "occasionally"]; +const adjectives = ["adorable", "clueless", "dirty", "odd", "stupid"]; + +const pick = (words: string[]) => words[Math.floor(Math.random() * words.length)]; +const sentence = (nouns: string[]) => + [nouns, verbs, adverbs, adjectives].map(pick).join(" "); + +const vectors = Array.from({ length: n }, () => + Array.from({ length: 128 }, () => Math.random() * 2 - 1) +); +const text = Array.from({ length: n }, () => sentence(["puppy", "car"])); +const text2 = Array.from({ length: n }, () => sentence(["rabbit", "girl", "monkey"])); +const count = Array.from({ length: n }, () => Math.floor(Math.random() * 10000) + 1); +``` + + +### Create Table {#create-table} + + +```python Python icon="python" +# Create table with sample data +table = db.create_table( + table_name, + data=pd.DataFrame({ + "vector": vectors, + "id": [i % 2 for i in range(100)], + "text": text, + "text2": text2, + "count": count, + }), + mode="overwrite" +) +``` + +```typescript TypeScript icon="square-js" +// Create table with sample data +const data = makeArrowTable( + vectors.map((vector, i) => ({ + vector, + id: i % 2, + text: text[i], + text2: text2[i], + count: count[i], + })) +); + +const table = await db.createTable(tableName, data, { mode: "overwrite" }); +``` + + +### Construct the index for these examples {#example-construct-fts-index} + +Create a full-text search index on the first text column: + + +```python Python icon="python" +# Create FTS index on first text column +table.create_fts_index("text") +wait_for_index(table, "text_idx") +``` + +```typescript TypeScript icon="square-js" +// Create FTS index on first text column +await table.createIndex("text", { config: Index.fts() }); +await waitForIndex(table, "text_idx"); +``` + + +Then, create an index on the second text column: + + +```python Python icon="python" +# Create FTS index on second text column +table.create_fts_index("text2") +wait_for_index(table, "text2_idx") +``` + +```typescript TypeScript icon="square-js" +// Create FTS index on second text column +await table.createIndex("text2", { config: Index.fts() }); +await waitForIndex(table, "text2_idx"); +``` + + +### Basic and Fuzzy Search {#basic-and-fuzzy-search} + +Now we can perform basic, fuzzy, and prefix match searches: + +#### Basic Exact Search {#basic-exact-search} + + +```python Python icon="python" +from lancedb.query import MatchQuery + +# Basic match (exact search) +basic_match_results = ( + table.search(MatchQuery("crazily", "text")) + .select(["id", "text"]) + .limit(100) + .to_pandas() +) +``` + +```typescript TypeScript icon="square-js" +import { MatchQuery } from "@lancedb/lancedb"; + +// Basic match (exact search) +const basicMatchResults = await table.query() + .fullTextSearch(new MatchQuery("crazily", "text")) + .select(["id", "text"]) + .limit(100) + .toArray(); +``` + + +#### Fuzzy Search with Typos {#fuzzy-search-with-typos} + + +```python Python icon="python" +# Fuzzy match (allows typos) +fuzzy_results = ( + table.search(MatchQuery("craziou", "text", fuzziness=2)) + .select(["id", "text"]) + .limit(100) + .to_pandas() +) +``` + +```typescript TypeScript icon="square-js" +// Fuzzy match (allows typos) +const fuzzyResults = await table.query() + .fullTextSearch(new MatchQuery("craziou", "text", { + fuzziness: 2, + })) + .select(["id", "text"]) + .limit(100) + .toArray(); +``` + + +#### Prefix based Match {#prefix-based-match} + +Prefix-based match allows you to search for documents containing words that start with a specific prefix. + + +```python Python icon="python" +# Fuzzy match (allows typos) +fuzzy_results = ( + table.search(MatchQuery("cra", "text", prefix_length=3)) + .select(["id", "text"]) + .limit(100) + .to_pandas() +) +``` + +```typescript TypeScript icon="square-js" +// Fuzzy match (allows typos) +const fuzzyResults = await table.query() + .fullTextSearch(new MatchQuery("cra", "text", { + prefixLength: 3, + })) + .select(["id", "text"]) + .limit(100) + .toArray(); +``` + + +### Phrase Match {#phrase-match} + +Phrase matching enables you to search for exact sequences of words. Unlike regular text search +which matches individual terms independently, phrase matching requires words to appear in the +specified order with no intervening terms. + + +Phrase queries are supported but only for a single column; providing multiple columns with a quoted phrase raises an error. + + +Phrase matching is particularly useful for: + +- Searching for specific multi-word expressions +- Matching exact titles or quotes +- Finding precise word combinations in a specific order + + +```python Python icon="python" +# Exact phrase match +from lancedb.query import PhraseQuery + +phrase_results = ( + table.search(PhraseQuery("puppy runs", "text")) + .select(["id", "text"]) + .limit(100) + .to_pandas() +) +``` + +```typescript TypeScript icon="square-js" +import { PhraseQuery } from "@lancedb/lancedb"; + +// Exact phrase match +const phraseResults = await table.query() + .fullTextSearch(new PhraseQuery("puppy runs", "text")) + .select(["id", "text"]) + .limit(100) + .toArray(); +``` + + +#### Flexible Phrase Match {#flexible-phrase-match} +To provide more flexible phrase matching, LanceDB supports the `slop` parameter. This allows you to match phrases where the terms appear close to each other, even if they are not directly adjacent or in the exact order, as long as they are within the specified `slop` value. + +For example, the phrase query "puppy merrily" would not return any results by default. However, if you set `slop=1`, it will match phrases like "puppy jumps merrily", "puppy runs merrily", and similar variations where one word appears between "puppy" and "merrily". + + +```python Python icon="python" +# Flexible phrase match with slop=1 for 'puppy merrily' +from lancedb.query import PhraseQuery + +phrase_results = ( + table.search(PhraseQuery("puppy merrily", "text", slop=1)) + .select(["id", "text"]) + .limit(100) + .to_pandas() +) +``` + +```typescript TypeScript icon="square-js" +import { PhraseQuery } from "@lancedb/lancedb"; + +// Flexible phrase match with slop=1 for 'puppy runs' +const phraseResults = await table.query() + .fullTextSearch(new PhraseQuery("puppy runs", "text", { slop: 1 })) + .select(["id", "text"]) + .limit(100) + .toArray(); +``` + + +### Search with Boosting {#search-with-boosting} + +Boosting allows you to control the relative importance of different search terms or fields +in your queries. This feature is particularly useful when you need to: + +* Prioritize matches in certain columns +* Promote specific terms while demoting others +* Fine-tune relevance scoring for better search results + +| Parameter | Type | Default | Description | +| -------------- | ----- | -------- | ------------------------------------------------------------------ | +| positive | Query | required | The primary query terms to match and promote in results | +| negative | Query | required | Terms to demote in the search results | +| negative_boost | float | 0.5 | Multiplier for negative matches (lower values = stronger demotion) | + + +```python Python icon="python" +from lancedb.query import MatchQuery, BoostQuery, MultiMatchQuery + +# Boost data with 'runs' in text more than 'puppy' in text +boosting_results = ( + table.search( + BoostQuery( + MatchQuery("runs", "text"), + MatchQuery("puppy", "text"), + negative_boost=0.2, + ), + ) + .select(["id", "text"]) + .limit(100) + .to_pandas() +) + +# Search across both text and text2 +multi_match_results = ( + table.search(MultiMatchQuery("crazily", ["text", "text2"])) + .select(["id", "text", "text2"]) + .limit(100) + .to_pandas() +) + +# Search with field boosting +multi_match_boosting_results = ( + table.search( + MultiMatchQuery("crazily", ["text", "text2"], boosts=[1.0, 2.0]), + ) + .select(["id", "text", "text2"]) + .limit(100) + .to_pandas() +) +``` + +```typescript TypeScript icon="square-js" +import { MatchQuery, BoostQuery, MultiMatchQuery } from "@lancedb/lancedb"; + +// Boosting Example +const boostingResults = await table.query() + .fullTextSearch(new BoostQuery(new MatchQuery("runs", "text"), new MatchQuery("puppy", "text"), { + negativeBoost: 0.2, + })) + .select(["id", "text"]) + .limit(100) + .toArray(); + +// Search across both text fields +const multiMatchResults = await table.query() + .fullTextSearch(new MultiMatchQuery("crazily", ["text", "text2"])) + .select(["id", "text", "text2"]) + .limit(100) + .toArray(); + +// Search with field boosting +const multiMatchBoostingResults = await table.query() + .fullTextSearch(new MultiMatchQuery("crazily", ["text", "text2"], { + boosts: [1.0, 2.0], + })) + .select(["id", "text", "text2"]) + .limit(100) + .toArray(); +``` + + + +- Use fuzzy search when handling user input that may contain typos or variations +- Apply field boosting to prioritize matches in more important columns +- Combine fuzzy search with boosting for robust and precise search results + +**Recommendations for optimal FTS performance:** + +- Create full-text search indices on text columns that will be frequently searched +- For hybrid search combining text and vectors, see our [hybrid search guide](/search/hybrid-search/) +- For performance benchmarks, check our [benchmark results](/enterprise/benchmarks/) +- For complex queries, use SQL to combine FTS with other filter conditions + + +### Boolean Queries {#boolean-queries} +LanceDB supports boolean logic in full-text search, allowing you to combine multiple queries using `and` and `or` operators. This is useful when you want to match documents that satisfy multiple conditions (intersection) or at least one of several conditions (union). + +#### Combining Two Match Queries {#combining-two-match-queries} + +In Python, combine two `MatchQuery` objects with `&` for intersection or `|` for union. Python's `and` and `or` operators return one operand and do not construct a boolean query. + + +{PyFtsBooleanQueries} + + +In TypeScript, boolean queries are constructed using the `BooleanQuery` class with a list of [Occur, subquery] pairs. For example, to perform an AND query: + +```sql SQL icon="code" +BooleanQuery([ +[Occur.Must, new MatchQuery("puppy", "text")], +[Occur.Must, new MatchQuery("merrily", "text")], +]) +``` + +This approach allows you to specify complex boolean logic by combining multiple subqueries with different Occur values (such as `Must`, `Should`, or `MustNot`). + + +**Which queries are allowed?** + +A boolean query must include at least one `SHOULD` or `MUST` clause. Queries that contain only a `MUST_NOT` clause are not allowed. + + + +```python Python icon="python" +from lancedb.query import MatchQuery + +# Example: Find documents containing both "puppy" and "merrily" +and_query = MatchQuery("puppy", "text") & MatchQuery("merrily", "text") +and_results = ( + table.search(and_query) + .select(["id", "text"]) + .limit(100) + .to_pandas() +) + +# Example: Find documents containing either "puppy" or "merrily" +or_query = MatchQuery("puppy", "text") | MatchQuery("merrily", "text") +or_results = ( + table.search(or_query) + .select(["id", "text"]) + .limit(100) + .to_pandas() +) +``` + +```typescript TypeScript icon="square-js" expandable=true +import { MatchQuery, BooleanQuery, Occur } from "@lancedb/lancedb"; + +// Flexible boolean queries with MatchQuery + +// Find documents containing both "puppy" and "merrily" +const mustResults = await table + .search( + new BooleanQuery([ + [Occur.Must, new MatchQuery("puppy", "text")], + [Occur.Must, new MatchQuery("merrily", "text")], + ]), + ) + .select(["id", "text"]) + .limit(100) + .toArray(); + +// Find documents containing either "puppy" or "merrily" +const shouldResults = await table + .search( + new BooleanQuery([ + [Occur.Should, new MatchQuery("puppy", "text")], + [Occur.Should, new MatchQuery("merrily", "text")], + ]), + ) + .select(["id", "text"]) + .limit(100) + .toArray(); +``` + + + +**How to use booleans?** + +- Use `and`/`&`(Python), `Occur.Must`(Typescript) for intersection (documents must match all queries). +- Use `or`/`|`(Python), `Occur.Should`(Typescript) for union (documents must match at least one query). + + +## Substring Search Example {#substring-search-example} + +LanceDB supports searching for substrings in text columns using n-gram tokenization. This is useful for finding partial matches within text content. + +### Setting Up the Table {#setting-up-the-table} + +First, create a table with sample text data and configure n-gram tokenization: + + +```python Python icon="python" +import pyarrow as pa +import lancedb + +db = lancedb.connect(":memory:") + +data = pa.table({"text": ["hello world", "lance database", "lance is cool"]}) +table = db.create_table("test", data=data) +table.create_fts_index("text", base_tokenizer="ngram") +``` + + +### Basic Substring Search {#basic-substring-search} + +With the default n-gram settings (minimum length of 3), you can search for substrings of length 3 or more: + + +```python Python icon="python" +results = table.search("lan", query_type="fts").limit(10).to_list() +assert len(results) == 2 +assert set(r["text"] for r in results) == {"lance database", "lance is cool"} + +results = ( + table.search("nce", query_type="fts").limit(10).to_list() +) # spellchecker:disable-line +assert len(results) == 2 +assert set(r["text"] for r in results) == {"lance database", "lance is cool"} +``` + + +### Handling Short Substrings {#handling-short-substrings} + +By default, the minimum n-gram length is 3, so shorter substrings like "la" won't match: + + +```python Python icon="python" +results = table.search("la", query_type="fts").limit(10).to_list() +assert len(results) == 0 +``` + + +### Customizing N-gram Parameters {#customizing-n-gram-parameters} + +You can customize the n-gram behavior by adjusting the minimum length and using prefix-only matching: + + +```python Python icon="python" +table.create_fts_index( + "text", + base_tokenizer="ngram", + replace=True, + ngram_min_length=2, + prefix_only=True, +) +``` + + +### Testing Custom N-gram Settings {#testing-custom-n-gram-settings} + +With the new settings, you can now search for shorter substrings and use prefix-only matching: + + +```python Python icon="python" +results = table.search("lan", query_type="fts").limit(10).to_list() +assert len(results) == 2 +assert set(r["text"] for r in results) == {"lance database", "lance is cool"} + +results = ( + table.search("nce", query_type="fts").limit(10).to_list() +) # spellchecker:disable-line +assert len(results) == 0 + +results = table.search("la", query_type="fts").limit(10).to_list() +assert len(results) == 2 +assert set(r["text"] for r in results) == {"lance database", "lance is cool"} +``` + + +## Full-Text Search on Array Fields {#full-text-search-on-array-fields} + +LanceDB supports full-text search on string array columns, enabling efficient keyword-based search across multiple values within a single field (e.g., tags, keywords). + +### Setting Up the Connection {#setting-up-the-connection} + +Connect to your LanceDB instance: + + +```python Python icon="python" +import lancedb + +# Connect to LanceDB +db = lancedb.connect( + uri="db://your-project-slug", + api_key="your-api-key", + region="us-east-1" +) +``` + +```typescript TypeScript icon="square-js" expandable=true +import * as lancedb from "@lancedb/lancedb" + +const db = await lancedb.connect({ + uri: "db://your-project-slug", + apiKey: "your-api-key", + region: "us-east-1" +}); +``` + + +### Defining the Schema {#defining-the-schema} + +Create a schema that includes an array field for tags: + + +```python Python icon="python" +table_name = "fts-array-field-test" +schema = pa.schema([ + pa.field("id", pa.string()), + pa.field("tags", pa.list_(pa.string())), + pa.field("description", pa.string()) +]) +``` + +```typescript TypeScript icon="square-js" expandable=true +const tableName = "fts-array-field-test-ts"; + +// Create schema +const schema = new Schema([ + new Field("id", new Utf8(), false), + new Field("tags", new List(new Field("item", new Utf8()))), + new Field("description", new Utf8(), false) +]); +``` + + +### Creating Sample Data {#creating-sample-data} + +Generate sample data with array fields containing tags: + + +```python Python icon="python" expandable=true +# Generate sample data +data = { + "id": [f"doc_{i}" for i in range(10)], + "tags": [ + ["python", "machine learning", "data science"], + ["deep learning", "neural networks", "AI"], + ["database", "indexing", "search"], + ["vector search", "embeddings", "AI"], + ["full text search", "indexing", "database"], + ["python", "web development", "flask"], + ["machine learning", "deep learning", "pytorch"], + ["database", "SQL", "postgresql"], + ["search engine", "elasticsearch", "indexing"], + ["AI", "transformers", "NLP"] + ], + "description": [ + "Python for data science projects", + "Deep learning fundamentals", + "Database indexing techniques", + "Vector search implementations", + "Full-text search guide", + "Web development with Python", + "Machine learning with PyTorch", + "Database management systems", + "Search engine optimization", + "AI and NLP applications" + ] +} +``` + +```typescript TypeScript icon="square-js" expandable=true +// Generate sample data +const data = makeArrowTable( + Array(10).fill(0).map((_, i) => ({ + id: `doc_${i}`, + tags: [ + ["python", "machine learning", "data science"], + ["deep learning", "neural networks", "AI"], + ["database", "indexing", "search"], + ["vector search", "embeddings", "AI"], + ["full text search", "indexing", "database"], + ["python", "web development", "flask"], + ["machine learning", "deep learning", "pytorch"], + ["database", "SQL", "postgresql"], + ["search engine", "elasticsearch", "indexing"], + ["AI", "transformers", "NLP"] + ][i], + description: [ + "Python for data science projects", + "Deep learning fundamentals", + "Database indexing techniques", + "Vector search implementations", + "Full-text search guide", + "Web development with Python", + "Machine learning with PyTorch", + "Database management systems", + "Search engine optimization", + "AI and NLP applications" + ][i] + })), + { schema } +); +``` + + +### Creating the Table and Adding Data {#creating-the-table-and-adding-data} + +Create the table and populate it with the sample data: + + +```python Python icon="python" +# Create table and add data +table = db.create_table(table_name, schema=schema, mode="overwrite") +table_data = pa.Table.from_pydict(data, schema=schema) +table.add(table_data) +``` + +```typescript TypeScript icon="square-js" +// Create table +const table = await db.createTable(tableName, data, { mode: "overwrite" }); +console.log(`Created table: ${tableName}`); +``` + + +### Building the Full-Text Search Index {#building-the-full-text-search-index} + +Create an FTS index on the tags column to enable efficient text search: + + +```python Python icon="python" +# Create FTS index +table.create_fts_index("tags") +wait_for_index(table, "tags_idx") +``` + +```typescript TypeScript icon="square-js" +// Create FTS index +console.log("Creating FTS index on 'tags' column..."); +await table.createIndex("tags", { + config: Index.fts() +}); + +// Wait for index +const ftsIndexName = "tags_idx"; +await waitForIndex(table, ftsIndexName); +``` + + +### Performing Fuzzy Search {#performing-fuzzy-search} + +Search for terms with typos using fuzzy matching: + + +```python Python icon="python" +# Search examples +print("\nSearching for 'learning' in tags with a typo:") +result = ( + table.search(MatchQuery("learnin", column="tags", fuzziness=1)) + .select(['id', 'tags', 'description']) + .to_arrow() +) +``` + +```typescript TypeScript icon="square-js"> +// Search examples +console.log("\nSearching for 'learning' in tags with a typo:"); +const fuzzyResults = await table.query() + .fullTextSearch(new MatchQuery("learnin", "tags", { + fuzziness: 2, + })) + .select(["id", "tags", "description"]) + .toArray(); +console.log(fuzzyResults); +``` + + +### Performing Phrase Search {#performing-phrase-search} + +Search for exact phrases within the array fields: + + +```python Python icon="python" +print("\nSearching for 'machine learning' in tags:") +result = ( + table.search(PhraseQuery("machine learning", column="tags")) + .select(['id', 'tags', 'description']) + .to_arrow() +) +``` + +```typescript TypeScript icon="square-js" +console.log("\nSearching for 'machine learning' in tags:"); +const phraseResults = await table.query() + .fullTextSearch(new PhraseQuery("machine learning", "tags")) + .select(["id", "tags", "description"]) + .toArray(); +console.log(phraseResults); +``` + diff --git a/docs/web/search/hybrid-search.mdx b/docs/web/search/hybrid-search.mdx new file mode 100644 index 000000000..4a2d2dead --- /dev/null +++ b/docs/web/search/hybrid-search.mdx @@ -0,0 +1,355 @@ +--- +title: Hybrid Search +sidebarTitle: "Hybrid Search" +description: Learn how to perform hybrid search in LanceDB by combining vector and full-text search techniques with reranking. +icon: "search" +--- + +In certain cases, you may want to retrieve documents that are semantically similar to a given query, +but also prioritize specific keywords. This is an example of **hybrid search**, a query method that combines +multiple search techniques. + +For detailed examples, look at this [Python Notebook](https://colab.research.google.com/github/lancedb/vectordb-recipes/blob/main/examples/saas_examples/python_notebook/Hybrid_search.ipynb) or the [**TypeScript Example**](https://github.com/lancedb/vectordb-recipes/tree/main/examples/saas_examples/ts_example/hybrid-search) + +## Example: Hybrid Search {#example-hybrid-search} + +### 1\. Setup {#1-setup} +Import the necessary libraries and dependencies for working with LanceDB, OpenAI embeddings, and reranking. + + +```python Python icon="python" +import os +import lancedb +import openai +from lancedb.embeddings import get_registry +from lancedb.pydantic import LanceModel, Vector +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; +import "@lancedb/lancedb/embedding/openai"; +import { Utf8 } from "apache-arrow"; +``` + + +### 2\. Connect to LanceDB {#2-connect-to-lancedb} +Establish a connection to your LanceDB instance, with different options for Enterprise setups or open source. + +OSS + + +```python Python icon="python" +uri = "data/sample-lancedb" +db = lancedb.connect(uri) +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; +import * as arrow from "apache-arrow"; + +const databaseDir = "data/sample-lancedb"; +const db = await lancedb.connect(databaseDir); +``` + + +Enterprise + +For LanceDB Enterprise, set the `db://` URI, region and the host override to your private cloud endpoint: + + +```python Python icon="python" +host_override = os.environ.get("LANCEDB_HOST_OVERRIDE") + +db = lancedb.connect( + uri=uri, + api_key=api_key, + region=region, + host_override=host_override +) +``` + +```typescript TypeScript icon="square-js" +import * as lancedb from "@lancedb/lancedb"; +import * as arrow from "apache-arrow"; + +const uri = "db://my-lancedb-instance/my-database"; +const apiKey = process.env.LANCEDB_API_KEY; +const region = process.env.LANCEDB_REGION; +const hostOverride = process.env.LANCEDB_HOST_OVERRIDE; + +const db = await lancedb.connect(uri, { + apiKey, + region + hostOverride, +}); +``` + + + + +### 3\. Configure Embedding Model {#3-configure-embedding-model} +Set up the any embedding model that will convert text into vector representations for semantic search. + + +```python Python icon="python" +embeddings = get_registry().get("sentence-transformers").create() +``` + +```typescript TypeScript icon="square-js" +const embedFunc = lancedb.embedding.getRegistry().get("openai")?.create({ + model: "text-embedding-ada-002", +}) as lancedb.embedding.EmbeddingFunction; +``` + + +### 4\. Create Table and Schema {#4-create-table-and-schema} +Define the data structure for your documents, including both the text content and its vector representation. + + +```python Python icon="python" +class Documents(LanceModel): + text: str = embeddings.SourceField() + vector: Vector(embeddings.ndims()) = embeddings.VectorField() + +table_name = "hybrid_search_example" +table = db.create_table(table_name, schema=Documents, mode="overwrite") +``` + +```typescript TypeScript icon="square-js" +const documentSchema = lancedb.embedding.LanceSchema({ + text: embedFunc.sourceField(new Utf8()), + vector: embedFunc.vectorField(), +}); + +const tableName = "hybrid_search_example"; +const table = await db.createEmptyTable(tableName, documentSchema, { + mode: "overwrite", +}); +``` + + +### 5\. Add Data {#5-add-data} +Insert sample documents into your table, which will be used for both semantic and keyword search. + + +```python Python icon="python" +data = [ + {"text": "rebel spaceships striking from a hidden base"}, + {"text": "have won their first victory against the evil Galactic Empire"}, + {"text": "during the battle rebel spies managed to steal secret plans"}, + {"text": "to the Empire's ultimate weapon the Death Star"}, +] +table.add(data=data) +``` + +```typescript TypeScript icon="square-js" +const data = [ + { text: "rebel spaceships striking from a hidden base" }, + { text: "have won their first victory against the evil Galactic Empire" }, + { text: "during the battle rebel spies managed to steal secret plans" }, + { text: "to the Empire's ultimate weapon the Death Star" }, +]; +await table.add(data); +console.log(`Created table: ${tableName} with ${data.length} rows`); +``` + + +### 6\. Build Full Text Index {#6-build-full-text-index} +Create a full-text search index on the text column to enable keyword-based search capabilities. + + +```python Python icon="python" +table.create_fts_index("text") +wait_for_index(table, "text_idx") +``` + +```typescript TypeScript icon="square-js" +console.log("Creating full-text search index..."); +await table.createIndex("text", { + config: lancedb.Index.fts(), +}); +await waitForIndex(table as any, "text_idx"); +``` + + +### 7\. Set Reranker [Optional] {#7-set-reranker-optional} +Initialize the reranker that will combine and rank results from both semantic and keyword search. By default, lancedb uses RRF reranker, but you can choose other rerankers like `Cohere`, `CrossEncoder`, or others lister in integrations section. + + +```python Python icon="python" +reranker = RRFReranker() +``` + +```typescript TypeScript icon="square-js" +const reranker = await lancedb.rerankers.RRFReranker.create(); +``` + + +### 8\. Hybrid Search {#8-hybrid-search} +Perform a hybrid search query that combines semantic similarity with keyword matching, using the specified reranker to merge and rank the results. + + +```python Python icon="python" +results = ( + table.search( + "flower moon", + query_type="hybrid", + vector_column_name="vector", + fts_columns="text", + ) + .rerank(reranker) + .limit(10) + .to_pandas() +) + +print("Hybrid search results:") +print(results) +``` + +```typescript TypeScript icon="square-js" +console.log("Performing hybrid search..."); +const queryVector = await embedFunc.computeQueryEmbeddings("full moon in May"); +const hybridResults = await table + .query() + .fullTextSearch("flower moon") + .nearestTo(queryVector) + .rerank(reranker) + .select(["text"]) + .limit(10) + .toArray(); + +console.log("Hybrid search results:"); +console.log(hybridResults); +``` + + +### 9\. Hybrid Search - Explicit Vector and Text Query pattern {#9-hybrid-search-explicit-vector-and-text-query-pattern} +You can also pass the vector and text query explicitly. This is useful if you're not using the embedding API or if you're using a separate embedder service. + + +```python Python icon="python" +vector_query = [0.1, 0.2, 0.3, 0.4, 0.5] +text_query = "flower moon" +( + table.search(query_type="hybrid") + .vector(vector_query) + .text(text_query) + .limit(5) + .to_pandas() +) +``` + + +## Query controls {#query-controls} + +Hybrid queries inherit the same builder API as vector and FTS queries, so the same knobs for filtering, distance bounds, and row identity apply. These compose with `.rerank(...)` and the explicit `.vector()` / `.text()` form shown above. + + +Always set `.limit(...)` on production hybrid queries. LanceDB's default search limit is 10, but an +explicit cap gives you a clear top-k contract to tune before reranking. + + +### Returning row IDs {#returning-row-ids} + +Pass `with_row_id(True)` (Python) or `withRowId()` (TypeScript) to include the internal `_rowid` column in the results. This is useful for joining hybrid results back to a primary table, or for deduping across multiple queries: + + +```python Python icon="python" +results = ( + table.search("flower moon", query_type="hybrid") + .with_row_id(True) + .limit(10) + .to_pandas() +) +# results now contains a `_rowid` column alongside `_relevance_score` +``` + +```typescript TypeScript icon="square-js" +const results = await table + .query() + .fullTextSearch("flower moon") + .nearestTo(queryVector) + .withRowId() + .limit(10) + .toArray(); +``` + + +### Bounding vector distance {#bounding-vector-distance} + +`distance_range(lower, upper)` (Python) and `distanceRange(lower, upper)` (TypeScript) constrain the vector half of the hybrid query to the half-open interval `[lower, upper)`. This is helpful when you want to cap how far semantic candidates can drift from the query vector before reranking: + + +```python Python icon="python" +results = ( + table.search("flower moon", query_type="hybrid") + .distance_range(lower_bound=0.0, upper_bound=0.4) + .limit(10) + .to_pandas() +) +``` + +```typescript TypeScript icon="square-js" +const results = await table + .query() + .fullTextSearch("flower moon") + .nearestTo(queryVector) + .distanceRange(0.0, 0.4) + .limit(10) + .toArray(); +``` + + +Either bound can be omitted to leave that side unbounded. + +### Prefilter vs. postfilter {#prefilter-vs-postfilter} + +When the query carries a metadata filter via `where(...)`, you can choose whether the filter runs before or after the vector and FTS sub-queries. **Prefiltering** (the default) applies `where` to the candidate set before scoring, which is usually what you want — it shrinks the working set and benefits from any scalar indexes on the filter columns. **Postfiltering** runs the filter on the already-ranked top-k from each sub-query; this can be faster when the filter is non-selective or unindexed, but it may return fewer than `limit` rows because some of the top-k may be filtered out. + + +```python Python icon="python" +# Prefilter (default): filter applied before scoring +table.search("flower moon", query_type="hybrid") \ + .where("category = 'film'", prefilter=True) \ + .limit(10) \ + .to_pandas() + +# Postfilter: filter applied after the sub-queries return top-k +table.search("flower moon", query_type="hybrid") \ + .where("category = 'film'", prefilter=False) \ + .limit(10) \ + .to_pandas() +``` + +```typescript TypeScript icon="square-js" +// Prefilter (default): just call .where(...) +await table.query() + .fullTextSearch("flower moon") + .nearestTo(queryVector) + .where("category = 'film'") + .limit(10) + .toArray(); + +// Postfilter: chain .postfilter() after .where(...) +await table.query() + .fullTextSearch("flower moon") + .nearestTo(queryVector) + .where("category = 'film'") + .postfilter() + .limit(10) + .toArray(); +``` + + +The choice gets baked into both sub-queries, so the vector and FTS halves see the filter applied the same way. Use [`explain_plan`](/search/explain#analyzing-non-vector-queries) on a hybrid query to see whether the filter pushed into the scan or ran as a separate `FilterExec` step. + +## More on Reranking {#more-on-reranking} + +You can perform hybrid search in LanceDB by combining the results of semantic and full-text search via a reranking algorithm of your choice. LanceDB comes with [**built-in rerankers**](https://docs.lancedb.com/reranking) and you can implement your own **custom reranker** as well. + +By default, LanceDB uses `RRFReranker()`, which uses reciprocal rank fusion score, to combine and rerank the results of semantic and full-text search. You can customize the hyperparameters as needed or write your own custom reranker. Here's how you can use any of the available rerankers: + +| Argument | Type | Default | Description | +|:---------|:-----|:--------|:------------| +| `normalize` | `str` | `"score"` | The method to normalize the scores. Can be `rank` or `score`. If `rank`, the scores are converted to ranks and then normalized. If `score`, the scores are normalized directly. | +| `reranker` | `Reranker` | `RRF()` | The reranker to use. If not specified, the default reranker is used. | diff --git a/docs/web/search/index.mdx b/docs/web/search/index.mdx new file mode 100644 index 000000000..886751331 --- /dev/null +++ b/docs/web/search/index.mdx @@ -0,0 +1,88 @@ +--- +title: Search +sidebarTitle: Overview +description: "Comprehensive guide to all search capabilities in LanceDB including vector search, full-text search, hybrid search, and more." +icon: "list" +--- + +| Feature | Description | +|:---------------|:------------| +| [Vector Search](/search/vector-search/) | Semantic similarity search with multiple distance metrics | +| [Multivector Search](/search/vector-search) | Search using multiple vector embeddings per document | +| [Full-Text Search](/search/full-text-search/) | Keyword-based search with BM25 and pre-filtering | +| [Hybrid Search](/search/hybrid-search/) | Combines vector and full-text search with reranking | +| [Filtering](/search/filtering/) | Filter results based on metadata fields | +| [SQL Queries](/sql) | SQL query capabilities for data exploration and analytics | + +## Before you search {#before-you-search} + +- Vector search can run without an ANN index as an exhaustive scan. That's useful while prototyping, but build a vector index before relying on low-latency searches over larger tables. +- Full-text and hybrid text search require an FTS index on the text column you query. If a table has multiple FTS indexes, specify the target column. FTS also supports phrase, boolean, boosted, multi-match, and fuzzy query forms when you need more than plain terms. +- Phrase FTS queries require an index created with token positions enabled. +- Multivector search currently uses cosine similarity and accepts either one query vector or a matrix of query vectors; every query vector must match the inner dimension of the multivector column. +- Set an explicit `.limit(...)` for production queries. The default top-k is 10 for search builders, + but spelling it out makes latency and result-count assumptions visible. Query builders also + support controls such as prefilter/postfilter, distance ranges, row-id inclusion, offset + pagination, and Arrow/Pandas/list result materialization. + +## Performance {#performance} + +Three knobs materially affect query latency, memory use, and recall. Be deliberate about each one on every query: + + + + If queries return more data than you need or take longer than expected, the cause is usually projecting more columns than necessary, or letting the result count run unbounded. The best practice is to always pass both `select()` and `limit()`: + + ```python Python icon="python" + table.search(emb).select(["id", "title"]).limit(20) + ``` + + + If a query with `prefilter=False` returns fewer than `limit` results — sometimes zero — the cause is that post-filter applies the predicate after the top-k is selected, so only candidates that already passed the search are filtered. Pre-filter is the default and guarantees every result satisfies the predicate. Switch to `prefilter=False` only when fewer-than-`limit` results are acceptable. See [Filtering](/search/filtering). + + + If recall is lower than expected, the cause is that the search-time knobs limit how many candidates the index considers. Adjust based on your index type: + + - Quantized indexes (PQ, RQ, SQ): `refine_factor` pulls extra candidates and re-scores them on full vectors. + - HNSW-backed indexes: `ef` at search time. Start at `1.5 × k`, raise toward `10 × k` if recall is short. + - IVF candidate breadth: `nprobes` is auto-tuned; override only when a selective pre-filter leaves too few neighbors. + + + +For hybrid search, the default `RRFReranker()` combines vector and FTS results into a single ranking via reciprocal rank fusion. See [Hybrid Search](/search/hybrid-search). + +#### Avoid materializing the whole table {#avoid-materializing-the-whole-table} + +If you need every row in the table (for training, export, or migration), calling `to_pandas()` or `to_arrow()` will run you out of memory on any non-trivial dataset — both materialize the full table at once. The best practice is to iterate via `table.search(...)` or `table.query(...)`, which work the same way in both LanceDB OSS and Enterprise. + +In OSS, you can also stream batches through the underlying Lance dataset directly — useful when you need filter pushdown or fragment-level parallelism via `ds.scanner(...)`: + +```python Python icon="python" +ds = table.to_lance() +for batch in ds.to_batches(columns=["id", "text"], batch_size=10000): + process(batch) +``` + + +**API disparity between OSS and Enterprise.** + +LanceDB OSS exposes the `to_pandas()`, `to_arrow()`, and `table.to_lance()` methods for direct Lance dataset access. Enterprise's `RemoteTable` exposes none of these. Only `table.search(...)` and `table.query(...)`. In general, it's always best to go through `search()` and `query()` to keep your code portable across both OSS and Enterprise. + + +#### Diagnostics {#diagnostics} + +To analyze a slow query, inspect what the query engine actually did and the state of the indexes it touched. These two tools surface that information — use them in this order: + +```python Python icon="python" +print(table.search(emb).where("year > 2000").limit(10).analyze_plan()) +print(table.index_stats("vector_idx")) # num_unindexed_rows should be ~0 +``` + +`analyze_plan()` returns the execution plan with per-stage timings, so you can see where the query actually spent its time. `index_stats()` shows how many rows are still unindexed — you want `num_unindexed_rows` to be ~0. In `analyze_plan`, look for: + +| Plan pattern | Fix | +|--------------|-----| +| `LanceScan` with high `bytes_read` / `iops` | Add a missing index, project columns with `select()`, or check that the dataset has been compacted | +| Multiple sequential filters | Reorder filter conditions | + +[Explain Analyze](/search/explain) walks through a fully worked before/after example, including how `KNNVectorDistance` and `output_batches` change once indexes are in place. diff --git a/docs/web/search/vector-search.mdx b/docs/web/search/vector-search.mdx new file mode 100644 index 000000000..c7324701a --- /dev/null +++ b/docs/web/search/vector-search.mdx @@ -0,0 +1,711 @@ +--- +title: "Vector Search" +sidebarTitle: "Vector Search" +description: "Learn how to run vector search queries in LanceDB. Includes best practices, tips and examples." +icon: "arrow-up-right-dots" +--- + +import { + PyConfigureDistanceMetric as ConfigureDistanceMetric, + PySelectVectorColumn as SelectVectorColumn, + PyIndexNestedColumn as IndexNestedColumn, + PyExactVsApproximateDistances as ExactVsApproximateDistances, + PyVectorSearchPrefilter as VectorSearchPrefilter, + PyVectorSearchPostfilter as VectorSearchPostfilter, + PyMultivectorSearch as MultivectorSearch, + PySearchDistanceRange as SearchDistanceRange, + PySearchBinaryVectors as SearchBinaryVectors, + PyBatchSearch as BatchSearch, + PyFastSearch as FastSearch, + PyBruteForceSearch as BruteForceSearch, + PyBypassVectorIndex as BypassVectorIndex, + TsSearch2, + TsSelectVectorColumn, + TsIndexNestedColumn, + TsExactVsApproximate, + TsVectorSearchPrefilter, + TsVectorSearchPostfilter, + TsDistanceRange, + TsBinarySearch, + TsBatchSearch, + TsFastSearch, + TsBruteForceSearch, + TsBypassVectorIndex, + RsConfigureDistanceMetric, + RsExactVsApproximate, + RsVectorSearchPrefilter, + RsVectorSearchPostfilter, + RsSearchDistanceRange, + RsFastSearch, + RsBruteForceSearch, + RsBypassVectorIndex, + RsBatchSearch, + RsBinarySearch, +} from '/snippets/search.mdx'; + +Vector search is a technique used to search for similar items based on their vector representations, called embeddings. It is also known as similarity search, nearest neighbor search, or approximate nearest neighbor search. + +![](/static/assets/images/search/vector-db-basics.png) + +Raw data (e.g. text, images, audio, etc.) is converted into embeddings via an embedding model, which are then stored in a multimodal lakehouse like LanceDB. To perform similarity search at scale, an index is created on the stored embeddings, which can then used to perform fast lookups. + + +Vector search has **no SQL form**. Run it here and use [SQL](/sql) over the result. + + +## Supported distance metrics + +Distance metrics determine how LanceDB compares vectors to find similar matches. Euclidean or `l2` is the default, and used for general-purpose similarity, `cosine` for unnormalized embeddings, `dot` for normalized embeddings (best performance), or `hamming` for binary vectors. + + +Ensure you always use the same distance metric that your embedding model was trained with. Most modern embedding models use cosine similarity, so `cosine` is often the best choice. However, if your vectors are normalized, you should use `dot` for best performance. + + +The right metric improves both search accuracy and query performance. Currently, LanceDB supports the following metrics: + +| Distance metric | Mathematical form | Notes | +|---|---|---| +| `l2` | $$\|x-y\|_2= \sqrt{\sum_i (x_i-y_i)^2}$$ | The $\ell_2$ norm. Measures Euclidean distance between two points in geometric space. | +| `cosine` | $1-\frac{x\cdot y}{\|x\|_2\|y\|_2}$ | Measures directional difference between vectors. Insensitive to nonzero scaling of vectors. | +| `dot` | $x\cdot y= \underset{i}{\sum} x_i y_i$ | The standard dot product of real vectors. Sensitive to nonzero scaling of vectors; use on normalized vectors. | +| `hamming` | $\underset{i}{\sum} \,\: \mathbf{1}_{\, [x_i\neq y_i]}$ | Counts the number of bitwise-differing bits between binary vectors. | + +For indexed search, supported distance metrics vary by index type: + +| Index type | Supported distance metrics | +|---|---| +| `IVF_FLAT` | `["l2", "cosine", "dot", "hamming"]` | +| `IVF_PQ` | `["l2", "cosine", "dot"]` | +| `IVF_SQ` | `["l2", "cosine", "dot"]` | +| `IVF_RQ` | `["l2", "cosine", "dot"]` | +| `IVF_HNSW_FLAT` | `["l2", "cosine", "dot"]` | +| `IVF_HNSW_PQ` | `["l2", "cosine", "dot"]` | +| `IVF_HNSW_SQ` | `["l2", "cosine", "dot"]` | + +### Configure Distance Metric + +Without a vector index, searches use `l2` by default. You can specify `cosine` or +`dot` instead (`hamming` is supported for `IVF_FLAT` indexes only). + +When a vector index exists, the search metric must match the metric used to build +the index. If you explicitly request a different metric, LanceDB cannot use that +index and falls back to an exhaustive (brute-force) search. On LanceDB Cloud, the +brute-force KNN safety limit can reject that search on a large table instead of +executing it. LanceDB Enterprise doesn't have the limit. + +If you omit the search metric, current SDKs use the index metric when an index is +available. Some older SDK versions sent `l2` when the metric was omitted; with a +non-`l2` index, explicitly set the matching metric or upgrade the SDK. + + + +{ConfigureDistanceMetric} + + + +{TsSearch2} + + + +{RsConfigureDistanceMetric} + + + +Here you can see the same search but using `cosine` similarity instead of `l2` +distance. If the table has a vector index, make sure it was also built with +`cosine`. The result focuses on vector direction rather than absolute distance, +which works better for unnormalized embeddings. + +Set `.limit(...)` on vector searches you run in applications. You can page through results with `.offset(...)` and include LanceDB's internal row id with `.with_row_id()` / `.withRowId()` when you need a stable handle for follow-up operations. + +## Selecting the vector column + +If your table has exactly one vector column, you can omit the column name and LanceDB will pick it for you. This works for both top-level columns (such as `vector`) and vector fields nested inside a struct (such as `image.embedding`). + +When LanceDB can't infer a single column, it raises a `ValueError` (Python) or rejects the query (Node/Rust). Two cases trigger this: + +- No vector column: the schema has no `fixed_size_list` or `list` of floats. +- Multiple candidates: more than one column matches the query's dimension. The error lists every candidate path so you can pick one explicitly. + +To disambiguate, pass the field path with dot notation. Wrap any segment that contains characters outside `[A-Za-z0-9_]` in backticks (for example, `` `image-meta`.`embedding.v1` ``). + + + +{SelectVectorColumn} + + + +{TsSelectVectorColumn} + + + +The same field-path syntax works when creating an index on a nested vector column: + + + +{IndexNestedColumn} + + + +{TsIndexNestedColumn} + + + +When several columns share a name across structs (for example, `image.embedding` and `text.embedding`), LanceDB still picks the one whose dimension matches your query vector. If two candidates have the same dimension, you must pass the column name explicitly. + +## Vector Search With ANN Index + +Instead of performing an exhaustive search on the entire database for each and every query, approximate nearest neighbour (ANN) algorithms use an index to narrow down the search space, which significantly reduces query latency. + +The trade-off is that the results are not guaranteed to be the true nearest neighbors of the query, but are usually "good enough" for most use cases. + +Use ANN search for large-scale applications where speed matters more than perfect recall. LanceDB uses approximate nearest neighbor algorithms to deliver fast results without examining every vector in your dataset. + + +When a vector index is used, `_distance` is not always the true distance between full vectors. On quantized ANN indexes, LanceDB may compute `_distance` from the compressed representation for speed. Use `refine_factor` when you want reranking on full vectors. + + +### Exact vs Approximate Distances + +When doing vector search, the meaning of "distance" depends on whether you are using an index and whether `refine_factor` is specified as part of your query. +`nprobes` controls how many partitions are searched to find candidates, `approx_mode` controls the query-time speed/recall trade-off for RQ-quantized indexes, and `refine_factor` controls how many candidates are rescored on full vectors for better distance fidelity and reranking quality. + +The table below summarizes the behavior of `_distance` in search results based on your query configuration: + +| Query mode | Neighbor quality | `_distance` in results | +| :--- | :--- | :--- | +| No index or `.bypass_vector_index()` | Exact kNN (100% recall) | True distance on full vectors | +| Indexed ANN, no `refine_factor` | Approximate neighbors | Distance on the index representation: exact for flat indexes, approximate for quantized indexes | +| Indexed ANN + `refine_factor(1)` | Approximate neighbors (same candidate set) | Distances recomputed on full vectors for reranked candidates | +| Indexed ANN + `refine_factor(>1)` | Better recall than no refine (usually) | Distances recomputed on full vectors for reranked candidates | + + + +{ExactVsApproximateDistances} + + + +{TsExactVsApproximate} + + + +{RsExactVsApproximate} + + + +For deeper tuning guidance on indexing and performance estimation, see the [vector indexes](/indexing/vector-index) page, +For tuning `nprobes`, see below. + +### Tuning `approx_mode` + +Use `approx_mode` when you want to adjust the speed/recall trade-off for approximate vector search at query time. This setting currently applies only to RQ-quantized indexes, such as `IVF_RQ`; other index types ignore it. + +The supported values are: + +| Value | Behavior | +| :--- | :--- | +| `fast` | Prefer lower query latency, which can reduce recall. | +| `normal` | Use the default balance between query latency and recall. This only has an effect for RQ indexes built with `num_bits > 1`. | +| `accurate` | Prefer higher recall, which can increase query latency. | + +You can change `approx_mode` per query without rebuilding the index. For RQ indexes built with `num_bits=1`, `normal` uses the same one-bit scoring path as `fast`. If you also set `refine_factor`, LanceDB first uses `approx_mode` while finding candidates, then reranks the selected candidates on full vectors. + +### Tuning `nprobes` + +- `nprobes` controls how many partitions are searched at query time. +- `nprobes` improves candidate recall, but does not by itself make `_distance` exact. +- By default, LanceDB automatically tunes `nprobes` to achieve the best performance without noticeably sacrificing accuracy. +- In most cases, leave `nprobes` unset and use the auto-tuned value. +- Only tune `nprobes` manually when recall is below your target, or when you need even higher performance for your workload. +- If recall is too low, increase `nprobes` gradually, but after a certain threshold, increasing `nprobes` yields only marginal accuracy gains. +- If you need higher performance and have recall headroom, decrease `nprobes` gradually. + +For filtered ANN searches, you can also set `minimum_nprobes` and `maximum_nprobes`. LanceDB starts +with the minimum and can scan more partitions up to the maximum if the filter leaves too few +candidates. Calling `nprobes(n)` fixes both values to `n`, which disables that adaptive behavior. + +### Vector Search with Prefiltering + +This is the default vector search setting. You can use prefiltering to boost query performance by reducing the search space before vector calculations begin. The system first applies your filter criteria to the dataset, then conducts vector search operations only on the remaining relevant subset. + + + +{VectorSearchPrefilter} + + + +{TsVectorSearchPrefilter} + + + +{RsVectorSearchPrefilter} + + + +This filters out rows where label ≤ 2 before doing vector search, then picks specific columns from the top 5 matches. + +The `.where("label > 2")` applies a filter before vector search, `.select(["text", "keywords", "label"])` chooses specific columns to return, and `.limit(5)` restricts results to the top `5` most similar vectors. + +As a result, you'll see a result with just the data you want from the most similar vectors. + +### Vector Search with Postfiltering + +Use postfiltering to prioritize vector similarity by searching the full dataset first, then applying metadata filters to the top results. This approach ensures you get the most similar vectors before filtering, which can be crucial when similarity is more important than metadata constraints. + + + +{VectorSearchPostfilter} + + + +{TsVectorSearchPostfilter} + + + +{RsVectorSearchPostfilter} + + + +Here you can see how to do vector search first to get the most similar vectors, then filter by label > 1 on those results. + +The `prefilter=False` parameter tells LanceDB to apply the filter after vector search instead of before, `.where("label > 1")` filters the top results by metadata, and `.select()` chooses which columns to include. + +In the end, you receive a query result with the best matches that also meet your metadata requirements. + + +[Post-filtering](/search/filtering/#post-filtering-with-vector-search) in LanceDB applies +the filter condition after obtaining the nearest neighbors based on vector similarity. + + +## Multivector Search + +Use multivector search when your documents contain multiple embeddings and you need sophisticated matching between query and document vector pairs. The late interaction approach finds the most relevant combinations across all available embeddings and provides nuanced similarity scoring. + +Only `cosine` similarity is supported as the distance metric for multivector search operations. +Every query vector must match the inner dimension of the multivector column; LanceDB rejects mismatched query dimensions rather than guessing how to reshape them. + + + +{MultivectorSearch} + + + +Here you can see how to take 2 query vectors and find the best matching pairs between them and document vectors using late interaction. The `np.random.random(size=(2, 256))` creates a 2×256 array with two random query vectors, `.limit(5)` returns the top 5 best document-query combinations, and `.to_pandas()` provides results in a DataFrame format. + +**Read more:** [Multivector search](/search/vector-search) + +## Advanced Search Scenarios + +### Search With Distance Range + +Use `distance_range` search when you need vectors within particular similarity bounds rather than just the closest neighbors. The system filters results to only include vectors that fall within your specified distance thresholds from the query. + + + +{SearchDistanceRange} + + + +{TsDistanceRange} + + + +{RsSearchDistanceRange} + + + +This shows three ways to search within distance ranges: bounded, upper bound only, and lower bound only. + +The `distance_range()` method filters results by similarity thresholds - the first example finds vectors with distance between `0.1` and `0.5`, the second finds vectors closer than `0.5`, and the third finds vectors farther than `0.1`. + + Each approach returns Arrow tables with vectors that fall within your specified distance thresholds. + +### Search With Binary Vectors + +Use binary vector search for scenarios involving binary embeddings, such as those produced by hashing algorithms. The system stores these efficiently as packed uint8 arrays and uses Hamming distance calculations to determine vector similarity. + + +The number of dimensions of the binary vector must be a multiple of 8. A vector of dimensionality 128 will be stored as a `uint8` array of size 16. + + + + +{SearchBinaryVectors} + + + +{TsBinarySearch} + + + +{RsBinarySearch} + + + +Here you can see how to set up a table for binary vectors, pack them efficiently into bytes, and search using Hamming distance. + +The schema defines a 32-byte vector field (256 bits ÷ 8), `np.random.randint(0, 2, size=256)` creates binary vectors, `np.packbits()` compresses them to bytes, and `.distance_type("hamming")` specifies `hamming` distance for similarity calculation. + +The search produces an Arrow table with binary vectors ranked by how many bits differ from the query. + +## Scaling Vector Search + +### Batch Search + +Use batch search to handle multiple query vectors simultaneously. This gives you significant efficiency gains over individual queries. LanceDB processes all vectors in parallel and organizes results with a `query_index` field that maps each result set back to its originating query. + + + +{BatchSearch} + + + +{TsBatchSearch} + + + +{RsBatchSearch} + + + +This takes 5 query embeddings and finds the top 5 matches for each one in a single batch operation. + +The `load_dataset()` loads embeddings from a Hugging Face dataset, `query_embeds` contains `5` query vectors, and `.search(query_embeds)` processes all queries simultaneously. + +The final query result contains all results, including a `query_index` to tell you which query each result came from. + + +When processing batch queries, the results include a `query_index` field +to explicitly associate each result set with its corresponding query in +the input batch. + + +### Search With Asynchronous Indexing + +To optimize for speed over completeness, enable the `fast_search` flag in your query to skip searching unindexed data. + +While vector indexing occurs asynchronously, newly added vectors are immediately +searchable through a fallback brute-force search mechanism. This ensures zero +latency between data insertion and searchability, though it may temporarily +increase query response times. + + + +{FastSearch} + + + +{TsFastSearch} + + + +{RsFastSearch} + + + +Here you can see how to turn on fast search mode to skip unindexed vectors and only look through indexed data for speed. + +The `fast_search=True` parameter tells LanceDB to only search indexed vectors, skipping any recently added data that hasn't been indexed yet. + +You'll obtain a query result with the top `5` matches from indexed vectors, but might miss data that was just added. + +## Brute Force Search + +### Search With No Index + +The simplest way to perform vector search is to perform a brute force search, without an index, where the distance between the query vector and all the vectors in the database are computed, with the top-k closest vectors returned. + +This is equivalent to a k-nearest neighbours (kNN) search in vector space. + +Choose brute force search when you need guaranteed 100% recall, typically with smaller datasets where query speed isn't the primary concern. The system scans every vector in the table and calculates precise distances to find the exact nearest neighbors. + + + +{BruteForceSearch} + + + +{TsBruteForceSearch} + + + +{RsBruteForceSearch} + + + +This carries out a brute force search through every vector in the table to find the 3 closest matches to a random 1536-dimensional query. You'll get back a list of the most similar vectors with exact distances. + +![](/static/assets/images/search/knn_search.png) + +As you can imagine, the brute force approach is not scalable for datasets larger than a few hundred thousand vectors, as the latency of the search grows linearly with the size of the dataset. This is where approximate nearest neighbour (ANN) algorithms come in. + +### Bypass the Vector Index + +Use `bypass_vector_index` to get exact, ground-truth results by performing exhaustive searches across all vectors. Instead of relying on approximate methods, the system directly compares your query against every vector in the table, ensuring 100% recall at the cost of increased query time. + + + +{BypassVectorIndex} + + + +{TsBypassVectorIndex} + + + +{RsBypassVectorIndex} + + + +This skips the approximate index and checks every single vector for exact, ground-truth results. + +The `.bypass_vector_index()` method forces LanceDB to perform an exhaustive search through all vectors instead of using the approximate nearest neighbor index, ensuring exact results but at the cost of slower performance. + +The output is a query result with the top 5 exact matches, guaranteeing 100% recall but taking longer to run. + +This approach is particularly useful when: +- Evaluating ANN index quality +- Calculating recall metrics to tune index parameters +- Ensuring exact results for critical applications + +## Multi-vector search {#multivector} + +LanceDB's multivector support enables you to store and search multiple vector embeddings for a single item. + +This capability is particularly valuable when working with late-interaction models like ColBERT and ColPaLi, which generate multiple embeddings per document. + +In this tutorial, you'll create a table with multiple vector embeddings per document and learn how to perform multivector search. For more end-to-end examples, see the [VectorDB recipes repository](https://github.com/lancedb/vectordb-recipes/tree/main/examples). + +### Multivector Support + +Each item in your dataset can have a column containing multiple vectors, which LanceDB can efficiently index and search. When performing a search, you can query with either a single vector embedding or multiple vector embeddings. + + +Currently, only the `cosine` metric is supported for multivector search. The vector value type can be `float16`, `float32`, or `float64`. + + +Each query vector must match the inner vector dimension in the multivector column. This applies to both single-vector queries and multi-vector query matrices. + +### Computing Similarity + +MaxSim (Maximum Similarity) is a key concept in late-interaction models that: + +- Computes the maximum similarity between each query embedding and all document embeddings +- Sums these maximum similarities to get the final relevance score +- Effectively captures fine-grained semantic matches between query and document tokens + +The MaxSim calculation can be expressed as: + +$$ +\text{MaxSim}(Q, D) = \sum_{i=1}^{|Q|} \max_{j=1}^{|D|} \text{sim}(q_i, d_j) +$$ + +Where $sim$ is the similarity function (e.g., cosine similarity). + +$$ +Q = \{q_1, q_2, ..., q_{|Q|}\} +$$ + +$Q$ represents the query embeddings, and $D = \{d_1, d_2, ..., d_{|D|}\}$ represents the document embeddings. + +### Using Multivector Search + +#### 1. Setup + +Connect to LanceDB and import the required libraries. + + +```python Python icon="python" +import lancedb +import numpy as np +import pyarrow as pa + +db = lancedb.connect( + uri="db://your-project-slug", + api_key="your-api-key", + region="your-region" +) +``` + + +#### 2. Define Schema + +Define a schema that specifies a multivector field. A multivector field is a nested list structure in which each document contains multiple vectors. In this case, we'll create a schema with: + +1. An ID field as an integer (int64) +2. A vector field that is a list of lists of float32 values + - The outer list represents multiple vectors per document + - Each inner list is a 256-dimensional vector + - Using float32 for memory efficiency while maintaining precision + + +```python Python icon="python" +db = lancedb.connect("data/multivector_demo") +schema = pa.schema( + [ + pa.field("id", pa.int64()), + # float16, float32, and float64 are supported + pa.field("vector", pa.list_(pa.list_(pa.float32(), 256))), + ] +) +``` + + +#### 3. Generate Multivectors + +Generate sample data where each document contains multiple vector embeddings, which can represent different aspects or views of the same document. + +In this example, we create **1024 documents** where each document has **2 random vectors** of **dimension 256**, simulating a real-world scenario where you might have multiple embeddings per item. + + +```python Python icon="python" +data = [ + { + "id": i, + "vector": np.random.random(size=(2, 256)).tolist(), # Each document has 2 vectors + } + for i in range(1024) +] +``` + + +#### 4. Create a Table + +Create a table with the defined schema and sample data, which will store multiple vectors per document for similarity search. + + +```python Python icon="python" +tbl = db.create_table("multivector_example", data=data, schema=schema) +``` + + +#### 5. Build an Index + +Only cosine similarity is supported for multivector search operations. +For faster search, build the standard `IVF_PQ` index over your vectors: + + +```python Python icon="python" +tbl.create_index(metric="cosine", vector_column_name="vector") +``` + + + +**Indexing matters more for multivector tables than for single-vector ones.** + +A brute-force scan over a multivector column has to compare every query vector to every document vector in every row, so the cost grows with both the row count and the number of vectors per row. +In LanceDB OSS, the query will just run, so a large unindexed multivector table can stall a process for a long time before returning results. + +On LanceDB Cloud, the brute-force KNN safety check applies a stricter row threshold to multivector columns — roughly 10× lower than for single-vector columns. So an unindexed multivector table can be rejected with a "vector search would use brute-force KNN" error well before a comparable single-vector table would. LanceDB Enterprise doesn't have the limit. Build the index before you start hitting it from production traffic, even if the dataset is small enough that you'd skip indexing for a single-vector workload. + + +#### 6. Query a Single Vector + +When searching with a single query vector, it will be compared against all vectors in each document, and the similarity scores will be aggregated to find the most relevant documents. + + +```python Python icon="python" +query = np.random.random(256) +results_single = tbl.search(query).limit(5).to_pandas() +``` + + +#### 7. Query Multiple Vectors + +With multiple query vectors, LanceDB calculates similarity using late interaction, a late-interaction technique that computes relevance by finding the best-matching pairs between query and document vectors. This approach provides more nuanced matching while maintaining fast retrieval speeds. + + +```python Python icon="python" +query_multi = np.random.random(size=(2, 256)) +results_multi = tbl.search(query_multi).limit(5).to_pandas() +``` + + + +Visit the [Hugging Face embedding integration](/integrations/embedding/huggingface/) page for information on embedding models. + +### Simple Example: ColBERT Embeddings + +[ColBERT](https://arxiv.org/abs/2004.12832) is the most well-known late-interaction retrieval model that +represents each document and query as multiple token embeddings and scores matches by taking the best +token-to-token similarities (MaxSim) across them. + +Install the dependencies before running this example: + +```bash +pip install pylate lancedb pandas +``` + + +```python Python icon="python" +import numpy as np +import pyarrow as pa +import lancedb +from pylate import models + +## 1) Load a late-interaction model via PyLate +## PyLate docs show ColBERT() + encode(..., is_query=...) :contentReference[oaicite:2]{index=2} +model = models.ColBERT(model_name_or_path="lightonai/GTE-ModernColBERT-v1") + +## You can discover dim from one embedding (avoid guessing) +dim = model.encode(["hello"], is_query=True)[0].shape[1] + +## 2) Create a LanceDB table with a multivector column +db = lancedb.connect("./pylate_lancedb") +schema = pa.schema([ + pa.field("doc_id", pa.string()), + pa.field("text", pa.string()), + # multivector: list> :contentReference[oaicite:3]{index=3} + pa.field("mv", pa.list_(pa.list_(pa.float32(), dim))), +]) + +docs = [ + {"doc_id": "1", "text": "The train to Tokyo leaves at 5pm."}, + {"doc_id": "2", "text": "That Pho restaurant in Hanoi is highly rated."}, + {"doc_id": "3", "text": "This is a noodle bar in Osaka, Japan."}, +] + +## 3) Encode documents with PyLate (token vectors per doc) +doc_texts = [d["text"] for d in docs] +doc_embs = model.encode(doc_texts, is_query=False) # list/array of (T, dim) per doc :contentReference[oaicite:4]{index=4} + +rows = [] +for d, emb in zip(docs, doc_embs): + emb = np.asarray(emb, dtype=np.float32) + rows.append({**d, "mv": emb.tolist()}) + +tbl = db.create_table("docs", data=rows, schema=schema, mode="overwrite") + +## 4) Build an index + query using a query matrix. +## Multivector brute-force scales with rows × vectors-per-row, so build the index +## at much smaller dataset sizes than you would for single-vector search — and +## always before exposing the table to remote traffic. +tbl.create_index(vector_column_name="mv", metric="cosine") + +query = "Tell me about ramen in Japan" +q_emb = np.asarray(model.encode([query], is_query=True)[0], dtype=np.float32) # (Tq, dim) :contentReference[oaicite:5]{index=5} + +out = tbl.search(q_emb).limit(5).to_pandas() # multivector search accepts a matrix :contentReference[oaicite:6]{index=6} +print(out[["doc_id", "text"]]) +``` + + +Late-interaction model implementations evolve rapidly, so it's a good idea to check the latest popular models +when trying multivector search. + +### Advanced Example: XTR Embeddings + +[ConteXtualized Token Retriever (XTR)](https://arxiv.org/abs/2304.01982) is a late-interaction retrieval model that represents text as token-level vectors instead of a single embedding. +This lets search score token-to-token matches (MaxSim), which can improve fine-grained relevance. + +The notebook linked below shows how to integrate XTR, which prioritizes critical document +tokens during the initial retrieval stage and removes the gathering stage to improve performance significantly. +By focusing on the most semantically salient tokens early in the process, XTR reduces computational complexity +while improving recall and ensuring rapid identification of candidate documents. + + + diff --git a/docs/web/snippets/ann_indexes.mdx b/docs/web/snippets/ann_indexes.mdx new file mode 100644 index 000000000..ef4021e16 --- /dev/null +++ b/docs/web/snippets/ann_indexes.mdx @@ -0,0 +1,12 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const TsImport = "import * as lancedb from \"@lancedb/lancedb\";\nimport type { VectorQuery } from \"@lancedb/lancedb\";\n"; + +export const TsIngest = "const db = await lancedb.connect(databaseDir);\n\nconst data = Array.from({ length: 5_000 }, (_, i) => ({\n vector: Array(128).fill(i),\n id: `${i}`,\n content: \"\",\n longId: `${i}`,\n}));\n\nconst table = await db.createTable(\"my_vectors\", data, {\n mode: \"overwrite\",\n});\nawait table.createIndex(\"vector\", {\n config: lancedb.Index.ivfPq({\n numPartitions: 10,\n numSubVectors: 16,\n }),\n});\n"; + +export const TsSearch1 = "const search = table.search(Array(128).fill(1.2)).limit(2) as VectorQuery;\nconst results1 = await search.nprobes(20).refineFactor(10).toArray();\n"; + +export const TsSearch2 = "const results2 = await table\n .search(Array(128).fill(1.2))\n .where(\"id != '1141'\")\n .limit(2)\n .toArray();\n"; + +export const TsSearch3 = "const results3 = await table\n .search(Array(128).fill(1.2))\n .select([\"id\"])\n .limit(2)\n .toArray();\n"; + diff --git a/docs/web/snippets/basic_usage.mdx b/docs/web/snippets/basic_usage.mdx new file mode 100644 index 000000000..d377efa9c --- /dev/null +++ b/docs/web/snippets/basic_usage.mdx @@ -0,0 +1,100 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyBasicAddColumns = "table.add_columns(\n {\n \"power\": \"cast(((stats.strength + stats.courage + stats.magic + stats.wisdom) / 4.0) as float)\"\n }\n)\n"; + +export const PyBasicAddData = "magical_characters = [\n {\n \"id\": 9,\n \"name\": \"Morgan le Fay\",\n \"role\": \"Sorceress\",\n \"description\": \"A powerful enchantress, Arthur's half-sister, and a complex figure who oscillates between aiding and opposing Camelot.\",\n \"vector\": [0.10, 0.84, 0.25, 0.70],\n \"stats\": { \"strength\": 2, \"courage\": 3, \"magic\": 5, \"wisdom\": 4 }\n },\n {\n \"id\": 10,\n \"name\": \"The Lady of the Lake\",\n \"role\": \"Mystical Guardian\",\n \"description\": \"A mysterious supernatural figure associated with Avalon, known for giving Arthur the sword Excalibur.\",\n \"vector\": [0.00, 0.90, 0.58, 0.88],\n \"stats\": { \"strength\": 2, \"courage\": 3, \"magic\": 5, \"wisdom\": 5 }\n }\n]\ntable.add(magical_characters)\n"; + +export const PyBasicAsyncApi = "import lancedb\n\nasync_db = await lancedb.connect_async(uri)\nasync_table = await async_db.create_table(\n \"camelot_async\",\n data=data,\n mode=\"overwrite\",\n)\n\nquery_vector = [0.03, 0.85, 0.61, 0.90]\nasync_results = await (\n await async_table.search(query_vector)\n).limit(5).select([\"name\", \"role\", \"description\"]).to_polars()\nprint(async_results)\n"; + +export const PyBasicCreateEmptyTable = "schema = pa.schema(\n [\n pa.field(\"id\", pa.uint16()),\n pa.field(\"name\", pa.string()),\n pa.field(\"role\", pa.string()),\n pa.field(\"description\", pa.string()),\n pa.field(\"vector\", pa.list_(pa.float32(), 4)),\n pa.field(\n \"stats\",\n pa.struct(\n [\n pa.field(\"strength\", pa.int8()),\n pa.field(\"courage\", pa.int8()),\n pa.field(\"magic\", pa.int8()),\n pa.field(\"wisdom\", pa.int8()),\n ]\n ),\n ),\n ]\n)\ndb.create_table(\"camelot_pa\", schema=schema, mode=\"overwrite\")\n"; + +export const PyBasicCreateTable = "table = db.create_table(\"camelot\", data=data, mode=\"overwrite\")\n"; + +export const PyBasicCreateTablePandas = "pandas_df = pd.DataFrame(data)\ntable_pd = db.create_table(\"camelot_pd\", data=pandas_df, mode=\"overwrite\")\n"; + +export const PyBasicCreateTablePolars = "polars_df = pl.DataFrame(data)\ntable_pl = db.create_table(\"camelot_pl\", data=polars_df, mode=\"overwrite\")\n"; + +export const PyBasicDeleteRows = "table.delete('role = \"Traitor Knight\"')\n"; + +export const PyBasicDropColumns = "table.drop_columns([\"power\"])\n"; + +export const PyBasicDropTable = "db.drop_table(\"camelot\")\n"; + +export const PyBasicImports = "import json\n\nimport lancedb\nimport pandas as pd\nimport polars as pl\nimport pyarrow as pa\n"; + +export const PyBasicOpenTable = "table = db.open_table(\"camelot\")\n"; + +export const PyBasicSortPolars = "# Sort Polars DataFrame by power in descending order\nprint(r4.sort(\"power\", descending=True).limit(5))\n"; + +export const PyBasicVectorSearch = "query_vector = [0.03, 0.85, 0.61, 0.90]\nresult = table.search(query_vector).limit(5).to_polars()\nprint(result)\n"; + +export const PyBasicVectorSearchQ1 = "# Who are the characters similar to \"wizard\"?\nquery_vector_1 = [0.03, 0.85, 0.61, 0.90]\nr1 = (\n table.search(query_vector_1)\n .limit(5)\n .select([\"name\", \"role\", \"description\"])\n .to_polars()\n)\nprint(r1)\n"; + +export const PyBasicVectorSearchQ2 = "# Who are the characters with high magic stats?\nquery_vector_2 = [0.03, 0.85, 0.61, 0.90]\nr2 = (\n table.search(query_vector_2)\n .where(\"stats.magic > 3\")\n .select([\"name\", \"role\", \"description\"])\n .limit(5)\n .to_polars()\n)\nprint(r2)\n"; + +export const PyBasicVectorSearchQ3 = "# Who are the strongest characters?\nr3 = (\n table.search()\n .where(\"stats.strength > 3\")\n .select([\"name\", \"role\", \"description\"])\n .limit(5)\n .to_polars()\n)\nprint(r3)\n"; + +export const PyBasicVectorSearchQ4 = "# Who are the strongest characters?\nr4 = (\n table.search()\n .select([\"name\", \"role\", \"description\", \"power\"])\n .to_polars()\n)\nprint(r4)\n"; + +export const PyDataLoad = "with open(data_path, \"r\") as f:\n data = json.load(f)\n"; + +export const TsBasicAddColumns = "await table.addColumns([\n {\n name: \"power\",\n valueSql:\n \"cast(((stats.strength + stats.courage + stats.magic + stats.wisdom) / 4.0) as float)\",\n },\n]);\n"; + +export const TsBasicAddData = "const magicalCharacters = [\n {\n id: 9,\n name: \"Morgan le Fay\",\n role: \"Sorceress\",\n description:\n \"A powerful enchantress, Arthur's half-sister, and a complex figure who oscillates between aiding and opposing Camelot.\",\n vector: [0.1, 0.84, 0.25, 0.7],\n stats: { strength: 2, courage: 3, magic: 5, wisdom: 4 },\n },\n {\n id: 10,\n name: \"The Lady of the Lake\",\n role: \"Mystical Guardian\",\n description:\n \"A mysterious supernatural figure associated with Avalon, known for giving Arthur the sword Excalibur.\",\n vector: [0.0, 0.9, 0.58, 0.88],\n stats: { strength: 2, courage: 3, magic: 5, wisdom: 5 },\n },\n];\nawait table.add(magicalCharacters);\n"; + +export const TsBasicCreateEmptyTable = "const schema = new arrow.Schema([\n new arrow.Field(\"id\", new arrow.Int16()),\n new arrow.Field(\"name\", new arrow.Utf8()),\n new arrow.Field(\"role\", new arrow.Utf8()),\n new arrow.Field(\"description\", new arrow.Utf8()),\n new arrow.Field(\n \"vector\",\n new arrow.FixedSizeList(\n 4,\n new arrow.Field(\"item\", new arrow.Float32(), true),\n ),\n ),\n new arrow.Field(\n \"stats\",\n new arrow.Struct([\n new arrow.Field(\"strength\", new arrow.Int8()),\n new arrow.Field(\"courage\", new arrow.Int8()),\n new arrow.Field(\"magic\", new arrow.Int8()),\n new arrow.Field(\"wisdom\", new arrow.Int8()),\n ]),\n ),\n]);\nawait db.createEmptyTable(\"camelot_empty\", schema, { mode: \"overwrite\" });\n"; + +export const TsBasicCreateTable = "let table = await db.createTable(\"camelot\", data, {\n mode: \"overwrite\",\n});\n"; + +export const TsBasicDeleteRows = "await table.delete('role = \"Traitor Knight\"');\n"; + +export const TsBasicDropColumns = "await table.dropColumns([\"power\"]);\n"; + +export const TsBasicDropTable = "await db.dropTable(\"camelot\");\n"; + +export const TsBasicImports = "import * as lancedb from \"@lancedb/lancedb\";\nimport * as arrow from \"apache-arrow\";\n"; + +export const TsBasicOpenTable = "table = await db.openTable(\"camelot\");\n"; + +export const TsBasicVectorSearch = "const queryVector = [0.03, 0.85, 0.61, 0.9];\nconst result = await table.search(queryVector).limit(5).toArray();\nconsole.log(result);\n"; + +export const TsBasicVectorSearchQ1 = "// Who are the characters similar to \"wizard\"?\nconst queryVector1 = [0.03, 0.85, 0.61, 0.9];\nconst r1 = await table\n .search(queryVector1)\n .limit(5)\n .select([\"name\", \"role\", \"description\"])\n .toArray();\nconsole.log(r1);\n"; + +export const TsBasicVectorSearchQ2 = "// Who are the characters similar to \"wizard\" with high magic stats?\nconst queryVector2 = [0.03, 0.85, 0.61, 0.9];\nconst r2 = await table\n .search(queryVector2)\n .where(\"stats.magic > 3\")\n .select([\"name\", \"role\", \"description\"])\n .limit(5)\n .toArray();\nconsole.log(r2);\n"; + +export const TsBasicVectorSearchQ3 = "// Who are the strongest characters?\nconst r3 = await table\n .query()\n .where(\"stats.strength > 3\")\n .select([\"name\", \"role\", \"description\"])\n .limit(5)\n .toArray();\nconsole.log(r3);\n"; + +export const TsBasicVectorSearchQ4 = "// Who are the strongest characters?\nconst r4 = await table\n .query()\n .select([\"name\", \"role\", \"description\", \"power\"])\n .toArray();\nconsole.log(r4);\n"; + +export const TsDataLoad = "const data = JSON.parse(fs.readFileSync(dataPath, \"utf-8\"));\n"; + +export const RsBasicAddColumns = "table\n .add_columns(\n NewColumnTransform::SqlExpressions(vec![(\n \"power\".to_string(),\n \"cast(((stats.strength + stats.courage + stats.magic + stats.wisdom) / 4.0) as float)\"\n .to_string(),\n )]),\n None,\n )\n .await\n .unwrap();\n"; + +export const RsBasicAddData = "let magical_characters = vec![\n Character {\n id: 9,\n name: \"Morgan le Fay\".to_string(),\n role: \"Sorceress\".to_string(),\n description: \"A powerful enchantress, Arthur's half-sister, and a complex figure who oscillates between aiding and opposing Camelot.\".to_string(),\n vector: [0.10, 0.84, 0.25, 0.70],\n stats: Stats {\n strength: 2,\n courage: 3,\n magic: 5,\n wisdom: 4,\n },\n },\n Character {\n id: 10,\n name: \"The Lady of the Lake\".to_string(),\n role: \"Mystical Guardian\".to_string(),\n description: \"A mysterious supernatural figure associated with Avalon, known for giving Arthur the sword Excalibur.\".to_string(),\n vector: [0.00, 0.90, 0.58, 0.88],\n stats: Stats {\n strength: 2,\n courage: 3,\n magic: 5,\n wisdom: 5,\n },\n },\n];\ntable\n .add(characters_to_reader(camelot_schema(), &magical_characters))\n .execute()\n .await\n .unwrap();\n"; + +export const RsBasicCreateEmptyTable = "let schema = Arc::new(Schema::new(vec![\n Field::new(\"id\", DataType::Int16, false),\n Field::new(\"name\", DataType::Utf8, false),\n Field::new(\"role\", DataType::Utf8, false),\n Field::new(\"description\", DataType::Utf8, false),\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 4),\n false,\n ),\n Field::new(\n \"stats\",\n DataType::Struct(arrow_schema::Fields::from(vec![\n Arc::new(Field::new(\"strength\", DataType::Int8, false)),\n Arc::new(Field::new(\"courage\", DataType::Int8, false)),\n Arc::new(Field::new(\"magic\", DataType::Int8, false)),\n Arc::new(Field::new(\"wisdom\", DataType::Int8, false)),\n ])),\n false,\n ),\n]));\ndb.create_empty_table(\"camelot_empty\", schema)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsBasicCreateTable = "let mut table = db\n .create_table(\"camelot\", characters_to_reader(schema.clone(), &data))\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsBasicDeleteRows = "table.delete(\"role = 'Traitor Knight'\").await.unwrap();\n"; + +export const RsBasicDropColumns = "table.drop_columns(&[\"power\"]).await.unwrap();\n"; + +export const RsBasicDropTable = "db.drop_table(\"camelot\", &[]).await.unwrap();\n"; + +export const RsBasicImports = "use arrow_array::types::Float32Type;\nuse arrow_array::{\n FixedSizeListArray, Int8Array, Int16Array, RecordBatch, RecordBatchIterator, StringArray,\n StructArray,\n};\nuse arrow_schema::{DataType, Field, FieldRef, Schema};\nuse futures_util::TryStreamExt;\nuse lancedb::database::CreateTableMode;\nuse lancedb::query::{ExecutableQuery, QueryBase, Select};\nuse lancedb::{connect, table::NewColumnTransform};\n"; + +export const RsBasicOpenTable = "table = db.open_table(\"camelot\").execute().await.unwrap();\n"; + +export const RsBasicVectorSearch = "let query_vector = [0.03, 0.85, 0.61, 0.90];\nlet result = table\n .query()\n .nearest_to(&query_vector)\n .unwrap()\n .limit(5)\n .execute()\n .await\n .unwrap()\n .try_collect::>()\n .await\n .unwrap();\nprintln!(\"{result:?}\");\n"; + +export const RsBasicVectorSearchQ1 = "// Who are the characters similar to \"wizard\"?\nlet query_vector_1 = [0.03, 0.85, 0.61, 0.90];\nlet r1 = table\n .query()\n .nearest_to(&query_vector_1)\n .unwrap()\n .limit(5)\n .select(Select::Columns(vec![\n \"name\".to_string(),\n \"role\".to_string(),\n \"description\".to_string(),\n ]))\n .execute()\n .await\n .unwrap()\n .try_collect::>()\n .await\n .unwrap();\nprintln!(\"{r1:?}\");\n"; + +export const RsBasicVectorSearchQ2 = "// Who are the characters similar to \"wizard\" with high magic stats?\nlet query_vector_2 = [0.03, 0.85, 0.61, 0.90];\nlet r2 = table\n .query()\n .nearest_to(&query_vector_2)\n .unwrap()\n .only_if(\"stats.magic > 3\")\n .select(Select::Columns(vec![\n \"name\".to_string(),\n \"role\".to_string(),\n \"description\".to_string(),\n ]))\n .limit(5)\n .execute()\n .await\n .unwrap()\n .try_collect::>()\n .await\n .unwrap();\nprintln!(\"{r2:?}\");\n"; + +export const RsBasicVectorSearchQ3 = "// Who are the strongest characters?\nlet r3 = table\n .query()\n .only_if(\"stats.strength > 3\")\n .select(Select::Columns(vec![\n \"name\".to_string(),\n \"role\".to_string(),\n \"description\".to_string(),\n ]))\n .limit(5)\n .execute()\n .await\n .unwrap()\n .try_collect::>()\n .await\n .unwrap();\nprintln!(\"{r3:?}\");\n"; + +export const RsBasicVectorSearchQ4 = "// Who are the strongest characters?\nlet r4 = table\n .query()\n .select(Select::Columns(vec![\n \"name\".to_string(),\n \"role\".to_string(),\n \"description\".to_string(),\n \"power\".to_string(),\n ]))\n .execute()\n .await\n .unwrap()\n .try_collect::>()\n .await\n .unwrap();\nprintln!(\"{r4:?}\");\n"; + +export const RsDataLoad = "let data: Vec =\n serde_json::from_str(&fs::read_to_string(camelot_json_path()).unwrap()).unwrap();\n"; + diff --git a/docs/web/snippets/build_with_ai_agents.mdx b/docs/web/snippets/build_with_ai_agents.mdx new file mode 100644 index 000000000..1a82d6fb6 --- /dev/null +++ b/docs/web/snippets/build_with_ai_agents.mdx @@ -0,0 +1,7 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyCamelotBatches = "import json\nfrom collections.abc import Iterator\nfrom pathlib import Path\n\n\ndef validated_batches(\n input_path: Path, batch_size: int\n) -> Iterator[list[dict]]:\n raw_records = json.loads(input_path.read_text())\n asset_root = input_path.parent.parent\n batch: list[dict] = []\n\n for raw in raw_records:\n payload = dict(raw)\n image_path = asset_root / payload.pop(\"img\")\n payload[\"image_filename\"] = image_path.name\n payload[\"image\"] = image_path.read_bytes()\n\n character = Character.model_validate(payload)\n batch.append(character.model_dump(mode=\"python\"))\n\n if len(batch) == batch_size:\n yield batch\n batch = []\n\n if batch:\n yield batch\n"; + +export const PyCamelotOssIngestion = "import lancedb\n\n\ndef ingest_oss(\n input_path: Path,\n uri: str = \"data/camelot.lancedb\",\n table_name: str = \"camelot_multimodal\",\n batch_size: int = 64,\n):\n db = lancedb.connect(uri)\n if table_name in db.list_tables():\n raise ValueError(\n f\"Table {table_name!r} already exists. Choose a fresh table name.\"\n )\n\n table = db.create_table(table_name, schema=Character)\n for batch in validated_batches(input_path, batch_size):\n table.add(batch)\n\n table.optimize()\n return table\n\n\nif __name__ == \"__main__\":\n ingest_oss(Path(\"data/camelot.json\"))\n"; + +export const PyCamelotSchema = "from lancedb.pydantic import LanceModel\nfrom pydantic import ConfigDict\n\n\nclass Stats(LanceModel):\n model_config = ConfigDict(strict=True, extra=\"forbid\")\n\n strength: int\n courage: int\n magic: int\n wisdom: int\n\n\nclass Character(LanceModel):\n model_config = ConfigDict(strict=True, extra=\"forbid\")\n\n id: int\n name: str\n role: str\n description: str\n stats: Stats\n image_filename: str\n image: bytes\n"; diff --git a/docs/web/snippets/connection.mdx b/docs/web/snippets/connection.mdx new file mode 100644 index 000000000..c7d8a187d --- /dev/null +++ b/docs/web/snippets/connection.mdx @@ -0,0 +1,36 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyConnect = "import lancedb\n\nuri = \"ex_lancedb\"\ndb = lancedb.connect(uri)\n"; + +export const PyConnectAsync = "import lancedb\n\nuri = \"ex_lancedb\"\nasync_db = await lancedb.connect_async(uri)\n"; + +export const PyConnectEnterpriseQuickstart = "uri = \"db://your-database-uri\"\napi_key = \"your-api-key\"\nregion = \"us-east-1\"\nhost_override = \"https://your-enterprise-endpoint.com\"\n\ndb = lancedb.connect(\n uri=uri,\n api_key=api_key,\n region=region,\n host_override=host_override,\n)\n"; + +export const PyConnectObjectStorage = "import lancedb\n\nuri = \"s3://your-bucket/path\"\n# You can also use \"gs://your-bucket/path\" or \"az://your-container/path\".\ndb = lancedb.connect(uri)\n"; + +export const PyConnectObjectStorageAsync = "import lancedb\n\nuri = \"s3://your-bucket/path\"\n# You can also use \"gs://your-bucket/path\" or \"az://your-container/path\".\nasync_db = await lancedb.connect_async(uri)\n"; + +export const PyNamespaceAdminOps = "import lancedb\n\ndb = lancedb.connect_namespace(\"dir\", {\"root\": \"./local_lancedb\"})\nnamespace = [\"prod\", \"search\"]\n\ndb.create_namespace([\"prod\"])\ndb.create_namespace([\"prod\", \"search\"])\n\nchild_namespaces = db.list_namespaces(namespace_path=[\"prod\"]).namespaces\nprint(f\"Child namespaces under {namespace}: {child_namespaces}\")\n# Child namespaces under ['prod', 'search']: ['search']\n\nmetadata = db.describe_namespace([\"prod\", \"search\"])\nprint(f\"Metadata for namespace {namespace}: {metadata}\")\n# Metadata for namespace ['prod', 'search']: properties=None\n\ndb.drop_namespace([\"prod\", \"search\"], mode=\"skip\")\ndb.drop_namespace([\"prod\"], mode=\"skip\")\n"; + +export const PyNamespaceTableOps = "import lancedb\n\ndb = lancedb.connect_namespace(\"dir\", {\"root\": \"./local_lancedb\"})\n\n# Create namespace tree: prod/search\ndb.create_namespace([\"prod\"], mode=\"exist_ok\")\ndb.create_namespace([\"prod\", \"search\"], mode=\"exist_ok\")\ndb.create_namespace([\"prod\", \"recommendations\"], mode=\"exist_ok\")\n\ndb.create_table(\n \"user\",\n data=[{\"id\": 1, \"vector\": [0.1, 0.2], \"name\": \"alice\"}],\n namespace_path=[\"prod\", \"search\"],\n mode=\"create\", # use \"overwrite\" only if you want to replace existing table\n)\n\ndb.create_table(\n \"user\",\n data=[{\"id\": 2, \"vector\": [0.3, 0.4], \"name\": \"bob\"}],\n namespace_path=[\"prod\", \"recommendations\"],\n mode=\"create\", # use \"overwrite\" only if you want to replace existing table\n)\n\n# Verify\nprint(db.list_namespaces()) # ['prod']\nprint(db.list_namespaces(namespace_path=[\"prod\"])) # ['recommendations', 'search']\nprint(db.list_tables(namespace_path=[\"prod\", \"search\"])) # ['user']\nprint(db.list_tables(namespace_path=[\"prod\", \"recommendations\"])) # ['user']\n"; + +export const TsConnect = "import * as lancedb from \"@lancedb/lancedb\";\n\nasync function connectExample(uri: string) {\n const db = await lancedb.connect(uri);\n return db;\n}\n"; + +export const TsConnectEnterpriseQuickstart = "const uri = \"db://your-database-uri\";\nconst apiKey = \"your-api-key\";\nconst region = \"us-east-1\";\nconst hostOverride = \"https://your-enterprise-endpoint.com\";\n\nconst db = await lancedb.connect(uri, {\n apiKey,\n region,\n hostOverride,\n});\n"; + +export const TsConnectObjectStorage = "async function connectObjectStorageExample() {\n const uri = \"s3://your-bucket/path\";\n // You can also use \"gs://your-bucket/path\" or \"az://your-container/path\".\n const db = await lancedb.connect(uri);\n return db;\n}\n"; + +export const TsNamespaceAdminOps = "const db = await lancedb.connectNamespace(\"dir\", { root: \"./local_lancedb\" });\nconst namespace = [\"prod\", \"search\"];\n\nawait db.createNamespace([\"prod\"]);\nawait db.createNamespace([\"prod\", \"search\"]);\n\nconst childNamespaces = (await db.listNamespaces([\"prod\"])).namespaces;\nconsole.log(`Child namespaces under ${JSON.stringify(namespace)}:`, childNamespaces);\n// Child namespaces under [\"prod\",\"search\"]: [ 'search' ]\n\nconst metadata = await db.describeNamespace([\"prod\", \"search\"]);\nconsole.log(`Metadata for namespace ${JSON.stringify(namespace)}:`, metadata);\n\nawait db.dropNamespace([\"prod\", \"search\"], { mode: \"skip\" });\nawait db.dropNamespace([\"prod\"], { mode: \"skip\" });\n"; + +export const TsNamespaceTableOps = "const db = await lancedb.connectNamespace(\"dir\", { root: \"./local_lancedb\" });\n\n// Create namespace tree: prod/search and prod/recommendations\nawait db.createNamespace([\"prod\"], { mode: \"exist_ok\" });\nawait db.createNamespace([\"prod\", \"search\"], { mode: \"exist_ok\" });\nawait db.createNamespace([\"prod\", \"recommendations\"], { mode: \"exist_ok\" });\n\nawait db.createTable(\n \"user\",\n [{ id: 1, vector: [0.1, 0.2], name: \"alice\" }],\n [\"prod\", \"search\"],\n { mode: \"create\" }, // use \"overwrite\" only if you want to replace existing table\n);\n\nawait db.createTable(\n \"user\",\n [{ id: 2, vector: [0.3, 0.4], name: \"bob\" }],\n [\"prod\", \"recommendations\"],\n { mode: \"create\" },\n);\n\n// Verify\nconsole.log((await db.listNamespaces()).namespaces); // [\"prod\"]\nconsole.log((await db.listNamespaces([\"prod\"])).namespaces); // [\"recommendations\", \"search\"]\nconsole.log(await db.tableNames([\"prod\", \"search\"])); // [\"user\"]\nconsole.log(await db.tableNames([\"prod\", \"recommendations\"])); // [\"user\"]\n"; + +export const RsConnect = "async fn connect_example(uri: &str) {\n let db = connect(uri).execute().await.unwrap();\n let _ = db;\n}\n"; + +export const RsConnectEnterpriseQuickstart = "let uri = \"db://your-database-uri\";\nlet api_key = \"your-api-key\";\nlet region = \"us-east-1\";\nlet host_override = \"https://your-enterprise-endpoint.com\";\n"; + +export const RsConnectObjectStorage = "let uri = \"s3://your-bucket/path\";\n// You can also use \"gs://your-bucket/path\" or \"az://your-container/path\".\n"; + +export const RsNamespaceAdminOps = "let mut properties = std::collections::HashMap::new();\nproperties.insert(\"root\".to_string(), \"./local_lancedb\".to_string());\nlet db = lancedb::connect_namespace(\"dir\", properties).execute().await?;\nlet namespace = vec![\"prod\".to_string(), \"search\".to_string()];\n\ndb.create_namespace(lance_namespace::models::CreateNamespaceRequest {\n id: Some(vec![\"prod\".to_string()]),\n ..Default::default()\n})\n.await?;\ndb.create_namespace(lance_namespace::models::CreateNamespaceRequest {\n id: Some(namespace.clone()),\n ..Default::default()\n})\n.await?;\n\nlet child_namespaces = db\n .list_namespaces(lance_namespace::models::ListNamespacesRequest {\n id: Some(vec![\"prod\".to_string()]),\n ..Default::default()\n })\n .await?;\nprintln!(\n \"Child namespaces under {:?}: {:?}\",\n namespace, child_namespaces\n);\n// Child namespaces under [\"prod\", \"search\"]: [\"search\"]\n\ndb.drop_namespace(lance_namespace::models::DropNamespaceRequest {\n id: Some(namespace.clone()),\n ..Default::default()\n})\n.await?;\ndb.drop_namespace(lance_namespace::models::DropNamespaceRequest {\n id: Some(vec![\"prod\".to_string()]),\n ..Default::default()\n})\n.await?;\n"; + +export const RsNamespaceTableOps = "let conn = connect(uri).execute().await?;\nlet search_namespace = vec![\"prod\".to_string(), \"search\".to_string()];\nlet recommendations_namespace = vec![\"prod\".to_string(), \"recommendations\".to_string()];\n\nlet schema = std::sync::Arc::new(arrow_schema::Schema::new(vec![\n arrow_schema::Field::new(\"id\", arrow_schema::DataType::Int64, false),\n]));\n\nconn.create_empty_table(\"user\", schema.clone())\n .namespace(search_namespace.clone())\n .execute()\n .await?;\n\nconn.create_empty_table(\"user\", schema)\n .namespace(recommendations_namespace.clone())\n .execute()\n .await?;\n\nlet search_table_names = conn\n .table_names()\n .namespace(search_namespace)\n .execute()\n .await?;\nlet recommendation_table_names = conn\n .table_names()\n .namespace(recommendations_namespace)\n .execute()\n .await?;\n\nprintln!(\"{search_table_names:?}\"); // [\"user\"]\nprintln!(\"{recommendation_table_names:?}\"); // [\"user\"]\n"; + diff --git a/docs/web/snippets/custom_embedding_function.mdx b/docs/web/snippets/custom_embedding_function.mdx new file mode 100644 index 000000000..bdbb591d8 --- /dev/null +++ b/docs/web/snippets/custom_embedding_function.mdx @@ -0,0 +1,8 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const TsCallCustomFunction = "const registry = getRegistry();\n\nconst sentenceTransformer = await registry\n .get(\"sentence-transformers\")!\n .create();\n\nconst schema = LanceSchema({\n vector: sentenceTransformer.vectorField(),\n text: sentenceTransformer.sourceField(),\n});\n\nconst db = await lancedb.connect(databaseDir);\nconst table = await db.createEmptyTable(\"table\", schema, {\n mode: \"overwrite\",\n});\n\nawait table.add([{ text: \"hello\" }, { text: \"world\" }]);\n\nconst results = await table.search(\"greeting\").limit(1).toArray();\n"; + +export const TsEmbeddingImpl = "@register(\"sentence-transformers\")\nclass SentenceTransformersEmbeddings extends TextEmbeddingFunction {\n name = \"Xenova/all-miniLM-L6-v2\";\n #ndims!: number;\n extractor!: FeatureExtractionPipeline;\n\n async init() {\n this.extractor = await pipeline(\"feature-extraction\", this.name, {\n dtype: \"fp32\",\n });\n this.#ndims = await this.generateEmbeddings([\"hello\"]).then(\n (e) => e[0].length,\n );\n }\n\n ndims() {\n return this.#ndims;\n }\n\n toJSON() {\n return {\n name: this.name,\n };\n }\n async generateEmbeddings(texts: string[]) {\n const output = await this.extractor(texts, {\n pooling: \"mean\",\n normalize: true,\n });\n return output.tolist();\n }\n}\n"; + +export const TsImports = "import * as lancedb from \"@lancedb/lancedb\";\nimport {\n LanceSchema,\n TextEmbeddingFunction,\n getRegistry,\n register,\n} from \"@lancedb/lancedb/embedding\";\n"; + diff --git a/docs/web/snippets/doc_function_examples.mdx b/docs/web/snippets/doc_function_examples.mdx new file mode 100644 index 000000000..afc637e03 --- /dev/null +++ b/docs/web/snippets/doc_function_examples.mdx @@ -0,0 +1,4 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyFtsBooleanQueries = "from lancedb.query import MatchQuery\n\nintersection = MatchQuery(\"puppy\", \"text\") & MatchQuery(\"merrily\", \"text\")\nunion = MatchQuery(\"puppy\", \"text\") | MatchQuery(\"merrily\", \"text\")\n"; + diff --git a/docs/web/snippets/doc_storage_examples.mdx b/docs/web/snippets/doc_storage_examples.mdx new file mode 100644 index 000000000..47e2da787 --- /dev/null +++ b/docs/web/snippets/doc_storage_examples.mdx @@ -0,0 +1,4 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyStorageRebuildVectorIndex = "from lancedb.index import IvfFlat\n\ntable.create_index(\n \"vector\",\n config=IvfFlat(distance_type=\"l2\", num_partitions=4),\n replace=True,\n)\n"; + diff --git a/docs/web/snippets/embedding.mdx b/docs/web/snippets/embedding.mdx new file mode 100644 index 000000000..8db4472dc --- /dev/null +++ b/docs/web/snippets/embedding.mdx @@ -0,0 +1,46 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyAsyncOpenaiEmbeddings = "db = await lancedb.connect_async(uri)\nfunc = get_registry().get(\"openai\").create(name=\"text-embedding-ada-002\")\n\nclass Words(LanceModel):\n text: str = func.SourceField()\n vector: Vector(func.ndims()) = func.VectorField()\n\ntable = await db.create_table(\"words\", schema=Words, mode=\"overwrite\")\nawait table.add([{\"text\": \"hello world\"}, {\"text\": \"goodbye world\"}])\n\nquery = \"greetings\"\nactual = await (await table.search(query)).limit(1).to_pydantic(Words)[0]\nprint(actual.text)\n"; + +export const PyCreateEmbeddingFunction = "func = get_registry().get(\"openai\").create(\n name=\"text-embedding-3-small\",\n max_retries=7,\n)\n"; + +export const PyEmbeddingFunction = "from functools import cached_property\n\nfrom lancedb.embeddings import TextEmbeddingFunction, register\n\nclass MyEmbeddingModel:\n def __init__(self, model_name: str):\n self.model_name = model_name\n\n def encode(self, texts: list[str]) -> list[list[float]]:\n return [[1.0, 2.0, 3.0] for _ in texts]\n\n@register(\"my-embedder\")\nclass MyTextEmbedder(TextEmbeddingFunction):\n model_name: str = \"my-model\"\n\n def generate_embeddings(self, texts: list[str]) -> list[list[float]]:\n # Your embedding logic here\n return self._model.encode(texts)\n\n def ndims(self) -> int:\n # Return the dimensionality of the embeddings\n return len(self.generate_embeddings([\"test\"])[0])\n\n @cached_property\n def _model(self) -> MyEmbeddingModel:\n # Initialize your model once\n return MyEmbeddingModel(self.model_name)\n"; + +export const PyImports = "from lancedb.pydantic import LanceModel, Vector\nfrom lancedb.embeddings import get_registry\n"; + +export const PyManualQueryEmbeddings = "db = lancedb.connect(\"/tmp/db\")\nfunc = get_registry().get(\"openai\").create(name=\"text-embedding-ada-002\")\n\nclass Words(LanceModel):\n text: str = func.SourceField()\n vector: Vector(func.ndims()) = func.VectorField()\n\ntable = db.create_table(\"words\", schema=Words, mode=\"overwrite\")\ntable.add([{\"text\": \"hello world\"}, {\"text\": \"goodbye world\"}])\n\nquery_vector = func.generate_embeddings([\"greetings\"])[0]\n# --8<-- [start:manual_query_search]\n# query_vector is assumed to already be generated by your embedding function\nactual = table.search(query_vector).limit(1).to_pydantic(Words)[0]\nprint(actual.text)\n# --8<-- [end:manual_query_search]\n"; + +export const PyManualQuerySearch = "# query_vector is assumed to already be generated by your embedding function\nactual = table.search(query_vector).limit(1).to_pydantic(Words)[0]\nprint(actual.text)\n"; + +export const PyOpenaiEmbeddings = "db = lancedb.connect(\"/tmp/db\")\nfunc = get_registry().get(\"openai\").create(name=\"text-embedding-ada-002\")\n\nclass Words(LanceModel):\n text: str = func.SourceField()\n vector: Vector(func.ndims()) = func.VectorField()\n\ntable = db.create_table(\"words\", schema=Words, mode=\"overwrite\")\ntable.add([{\"text\": \"hello world\"}, {\"text\": \"goodbye world\"}])\n\nquery = \"greetings\"\nactual = table.search(query).limit(1).to_pydantic(Words)[0]\nprint(actual.text)\n"; + +export const PyRegisterDevice = "import torch\n\nregistry = get_registry()\nif torch.cuda.is_available():\n registry.set_var(\"device\", \"cuda\")\n\nfunc = registry.get(\"huggingface\").create(device=\"$var:device:cpu\")\n"; + +export const PyRegisterSecret = "registry = get_registry()\nregistry.set_var(\"api_key\", \"sk-...\")\n\nfunc = registry.get(\"openai\").create(api_key=\"$var:api_key\")\n"; + +export const TsCreateEmbeddingFunction = "const func = getRegistry().get(\"openai\")!.create({\n model: \"text-embedding-3-small\",\n});\n"; + +export const TsEmbeddingFunction = "const db = await lancedb.connect(databaseDir);\n\n@register(\"my_embedding\")\nclass MyEmbeddingFunction extends EmbeddingFunction {\n constructor(optionsRaw = {}) {\n super();\n const options = this.resolveVariables(optionsRaw);\n // Initialize using options\n }\n ndims() {\n return 3;\n }\n protected getSensitiveKeys(): string[] {\n return [];\n }\n embeddingDataType(): Float {\n return new Float32();\n }\n async computeQueryEmbeddings(_data: string) {\n // This is a placeholder for a real embedding function\n return [1, 2, 3];\n }\n async computeSourceEmbeddings(data: string[]) {\n // This is a placeholder for a real embedding function\n return Array.from({ length: data.length }).fill([\n 1, 2, 3,\n ]) as number[][];\n }\n}\n\nconst func = new MyEmbeddingFunction();\n\nconst data = [{ text: \"pepperoni\" }, { text: \"pineapple\" }];\n\n// Option 1: manually specify the embedding function\nconst table = await db.createTable(\"vectors\", data, {\n embeddingFunction: {\n function: func,\n sourceColumn: \"text\",\n vectorColumn: \"vector\",\n },\n mode: \"overwrite\",\n});\n\n// Option 2: provide the embedding function through a schema\n\nconst schema = LanceSchema({\n text: func.sourceField(new Utf8()),\n vector: func.vectorField(),\n});\n\nconst table2 = await db.createTable(\"vectors2\", data, {\n schema,\n mode: \"overwrite\",\n});\n"; + +export const TsImports = "import * as lancedb from \"@lancedb/lancedb\";\nimport \"@lancedb/lancedb/embedding/openai\";\nimport { LanceSchema, getRegistry, register } from \"@lancedb/lancedb/embedding\";\nimport { EmbeddingFunction } from \"@lancedb/lancedb/embedding\";\nimport { type Float, Float32, Utf8 } from \"apache-arrow\";\n"; + +export const TsManualQueryEmbeddings = "const db = await lancedb.connect(databaseDir);\nconst func = getRegistry()\n .get(\"openai\")\n ?.create({ model: \"text-embedding-ada-002\" }) as EmbeddingFunction;\n\nconst wordsSchema = LanceSchema({\n text: func.sourceField(new Utf8()),\n vector: func.vectorField(),\n});\nconst tbl = await db.createEmptyTable(\"words\", wordsSchema, {\n mode: \"overwrite\",\n});\nawait tbl.add([{ text: \"hello world\" }, { text: \"goodbye world\" }]);\n\nconst queryVector = await func.computeQueryEmbeddings(\"greetings\");\n// --8<-- [start:manual_query_search]\n// queryVector is assumed to already be generated by your embedding function\nconst actual = (await tbl.search(queryVector).limit(1).toArray())[0];\n// --8<-- [end:manual_query_search]\n"; + +export const TsManualQuerySearch = "// queryVector is assumed to already be generated by your embedding function\nconst actual = (await tbl.search(queryVector).limit(1).toArray())[0];\n"; + +export const TsOpenaiEmbeddings = "const db = await lancedb.connect(databaseDir);\nconst func = getRegistry()\n .get(\"openai\")\n ?.create({ model: \"text-embedding-ada-002\" }) as EmbeddingFunction;\n\nconst wordsSchema = LanceSchema({\n text: func.sourceField(new Utf8()),\n vector: func.vectorField(),\n});\nconst tbl = await db.createEmptyTable(\"words\", wordsSchema, {\n mode: \"overwrite\",\n});\nawait tbl.add([{ text: \"hello world\" }, { text: \"goodbye world\" }]);\n\nconst query = \"greetings\";\nconst actual = (await tbl.search(query).limit(1).toArray())[0];\n"; + +export const TsRegisterModelFallback = "const registry = getRegistry();\nregistry.setVar(\"openai_model\", \"text-embedding-3-large\");\n\nconst func = registry.get(\"openai\")!.create({\n model: \"$var:openai_model:text-embedding-3-small\",\n});\n"; + +export const TsRegisterSecret = "const registry = getRegistry();\nregistry.setVar(\"api_key\", \"sk-...\");\n\nconst func = registry.get(\"openai\")!.create({\n apiKey: \"$var:api_key\",\n});\n"; + +export const RsCreateEmbeddingFunction = "use std::sync::Arc;\n\nuse lancedb::embeddings::openai::OpenAIEmbeddingFunction;\n\nlet api_key = std::env::var(\"OPENAI_API_KEY\").expect(\"OPENAI_API_KEY is not set\");\nlet embedding = Arc::new(\n OpenAIEmbeddingFunction::new_with_model(api_key, \"text-embedding-3-small\")\n .expect(\"failed to create OpenAI embedding function\"),\n);\n"; + +export const RsEmbeddingFunction = "use std::{borrow::Cow, sync::Arc};\n\nuse arrow_array::{Array, FixedSizeListArray, Float32Array};\nuse arrow_schema::{DataType, Field, Schema};\nuse lancedb::{\n connect,\n embeddings::{EmbeddingDefinition, EmbeddingFunction},\n Result,\n};\n\n#[derive(Debug, Clone)]\nstruct MyTextEmbedder {\n dim: usize,\n}\n\nimpl EmbeddingFunction for MyTextEmbedder {\n fn name(&self) -> &str {\n \"my-embedder\"\n }\n\n fn source_type(&self) -> Result> {\n Ok(Cow::Owned(DataType::Utf8))\n }\n\n fn dest_type(&self) -> Result> {\n Ok(Cow::Owned(DataType::new_fixed_size_list(\n DataType::Float32,\n self.dim as i32,\n true,\n )))\n }\n\n fn compute_source_embeddings(&self, source: Arc) -> Result> {\n let values = Arc::new(Float32Array::from(vec![1.0f32; source.len() * self.dim]));\n let field = Arc::new(Field::new(\"item\", DataType::Float32, true));\n Ok(Arc::new(FixedSizeListArray::new(\n field,\n self.dim as i32,\n values,\n None,\n )))\n }\n\n fn compute_query_embeddings(&self, _input: Arc) -> Result> {\n unimplemented!()\n }\n}\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n let db = connect(\"./mydb\").execute().await?;\n db.embedding_registry()\n .register(\"my-embedder\", Arc::new(MyTextEmbedder { dim: 3 }))?;\n\n let schema = Arc::new(Schema::new(vec![Field::new(\"text\", DataType::Utf8, false)]));\n db.create_empty_table(\"mytable\", schema)\n .add_embedding(EmbeddingDefinition::new(\n \"text\",\n \"my-embedder\",\n Some(\"vector\"),\n ))?\n .execute()\n .await?;\n\n Ok(())\n}\n"; + +export const RsManualQueryEmbeddings = "use std::{iter::once, sync::Arc};\n\nuse arrow_array::{record_batch, StringArray};\nuse arrow_schema::{DataType, Field, Schema};\nuse futures::StreamExt;\nuse lancedb::{\n connect,\n embeddings::{openai::OpenAIEmbeddingFunction, EmbeddingDefinition, EmbeddingFunction},\n query::{ExecutableQuery, QueryBase},\n Result,\n};\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n let db = connect(\"./mydb\").execute().await?;\n let api_key = std::env::var(\"OPENAI_API_KEY\").expect(\"OPENAI_API_KEY is not set\");\n let embedding = Arc::new(OpenAIEmbeddingFunction::new_with_model(\n api_key,\n \"text-embedding-3-large\",\n )?);\n db.embedding_registry().register(\"openai\", embedding.clone())?;\n\n let schema = Arc::new(Schema::new(vec![Field::new(\"text\", DataType::Utf8, false)]));\n let table = db\n .create_empty_table(\"mytable\", schema)\n .add_embedding(EmbeddingDefinition::new(\"text\", \"openai\", Some(\"vector\")))?\n .execute()\n .await?;\n\n table\n .add(record_batch!((\"text\", Utf8, [\"This is a test.\", \"Another example.\"]))?)\n .execute()\n .await?;\n\n // Manually generate embeddings for the query (Enterprise path)\n let query = Arc::new(StringArray::from_iter_values(once(\"test example\")));\n let query_vector = embedding.compute_query_embeddings(query)?;\n // --8<-- [start:manual_query_search]\n // query_vector is assumed to already be generated by your embedding function\n let mut results = table.vector_search(query_vector)?.limit(5).execute().await?;\n\n while let Some(batch) = results.next().await {\n println!(\"{:?}\", batch?);\n }\n // --8<-- [end:manual_query_search]\n\n Ok(())\n}\n"; + +export const RsManualQuerySearch = "// query_vector is assumed to already be generated by your embedding function\nlet mut results = table.vector_search(query_vector)?.limit(5).execute().await?;\n\nwhile let Some(batch) = results.next().await {\n println!(\"{:?}\", batch?);\n}\n"; + +export const RsOpenaiEmbeddings = "use std::{iter::once, sync::Arc};\n\nuse arrow_array::{record_batch, StringArray};\nuse arrow_schema::{DataType, Field, Schema};\nuse futures::StreamExt;\nuse lancedb::{\n connect,\n embeddings::{openai::OpenAIEmbeddingFunction, EmbeddingDefinition, EmbeddingFunction},\n query::{ExecutableQuery, QueryBase},\n Result,\n};\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n let db = connect(\"./mydb\").execute().await?;\n let api_key = std::env::var(\"OPENAI_API_KEY\").expect(\"OPENAI_API_KEY is not set\");\n let embedding = Arc::new(OpenAIEmbeddingFunction::new_with_model(\n api_key,\n \"text-embedding-3-large\",\n )?);\n\n db.embedding_registry().register(\"openai\", embedding.clone())?;\n\n let schema = Arc::new(Schema::new(vec![Field::new(\"text\", DataType::Utf8, false)]));\n let table = db\n .create_empty_table(\"mytable\", schema)\n .add_embedding(EmbeddingDefinition::new(\"text\", \"openai\", Some(\"vector\")))?\n .execute()\n .await?;\n\n table\n .add(record_batch!((\"text\", Utf8, [\"This is a test.\", \"Another example.\"]))?)\n .execute()\n .await?;\n\n let query = Arc::new(StringArray::from_iter_values(once(\"test example\")));\n let query_vector = embedding.compute_query_embeddings(query)?;\n let mut results = table.vector_search(query_vector)?.limit(5).execute().await?;\n\n while let Some(batch) = results.next().await {\n println!(\"{:?}\", batch?);\n }\n\n Ok(())\n}\n"; + diff --git a/docs/web/snippets/filtering.mdx b/docs/web/snippets/filtering.mdx new file mode 100644 index 000000000..f4fc0cae8 --- /dev/null +++ b/docs/web/snippets/filtering.mdx @@ -0,0 +1,8 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const TsSearch = "const _result = await tbl\n .search(Array(1536).fill(0.5))\n .limit(1)\n .where(\"id = 10\")\n .toArray();\n"; + +export const TsSqlSearch = "await tbl.query().where(\"id = 10\").limit(10).toArray();\n"; + +export const TsVecSearch = "const result = await (\n tbl.search(Array(1536).fill(0)) as lancedb.VectorQuery\n)\n .where(\"(item IN ('item 0', 'item 2')) AND (id > 10)\")\n .postfilter()\n .toArray();\n"; + diff --git a/docs/web/snippets/full_text_search.mdx b/docs/web/snippets/full_text_search.mdx new file mode 100644 index 000000000..7d71409ee --- /dev/null +++ b/docs/web/snippets/full_text_search.mdx @@ -0,0 +1,4 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const TsFullTextSearch = "const result = await tbl\n .query()\n .nearestToText(\"apple\")\n .select([\"id\", \"doc\"])\n .limit(10)\n .toArray();\nexpect(result.length).toBe(10);\n"; + diff --git a/docs/web/snippets/function.mdx b/docs/web/snippets/function.mdx new file mode 100644 index 000000000..728810e2a --- /dev/null +++ b/docs/web/snippets/function.mdx @@ -0,0 +1,18 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyFunctionApply = "from lancedb import col\n\ntable.add_columns({\"doubled\": version(value=col(\"value\"))})\n"; + +export const PyFunctionConnect = "import os\n\nimport lancedb\n\ndb = lancedb.connect(\n uri=os.environ[\"LANCEDB_URI\"], # db://your-database\n api_key=os.environ[\"LANCEDB_API_KEY\"],\n region=os.environ.get(\"LANCEDB_REGION\", \"us-east-1\"),\n host_override=os.environ[\"LANCEDB_HOST_OVERRIDE\"],\n)\n"; + +export const PyFunctionCreateTable = "import pyarrow as pa\n\nschema = pa.schema([pa.field(\"value\", pa.float64(), nullable=False)])\ntable = db.create_table(\n \"function_demo\",\n [{\"value\": 1.0}, {\"value\": 2.0}, {\"value\": 3.0}],\n schema=schema,\n)\n"; + +export const PyFunctionDefine = "from lancedb import udf\n\n\n@udf\ndef double(value: float) -> float:\n return value * 2\n"; + +export const PyFunctionInspect = "rows = table.search().select([\"value\", \"doubled\"]).to_list()\nprint(sorted((row[\"value\"], row[\"doubled\"]) for row in rows))\n# [(1.0, 2.0), (2.0, 4.0), (3.0, 6.0)]\n"; + +export const PyFunctionLocalCall = "print(double(1.5))\n# 3.0\n"; + +export const PyFunctionRefresh = "refresh = table.refresh_column_async(\"doubled\")\nresult = refresh.wait()\nprint(result.rows_assigned)\n# 3\n"; + +export const PyFunctionRegister = "job = db.create_function_async(double)\nversion = job.wait()\n"; + diff --git a/docs/web/snippets/indexing.mdx b/docs/web/snippets/indexing.mdx new file mode 100644 index 000000000..34e9b72f7 --- /dev/null +++ b/docs/web/snippets/indexing.mdx @@ -0,0 +1,74 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyFtsIndexAsync = "import asyncio\n\nimport lancedb\nimport polars as pl\nfrom lancedb.index import FTS\n\ndata = pl.DataFrame(\n {\n \"id\": [1, 2],\n \"text\": [\n \"His first language is spanish\",\n \"Her first language is english\",\n ],\n }\n)\n\nasync def main(data: pl.DataFrame):\n uri = \"ex_lancedb\"\n db = await lancedb.connect_async(uri)\n tbl = await db.create_table(\"my_text\", data=data, mode=\"overwrite\")\n\n await tbl.create_index(\"text\", config=FTS(language=\"English\"))\n\n response = await tbl.search(\"spanish\", query_type=\"fts\")\n result = await response.limit(1).to_polars()\n print(result)\n return result\n\nif __name__ == \"__main__\":\n asyncio.run(main(data))\n"; + +export const PyFtsIndexCreate = "table_name = \"fts-index-create\"\ntable = db.open_table(table_name)\ntable.create_fts_index(\"text\")\n"; + +export const PyFtsIndexNested = "from lancedb.query import MatchQuery, PhraseQuery\n\ntable = db.open_table(\"fts-index-nested\")\n\n# Index a text leaf inside a struct column using a dotted path.\ntable.create_fts_index(\"payload.text\", with_position=True)\n\n# The same dotted path works in MatchQuery and PhraseQuery.\nmatches = (\n table.search(MatchQuery(\"puppy\", \"payload.text\")).limit(5).to_list()\n)\nphrases = (\n table.search(PhraseQuery(\"puppy runs\", \"payload.text\"))\n .limit(5)\n .to_list()\n)\n"; + +export const PyFtsIndexWait = "table_name = \"fts-index-wait\"\n\ntable = db.open_table(table_name)\ntable.create_fts_index(\"text\")\n\nindex_name = \"text_idx\"\ntable.wait_for_index([index_name])\n"; + +export const PyGpuIndexCuda = "table.create_index(\n num_partitions=256,\n num_sub_vectors=96,\n accelerator=\"cuda\",\n)\n"; + +export const PyGpuIndexMps = "table.create_index(\n num_partitions=256,\n num_sub_vectors=96,\n accelerator=\"mps\",\n)\n"; + +export const PyQuantizationCustomParams = "table.create_index(\n index_type=\"IVF_RQ\",\n num_bits=2,\n max_iterations=100,\n sample_rate=512,\n)\n"; + +export const PyReindexingIncremental = "table = db.open_table(\"reindexing_incremental\")\ntable.add([{\"vector\": [3.1, 4.1], \"text\": \"Frodo was a happy puppy\"}])\ntable.optimize()\n"; + +export const PyScalarIndexBuild = "tbl = db.open_table(\"scalar_index_build\")\ntbl.create_scalar_index(\"book_id\")\ntbl.create_scalar_index(\"publisher\", index_type=\"BITMAP\")\n"; + +export const PyScalarIndexFilter = "table = db.open_table(\"books\")\nresult = table.search().where(\"book_id = 2\").limit(10).to_pandas()\n"; + +export const PyScalarIndexNestedFields = "import pyarrow as pa\nfrom lancedb.index import BTree\n\nmetadata_type = pa.struct(\n [\n pa.field(\"user_id\", pa.int32()),\n pa.field(\"user.id\", pa.int32()),\n ]\n)\ndata = pa.Table.from_arrays(\n [\n pa.array([1, 2, 3], type=pa.int32()),\n pa.array(\n [\n {\"user_id\": 10, \"user.id\": 100},\n {\"user_id\": 20, \"user.id\": 200},\n {\"user_id\": 30, \"user.id\": 300},\n ],\n type=metadata_type,\n ),\n ],\n names=[\"user_id\", \"metadata\"],\n)\ntable = await db.create_table(\"nested_scalar_index\", data)\n\n# Index a nested struct field.\nawait table.create_index(\n \"metadata.user_id\", config=BTree(), name=\"nested_user_id_idx\"\n)\n\n# Escape literal dots inside a segment with backticks.\nawait table.create_index(\n \"metadata.`user.id`\", config=BTree(), name=\"escaped_user_id_idx\"\n)\n\n# `columns` is returned as the canonical path you passed in.\nfor index in await table.list_indices():\n print(index.name, index.columns)\n# nested_user_id_idx ['metadata.user_id']\n# escaped_user_id_idx ['metadata.`user.id`']\n"; + +export const PyScalarIndexOptimize = "table.add([{\"vector\": [7, 8], \"book_id\": 4}])\ntable.optimize()\n"; + +export const PyScalarIndexPrefilter = "table = db.open_table(\"book_with_embeddings\")\ntable.search([1.2] * 2).where(\"book_id != 3\").limit(10).to_pandas()\n"; + +export const PyScalarIndexUuidData = "def generate_random_names():\n base_names = [\"Alice\", \"Bob\", \"Carla\", \"David\", \"Eve\", \"Frank\", \"Grace\"]\n letter = random.choice(string.ascii_uppercase)\n return f\"{random.choice(base_names)} {letter}.\"\n\ndef generate_uuids(num_items):\n return [uuid.uuid4().bytes for _ in range(num_items)]\n\n# Generate some UUIDs and random names\nn = 7\nuuids = generate_uuids(n)\nnames = [generate_random_names() for _ in range(n)]\n"; + +export const PyScalarIndexUuidTable = "table_name = \"index-on-uuid\"\n\nuuid_array = pa.array(uuids, pa.uuid())\nname_array = pa.array(names, pa.string())\nschema = pa.schema(\n [\n pa.field(\"id\", pa.uuid()),\n pa.field(\"name\", pa.string()),\n ]\n)\ndata_table = pa.Table.from_arrays([uuid_array, name_array], schema=schema)\ntable = db.create_table(table_name, data=data_table, mode=\"overwrite\")\n"; + +export const PyScalarIndexUuidType = "import pyarrow as pa\n"; + +export const PyScalarIndexUuidUpsert = "new_users = [\n {\"id\": uuid.uuid4().bytes, \"name\": \"Hannah D.\"},\n {\"id\": uuid.uuid4().bytes, \"name\": \"Ian B.\"},\n]\n# Insert or update using the UUID index\ntable.merge_insert(\n \"id\"\n).when_matched_update_all().when_not_matched_insert_all().execute(new_users)\n"; + +export const PyScalarIndexUuidWait = "index_name = \"id_idx\"\ntable.create_scalar_index(\"id\")\ntable.wait_for_index([index_name])\n"; + +export const PyScalarIndexWait = "index_name = \"label_idx\"\ntable.wait_for_index([index_name])\n"; + +export const PyVectorIndexAsyncConfig = "import lancedb\nimport numpy as np\nfrom lancedb.index import IvfPq\n\nasync def main():\n data = [\n {\"id\": i, \"vector\": np.random.random(8).astype(np.float32).tolist()}\n for i in range(512)\n ]\n\n db = await lancedb.connect_async(\"ex_lancedb\")\n table = await db.create_table(\n \"vector_index_async\", data=data, mode=\"overwrite\"\n )\n\n await table.create_index(\n \"vector\",\n config=IvfPq(\n distance_type=\"cosine\",\n num_partitions=16,\n num_sub_vectors=4,\n ),\n )\n return await table.list_indices()\n"; + +export const PyVectorIndexBinaryAddData = "table.add(data)\n"; + +export const PyVectorIndexBinaryBuildIndex = "table.create_index(\n metric=\"hamming\",\n vector_column_name=\"vector\",\n index_type=\"IVF_FLAT\",\n)\n"; + +export const PyVectorIndexBinarySchema = "table = tmp_db.create_table(table_name, schema=schema, mode=\"overwrite\")\n"; + +export const PyVectorIndexBinarySearch = "query = np.random.randint(0, 2, size=ndim)\nquery = np.packbits(query)\ndf = table.search(query).metric(\"hamming\").limit(10).to_pandas()\ndf.vector = df.vector.apply(np.unpackbits)\n"; + +export const PyVectorIndexBuildHnsw = "table.create_index(index_type=\"IVF_HNSW_SQ\")\n"; + +export const PyVectorIndexBuildIvf = "table_name = \"vector-index-build-ivf\"\ntable = db.open_table(table_name)\ntable.create_index(\n metric=\"cosine\",\n vector_column_name=\"keywords_embeddings\",\n)\n"; + +export const PyVectorIndexBypassRecall = "query = np.random.random(128)\nk = 10\n\n# Ground truth: flat (exhaustive) scan, ignoring the ANN index.\ntruth = set(table.search(query).bypass_vector_index().limit(k).to_pandas()[\"id\"])\n\n# ANN results with the current nprobes setting.\nann = set(table.search(query).nprobes(20).limit(k).to_pandas()[\"id\"])\n\nrecall_at_k = len(truth & ann) / k\n"; + +export const PyVectorIndexCheckStatus = "index_name = \"keywords_embeddings_idx\"\ntable.wait_for_index([index_name])\nprint(table.index_stats(index_name))\n"; + +export const PyVectorIndexConfigureIvf = "table.create_index(metric=\"l2\", num_partitions=16, num_sub_vectors=4)\n"; + +export const PyVectorIndexCustomName = "# Override the default `{column}_idx` convention by passing `name=...`.\ntable.create_index(\n metric=\"cosine\",\n vector_column_name=\"keywords_embeddings\",\n name=\"my_custom_index\",\n)\ntable.wait_for_index([\"my_custom_index\"])\nprint(table.index_stats(\"my_custom_index\"))\n"; + +export const PyVectorIndexDistanceRange = "# Only return results whose distance falls within [0.0, 0.5).\n# Useful for near-duplicate detection or thresholded similarity search.\n(\n table.search(np.random.random(128))\n .distance_range(lower_bound=0.0, upper_bound=0.5)\n .limit(10)\n .to_pandas()\n)\n"; + +export const PyVectorIndexNestedField = "# The vector column `embedding` is nested inside the `image` struct.\n# Pass its full dotted path as `vector_column_name`; the same path is used\n# at query time and is what `list_indices()` reports under `columns`.\ntable.create_index(\n vector_column_name=\"image.embedding\",\n num_partitions=1,\n num_sub_vectors=1,\n name=\"image_embedding_idx\",\n)\n\nresults = (\n table.search([0.0, 1.0], vector_column_name=\"image.embedding\")\n .limit(1)\n .to_list()\n)\n"; + +export const PyVectorIndexNprobes = "# Always scan 10 partitions; scan up to 50 only if the initial pass\n# returns fewer than `limit` results (common with narrow filters).\n(\n table.search(np.random.random(128))\n .minimum_nprobes(10)\n .maximum_nprobes(50)\n .where(\"id > 100\")\n .limit(5)\n .to_pandas()\n)\n"; + +export const PyVectorIndexQueryHnsw = "tbl = table\ntbl.search(np.random.random((16))).limit(2).to_pandas()\n"; + +export const PyVectorIndexQueryIvf = "tbl = table\ntbl.search(np.random.random((1536))).limit(2).nprobes(20).refine_factor(\n 10\n).to_pandas()\n"; + +export const PyVectorIndexSetup = "table_name = \"vector-index-tbl\"\ntable = db.open_table(table_name)\n"; + diff --git a/docs/web/snippets/integrations.mdx b/docs/web/snippets/integrations.mdx new file mode 100644 index 000000000..22bbde338 --- /dev/null +++ b/docs/web/snippets/integrations.mdx @@ -0,0 +1,182 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyEmbeddingAwsUsage = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nimport pandas as pd\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\n\nmodel = get_registry().get(\"bedrock-text\").create()\n\nclass TextModel(LanceModel):\n text: str = model.SourceField()\n vector: Vector(model.ndims()) = model.VectorField()\n\ndf = pd.DataFrame({\"text\": [\"hello world\", \"goodbye world\"]})\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"bedrock-demo\"))\ntbl = db.create_table(\"test\", schema=TextModel, mode=\"overwrite\")\n\ntbl.add(df)\nrs = tbl.search(\"hello\").limit(1).to_pandas()\nprint(rs.head())\n"; + +export const PyEmbeddingCohereUsage = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nfrom lancedb.embeddings import EmbeddingFunctionRegistry\nfrom lancedb.pydantic import LanceModel, Vector\n\ncohere = (\n EmbeddingFunctionRegistry.get_instance()\n .get(\"cohere\")\n .create(name=\"embed-multilingual-v2.0\")\n)\n\nclass TextModel(LanceModel):\n text: str = cohere.SourceField()\n vector: Vector(cohere.ndims()) = cohere.VectorField()\n\ndata = [{\"text\": \"hello world\"}, {\"text\": \"goodbye world\"}]\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"cohere-demo\"))\ntbl = db.create_table(\"test\", schema=TextModel, mode=\"overwrite\")\ntbl.add(data)\n"; + +export const PyEmbeddingColpaliSetup = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nimport pandas as pd\nimport requests\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, MultiVector\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"colpali-demo\"))\nfunc = get_registry().get(\"colpali\").create()\n\nclass Images(LanceModel):\n label: str\n image_uri: str = func.SourceField()\n image_bytes: bytes = func.SourceField()\n vector: MultiVector(func.ndims()) = func.VectorField()\n vec_from_bytes: MultiVector(func.ndims()) = func.VectorField()\n\ntable = db.create_table(\"images\", schema=Images)\nlabels = [\"cat\", \"dog\", \"horse\"]\nuris = [\n \"http://farm1.staticflickr.com/53/167798175_7c7845bbbd_z.jpg\",\n \"http://farm9.staticflickr.com/8387/8602747737_2e5c2a45d4_z.jpg\",\n \"http://farm9.staticflickr.com/8216/8434969557_d37882c42d_z.jpg\",\n]\nimage_bytes = [requests.get(uri).content for uri in uris]\ntable.add(\n pd.DataFrame({\"label\": labels, \"image_uri\": uris, \"image_bytes\": image_bytes})\n)\n"; + +export const PyEmbeddingColpaliTextSearch = "actual = (\n table.search(\"a furry pet\", vector_column_name=\"vector\")\n .limit(1)\n .to_pydantic(Images)[0]\n)\nprint(actual.label)\n"; + +export const PyEmbeddingGeminiUsage = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nimport pandas as pd\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\n\nmodel = get_registry().get(\"gemini-text\").create()\n\nclass TextModel(LanceModel):\n text: str = model.SourceField()\n vector: Vector(model.ndims()) = model.VectorField()\n\ndf = pd.DataFrame({\"text\": [\"hello world\", \"goodbye world\"]})\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"gemini-demo\"))\ntbl = db.create_table(\"test\", schema=TextModel, mode=\"overwrite\")\n\ntbl.add(df)\nrs = tbl.search(\"hello\").limit(1).to_pandas()\nprint(rs.head())\n"; + +export const PyEmbeddingHuggingfaceUsage = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nimport pandas as pd\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"huggingface-demo\"))\nmodel = get_registry().get(\"huggingface\").create(name=\"facebook/bart-base\")\n\nclass Words(LanceModel):\n text: str = model.SourceField()\n vector: Vector(model.ndims()) = model.VectorField()\n\ndf = pd.DataFrame({\"text\": [\"hi hello sayonara\", \"goodbye world\"]})\ntable = db.create_table(\"greets\", schema=Words)\ntable.add(df)\nquery = \"old greeting\"\nactual = table.search(query).limit(1).to_pydantic(Words)[0]\nprint(actual.text)\n"; + +export const PyEmbeddingIbmUsage = "import os\nimport tempfile\nfrom pathlib import Path\n\nimport lancedb\nfrom lancedb.embeddings import EmbeddingFunctionRegistry\nfrom lancedb.pydantic import LanceModel, Vector\n\nwatsonx_embed = (\n EmbeddingFunctionRegistry.get_instance()\n .get(\"watsonx\")\n .create(\n name=\"ibm/slate-125m-english-rtrvr\",\n api_key=os.environ.get(\"WATSONX_API_KEY\"),\n project_id=os.environ.get(\"WATSONX_PROJECT_ID\"),\n )\n)\n\nclass TextModel(LanceModel):\n text: str = watsonx_embed.SourceField()\n vector: Vector(watsonx_embed.ndims()) = watsonx_embed.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"watsonx-demo\"))\ntbl = db.create_table(\"watsonx_test\", schema=TextModel, mode=\"overwrite\")\ntbl.add(data)\n\nrs = tbl.search(\"hello\").limit(1).to_pandas()\nprint(rs.head())\n"; + +export const PyEmbeddingImagebindAudioSearch = "query_audio = \"./assets/car_audio2.wav\"\nactual = table.search(query_audio).limit(1).to_pydantic(ImageBindModel)[0]\nprint(actual.text == \"car\")\n"; + +export const PyEmbeddingImagebindImageSearch = "query_image = \"./assets/dog_image2.jpg\"\nactual = table.search(query_image).limit(1).to_pydantic(ImageBindModel)[0]\nprint(actual.text == \"dog\")\n"; + +export const PyEmbeddingImagebindSetup = "import lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\n\ndb = lancedb.connect(\"/tmp/imagebind-db\")\nfunc = get_registry().get(\"imagebind\").create()\n\nclass ImageBindModel(LanceModel):\n text: str\n image_uri: str = func.SourceField()\n audio_path: str\n vector: Vector(func.ndims()) = func.VectorField()\n\ntext_list = [\"A dog.\", \"A car\", \"A bird\"]\nimage_paths = [\n \"./assets/dog_image.jpg\",\n \"./assets/car_image.jpg\",\n \"./assets/bird_image.jpg\",\n]\naudio_paths = [\n \"./assets/dog_audio.wav\",\n \"./assets/car_audio.wav\",\n \"./assets/bird_audio.wav\",\n]\n\ninputs = [\n {\"text\": a, \"audio_path\": b, \"image_uri\": c}\n for a, b, c in zip(text_list, audio_paths, image_paths)\n]\n\ntable = db.create_table(\"img_bind\", schema=ImageBindModel)\ntable.add(inputs)\n"; + +export const PyEmbeddingImagebindTextSearch = "query = \"an animal which flies and tweets\"\nactual = table.search(query).limit(1).to_pydantic(ImageBindModel)[0]\nprint(actual.text == \"bird\")\n"; + +export const PyEmbeddingInstructorUsage = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\n\ninstructor = (\n get_registry()\n .get(\"instructor\")\n .create(\n source_instruction=\"represent the document for retrieval\",\n query_instruction=\"represent the document for retrieving the most similar documents\",\n )\n)\n\nclass Schema(LanceModel):\n vector: Vector(instructor.ndims()) = instructor.VectorField()\n text: str = instructor.SourceField()\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"instructor-demo\"))\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\n\ntexts = [\n {\n \"text\": \"Capitalism has been dominant in the Western world since the end of feudalism.\"\n },\n {\n \"text\": \"The disparate impact theory is especially controversial under the Fair Housing Act.\"\n },\n {\n \"text\": \"Disparate impact in United States labor law refers to practices in employment.\"\n },\n]\n\ntbl.add(texts)\n"; + +export const PyEmbeddingJinaMultimodal = "import os\nimport tempfile\nfrom pathlib import Path\n\nimport lancedb\nimport pandas as pd\nimport requests\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\n\nos.environ[\"JINA_API_KEY\"] = os.environ.get(\"JINA_API_KEY\", \"jina_*\")\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"jina-images\"))\nfunc = get_registry().get(\"jina\").create()\n\nclass Images(LanceModel):\n label: str\n image_uri: str = func.SourceField()\n image_bytes: bytes = func.SourceField()\n vector: Vector(func.ndims()) = func.VectorField()\n vec_from_bytes: Vector(func.ndims()) = func.VectorField()\n\ntable = db.create_table(\"images\", schema=Images)\nlabels = [\"cat\", \"cat\", \"dog\", \"dog\", \"horse\", \"horse\"]\nuris = [\n \"http://farm1.staticflickr.com/53/167798175_7c7845bbbd_z.jpg\",\n \"http://farm1.staticflickr.com/134/332220238_da527d8140_z.jpg\",\n \"http://farm9.staticflickr.com/8387/8602747737_2e5c2a45d4_z.jpg\",\n \"http://farm5.staticflickr.com/4092/5017326486_1f46057f5f_z.jpg\",\n \"http://farm9.staticflickr.com/8216/8434969557_d37882c42d_z.jpg\",\n \"http://farm6.staticflickr.com/5142/5835678453_4f3a4edb45_z.jpg\",\n]\nimage_bytes = [requests.get(uri).content for uri in uris]\ntable.add(\n pd.DataFrame({\"label\": labels, \"image_uri\": uris, \"image_bytes\": image_bytes})\n)\n"; + +export const PyEmbeddingJinaText = "import os\nimport tempfile\nfrom pathlib import Path\n\nimport lancedb\nfrom lancedb.embeddings import EmbeddingFunctionRegistry\nfrom lancedb.pydantic import LanceModel, Vector\n\nos.environ[\"JINA_API_KEY\"] = os.environ[\"JINA_API_KEY\"]\n\njina_embed = (\n EmbeddingFunctionRegistry.get_instance()\n .get(\"jina\")\n .create(name=\"jina-embeddings-v2-base-en\")\n)\n\nclass TextModel(LanceModel):\n text: str = jina_embed.SourceField()\n vector: Vector(jina_embed.ndims()) = jina_embed.VectorField()\n\ndata = [{\"text\": \"hello world\"}, {\"text\": \"goodbye world\"}]\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"jina-text\"))\ntbl = db.create_table(\"test\", schema=TextModel, mode=\"overwrite\")\n\ntbl.add(data)\n"; + +export const PyEmbeddingOllamaUsage = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"ollama-demo\"))\nfunc = get_registry().get(\"ollama\").create(name=\"nomic-embed-text\")\n\nclass Words(LanceModel):\n text: str = func.SourceField()\n vector: Vector(func.ndims()) = func.VectorField()\n\ntable = db.create_table(\"words\", schema=Words, mode=\"overwrite\")\ntable.add(\n [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n ]\n)\n\nquery = \"greetings\"\nactual = table.search(query).limit(1).to_pydantic(Words)[0]\nprint(actual.text)\n"; + +export const PyEmbeddingOpenaiBasic = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\n\ndb_path = Path(tempfile.mkdtemp()) / \"openai-embeddings\"\ndb = lancedb.connect(str(db_path))\nfunc = get_registry().get(\"openai\").create(name=\"text-embedding-ada-002\")\n\nclass Words(LanceModel):\n text: str = func.SourceField()\n vector: Vector(func.ndims()) = func.VectorField()\n\ntable = db.create_table(\"words\", schema=Words, mode=\"overwrite\")\ntable.add(\n [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n ]\n)\n\nquery = \"greetings\"\nactual = table.search(query).limit(1).to_pydantic(Words)[0]\nprint(actual.text)\n"; + +export const PyEmbeddingOpenclipImageSearch = "import io\n\nfrom PIL import Image\n\nquery_image_uri = \"http://farm1.staticflickr.com/200/467715466_ed4a31801f_z.jpg\"\nimage_bytes = requests.get(query_image_uri).content\nquery_image = Image.open(io.BytesIO(image_bytes))\nactual = table.search(query_image).limit(1).to_pydantic(Images)[0]\nprint(actual.label == \"dog\")\n\nother = (\n table.search(query_image, vector_column_name=\"vec_from_bytes\")\n .limit(1)\n .to_pydantic(Images)[0]\n)\nprint(other.label)\n"; + +export const PyEmbeddingOpenclipSetup = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nimport pandas as pd\nimport requests\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"openclip-demo\"))\nfunc = get_registry().get(\"open-clip\").create()\n\nclass Images(LanceModel):\n label: str\n image_uri: str = func.SourceField()\n image_bytes: bytes = func.SourceField()\n vector: Vector(func.ndims()) = func.VectorField()\n vec_from_bytes: Vector(func.ndims()) = func.VectorField()\n\ntable = db.create_table(\"images\", schema=Images)\nlabels = [\"cat\", \"cat\", \"dog\", \"dog\", \"horse\", \"horse\"]\nuris = [\n \"http://farm1.staticflickr.com/53/167798175_7c7845bbbd_z.jpg\",\n \"http://farm1.staticflickr.com/134/332220238_da527d8140_z.jpg\",\n \"http://farm9.staticflickr.com/8387/8602747737_2e5c2a45d4_z.jpg\",\n \"http://farm5.staticflickr.com/4092/5017326486_1f46057f5f_z.jpg\",\n \"http://farm9.staticflickr.com/8216/8434969557_d37882c42d_z.jpg\",\n \"http://farm6.staticflickr.com/5142/5835678453_4f3a4edb45_z.jpg\",\n]\nimage_bytes = [requests.get(uri).content for uri in uris]\ntable.add(\n pd.DataFrame({\"label\": labels, \"image_uri\": uris, \"image_bytes\": image_bytes})\n)\n"; + +export const PyEmbeddingOpenclipTextSearch = "actual = table.search(\"man's best friend\").limit(1).to_pydantic(Images)[0]\nprint(actual.label)\n\nfrombytes = (\n table.search(\"man's best friend\", vector_column_name=\"vec_from_bytes\")\n .limit(1)\n .to_pydantic(Images)[0]\n)\nprint(frombytes.label)\n"; + +export const PyEmbeddingSentenceTransformersBaai = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"sentence-transformers\"))\nmodel = (\n get_registry()\n .get(\"sentence-transformers\")\n .create(name=\"BAAI/bge-small-en-v1.5\", device=\"cpu\")\n)\n\nclass Words(LanceModel):\n text: str = model.SourceField()\n vector: Vector(model.ndims()) = model.VectorField()\n\ntable = db.create_table(\"words\", schema=Words)\ntable.add(\n [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n ]\n)\n\nquery = \"greetings\"\nactual = table.search(query).limit(1).to_pydantic(Words)[0]\nprint(actual.text)\n"; + +export const PyEmbeddingVoyageaiMultimodal = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nfrom lancedb.embeddings import EmbeddingFunctionRegistry\nfrom lancedb.pydantic import LanceModel, Vector\n\n# Create multimodal embedding function with custom dimension\nvoyageai = (\n EmbeddingFunctionRegistry.get_instance()\n .get(\"voyageai\")\n .create(name=\"voyage-multimodal-3.5\", output_dimension=512)\n)\n\nclass ImageModel(LanceModel):\n image_uri: str = voyageai.SourceField()\n vector: Vector(voyageai.ndims()) = voyageai.VectorField()\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"voyageai-multimodal\"))\ntbl = db.create_table(\"images\", schema=ImageModel, mode=\"overwrite\")\n\n# Add images using URLs\ntbl.add(\n [\n {\"image_uri\": \"https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/300px-PNG_transparency_demonstration_1.png\"},\n ]\n)\n\n# Search with text query\nresults = tbl.search(\"dice\").limit(1).to_list()\nprint(results)\n"; + +export const PyEmbeddingVoyageaiUsage = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nfrom lancedb.embeddings import EmbeddingFunctionRegistry\nfrom lancedb.pydantic import LanceModel, Vector\n\nvoyageai = (\n EmbeddingFunctionRegistry.get_instance().get(\"voyageai\").create(name=\"voyage-3\")\n)\n\nclass TextModel(LanceModel):\n text: str = voyageai.SourceField()\n vector: Vector(voyageai.ndims()) = voyageai.VectorField()\n\ndata = [{\"text\": \"hello world\"}, {\"text\": \"goodbye world\"}]\n\ndb = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"voyageai-demo\"))\ntbl = db.create_table(\"test\", schema=TextModel, mode=\"overwrite\")\n\ntbl.add(data)\n"; + +export const PyFrameworksAgnoAgent = "agent = Agent(\n model=OpenAIResponses(id=\"gpt-5-mini\"),\n knowledge=knowledge,\n search_knowledge=True,\n instructions=\"Search the transcript and answer only from retrieved context.\",\n markdown=True,\n)\n"; + +export const PyFrameworksAgnoCliChat = "agent.print_response(\n \"Summarize the loaded video transcript in 5 concise bullet points.\",\n stream=True,\n)\nwhile True:\n question = input(\"You: \").strip()\n if question.lower() in {\"exit\", \"quit\", \"bye\"}:\n break\n agent.print_response(question, stream=True)\n"; + +export const PyFrameworksAgnoIngestYoutube = "youtube_url = \"https://www.youtube.com/watch?v=wl6mFyXoxos\"\nvideo_id = extract_video_id(youtube_url)\nytt = YouTubeTranscriptApi()\ntranscript_segments = ytt.fetch(video_id, languages=[\"en\", \"en-US\"]).to_raw_data()\ntranscript_text = \" \".join(segment[\"text\"] for segment in transcript_segments)\n\nknowledge.insert(\n name=f\"YouTube Transcript ({video_id})\",\n text_content=transcript_text,\n metadata={\"source\": \"youtube\", \"video_id\": video_id, \"video_url\": youtube_url},\n)\n"; + +export const PyFrameworksAgnoSetup = "import os\nimport re\n\nfrom agno.agent import Agent\nfrom agno.knowledge.embedder.openai import OpenAIEmbedder\nfrom agno.knowledge.knowledge import Knowledge\nfrom agno.models.openai import OpenAIResponses\nfrom agno.vectordb.lancedb import LanceDb, SearchType\nfrom youtube_transcript_api import YouTubeTranscriptApi\n\nif \"OPENAI_API_KEY\" not in os.environ:\n os.environ[\"OPENAI_API_KEY\"] = \"sk-...\"\n\ndef extract_video_id(youtube_url: str) -> str:\n match = re.search(r\"(?<=v=)[\\w-]+\", youtube_url) or re.search(\n r\"(?<=be/)[\\w-]+\", youtube_url\n )\n if not match:\n raise ValueError(\"Could not parse YouTube video ID from URL\")\n return match.group(0)\n\nknowledge = Knowledge(\n vector_db=LanceDb(\n uri=\"./tmp/lancedb\",\n table_name=\"youtube_transcripts\",\n search_type=SearchType.hybrid,\n embedder=OpenAIEmbedder(id=\"text-embedding-3-small\"),\n ),\n)\n"; + +export const PyFrameworksLangchainAddImages = "image_uris = [\"./assets/image-1.png\", \"./assets/image-2.png\"]\nvector_store.add_images(uris=image_uris)\n# here image_uris are local fs paths to the images.\n"; + +export const PyFrameworksLangchainAddTexts = "vector_store.add_texts(texts=[\"test_123\"], metadatas=[{\"source\": \"wiki\"}])\n\n# Additionally, to explore the table you can load it into a df or save it in a csv file:\n\ntbl = vector_store.get_table()\nprint(\"tbl:\", tbl)\npd_df = tbl.to_pandas()\npd_df.to_csv(\"docsearch.csv\", index=False)\n\n# you can also create a new vector store object using an older connection object:\nvector_store = LanceDB(connection=tbl, embedding=embeddings)\n"; + +export const PyFrameworksLangchainCreateIndex = "# for creating vector index\nvector_store.create_index(vector_col=\"vector\", metric=\"cosine\")\n\n# for creating scalar index(for non-vector columns)\nvector_store.create_index(col_name=\"text\")\n"; + +export const PyFrameworksLangchainMaxMarginalRelevance = "result = docsearch.max_marginal_relevance_search(query=\"text\")\nresult_texts = [doc.page_content for doc in result]\nprint(result_texts)\n\n# search by vector :\nresult = docsearch.max_marginal_relevance_search_by_vector(\n embeddings.embed_query(\"text\")\n)\nresult_texts = [doc.page_content for doc in result]\nprint(result_texts)\n"; + +export const PyFrameworksLangchainQuickStart = "import os\n\nfrom langchain.document_loaders import TextLoader\nfrom langchain.vectorstores import LanceDB\nfrom langchain_openai import OpenAIEmbeddings\nfrom langchain_text_splitters import CharacterTextSplitter\n\nos.environ[\"OPENAI_API_KEY\"] = \"sk-...\"\n\nloader = TextLoader(\n \"../../modules/state_of_the_union.txt\"\n) # Replace with your data path\ndocuments = loader.load()\n\ndocuments = CharacterTextSplitter().split_documents(documents)\nembeddings = OpenAIEmbeddings()\n\ndocsearch = LanceDB.from_documents(documents, embeddings)\nquery = \"What did the president say about Ketanji Brown Jackson\"\ndocs = docsearch.similarity_search(query)\nprint(docs[0].page_content)\n"; + +export const PyFrameworksLangchainSimilaritySearch = "docs = docsearch.similarity_search(query)\nprint(docs[0].page_content)\n"; + +export const PyFrameworksLangchainSimilaritySearchByVector = "docs = docsearch.similarity_search_by_vector(query)\nprint(docs[0].page_content)\n"; + +export const PyFrameworksLangchainSimilaritySearchByVectorWithScores = "query_embedding = embeddings.embed_query(\"text\")\ndocs = docsearch.similarity_search_by_vector_with_relevance_scores(query_embedding)\nprint(\"relevance score - \", docs[0][1])\nprint(\"text- \", docs[0][0].page_content[:1000])\n"; + +export const PyFrameworksLangchainSimilaritySearchWithScores = "docs = docsearch.similarity_search_with_relevance_scores(query)\nprint(\"relevance score - \", docs[0][1])\nprint(\"text- \", docs[0][0].page_content[:1000])\n"; + +export const PyFrameworksLangchainVectorStoreConfig = "db_url = \"db://lang_test\" # url of db you created\napi_key = \"xxxxx\" # your API key\nregion = \"us-east-1-dev\" # your selected region\n\nvector_store = LanceDB(\n uri=db_url,\n api_key=api_key, # (dont include for local API)\n region=region, # (dont include for local API)\n embedding=embeddings,\n table_name=\"langchain_test\", # Optional\n)\n"; + +export const PyFrameworksLerobotFilterFrames = "frame_rows = (\n frames.search()\n .where(\"episode_index = 0 AND frame_index < 10\", prefilter=True)\n .select([\"episode_index\", \"frame_index\", \"timestamp\", \"action\"])\n .limit(10)\n .to_list()\n)\n\nfor row in frame_rows:\n print(row[\"episode_index\"], row[\"frame_index\"], row[\"timestamp\"])\n"; + +export const PyFrameworksLerobotLoad = "from lerobot.datasets import LeRobotDataset\n\n# Hub dataset repo: storage_format in meta/info.json routes to the Lance reader\ndataset = LeRobotDataset(\"lance-format/pusht-lance\")\n\n# HF Storage Bucket, read in place, nothing downloaded\ndataset = LeRobotDataset(\"lance-format/lerobot-tests/pusht-lance\", repo_type=\"bucket\")\n\nsample = dataset[100]\nprint(sample[\"observation.state\"].shape)\nprint(sample[\"observation.image\"].shape)\n"; + +export const PyFrameworksLerobotOpenLanceTables = "import lancedb\n\ndb = lancedb.connect(\"hf://datasets/lance-format/pusht-lance\")\nframes = db.open_table(\"frames\")\nvideos = db.open_table(\"videos\")\n\nprint(frames.count_rows(), videos.count_rows())\nprint(frames.schema)\n"; + +export const PyFrameworksLerobotTrainReady = "import torch\n\ndataset = LeRobotDataset(\n \"lance-format/pusht-lance\",\n delta_timestamps={\"observation.image\": [-0.2, -0.1, 0.0]},\n return_uint8=True,\n)\nloader = torch.utils.data.DataLoader(dataset, batch_size=32, shuffle=True)\nbatch = next(iter(loader))\nprint(batch[\"observation.image\"].shape) # [32, 3, C, H, W]\n"; + +export const PyFrameworksLlamaindexAddReranker = "from lancedb.rerankers import ColbertReranker\n\nreranker = ColbertReranker()\nvector_store._add_reranker(reranker)\n"; + +export const PyFrameworksLlamaindexFiltering = "from llama_index.core.vector_stores import (\n FilterCondition,\n FilterOperator,\n MetadataFilter,\n MetadataFilters,\n)\n\nquery_filters = MetadataFilters(\n filters=[\n MetadataFilter(\n key=\"creation_date\", operator=FilterOperator.EQ, value=\"2024-05-23\"\n ),\n MetadataFilter(key=\"file_size\", value=75040, operator=FilterOperator.GT),\n ],\n condition=FilterCondition.AND,\n)\n"; + +export const PyFrameworksLlamaindexHybridSearch = "from lancedb.rerankers import ColbertReranker\n\nreranker = ColbertReranker()\nvector_store._add_reranker(reranker)\n\nquery_engine = index.as_query_engine(\n filters=query_filters,\n vector_store_kwargs={\n \"query_type\": \"hybrid\",\n },\n)\n\nresponse = query_engine.query(\"How much did Viaweb charge per month?\")\n"; + +export const PyFrameworksLlamaindexQuickStart = "import logging\nimport sys\nimport textwrap\n\nimport openai\n\n# Uncomment to see debug logs\n# logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)\n# logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))\nfrom llama_index.core import (\n Document,\n SimpleDirectoryReader,\n StorageContext,\n VectorStoreIndex,\n)\nfrom llama_index.vector_stores.lancedb import LanceDBVectorStore\n\nopenai.api_key = \"sk-...\"\n\ndocuments = SimpleDirectoryReader(\"./data/your-data-dir/\").load_data()\nprint(\"Document ID:\", documents[0].doc_id, \"Document Hash:\", documents[0].hash)\n\n## For LanceDB Enterprise :\n# vector_store = LanceDBVectorStore(\n# uri=\"db://db_name\", # your remote DB URI\n# api_key=\"sk_..\", # lancedb enterprise api key\n# region=\"your-region\" # the region you configured\n# host_override=\"https://your-host.com\" # if you have a custom host, otherwise omit this\n# )\n\nvector_store = LanceDBVectorStore(\n uri=\"./lancedb\", mode=\"overwrite\", query_type=\"vector\"\n)\nstorage_context = StorageContext.from_defaults(vector_store=vector_store)\n\nindex = VectorStoreIndex.from_documents(documents, storage_context=storage_context)\nlance_filter = \"metadata.file_name = 'paul_graham_essay.txt' \"\nretriever = index.as_retriever(vector_store_kwargs={\"where\": lance_filter})\nresponse = retriever.retrieve(\"What did the author do growing up?\")\n"; + +export const PyFrameworksPydanticBaseExample = "table = db.create_table(\"docs\", schema=LanceDocs, mode=\"overwrite\")\ntable.add(\n [\n {\"text\": \"hello world\", \"vector\": [1.0, 0.0]},\n {\"text\": \"goodbye world\", \"vector\": [0.0, 1.0]},\n ]\n)\nresults = table.search(\"hello world\").limit(1).to_pydantic(LanceDocs)\nprint(results[0].text)\n"; + +export const PyFrameworksPydanticBaseModel = "class LanceDocs(LanceModel):\n text: str\n vector: Vector(2)\n"; + +export const PyFrameworksPydanticImports = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nfrom lancedb.pydantic import LanceModel, Vector\n"; + +export const PyFrameworksPydanticSetUrl = "db = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"pydantic-docs\"))\n"; + +export const PyFrameworksPydanticTypeConversion = "from typing import List, Optional\n\nimport pyarrow as pa\nimport pydantic\nfrom lancedb.pydantic import Vector, pydantic_to_schema\n\nclass FooModel(pydantic.BaseModel):\n id: int\n s: str\n vec: Vector(1536) # fixed_size_list[1536]\n li: List[int]\n\nschema = pydantic_to_schema(FooModel)\nassert schema == pa.schema(\n [\n pa.field(\"id\", pa.int64(), False),\n pa.field(\"s\", pa.utf8(), False),\n pa.field(\"vec\", pa.list_(pa.float32(), 1536)),\n pa.field(\"li\", pa.list_(pa.int64()), False),\n ]\n)\n"; + +export const PyFrameworksPydanticVectorField = "import pyarrow as pa\nimport pydantic\nfrom lancedb.pydantic import Vector, pydantic_to_schema\n\nclass MyModel(pydantic.BaseModel):\n id: int\n url: str\n embeddings: Vector(768)\n\nschema = pydantic_to_schema(MyModel)\nassert schema == pa.schema(\n [\n pa.field(\"id\", pa.int64(), False),\n pa.field(\"url\", pa.utf8(), False),\n pa.field(\"embeddings\", pa.list_(pa.float32(), 768)),\n ]\n)\n"; + +export const PyFrameworksStableWorldmodelCollectLance = "import stable_worldmodel as swm\n\nworld = swm.World(\"swm/PushT-v1\", num_envs=8)\nworld.set_policy(your_expert_policy)\nworld.collect(\"data/pusht_demo.lance\", episodes=100, seed=0)\n"; + +export const PyFrameworksStableWorldmodelConvert = "swm.data.convert(\n \"data/pusht_demo.lance\",\n \"data/pusht_video\",\n dest_format=\"video\",\n fps=30,\n)\n"; + +export const PyFrameworksStableWorldmodelEvaluate = "from stable_worldmodel.policy import PlanConfig, WorldModelPolicy\nfrom stable_worldmodel.solver import CEMSolver\n\nsolver = CEMSolver(model=world_model, num_samples=300)\npolicy = WorldModelPolicy(solver=solver, config=PlanConfig(horizon=10))\n\nworld.set_policy(policy)\nresults = world.evaluate(episodes=50)\nprint(f\"Success Rate: {results['success_rate']:.1f}%\")\n"; + +export const PyFrameworksStableWorldmodelLoadLance = "dataset = swm.data.load_dataset(\"data/pusht_demo.lance\", num_steps=16)\n\nbatch = dataset[0]\nprint(batch.keys())\n"; + +export const PyPlatformsDltAdapterImport = "from dlt.destinations.adapters import lancedb_adapter\n"; + +export const PyPlatformsDltAdapterUsage = "load_info = pipeline.run(\n lancedb_adapter(\n movies_source,\n embed=\"Title\",\n )\n)\n"; + +export const PyPlatformsDltPipeline = "# Import necessary modules\nimport dlt\nfrom rest_api import rest_api_source\n\n# Configure the REST API source\nmovies_source = rest_api_source(\n {\n \"client\": {\n \"base_url\": \"https://www.omdbapi.com/\",\n \"auth\": { # authentication strategy for the OMDb API\n \"type\": \"api_key\",\n \"name\": \"apikey\",\n \"api_key\": dlt.secrets[\n \"sources.rest_api.api_token\"\n ], # read API credentials directly from secrets.toml\n \"location\": \"query\",\n },\n \"paginator\": { # pagination strategy for the OMDb API\n \"type\": \"page_number\",\n \"base_page\": 1,\n \"total_path\": \"totalResults\",\n \"maximum_page\": 5,\n },\n },\n \"resources\": [ # list of API endpoints to request\n {\n \"name\": \"movie_search\",\n \"endpoint\": {\n \"path\": \"/\",\n \"params\": {\n \"s\": \"godzilla\",\n \"type\": \"movie\",\n },\n },\n }\n ],\n }\n)\n\nif __name__ == \"__main__\":\n # Create a pipeline object\n pipeline = dlt.pipeline(\n pipeline_name=\"movies_pipeline\",\n destination=\"lancedb\", # this tells dlt to load the data into LanceDB\n dataset_name=\"movies_data_pipeline\",\n )\n\n # Run the pipeline\n load_info = pipeline.run(movies_source)\n\n # pretty print the information on data that was loaded\n print(load_info)\n"; + +export const PyPlatformsDuckdbCreateTable = "import lancedb\n\ndb = lancedb.connect(\"data/sample-lancedb\")\ndata = [\n {\"vector\": [3.1, 4.1], \"item\": \"foo\", \"price\": 10.0},\n {\"vector\": [5.9, 26.5], \"item\": \"bar\", \"price\": 20.0},\n]\ntable = db.create_table(\"pd_table\", data=data)\n"; + +export const PyPlatformsDuckdbMeanPrice = "duckdb.query(\"SELECT mean(price) FROM arrow_table\")\n"; + +export const PyPlatformsDuckdbQueryTable = "import duckdb\n\narrow_table = table.to_lance()\n\nduckdb.query(\"SELECT * FROM arrow_table\")\n"; + +export const PyPlatformsPandasAsyncExample = "async def run_pandas_async_example() -> None:\n async_db = await lancedb.connect_async(\n str(Path(tempfile.mkdtemp()) / \"pandas-async\")\n )\n async_df = pd.DataFrame(\n [\n {\"id\": \"10\", \"text\": \"sage\", \"vector\": [0.6, 0.4, 0.8]},\n {\"id\": \"11\", \"text\": \"bard\", \"vector\": [0.2, 0.7, 0.3]},\n ]\n )\n async_table = await async_db.create_table(\n \"creatures_async\", data=async_df, mode=\"overwrite\"\n )\n async_results = await (\n async_table.search([0.6, 0.4, 0.8])\n .select([\"text\", \"_distance\"])\n .limit(1)\n .to_pandas()\n )\n print(async_results)\n\nasyncio.run(run_pandas_async_example())\n"; + +export const PyPlatformsPandasCreateTable = "pandas_df = pd.DataFrame(\n [\n {\"id\": \"1\", \"text\": \"dragon\", \"vector\": [0.9, 0.1, 0.3]},\n {\"id\": \"2\", \"text\": \"griffin\", \"vector\": [0.4, 0.5, 0.2]},\n {\"id\": \"3\", \"text\": \"phoenix\", \"vector\": [0.7, 0.3, 0.6]},\n ]\n)\npandas_db = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"pandas-demo\"))\npandas_table = pandas_db.create_table(\"creatures\", data=pandas_df, mode=\"overwrite\")\n"; + +export const PyPlatformsPandasImports = "import asyncio\nimport tempfile\nfrom pathlib import Path\n\nimport lancedb\nimport pandas as pd\n"; + +export const PyPlatformsPandasVectorSearch = "pandas_results = (\n pandas_table.search([0.9, 0.1, 0.3])\n .select([\"text\", \"_distance\"])\n .limit(1)\n .to_pandas()\n)\nprint(pandas_results)\n"; + +export const PyPlatformsPolarsCreateTable = "birds = pl.DataFrame(\n {\n \"text\": [\"phoenix\", \"sparrow\"],\n \"vector\": [\n [0.1, 0.2, 0.3],\n [0.8, 0.6, 0.5],\n ],\n }\n)\npolars_db = lancedb.connect(str(Path(tempfile.mkdtemp()) / \"polars-demo\"))\npolars_table = polars_db.create_table(\n \"birds\", data=birds.to_arrow(), mode=\"overwrite\"\n)\n"; + +export const PyPlatformsPolarsImports = "import tempfile\nfrom pathlib import Path\n\nimport lancedb\nimport polars as pl\nfrom lancedb.pydantic import LanceModel, Vector\n"; + +export const PyPlatformsPolarsLazyframe = "lazy_frame = polars_table.to_polars().lazy()\nprint(lazy_frame.select([\"text\"]).collect())\n"; + +export const PyPlatformsPolarsPydantic = "class BirdModel(LanceModel):\n text: str\n vector: Vector(3)\n\nschema_table = polars_db.create_table(\n \"birds_schema\", schema=BirdModel, mode=\"overwrite\"\n)\nschema_table.add(birds.to_dicts())\n"; + +export const PyPlatformsPolarsVectorSearch = "polars_results = (\n polars_table.search([0.1, 0.2, 0.3])\n .select([\"text\", \"_distance\"])\n .limit(1)\n .to_polars()\n)\nprint(polars_results)\n"; + +export const PyPlatformsVoxel51BackendFlag = "import fiftyone.brain as fob\n\n# Re-run similarity creation using the LanceDB backend explicitly\nfob.compute_similarity(\n dataset,\n model=\"clip-vit-base32-torch\",\n brain_key=\"lancedb_index\",\n backend=\"lancedb\",\n)\n"; + +export const PyPlatformsVoxel51BackendParams = "lancedb_index = fob.compute_similarity(\n dataset,\n model=\"clip-vit-base32-torch\",\n backend=\"lancedb\",\n brain_key=\"lancedb_index\",\n table_name=\"your-table\",\n metric=\"euclidean\",\n uri=\"/tmp/lancedb\",\n)\n"; + +export const PyPlatformsVoxel51BrainConfig = "import fiftyone.brain as fob\n\n# Print your current brain config\nprint(fob.brain_config)\n"; + +export const PyPlatformsVoxel51Cleanup = "# Step 5 (optional): Cleanup\n\n# Delete the LanceDB table\nlancedb_index.cleanup()\n\n# Delete run record from FiftyOne\ndataset.delete_brain_run(\"lancedb_index\")\n"; + +export const PyPlatformsVoxel51ComputeSimilarity = "# Steps 2 and 3: Compute embeddings and create a similarity index\nlancedb_index = fob.compute_similarity(\n dataset,\n model=\"clip-vit-base32-torch\",\n brain_key=\"lancedb_index\",\n backend=\"lancedb\",\n)\n"; + +export const PyPlatformsVoxel51LoadDataset = "import fiftyone as fo\nimport fiftyone.brain as fob\nimport fiftyone.zoo as foz\n\n# Step 1: Load your data into FiftyOne\ndataset = foz.load_zoo_dataset(\"quickstart\")\n"; + +export const PyPlatformsVoxel51SortBySimilarity = "# Step 4: Query your data\nquery = dataset.first().id # query by sample ID\nview = dataset.sort_by_similarity(\n query,\n brain_key=\"lancedb_index\",\n k=10, # limit to 10 most similar samples\n)\n"; + +export const PyRerankingAnswerdotaiUsage = "import lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import AnswerdotaiRerankers\n\nembedder = get_registry().get(\"sentence-transformers\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\nreranker = AnswerdotaiRerankers()\n\n# Run vector search with a reranker\nresult = tbl.search(\"hello\").rerank(reranker=reranker).to_list()\n\n# Run FTS search with a reranker\nresult = tbl.search(\"hello\", query_type=\"fts\").rerank(reranker=reranker).to_list()\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n"; + +export const PyRerankingCohereUsage = "import os\n\nimport lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import CohereReranker\n\nembedder = get_registry().get(\"sentence-transformers\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\nreranker = CohereReranker(api_key=os.environ[\"COHERE_API_KEY\"])\n\n# Run vector search with a reranker\nresult = tbl.search(\"hello\").rerank(reranker=reranker).to_list()\n\n# Run FTS search with a reranker\nresult = tbl.search(\"hello\", query_type=\"fts\").rerank(reranker=reranker).to_list()\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n"; + +export const PyRerankingColbertUsage = "import lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import ColbertReranker\n\nembedder = get_registry().get(\"sentence-transformers\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\nreranker = ColbertReranker()\n\n# Run vector search with a reranker\nresult = tbl.search(\"hello\").rerank(reranker=reranker).to_list()\n\n# Run FTS search with a reranker\nresult = tbl.search(\"hello\", query_type=\"fts\").rerank(reranker=reranker).to_list()\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n"; + +export const PyRerankingCrossEncoderUsage = "import lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import CrossEncoderReranker\n\nembedder = get_registry().get(\"sentence-transformers\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\nreranker = CrossEncoderReranker()\n\n# Run vector search with a reranker\nresult = tbl.search(\"hello\").rerank(reranker=reranker).to_list()\n\n# Run FTS search with a reranker\nresult = tbl.search(\"hello\", query_type=\"fts\").rerank(reranker=reranker).to_list()\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n"; + +export const PyRerankingJinaUsage = "import os\n\nimport lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import JinaReranker\n\nembedder = get_registry().get(\"jina\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\nreranker = JinaReranker(api_key=os.environ[\"JINA_API_KEY\"])\n\n# Run vector search with a reranker\nresult = tbl.search(\"hello\").rerank(reranker=reranker).to_list()\n\n# Run FTS search with a reranker\nresult = tbl.search(\"hello\", query_type=\"fts\").rerank(reranker=reranker).to_list()\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n"; + +export const PyRerankingLinearCombinationUsage = "import lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import LinearCombinationReranker\n\nembedder = get_registry().get(\"sentence-transformers\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\nreranker = LinearCombinationReranker()\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n"; + +export const PyRerankingMrrUsage = "import lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import MRRReranker\n\nembedder = get_registry().get(\"sentence-transformers\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\nreranker = MRRReranker(weight_vector=0.7, weight_fts=0.3)\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n\n# Run multivector search across multiple vector columns\nrs1 = tbl.search(\"hello\").limit(10).with_row_id(True).to_arrow()\nrs2 = tbl.search(\"greeting\").limit(10).with_row_id(True).to_arrow()\ncombined = MRRReranker().rerank_multivector([rs1, rs2])\n"; + +export const PyRerankingOpenaiUsage = "import lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import OpenaiReranker\n\nembedder = get_registry().get(\"sentence-transformers\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\nreranker = OpenaiReranker()\n\n# Run vector search with a reranker\nresult = tbl.search(\"hello\").rerank(reranker=reranker).to_list()\n\n# Run FTS search with a reranker\nresult = tbl.search(\"hello\", query_type=\"fts\").rerank(reranker=reranker).to_list()\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n"; + +export const PyRerankingRrfUsage = "import lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import RRFReranker\n\nembedder = get_registry().get(\"sentence-transformers\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\nreranker = RRFReranker()\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n"; + +export const PyRerankingVoyageaiUsage = "import os\n\nimport lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import VoyageAIReranker\n\nembedder = get_registry().get(\"sentence-transformers\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\nreranker = VoyageAIReranker(model_name=\"rerank-2\")\n\n# Run vector search with a reranker\nresult = tbl.search(\"hello\").rerank(reranker=reranker).to_list()\n\n# Run FTS search with a reranker\nresult = tbl.search(\"hello\", query_type=\"fts\").rerank(reranker=reranker).to_list()\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n"; + +export const PyRerankingWatsonxUsage = "import os\n\nimport lancedb\nfrom lancedb.embeddings import get_registry\nfrom lancedb.pydantic import LanceModel, Vector\nfrom lancedb.rerankers import WatsonxReranker\n\nembedder = get_registry().get(\"sentence-transformers\").create()\ndb = lancedb.connect(\"~/.lancedb\")\n\nclass Schema(LanceModel):\n text: str = embedder.SourceField()\n vector: Vector(embedder.ndims()) = embedder.VectorField()\n\ndata = [\n {\"text\": \"hello world\"},\n {\"text\": \"goodbye world\"},\n]\ntbl = db.create_table(\"test\", schema=Schema, mode=\"overwrite\")\ntbl.add(data)\n\n# Credentials pulled from WATSONX_API_KEY and WATSONX_PROJECT_ID (or WATSONX_SPACE_ID)\nreranker = WatsonxReranker(\n api_key=os.environ[\"WATSONX_API_KEY\"],\n project_id=os.environ[\"WATSONX_PROJECT_ID\"],\n)\n\n# Run vector search with a reranker\nresult = tbl.search(\"hello\").rerank(reranker=reranker).to_list()\n\n# Run FTS search with a reranker\nresult = tbl.search(\"hello\", query_type=\"fts\").rerank(reranker=reranker).to_list()\n\n# Run hybrid search with a reranker\ntbl.create_fts_index(\"text\", replace=True)\nresult = (\n tbl.search(\"hello\", query_type=\"hybrid\").rerank(reranker=reranker).to_list()\n)\n"; + +export const TsFrameworksGenkitCustomIndexer = "export const menuPdfIndexer = lancedbIndexerRef({\n // Using all defaults, for dbUri, tableName, and embedder, etc\n});\n\nconst chunkingConfig = {\n minLength: 1000,\n maxLength: 2000,\n splitter: \"sentence\",\n overlap: 100,\n delimiters: \"\",\n} as any;\n\nasync function extractTextFromPdf(filePath: string) {\n const pdfFile = path.resolve(filePath);\n const dataBuffer = await readFile(pdfFile);\n const data = await pdf(dataBuffer);\n return data.text;\n}\n\nexport const indexMenu = ai.defineFlow(\n {\n name: \"indexMenu\",\n inputSchema: z.string().describe(\"PDF file path\"),\n outputSchema: z.void(),\n },\n async (filePath: string) => {\n filePath = path.resolve(filePath);\n\n // Read the pdf.\n const pdfTxt = await ai.run(\"extract-text\", () => extractTextFromPdf(filePath));\n\n // Divide the pdf text into segments.\n const chunks = await ai.run(\"chunk-it\", async () => chunk(pdfTxt, chunkingConfig));\n\n // Convert chunks of text into documents to store in the index.\n const documents = chunks.map((text) => {\n return Document.fromText(text, { filePath });\n });\n\n // Add documents to the index.\n await ai.index({\n indexer: menuPdfIndexer,\n documents,\n options: {\n writeMode: WriteMode.Overwrite,\n } as any,\n });\n },\n);\n"; + +export const TsFrameworksGenkitCustomRetriever = "export const menuRetriever = lancedbRetrieverRef({\n tableName: \"table\", // Use the same table name as the indexer.\n displayName: \"Menu\", // Use a custom display name.\n});\n\nexport const menuQAFlow = ai.defineFlow(\n { name: \"Menu\", inputSchema: z.string(), outputSchema: z.string() },\n async (input: string) => {\n // retrieve relevant documents\n const docs = await ai.retrieve({\n retriever: menuRetriever,\n query: input,\n options: {\n k: 3,\n },\n });\n\n const extractedContent = docs.map((doc) => {\n if (doc.content && Array.isArray(doc.content) && doc.content.length > 0) {\n if (doc.content[0].media && doc.content[0].media.url) {\n return doc.content[0].media.url;\n }\n }\n return \"No content found\";\n });\n\n console.log(\"Extracted content:\", extractedContent);\n\n const { text } = await ai.generate({\n model: gemini(\"gemini-2.0-flash\"),\n prompt: `\nYou are acting as a helpful AI assistant that can answer \nquestions about the food available on the menu at Genkit Grub Pub.\n\nUse only the context provided to answer the question.\nIf you don't know, do not make up an answer.\nDo not add or change items on the menu.\n\nContext:\n${extractedContent.join(\"\\n\\n\")}\n\nQuestion: ${input}`,\n docs,\n });\n\n return text;\n },\n);\n"; + +export const TsFrameworksGenkitUsage = "import { lancedbIndexerRef, lancedb, lancedbRetrieverRef, WriteMode } from \"genkitx-lancedb\";\nimport { textEmbedding004, vertexAI } from \"@genkit-ai/vertexai\";\nimport { gemini } from \"@genkit-ai/vertexai\";\nimport { z, genkit } from \"genkit\";\nimport { Document } from \"genkit/retriever\";\nimport { chunk } from \"llm-chunk\";\nimport { readFile } from \"fs/promises\";\nimport path from \"path\";\nimport pdf from \"pdf-parse/lib/pdf-parse\";\n\nconst ai = genkit({\n plugins: [\n // vertexAI provides the textEmbedding004 embedder\n vertexAI(),\n\n // the local vector store requires an embedder to translate from text to vector\n lancedb([\n {\n dbUri: \".db\", // optional lancedb uri, default to .db\n tableName: \"table\", // optional table name, default to table\n embedder: textEmbedding004,\n },\n ]),\n ],\n});\n"; + diff --git a/docs/web/snippets/ivf_pq.mdx b/docs/web/snippets/ivf_pq.mdx new file mode 100644 index 000000000..fd1c6360b --- /dev/null +++ b/docs/web/snippets/ivf_pq.mdx @@ -0,0 +1,6 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const RsCreateIndex = "// For this example, `table` is a lancedb::Table with a column named\n// \"vector\" that is a vector column with dimension 128.\n\n// By default, if the column \"vector\" appears to be a vector column,\n// then an IVF_PQ index with reasonable defaults is created.\ntable\n .create_index(&[\"vector\"], Index::Auto)\n .execute()\n .await?;\n// For advanced cases, it is also possible to specifically request an\n// IVF_PQ index and provide custom parameters.\ntable\n .create_index(\n &[\"vector\"],\n Index::IvfPq(\n // Here we specify advanced indexing parameters. In this case\n // we are creating an index that my have better recall than the\n // default but is also larger and slower.\n IvfPqIndexBuilder::default()\n // This overrides the default distance type of l2\n .distance_type(DistanceType::Cosine)\n // With 1000 rows this have been ~31 by default\n .num_partitions(50)\n // With dimension 128 this would have been 8 by default\n .num_sub_vectors(16),\n ),\n )\n .execute()\n .await?;\n"; + +export const RsSearch1 = "let query_vector = [1.0; 128];\n// By default the index will find the 10 closest results using default\n// search parameters that give a reasonable tradeoff between accuracy\n// and search latency\nlet mut results = table\n .vector_search(&query_vector)?\n // Note: you should always set the distance_type to match the value used\n // to train the index\n .distance_type(DistanceType::Cosine)\n .execute()\n .await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n// We can also provide custom search parameters. Here we perform a\n// slower but more accurate search\nlet mut results = table\n .vector_search(&query_vector)?\n .distance_type(DistanceType::Cosine)\n // Override the default of 10 to get more rows\n .limit(15)\n // Override the default of 20 to search more partitions\n .nprobes(30)\n // Override the default of None to apply a refine step\n .refine_factor(1)\n .execute()\n .await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\nOk(())\n"; + diff --git a/docs/web/snippets/materialized_view.mdx b/docs/web/snippets/materialized_view.mdx new file mode 100644 index 000000000..41f9def31 --- /dev/null +++ b/docs/web/snippets/materialized_view.mdx @@ -0,0 +1,6 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyMaterializedViewCreate = "import lancedb\n\ndb = lancedb.connect(\"./.lancedb\")\ntable = db.create_table(\n \"people\", [{\"name\": \"ada\", \"age\": 36}, {\"name\": \"kid\", \"age\": 7}]\n)\n\nview = db.create_materialized_view(\n \"adults\",\n \"people\",\n select=[\"name\", (\"shout\", \"upper(name)\")],\n where=\"age >= 18\",\n)\nprint(view.table.count_rows())\n# 1\n"; + +export const PyMaterializedViewRefresh = "table.add([{\"name\": \"bea\", \"age\": 41}])\n\nresult = view.refresh()\nprint(result.rows_written)\n# 2\n"; + diff --git a/docs/web/snippets/merge_insert.mdx b/docs/web/snippets/merge_insert.mdx new file mode 100644 index 000000000..bdd55a631 --- /dev/null +++ b/docs/web/snippets/merge_insert.mdx @@ -0,0 +1,8 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const TsInsertIfNotExists = "const table2 = await db.createTable(\"domains\", [\n { domain: \"google.com\", name: \"Google\" },\n { domain: \"github.com\", name: \"GitHub\" },\n]);\n\nconst newDomains = [\n { domain: \"google.com\", name: \"Google\" },\n { domain: \"facebook.com\", name: \"Facebook\" },\n];\nawait table2\n .mergeInsert(\"domain\")\n .whenNotMatchedInsertAll()\n .execute(newDomains);\nawait table2.countRows(); // 3\n"; + +export const TsReplaceRange = "const table3 = await db.createTable(\"chunks\", [\n { doc_id: 0, chunk_id: 0, text: \"Hello\" },\n { doc_id: 0, chunk_id: 1, text: \"World\" },\n { doc_id: 1, chunk_id: 0, text: \"Foo\" },\n { doc_id: 1, chunk_id: 1, text: \"Bar\" },\n]);\n\nconst newChunks = [{ doc_id: 1, chunk_id: 0, text: \"Baz\" }];\n\nawait table3\n .mergeInsert([\"doc_id\", \"chunk_id\"])\n .whenMatchedUpdateAll()\n .whenNotMatchedInsertAll()\n .whenNotMatchedBySourceDelete({ where: \"doc_id = 1\" })\n .execute(newChunks);\n\nawait table3.countRows(\"doc_id = 1\"); // 1\n"; + +export const TsUpsertBasic = "const table = await db.createTable(\"users\", [\n { id: 0, name: \"Alice\" },\n { id: 1, name: \"Bob\" },\n]);\n\nconst newUsers = [\n { id: 1, name: \"Bobby\" },\n { id: 2, name: \"Charlie\" },\n];\nawait table\n .mergeInsert(\"id\")\n .whenMatchedUpdateAll()\n .whenNotMatchedInsertAll()\n .execute(newUsers);\n\nawait table.countRows(); // 3\n"; + diff --git a/docs/web/snippets/multimodal.mdx b/docs/web/snippets/multimodal.mdx new file mode 100644 index 000000000..f41af6629 --- /dev/null +++ b/docs/web/snippets/multimodal.mdx @@ -0,0 +1,54 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyBlobApiIngest = "import lancedb\n\ndb = lancedb.connect(db_path_factory(\"blob_db\"))\n \n# Create sample data\ndata = [\n {\"id\": 1, \"video\": b\"fake_video_bytes_1\"},\n {\"id\": 2, \"video\": b\"fake_video_bytes_2\"}\n]\n \n# Create the table\ntbl = db.create_table(\"videos\", data=data, schema=schema)\n"; + +export const PyBlobApiSchema = "import pyarrow as pa\n\n# Define schema with Blob API metadata for lazy loading\nschema = pa.schema([\n pa.field(\"id\", pa.int64()),\n pa.field(\n \"video\", \n pa.large_binary(), \n metadata={\"lance-encoding:blob\": \"true\"} # Enable Blob API\n ),\n])\n"; + +export const PyBlobApiToPandas = "# Default: blob columns come back lazily\ndf_lazy = tbl.to_pandas()\n\n# Materialize blob bytes eagerly\ndf_bytes = tbl.to_pandas(blob_mode=\"bytes\")\n\n# Return descriptors instead of payloads\ndf_desc = tbl.to_pandas(blob_mode=\"descriptions\")\n\n# Forward extra kwargs to PyArrow's to_pandas\ndf_typed = tbl.to_pandas(split_blocks=True, self_destruct=True)\n"; + +export const PyCreateDummyData = "# Create some dummy images\ndef create_dummy_image(color):\n img = Image.new('RGB', (100, 100), color=color)\n buf = io.BytesIO()\n img.save(buf, format='PNG')\n return buf.getvalue()\n\n# Create dataset with metadata, vectors, and image blobs\ndata = [\n {\n \"id\": 1,\n \"filename\": \"red_square.png\",\n \"vector\": np.random.rand(128).astype(np.float32),\n \"image_blob\": create_dummy_image('red'),\n \"label\": \"red\"\n },\n {\n \"id\": 2,\n \"filename\": \"blue_square.png\",\n \"vector\": np.random.rand(128).astype(np.float32),\n \"image_blob\": create_dummy_image('blue'),\n \"label\": \"blue\"\n }\n]\n"; + +export const PyDefineSchema = "# Define schema explicitly to ensure image_blob is treated as binary\nschema = pa.schema([\n pa.field(\"id\", pa.int32()),\n pa.field(\"filename\", pa.string()),\n pa.field(\"vector\", pa.list_(pa.float32(), 128)),\n pa.field(\"image_blob\", pa.binary()), # Important: Use pa.binary() for blobs\n pa.field(\"label\", pa.string())\n])\n"; + +export const PyIngestData = "tbl = db.create_table(\"images\", data=data, schema=schema, mode=\"overwrite\")\n"; + +export const PyMultimodalImports = "import lancedb\nimport pyarrow as pa\nimport pandas as pd\nimport numpy as np\nimport io\nfrom PIL import Image\n"; + +export const PyProcessResults = "# Convert back to PIL Image\nfor _, row in results.iterrows():\n image_bytes = row['image_blob']\n image = Image.open(io.BytesIO(image_bytes))\n print(f\"Retrieved image: {row['filename']}, Size: {image.size}\")\n # You can now use 'image' with other libraries or display it\n"; + +export const PyQueryToPandasKwargs = "# Plain scan query: blob_mode is supported end to end\ndf_lazy = (\n tbl.search()\n .where(\"id = 1\")\n .select([\"id\", \"video\"])\n .to_pandas(blob_mode=\"lazy\")\n)\n\n# Same call shape works on async query builders\ndf_bytes = await (\n tbl_async.query()\n .where(\"id = 1\")\n .select([\"id\", \"video\"])\n .to_pandas(blob_mode=\"bytes\")\n)\n\n# Vector / FTS / hybrid queries can't materialize blob columns,\n# so omit them from the projection\ndf_vec = (\n tbl.search(query_vector)\n .limit(10)\n .select([\"id\", \"vector\"])\n .to_pandas(split_blocks=True, self_destruct=True)\n)\n"; + +export const PySearchData = "# Search for similar images\nquery_vector = np.random.rand(128).astype(np.float32)\nresults = tbl.search(query_vector).limit(1).to_pandas()\n"; + +export const TsBlobApiIngest = "const blobData = lancedb.makeArrowTable(\n [\n { id: 1, video: Buffer.from(\"fake_video_bytes_1\") },\n { id: 2, video: Buffer.from(\"fake_video_bytes_2\") },\n ],\n { schema: blobSchema },\n);\nconst blobTable = await db.createTable(\"videos\", blobData, {\n mode: \"overwrite\",\n});\n"; + +export const TsBlobApiSchema = "const blobSchema = new arrow.Schema([\n new arrow.Field(\"id\", new arrow.Int64()),\n new arrow.Field(\n \"video\",\n new arrow.LargeBinary(),\n true,\n new Map([[\"lance-encoding:blob\", \"true\"]]),\n ),\n]);\n"; + +export const TsCreateDummyData = "const createDummyImage = (color: string): Uint8Array => {\n const pngHeader = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]);\n return Buffer.concat([Buffer.from(pngHeader), Buffer.from(color, \"utf8\")]);\n};\n\nconst data = [\n {\n id: 1,\n filename: \"red_square.png\",\n vector: Array.from({ length: 128 }, (_, i) => (i % 16) / 16),\n image_blob: createDummyImage(\"red\"),\n label: \"red\",\n },\n {\n id: 2,\n filename: \"blue_square.png\",\n vector: Array.from({ length: 128 }, (_, i) => ((i + 8) % 16) / 16),\n image_blob: createDummyImage(\"blue\"),\n label: \"blue\",\n },\n];\n"; + +export const TsDefineSchema = "const schema = new arrow.Schema([\n new arrow.Field(\"id\", new arrow.Int32()),\n new arrow.Field(\"filename\", new arrow.Utf8()),\n new arrow.Field(\n \"vector\",\n new arrow.FixedSizeList(\n 128,\n new arrow.Field(\"item\", new arrow.Float32(), true),\n ),\n ),\n new arrow.Field(\"image_blob\", new arrow.Binary()),\n new arrow.Field(\"label\", new arrow.Utf8()),\n]);\n"; + +export const TsIngestData = "const multimodalData = lancedb.makeArrowTable(data, { schema });\nconst tbl = await db.createTable(\"images\", multimodalData, {\n mode: \"overwrite\",\n});\n"; + +export const TsMultimodalImports = "import * as arrow from \"apache-arrow\";\nimport { Buffer } from \"node:buffer\";\nimport * as lancedb from \"@lancedb/lancedb\";\n"; + +export const TsProcessResults = "for (const row of results) {\n const imageBytes = row.image_blob as Uint8Array;\n console.log(\n `Retrieved image: ${row.filename}, Byte length: ${imageBytes.length}`,\n );\n}\n"; + +export const TsSearchData = "const queryVector = Array.from({ length: 128 }, (_, i) => (i % 16) / 16);\nconst results = await tbl.search(queryVector).limit(1).toArray();\n"; + +export const RsBlobApiIngest = "let blob_rows = vec![\n (1_i64, b\"fake_video_bytes_1\".to_vec()),\n (2_i64, b\"fake_video_bytes_2\".to_vec()),\n];\n\nlet blob_schema = Arc::new(blob_schema);\nlet blob_batch = RecordBatch::try_new(\n blob_schema.clone(),\n vec![\n Arc::new(Int64Array::from_iter_values(blob_rows.iter().map(|row| row.0))),\n Arc::new(LargeBinaryArray::from_iter_values(\n blob_rows.iter().map(|row| row.1.as_slice()),\n )),\n ],\n)\n.unwrap();\nlet blob_reader: Box =\n Box::new(RecordBatchIterator::new(vec![Ok(blob_batch)].into_iter(), blob_schema));\nlet blob_table = db\n .create_table(\"videos\", blob_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsBlobApiSchema = "let blob_metadata = HashMap::from([(\n \"lance-encoding:blob\".to_string(),\n \"true\".to_string(),\n)]);\nlet blob_schema = Schema::new(vec![\n Field::new(\"id\", DataType::Int64, false),\n Field::new(\"video\", DataType::LargeBinary, true).with_metadata(blob_metadata),\n]);\n"; + +export const RsCreateDummyData = "let create_dummy_image = |color: u8| -> Vec {\n let mut png_like = vec![137, 80, 78, 71, 13, 10, 26, 10];\n png_like.push(color);\n png_like\n};\n\nlet data = vec![\n (\n 1_i32,\n \"red_square.png\",\n vec![0.1_f32; 128],\n create_dummy_image(1),\n \"red\",\n ),\n (\n 2_i32,\n \"blue_square.png\",\n vec![0.2_f32; 128],\n create_dummy_image(2),\n \"blue\",\n ),\n];\n"; + +export const RsDefineSchema = "let schema = Schema::new(vec![\n Field::new(\"id\", DataType::Int32, false),\n Field::new(\"filename\", DataType::Utf8, false),\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 128),\n false,\n ),\n Field::new(\"image_blob\", DataType::Binary, false),\n Field::new(\"label\", DataType::Utf8, false),\n]);\n"; + +export const RsIngestData = "let schema = Arc::new(schema);\nlet image_batch = RecordBatch::try_new(\n schema.clone(),\n vec![\n Arc::new(Int32Array::from_iter_values(data.iter().map(|row| row.0))),\n Arc::new(StringArray::from_iter_values(data.iter().map(|row| row.1))),\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n data.iter()\n .map(|row| Some(row.2.iter().copied().map(Some).collect::>())),\n 128,\n ),\n ),\n Arc::new(BinaryArray::from_iter_values(\n data.iter().map(|row| row.3.as_slice()),\n )),\n Arc::new(StringArray::from_iter_values(data.iter().map(|row| row.4))),\n ],\n)\n.unwrap();\nlet image_reader: Box =\n Box::new(RecordBatchIterator::new(vec![Ok(image_batch)].into_iter(), schema.clone()));\nlet table = db\n .create_table(\"images\", image_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsMultimodalImports = "use std::collections::HashMap;\nuse std::sync::Arc;\n\nuse arrow_array::types::Float32Type;\nuse arrow_array::{\n BinaryArray, FixedSizeListArray, Int32Array, Int64Array, LargeBinaryArray, RecordBatch,\n RecordBatchIterator, StringArray,\n};\nuse arrow_schema::{DataType, Field, Schema};\nuse futures_util::TryStreamExt;\nuse lancedb::connect;\nuse lancedb::database::CreateTableMode;\nuse lancedb::query::{ExecutableQuery, QueryBase};\n"; + +export const RsProcessResults = "for batch in &results {\n let filenames = batch\n .column_by_name(\"filename\")\n .unwrap()\n .as_any()\n .downcast_ref::()\n .unwrap();\n let images = batch\n .column_by_name(\"image_blob\")\n .unwrap()\n .as_any()\n .downcast_ref::()\n .unwrap();\n\n for row in 0..batch.num_rows() {\n let image_bytes = images.value(row);\n println!(\n \"Retrieved image: {}, Byte length: {}\",\n filenames.value(row),\n image_bytes.len()\n );\n }\n}\n"; + +export const RsSearchData = "let query_vector = vec![0.1_f32; 128];\nlet results = table\n .query()\n .nearest_to(query_vector)\n .unwrap()\n .limit(1)\n .execute()\n .await\n .unwrap()\n .try_collect::>()\n .await\n .unwrap();\n"; + diff --git a/docs/web/snippets/openai.mdx b/docs/web/snippets/openai.mdx new file mode 100644 index 000000000..f279e4797 --- /dev/null +++ b/docs/web/snippets/openai.mdx @@ -0,0 +1,6 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const RsImports = "\nuse std::{iter::once, sync::Arc};\n\nuse arrow_array::{Float64Array, Int32Array, RecordBatch, RecordBatchIterator, StringArray};\nuse arrow_schema::{DataType, Field, Schema};\nuse futures::StreamExt;\nuse lancedb::{\n arrow::IntoArrow,\n connect,\n embeddings::{openai::OpenAIEmbeddingFunction, EmbeddingDefinition, EmbeddingFunction},\n query::{ExecutableQuery, QueryBase},\n Result,\n};\n"; + +export const RsOpenaiEmbeddings = "#[tokio::main]\nasync fn main() -> Result<()> {\n let tempdir = tempfile::tempdir().unwrap();\n let tempdir = tempdir.path().to_str().unwrap();\n let api_key = std::env::var(\"OPENAI_API_KEY\").expect(\"OPENAI_API_KEY is not set\");\n let embedding = Arc::new(OpenAIEmbeddingFunction::new_with_model(\n api_key,\n \"text-embedding-3-large\",\n )?);\n\n let db = connect(tempdir).execute().await?;\n db.embedding_registry()\n .register(\"openai\", embedding.clone())?;\n\n let table = db\n .create_table(\"vectors\", make_data())\n .add_embedding(EmbeddingDefinition::new(\n \"text\",\n \"openai\",\n Some(\"embeddings\"),\n ))?\n .execute()\n .await?;\n\n let query = Arc::new(StringArray::from_iter_values(once(\"something warm\")));\n let query_vector = embedding.compute_query_embeddings(query)?;\n let mut results = table\n .vector_search(query_vector)?\n .limit(1)\n .execute()\n .await?;\n\n let rb = results.next().await.unwrap()?;\n let out = rb\n .column_by_name(\"text\")\n .unwrap()\n .as_any()\n .downcast_ref::()\n .unwrap();\n let text = out.iter().next().unwrap().unwrap();\n println!(\"Closest match: {}\", text);\n Ok(())\n}\n"; + diff --git a/docs/web/snippets/quickstart.mdx b/docs/web/snippets/quickstart.mdx new file mode 100644 index 000000000..7a4622979 --- /dev/null +++ b/docs/web/snippets/quickstart.mdx @@ -0,0 +1,82 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyQuickstartAddData = "more_data = [\n {\n \"id\": \"4\",\n \"name\": \"Morgana\",\n \"role\": \"Sorceress\",\n \"description\": \"Powerful sorceress of Avalon.\",\n \"stats\": {\"strength\": 2, \"magic\": 5, \"leadership\": 4, \"wisdom\": 4},\n \"vector\": [0.3, 0.9, 0.6, 0.8],\n \"power_score\": 3.75,\n },\n]\n\n# Add data to table\ntable.add(more_data)\n"; + +export const PyQuickstartAddFeature = "table.add_columns(\n {\n \"power_score\": \"cast(((stats.strength + stats.magic + stats.leadership + stats.wisdom) / 4.0) as float)\"\n }\n)\n"; + +export const PyQuickstartComputedFeature = "table.add_columns(computed={\"combat_score\": \"(stats.magic + stats.strength) / 2.0\"})\ntable.refresh_column(\"combat_score\")\n"; + +export const PyQuickstartCreateTable = "table = db.create_table(\"characters\", data=data, mode=\"overwrite\")\n"; + +export const PyQuickstartCreateTableAsync = "async_table = await async_db.create_table(\n \"characters\",\n data=data,\n mode=\"overwrite\",\n)\n"; + +export const PyQuickstartCreateTableNoOverwrite = "table = db.create_table(\"characters\", data=data)\n"; + +export const PyQuickstartCurateWithMetadata = "curated = (\n table.search(query_vector)\n .where(\"stats.magic >= 4\")\n .select([\"name\", \"role\", \"description\", \"_distance\"])\n .limit(2)\n .to_polars()\n)\nprint(curated)\n"; + +export const PyQuickstartData = "data = [\n {\n \"id\": \"1\",\n \"name\": \"King Arthur\",\n \"role\": \"King\",\n \"description\": \"Leader of Camelot and wielder of Excalibur.\",\n \"stats\": {\"strength\": 4, \"magic\": 1, \"leadership\": 5, \"wisdom\": 4},\n \"vector\": [0.7, 0.1, 0.9, 0.7],\n },\n {\n \"id\": \"2\",\n \"name\": \"Merlin\",\n \"role\": \"Wizard\",\n \"description\": \"Advisor and prophet with deep magical knowledge.\",\n \"stats\": {\"strength\": 2, \"magic\": 5, \"leadership\": 4, \"wisdom\": 5},\n \"vector\": [0.2, 0.9, 0.4, 0.9],\n },\n {\n \"id\": \"3\",\n \"name\": \"Sir Lancelot\",\n \"role\": \"Knight\",\n \"description\": \"Legendary knight known for courage and combat skill.\",\n \"stats\": {\"strength\": 5, \"magic\": 1, \"leadership\": 3, \"wisdom\": 3},\n \"vector\": [0.9, 0.1, 0.5, 0.4],\n },\n]\n"; + +export const PyQuickstartDataAsync = "data = [\n {\n \"id\": \"1\",\n \"name\": \"King Arthur\",\n \"role\": \"King\",\n \"description\": \"Leader of Camelot and wielder of Excalibur.\",\n \"stats\": {\"strength\": 4, \"magic\": 1, \"leadership\": 5, \"wisdom\": 4},\n \"vector\": [0.7, 0.1, 0.9, 0.7],\n },\n {\n \"id\": \"2\",\n \"name\": \"Merlin\",\n \"role\": \"Wizard\",\n \"description\": \"Advisor and prophet with deep magical knowledge.\",\n \"stats\": {\"strength\": 2, \"magic\": 5, \"leadership\": 4, \"wisdom\": 5},\n \"vector\": [0.2, 0.9, 0.4, 0.9],\n },\n {\n \"id\": \"3\",\n \"name\": \"Sir Lancelot\",\n \"role\": \"Knight\",\n \"description\": \"Legendary knight known for courage and combat skill.\",\n \"stats\": {\"strength\": 5, \"magic\": 1, \"leadership\": 3, \"wisdom\": 3},\n \"vector\": [0.9, 0.1, 0.5, 0.4],\n },\n]\n"; + +export const PyQuickstartMultimodalBytes = "from pathlib import Path\n\nimage_path = Path(\"docs/web/static/assets/images/quickstart/sir-lancelot.jpg\")\nimage_bytes = image_path.read_bytes()\n\nmultimodal_table = db.create_table(\n \"character_images\",\n data=[\n {\n \"id\": \"lancelot\",\n \"description\": \"Portrait of Sir Lancelot\",\n \"image\": image_bytes,\n \"vector\": [0.9, 0.1, 0.5, 0.4],\n }\n ],\n mode=\"overwrite\",\n)\n"; + +export const PyQuickstartOpenTable = "table = db.open_table(\"characters\")\n"; + +export const PyQuickstartOutputPandas = "# Ensure you run `pip install pandas` beforehand\nresult = table.search(query_vector).limit(2).to_pandas()\nprint(result)\n"; + +export const PyQuickstartQueryFeature = "features = table.search().select([\"name\", \"role\", \"power_score\"]).to_polars()\nprint(features)\n"; + +export const PyQuickstartTraining = "import torch\nfrom lancedb.permutation import Permutation\n\ndataset = Permutation.identity(table).select_columns(\n [\"power_score\", \"combat_score\"]\n)\ndataloader = torch.utils.data.DataLoader(dataset, batch_size=1024, shuffle=True)\n"; + +export const PyQuickstartTrainingProjection = "dataset = Permutation.identity(table).select_columns([\"power_score\"])\ndataloader = torch.utils.data.DataLoader(dataset, batch_size=1024)\n"; + +export const PyQuickstartVectorSearch1 = "# Search for examples similar to a \"wise magical advisor\"\nquery_vector = [0.2, 0.8, 0.4, 0.9]\n\n# Ensure you run `pip install polars` beforehand\nresult = (\n table.search(query_vector)\n .select([\"name\", \"role\", \"description\", \"_distance\"])\n .limit(2)\n .to_polars()\n)\nprint(result)\n"; + +export const PyQuickstartVectorSearch1Async = "# Search for examples similar to a \"wise magical advisor\"\nquery_vector = [0.2, 0.8, 0.4, 0.9]\n\n# Ensure you run `pip install polars` beforehand\nasync_result = await (\n await async_table.search(query_vector)\n).select([\"name\", \"role\", \"description\", \"_distance\"]).limit(2).to_polars()\nprint(async_result)\n"; + +export const PyQuickstartVectorSearch2 = "# Search for examples similar to a \"powerful sorceress\"\nquery_vector = [0.3, 0.9, 0.6, 0.8]\n\nresults = table.search(query_vector).limit(2).to_polars()\nprint(results)\n"; + +export const TsQuickstartAddData = "const moreData = [\n {\n id: \"4\",\n name: \"Morgana\",\n role: \"Sorceress\",\n description: \"Powerful sorceress of Avalon.\",\n stats: { strength: 2, magic: 5, leadership: 4, wisdom: 4 },\n vector: [0.3, 0.9, 0.6, 0.8],\n power_score: 3.75,\n },\n];\n\n// Add data to table\nawait table.add(moreData);\n"; + +export const TsQuickstartAddFeature = "await table.addColumns([\n {\n name: \"power_score\",\n valueSql:\n \"cast(((stats.strength + stats.magic + stats.leadership + stats.wisdom) / 4.0) as float)\",\n },\n]);\n"; + +export const TsQuickstartCreateTable = "let table = await db.createTable(\"characters\", data, { mode: \"overwrite\" });\n"; + +export const TsQuickstartCreateTableNoOverwrite = "table = await db.createTable(\"characters\", data);\n"; + +export const TsQuickstartCurateWithMetadata = "const curated = await table\n .search(queryVector)\n .where(\"stats.magic >= 4\")\n .select([\"name\", \"role\", \"description\", \"_distance\"])\n .limit(2)\n .toArray();\nconsole.table(curated);\n"; + +export const TsQuickstartData = "const data = [\n {\n id: \"1\",\n name: \"King Arthur\",\n role: \"King\",\n description: \"Leader of Camelot and wielder of Excalibur.\",\n stats: { strength: 4, magic: 1, leadership: 5, wisdom: 4 },\n vector: [0.7, 0.1, 0.9, 0.7],\n },\n {\n id: \"2\",\n name: \"Merlin\",\n role: \"Wizard\",\n description: \"Advisor and prophet with deep magical knowledge.\",\n stats: { strength: 2, magic: 5, leadership: 4, wisdom: 5 },\n vector: [0.2, 0.9, 0.4, 0.9],\n },\n {\n id: \"3\",\n name: \"Sir Lancelot\",\n role: \"Knight\",\n description: \"Legendary knight known for courage and combat skill.\",\n stats: { strength: 5, magic: 1, leadership: 3, wisdom: 3 },\n vector: [0.9, 0.1, 0.5, 0.4],\n },\n];\n"; + +export const TsQuickstartMultimodalBytes = "const arrow = await import(\"apache-arrow\");\nconst path = await import(\"node:path\");\nconst { readFile } = await import(\"node:fs/promises\");\n\nconst imagePath = path.join(\n __dirname,\n \"../../web/static/assets/images/quickstart/sir-lancelot.jpg\",\n);\nconst imageBytes = await readFile(imagePath);\nconst imageSchema = new arrow.Schema([\n new arrow.Field(\"id\", new arrow.Utf8()),\n new arrow.Field(\"description\", new arrow.Utf8()),\n new arrow.Field(\"image\", new arrow.Binary()),\n new arrow.Field(\n \"vector\",\n new arrow.FixedSizeList(\n 4,\n new arrow.Field(\"item\", new arrow.Float32(), true),\n ),\n ),\n]);\nconst imageData = lancedb.makeArrowTable(\n [\n {\n id: \"lancelot\",\n description: \"Portrait of Sir Lancelot\",\n image: imageBytes,\n vector: [0.9, 0.1, 0.5, 0.4],\n },\n ],\n { schema: imageSchema },\n);\nconst multimodalTable = await db.createTable(\n \"character_images\",\n imageData,\n { mode: \"overwrite\" },\n);\n"; + +export const TsQuickstartOpenTable = "table = await db.openTable(\"characters\");\n"; + +export const TsQuickstartOutputArray = "result = await table.search(queryVector).limit(2).toArray();\nconsole.table(result);\n"; + +export const TsQuickstartQueryFeature = "const features = await table\n .query()\n .select([\"name\", \"role\", \"power_score\"])\n .toArray();\nconsole.table(features);\n"; + +export const TsQuickstartVectorSearch1 = "// Search for examples similar to a \"wise magical advisor\"\nlet queryVector = [0.2, 0.8, 0.4, 0.9];\n\nlet result = await table\n .search(queryVector)\n .select([\"name\", \"role\", \"description\", \"_distance\"])\n .limit(2)\n .toArray();\nconsole.table(result);\n"; + +export const TsQuickstartVectorSearch2 = "// Search for examples similar to a \"powerful sorceress\"\nqueryVector = [0.3, 0.9, 0.6, 0.8];\n\nconst results = await table.search(queryVector).limit(2).toArray();\nconsole.table(results);\n"; + +export const RsQuickstartAddFeature = "table\n .add_columns(\n NewColumnTransform::SqlExpressions(vec![(\n \"power_score\".to_string(),\n \"cast(((stats.strength + stats.magic + stats.leadership + stats.wisdom) / 4.0) as float)\"\n .to_string(),\n )]),\n None,\n )\n .await\n .unwrap();\n"; + +export const RsQuickstartCreateTable = "let schema = characters_schema();\nlet table = db\n .create_table(\"characters\", characters_to_reader(schema.clone(), &data))\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsQuickstartCreateTableNoOverwrite = "let table = db\n .create_table(\"characters\", characters_to_reader(schema.clone(), &data))\n .execute()\n .await\n .unwrap();\n"; + +export const RsQuickstartCurateWithMetadata = "let curated: DataFrame = table\n .query()\n .nearest_to(&query_vector)\n .unwrap()\n .only_if(\"stats.magic >= 4\")\n .select(Select::Columns(vec![\n \"name\".to_string(),\n \"role\".to_string(),\n \"description\".to_string(),\n \"_distance\".to_string(),\n ]))\n .limit(2)\n .execute()\n .await\n .unwrap()\n .into_polars()\n .await\n .unwrap();\nprintln!(\"{curated:?}\");\n"; + +export const RsQuickstartData = "let data = vec![\n Character {\n id: \"1\".to_string(),\n name: \"King Arthur\".to_string(),\n role: \"King\".to_string(),\n description: \"Leader of Camelot and wielder of Excalibur.\".to_string(),\n stats: Stats {\n strength: 4,\n magic: 1,\n leadership: 5,\n wisdom: 4,\n },\n vector: [0.7, 0.1, 0.9, 0.7],\n },\n Character {\n id: \"2\".to_string(),\n name: \"Merlin\".to_string(),\n role: \"Wizard\".to_string(),\n description: \"Advisor and prophet with deep magical knowledge.\".to_string(),\n stats: Stats {\n strength: 2,\n magic: 5,\n leadership: 4,\n wisdom: 5,\n },\n vector: [0.2, 0.9, 0.4, 0.9],\n },\n Character {\n id: \"3\".to_string(),\n name: \"Sir Lancelot\".to_string(),\n role: \"Knight\".to_string(),\n description: \"Legendary knight known for courage and combat skill.\".to_string(),\n stats: Stats {\n strength: 5,\n magic: 1,\n leadership: 3,\n wisdom: 3,\n },\n vector: [0.9, 0.1, 0.5, 0.4],\n },\n];\n"; + +export const RsQuickstartDefineStruct = "// Define structs representing the data schema\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct Stats {\n strength: i8,\n magic: i8,\n leadership: i8,\n wisdom: i8,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct Character {\n id: String,\n name: String,\n role: String,\n description: String,\n stats: Stats,\n vector: [f32; 4],\n}\n\nfn characters_schema() -> Arc {\n Arc::new(Schema::new(vec![\n Field::new(\"id\", DataType::LargeUtf8, false),\n Field::new(\"name\", DataType::LargeUtf8, false),\n Field::new(\"role\", DataType::LargeUtf8, false),\n Field::new(\"description\", DataType::LargeUtf8, false),\n Field::new(\n \"stats\",\n DataType::Struct(arrow_schema::Fields::from(vec![\n Arc::new(Field::new(\"strength\", DataType::Int8, false)),\n Arc::new(Field::new(\"magic\", DataType::Int8, false)),\n Arc::new(Field::new(\"leadership\", DataType::Int8, false)),\n Arc::new(Field::new(\"wisdom\", DataType::Int8, false)),\n ])),\n false,\n ),\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 4),\n false,\n ),\n ]))\n}\n"; + +export const RsQuickstartMultimodalBytes = "use std::sync::Arc;\n\nuse arrow_array::{\n BinaryArray, FixedSizeListArray, LargeStringArray, RecordBatch, RecordBatchIterator,\n};\nuse arrow_schema::{DataType, Field, Schema};\n\nlet image_path = std::path::Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"../../web/static/assets/images/quickstart/sir-lancelot.jpg\");\nlet image_bytes = std::fs::read(image_path).unwrap();\n\nlet image_schema = Arc::new(Schema::new(vec![\n Field::new(\"id\", DataType::LargeUtf8, false),\n Field::new(\"description\", DataType::LargeUtf8, false),\n Field::new(\"image\", DataType::Binary, false),\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 4),\n false,\n ),\n]));\nlet image_vectors = [[0.9_f32, 0.1, 0.5, 0.4]];\nlet image_batch = RecordBatch::try_new(\n image_schema.clone(),\n vec![\n Arc::new(LargeStringArray::from_iter_values([\"lancelot\"])),\n Arc::new(LargeStringArray::from_iter_values([\n \"Portrait of Sir Lancelot\",\n ])),\n Arc::new(BinaryArray::from_iter_values([image_bytes.as_slice()])),\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n image_vectors\n .iter()\n .map(|vector| Some(vector.iter().copied().map(Some).collect::>())),\n 4,\n ),\n ),\n ],\n)\n.unwrap();\nlet image_reader: Box = Box::new(\n RecordBatchIterator::new(vec![Ok(image_batch)].into_iter(), image_schema),\n);\nlet multimodal_table = db\n .create_table(\"character_images\", image_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsQuickstartOutputArray = "let result: DataFrame = table\n .query()\n .nearest_to(&query_vector)\n .unwrap()\n .select(Select::Columns(vec![\n \"name\".to_string(),\n \"role\".to_string(),\n \"description\".to_string(),\n \"_distance\".to_string(),\n ]))\n .limit(2)\n .execute()\n .await\n .unwrap()\n .into_polars()\n .await\n .unwrap();\nprintln!(\"{result:?}\");\n"; + +export const RsQuickstartQueryFeature = "let features: DataFrame = table\n .query()\n .select(Select::Columns(vec![\n \"name\".to_string(),\n \"role\".to_string(),\n \"power_score\".to_string(),\n ]))\n .execute()\n .await\n .unwrap()\n .into_polars()\n .await\n .unwrap();\nprintln!(\"{features:?}\");\n"; + +export const RsQuickstartVectorSearch1 = "// Search for examples similar to a \"wise magical advisor\"\nlet query_vector = [0.2, 0.8, 0.4, 0.9];\n\nlet result: DataFrame = table\n .query()\n .nearest_to(&query_vector)\n .unwrap()\n .select(Select::Columns(vec![\n \"name\".to_string(),\n \"role\".to_string(),\n \"description\".to_string(),\n \"_distance\".to_string(),\n ]))\n .limit(2)\n .execute()\n .await\n .unwrap()\n .into_polars()\n .await\n .unwrap();\nprintln!(\"{result:?}\");\n"; + diff --git a/docs/web/snippets/search.mdx b/docs/web/snippets/search.mdx new file mode 100644 index 000000000..4d9f0eccb --- /dev/null +++ b/docs/web/snippets/search.mdx @@ -0,0 +1,172 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyBasicFts = "uri = \"data/sample-lancedb\"\ndb = lancedb.connect(uri)\n\ntable = db.create_table(\n \"my_table_fts\",\n data=[\n {\"vector\": [3.1, 4.1], \"text\": \"Frodo was a happy puppy\"},\n {\"vector\": [5.9, 26.5], \"text\": \"There are several kittens playing\"},\n ],\n mode=\"overwrite\",\n)\n\ntable.create_fts_index(\"text\")\ntable.search(\"puppy\").limit(10).select([\"text\"]).to_list()\n# [{'text': 'Frodo was a happy puppy', '_score': 0.6931471824645996}]\n# ...\n"; + +export const PyBasicFtsAsync = "uri = \"data/sample-lancedb\"\nasync_db = await lancedb.connect_async(uri)\n\nasync_tbl = await async_db.create_table(\n \"my_table_fts_async\",\n data=[\n {\"vector\": [3.1, 4.1], \"text\": \"Frodo was a happy puppy\"},\n {\"vector\": [5.9, 26.5], \"text\": \"There are several kittens playing\"},\n ],\n mode=\"overwrite\",\n)\n\n# async API uses our native FTS algorithm\nawait async_tbl.create_index(\"text\", config=FTS())\nawait (await async_tbl.search(\"puppy\")).select([\"text\"]).limit(10).to_list()\n# [{'text': 'Frodo was a happy puppy', '_score': 0.6931471824645996}]\n# ...\n"; + +export const PyBasicHybridSearch = "data = [\n {\"text\": \"rebel spaceships striking from a hidden base\"},\n {\"text\": \"have won their first victory against the evil Galactic Empire\"},\n {\"text\": \"during the battle rebel spies managed to steal secret plans\"},\n {\"text\": \"to the Empire's ultimate weapon the Death Star\"},\n]\nuri = \"data/sample-lancedb\"\ndb = lancedb.connect(uri)\ntable = db.create_table(\"documents\", schema=Documents)\n# ingest docs with auto-vectorization\ntable.add(data)\n# Create a fts index before the hybrid search\ntable.create_fts_index(\"text\")\n# hybrid search with default re-ranker\ntable.search(\"flower moon\", query_type=\"hybrid\").to_pandas()\n"; + +export const PyBasicHybridSearchAsync = "uri = \"data/sample-lancedb\"\nasync_db = await lancedb.connect_async(uri)\ndata = [\n {\"text\": \"rebel spaceships striking from a hidden base\"},\n {\"text\": \"have won their first victory against the evil Galactic Empire\"},\n {\"text\": \"during the battle rebel spies managed to steal secret plans\"},\n {\"text\": \"to the Empire's ultimate weapon the Death Star\"},\n]\nasync_tbl = await async_db.create_table(\"documents_async\", schema=Documents)\n# ingest docs with auto-vectorization\nawait async_tbl.add(data)\n# Create a fts index before the hybrid search\nawait async_tbl.create_index(\"text\", config=FTS())\ntext_query = \"flower moon\"\n# hybrid search with default re-ranker\nawait (await async_tbl.search(\"flower moon\", query_type=\"hybrid\")).to_pandas()\n"; + +export const PyBatchSearch = "# Load a batch of query embeddings\nquery_dataset = load_dataset(\n \"sunhaozhepy/ag_news_sbert_keywords_embeddings\", split=\"test[5000:5005]\"\n)\nquery_embeds = query_dataset[\"keywords_embeddings\"]\nbatch_results = table.search(query_embeds).limit(5).to_pandas()\nprint(batch_results)\n"; + +export const PyBruteForceSearch = "tbl.search(np.random.random((1536))).limit(3).to_list()\n"; + +export const PyBypassVectorIndex = "table.search(embedding).bypass_vector_index().limit(5).to_pandas()\n"; + +export const PyClassDefinition = "class Metadata(BaseModel):\n source: str\n timestamp: datetime\n\n\nclass Document(BaseModel):\n content: str\n meta: Metadata\n\n\nclass LanceSchema(LanceModel):\n id: str\n vector: Vector(1536)\n payload: Document\n"; + +export const PyClassDocuments = "class Documents(LanceModel):\n vector: Vector(embeddings.ndims()) = embeddings.VectorField()\n text: str = embeddings.SourceField()\n"; + +export const PyConfigureDistanceMetric = "tbl.search(np.random.random((1536))).distance_type(\"cosine\").limit(10).to_list()\n"; + +export const PyCreateTableAsyncWithNestedSchema = "# Let's add 100 sample rows to our dataset\ndata = [\n LanceSchema(\n id=f\"id{i}\",\n vector=np.random.randn(1536),\n payload=Document(\n content=f\"document{i}\",\n meta=Metadata(source=f\"source{i % 10}\", timestamp=datetime.now()),\n ),\n )\n for i in range(100)\n]\n\nasync_tbl = await async_db.create_table(\n \"documents_async\", data=data, mode=\"overwrite\"\n)\n"; + +export const PyCreateTableWithNestedSchema = "# Let's add 100 sample rows to our dataset\ndata = [\n LanceSchema(\n id=f\"id{i}\",\n vector=np.random.randn(1536),\n payload=Document(\n content=f\"document{i}\",\n meta=Metadata(source=f\"source{i % 10}\", timestamp=datetime.now()),\n ),\n )\n for i in range(100)\n]\n\n# Synchronous client\ntbl = db.create_table(\"documents\", data=data, mode=\"overwrite\")\n"; + +export const PyExactVsApproximateDistances = "# Indexed ANN search without refinement (fast, approximate `_distance`)\nfast_results = (\n table.search(embedding)\n .limit(10)\n .to_pandas()\n)\n\n# Recompute distances on full vectors for reranked candidates\nexact_distance_results = (\n table.search(embedding)\n .limit(10)\n .refine_factor(1)\n .to_pandas()\n)\n\n# Rerank a larger candidate set for better recall (higher latency)\nhigher_recall_results = (\n table.search(embedding)\n .limit(10)\n .refine_factor(20)\n .to_pandas()\n)\n"; + +export const PyExhaustiveSearch = "uri = \"data/sample-lancedb\"\ndb = lancedb.connect(uri)\ndata = [\n {\"vector\": row, \"item\": f\"item {i}\"}\n for i, row in enumerate(np.random.random((10_000, 1536)).astype(\"float32\"))\n]\ntbl = db.create_table(\"vector_search\", data=data, mode=\"overwrite\")\ntbl.search(np.random.random((1536))).limit(10).to_list()\n"; + +export const PyExhaustiveSearchAsync = "uri = \"data/sample-lancedb\"\nasync_db = await lancedb.connect_async(uri)\ndata = [\n {\"vector\": row, \"item\": f\"item {i}\"}\n for i, row in enumerate(np.random.random((10_000, 1536)).astype(\"float32\"))\n]\nasync_tbl = await async_db.create_table(\n \"vector_search_async\", data=data, mode=\"overwrite\"\n)\n(await (await async_tbl.search(np.random.random((1536)))).limit(10).to_list())\n"; + +export const PyExhaustiveSearchAsyncCosine = "(\n await (await async_tbl.search(np.random.random((1536))))\n .distance_type(\"cosine\")\n .limit(10)\n .to_list()\n)\n"; + +export const PyExhaustiveSearchCosine = "tbl.search(np.random.random((1536))).distance_type(\"cosine\").limit(10).to_list()\n"; + +export const PyFastSearch = "table.search(embedding, fast_search=True).limit(5).to_pandas()\n"; + +export const PyFtsConfigFolding = "table.create_fts_index(\n \"text\",\n language=\"French\",\n stem=True,\n ascii_folding=True,\n replace=True,\n)\n"; + +export const PyFtsConfigFoldingAsync = "await async_tbl.create_index(\n \"text\", config=FTS(language=\"French\", stem=True, ascii_folding=True)\n)\n"; + +export const PyFtsConfigStem = "table.create_fts_index(\"text\", tokenizer_name=\"en_stem\", replace=True)\n"; + +export const PyFtsConfigStemAsync = "await async_tbl.create_index(\n \"text\", config=FTS(language=\"English\", stem=True, remove_stop_words=True)\n)\n"; + +export const PyFtsIncrementalIndex = "table.add([{\"vector\": [3.1, 4.1], \"text\": \"Frodo was a happy puppy\"}])\ntable.optimize()\n"; + +export const PyFtsIncrementalIndexAsync = "await async_tbl.add([{\"vector\": [3.1, 4.1], \"text\": \"Frodo was a happy puppy\"}])\nawait async_tbl.optimize()\n"; + +export const PyFtsPostfiltering = "table.search(\"puppy\").limit(10).where(\"text='foo'\", prefilter=False).to_list()\n"; + +export const PyFtsPostfilteringAsync = "await (\n (await async_tbl.search(\"puppy\"))\n .limit(10)\n .where(\"text='foo'\")\n .postfilter()\n .to_list()\n)\n"; + +export const PyFtsPrefiltering = "table.search(\"puppy\").limit(10).where(\"text='foo'\", prefilter=True).to_list()\n"; + +export const PyFtsPrefilteringAsync = "await (await async_tbl.search(\"puppy\")).limit(10).where(\"text='foo'\").to_list()\n"; + +export const PyFtsWithPosition = "table.create_fts_index(\"text\", with_position=True, replace=True)\n"; + +export const PyFtsWithPositionAsync = "await async_tbl.create_index(\"text\", config=FTS(with_position=True))\n"; + +export const PyHybridSearchPassVectorText = "vector_query = [0.1, 0.2, 0.3, 0.4, 0.5]\ntext_query = \"flower moon\"\n(\n table.search(query_type=\"hybrid\")\n .vector(vector_query)\n .text(text_query)\n .limit(5)\n .to_pandas()\n)\n"; + +export const PyHybridSearchPassVectorTextAsync = "vector_query = [0.1, 0.2, 0.3, 0.4, 0.5]\ntext_query = \"flower moon\"\nawait (\n async_tbl.query()\n .nearest_to(vector_query)\n .nearest_to_text(text_query)\n .limit(5)\n .to_pandas()\n)\n"; + +export const PyImportDatetime = "from datetime import datetime\n"; + +export const PyImportEmbeddings = "from lancedb.embeddings import get_registry\n"; + +export const PyImportLancedb = "import lancedb\n"; + +export const PyImportLancedbFts = "from lancedb.index import FTS\n"; + +export const PyImportLancedbPydantic = "from lancedb.pydantic import Vector, LanceModel\n"; + +export const PyImportNumpy = "from lancedb.query import BoostQuery, MatchQuery\nimport numpy as np\nimport pyarrow as pa\n"; + +export const PyImportOpenai = "import openai\n"; + +export const PyImportOs = "import os\n"; + +export const PyImportPydanticBaseModel = "from pydantic import BaseModel\n"; + +export const PyIndexNestedColumn = "table.create_index(vector_column_name=\"image.embedding\")\n"; + +export const PyMultivectorSearch = "query_multi = np.random.random(size=(2, 256))\nresults_multi = tbl.search(query_multi).limit(5).to_pandas()\n"; + +export const PyOpenaiEmbeddings = "# Ingest embedding function in LanceDB table\n# Configuring the environment variable OPENAI_API_KEY\nif \"OPENAI_API_KEY\" not in os.environ:\n # OR set the key here as a variable\n openai.api_key = \"sk-...\"\nembeddings = get_registry().get(\"openai\").create()\n"; + +export const PySearchBinaryVectors = "import numpy as np\nimport pyarrow as pa\n\nschema = pa.schema(\n [\n pa.field(\"id\", pa.int64()),\n # for dim=256, lance stores every 8 bits in a byte\n # so the vector field should be a list of 256 / 8 = 32 bytes\n pa.field(\"vector\", pa.list_(pa.uint8(), 32)),\n ]\n)\ntbl = db.create_table(\"my_binary_vectors\", schema=schema)\n\ndata = []\nfor i in range(1024):\n vector = np.random.randint(0, 2, size=256)\n # pack the binary vector into bytes to save space\n packed_vector = np.packbits(vector)\n data.append(\n {\n \"id\": i,\n \"vector\": packed_vector,\n }\n )\ntbl.add(data)\n\nquery = np.random.randint(0, 2, size=256)\npacked_query = np.packbits(query)\ntbl.search(packed_query).distance_type(\"hamming\").to_arrow()\n"; + +export const PySearchDistanceRange = "query = np.random.random(256)\n\n# Search for the vectors within the range of [0.1, 0.5)\ntbl.search(query).distance_range(0.1, 0.5).to_arrow()\n\n# Search for the vectors with the distance less than 0.5\ntbl.search(query).distance_range(upper_bound=0.5).to_arrow()\n\n# Search for the vectors with the distance greater or equal to 0.1\ntbl.search(query).distance_range(lower_bound=0.1).to_arrow()\n"; + +export const PySearchResultAsList = "tbl.search(np.random.randn(1536)).to_list()\n"; + +export const PySearchResultAsPandas = "tbl.search(np.random.randn(1536)).to_pandas()\n"; + +export const PySearchResultAsPandasFlatten1 = "tbl.search(np.random.randn(1536)).to_pandas(flatten=1)\n"; + +export const PySearchResultAsPandasFlattenTrue = "tbl.search(np.random.randn(1536)).to_pandas(flatten=True)\n"; + +export const PySearchResultAsPyarrow = "tbl.search(np.random.randn(1536)).to_arrow()\n"; + +export const PySearchResultAsPydantic = "tbl.search(np.random.randn(1536)).to_pydantic(LanceSchema)\n"; + +export const PySearchResultAsyncAsList = "await (await async_tbl.search(np.random.randn(1536))).to_list()\n"; + +export const PySearchResultAsyncAsPandas = "await (await async_tbl.search(np.random.randn(1536))).to_pandas()\n"; + +export const PySearchResultAsyncAsPyarrow = "await (await async_tbl.search(np.random.randn(1536))).to_arrow()\n"; + +export const PySelectVectorColumn = "import pyarrow as pa\n\nschema = pa.schema([\n pa.field(\"id\", pa.int32()),\n pa.field(\n \"image\",\n pa.struct([pa.field(\"embedding\", pa.list_(pa.float32(), 2))]),\n ),\n])\ntable = db.create_table(\n \"nested\",\n data=[{\"id\": 0, \"image\": {\"embedding\": [0.0, 1.0]}}],\n schema=schema,\n)\n\n# Inferred: the only vector leaf is `image.embedding`.\ntable.search([0.0, 1.0]).limit(1).to_list()\n\n# Explicit: required when more than one vector column matches.\ntable.search([0.0, 1.0], vector_column_name=\"image.embedding\").limit(1).to_list()\n"; + +export const PyVectorSearchPostfilter = "results_post_filtered = (\n table.search(query_embed)\n .where(\"label > 1\", prefilter=False)\n .select([\"text\", \"keywords\", \"label\"])\n .limit(5)\n .to_pandas()\n)\n\nprint(\"Vector search results with post-filter:\")\nprint(results_post_filtered)\n"; + +export const PyVectorSearchPrefilter = "from datasets import load_dataset\n\n# Load query vector from dataset\nquery_dataset = load_dataset(\"sunhaozhepy/ag_news_sbert_keywords_embeddings\", split=\"test[5000:5001]\")\nprint(f\"Query keywords: {query_dataset[0]['keywords']}\")\nquery_embed = query_dataset[\"keywords_embeddings\"][0]\n\n# Open table and perform search\ntable_name = \"lancedb-enterprise-quickstart\"\ntable = db.open_table(table_name)\n\n# Vector search with filters (pre-filtering is the default)\nsearch_results = (\n table.search(query_embed)\n .where(\"label > 2\")\n .select([\"text\", \"keywords\", \"label\"])\n .limit(5)\n .to_pandas()\n)\n\nprint(\"Search results (with pre-filtering):\")\nprint(search_results)\n"; + +export const TsBatchSearch = "// Batch query\nconsole.log(\"Performing batch vector search...\");\nconst batchSize = 5;\nconst queryVectors = Array.from({ length: batchSize }, () =>\n Array.from({ length: dimensions }, () => Math.random() * 2 - 1),\n);\nlet batchQuery = table.search(queryVectors[0]) as lancedb.VectorQuery;\nfor (let i = 1; i < batchSize; i++) {\n batchQuery = batchQuery.addQueryVector(queryVectors[i]);\n}\nconst batchResults = await batchQuery\n .select([\"text\", \"keywords\", \"label\"])\n .limit(5)\n .toArray();\nconsole.log(\"Batch vector search results:\");\nconsole.log(batchResults);\n"; + +export const TsBinarySearch = "const tbl = await db.createTable(\"binary_vectors\", data, {\n mode: \"overwrite\",\n});\nawait tbl.createIndex(\"vector\", {\n config: lancedb.Index.ivfFlat({\n numPartitions: 10,\n distanceType: \"hamming\",\n }),\n});\n\nconst query = Array(32)\n .fill(1)\n .map(() => Math.floor(Math.random() * 255));\nconst results = await tbl.query().nearestTo(query).limit(10).toArray();\n"; + +export const TsBruteForceSearch = "const tbl = await db.openTable(\"my_vectors\");\n\nconst results1 = await tbl.search(Array(128).fill(1.2)).limit(3).toArray();\n"; + +export const TsBypassVectorIndex = "await table\n .query()\n .nearestTo(embedding)\n .bypassVectorIndex()\n .limit(5)\n .toArray();\n"; + +export const TsDistanceRange = "const results3 = await (\n tbl.search(Array(128).fill(1.2)) as lancedb.VectorQuery\n)\n .distanceType(\"cosine\")\n .distanceRange(0.1, 0.2)\n .limit(10)\n .toArray();\n"; + +export const TsExactVsApproximate = "// Indexed ANN search without refinement (fast, approximate `_distance`)\nconst fastResults = await (table.search(embedding) as lancedb.VectorQuery)\n .limit(10)\n .toArray();\n\n// Recompute distances on full vectors for reranked candidates\nconst exactDistanceResults = await (\n table.search(embedding) as lancedb.VectorQuery\n)\n .limit(10)\n .refineFactor(1)\n .toArray();\n\n// Rerank a larger candidate set for better recall (higher latency)\nconst higherRecallResults = await (\n table.search(embedding) as lancedb.VectorQuery\n)\n .limit(10)\n .refineFactor(20)\n .toArray();\n"; + +export const TsFastSearch = "await table\n .query()\n .nearestTo(embedding)\n .fastSearch()\n .limit(5)\n .toArray();\n"; + +export const TsImport = "import * as lancedb from \"@lancedb/lancedb\";\n"; + +export const TsImportBinUtil = "import { Field, FixedSizeList, Int32, Schema, Uint8 } from \"apache-arrow\";\n"; + +export const TsIndexNestedColumn = "await table.createIndex(\"image.embedding\");\n"; + +export const TsIngestBinaryData = "const schema = new Schema([\n new Field(\"id\", new Int32(), true),\n new Field(\"vec\", new FixedSizeList(32, new Field(\"item\", new Uint8()))),\n]);\nconst data = lancedb.makeArrowTable(\n Array(1_000)\n .fill(0)\n .map((_, i) => ({\n // the 256 bits would be store in 32 bytes,\n // if your data is already in this format, you can skip the packBits step\n id: i,\n vec: lancedb.packBits(Array(256).fill(i % 2)),\n })),\n { schema: schema },\n);\n\nconst tbl = await db.createTable(\"binary_table\", data);\nawait tbl.createIndex(\"vec\", {\n config: lancedb.Index.ivfFlat({\n numPartitions: 10,\n distanceType: \"hamming\",\n }),\n});\n"; + +export const TsSearch1 = "const db = await lancedb.connect(databaseDir);\nconst tbl = await db.openTable(\"my_vectors\");\n\nconst results1 = await tbl.search(Array(128).fill(1.2)).limit(10).toArray();\n"; + +export const TsSearch2 = "const results2 = await (\n tbl.search(Array(128).fill(1.2)) as lancedb.VectorQuery\n)\n .distanceType(\"cosine\")\n .limit(10)\n .toArray();\n"; + +export const TsSearchBinaryData = "const query = Array(32)\n .fill(1)\n .map(() => Math.floor(Math.random() * 255));\nconst results = await tbl.query().nearestTo(query).limit(10).toArrow();\n"; + +export const TsSelectVectorColumn = "const table = await db.openTable(\"nested\");\n\n// Inferred: LanceDB finds the single nested vector leaf automatically.\nawait table.query().nearestTo([0.0, 1.0]).limit(1).toArray();\n\n// Explicit: required when more than one vector column matches.\nawait table\n .query()\n .nearestTo([0.0, 1.0])\n .column(\"image.embedding\")\n .limit(1)\n .toArray();\n"; + +export const TsVectorSearchPostfilter = "const vectorResultsWithPostFilter = await (\n table.search(queryEmbed) as lancedb.VectorQuery\n)\n .where(\"label > 2\")\n .postfilter()\n .select([\"text\", \"keywords\", \"label\"])\n .limit(5)\n .toArray();\n\nconsole.log(\"Vector search results with post-filter:\");\nconsole.log(vectorResultsWithPostFilter);\n"; + +export const TsVectorSearchPrefilter = "// Generate a sample 768-dimension embedding vector (typical for BERT-based models)\n// In real applications, you would get this from an embedding model\nconst dimensions = 768;\nconst queryEmbed = Array.from(\n { length: dimensions },\n () => Math.random() * 2 - 1,\n);\n\n// Open table and perform search\nconst tableName = \"lancedb-enterprise-quickstart\";\nconst table = await db.openTable(tableName);\n\n// Vector search with filters (pre-filtering is the default)\nconst vectorResults = await table\n .search(queryEmbed)\n .where(\"label > 2\")\n .select([\"text\", \"keywords\", \"label\"])\n .limit(5)\n .toArray();\n\nconsole.log(\"Search results (with pre-filtering):\");\nconsole.log(vectorResults);\n"; + +export const RsBatchSearch = "// Search multiple query vectors in one call. Each result row carries a\n// `query_index` mapping it back to the query it matched.\nlet mut results = table\n .vector_search(&query_1)?\n .add_query_vector(&query_2)?\n .limit(5)\n .execute()\n .await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n"; + +export const RsBinarySearch = "// Binary vectors use `hamming` distance over the packed uint8 bytes.\nlet query: Arc = Arc::new(UInt8Array::from(vec![1u8; NUM_BYTES as usize]));\nlet mut results = tbl\n .vector_search(query)?\n .distance_type(DistanceType::Hamming)\n .limit(10)\n .execute()\n .await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n"; + +export const RsBruteForceSearch = "// A plain vector search returns the top-k closest rows.\nlet mut results = table.vector_search(&query_vector)?.limit(3).execute().await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n"; + +export const RsBypassVectorIndex = "// Force an exhaustive (flat) scan for exact, ground-truth results.\nlet mut results = table\n .vector_search(&query_vector)?\n .bypass_vector_index()\n .limit(5)\n .execute()\n .await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n"; + +export const RsConfigureDistanceMetric = "// Use the same distance metric the index was trained with.\nlet mut results = table\n .vector_search(&query_vector)?\n .distance_type(DistanceType::Cosine)\n .limit(10)\n .execute()\n .await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n"; + +export const RsExactVsApproximate = "// Approximate ANN search (fast, distances may come from the index representation)\nlet mut fast_results = table.vector_search(&query_vector)?.limit(10).execute().await?;\nwhile let Some(batch) = fast_results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n\n// Rerank a larger candidate set on full vectors for better recall\nlet mut refined_results = table\n .vector_search(&query_vector)?\n .limit(10)\n .refine_factor(20)\n .execute()\n .await?;\nwhile let Some(batch) = refined_results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n"; + +export const RsFastSearch = "// Skip unindexed data for lower latency.\nlet mut results = table\n .vector_search(&query_vector)?\n .fast_search()\n .limit(5)\n .execute()\n .await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n"; + +export const RsSearchDistanceRange = "// Only return rows whose distance falls within [0.1, 0.5).\nlet mut results = table\n .vector_search(&query_vector)?\n .distance_range(Some(0.1), Some(0.5))\n .execute()\n .await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n"; + +export const RsVectorSearchPostfilter = "// Apply the filter after vector search by calling postfilter().\nlet mut results = table\n .vector_search(&query_vector)?\n .only_if(\"id > 100\")\n .postfilter()\n .select(Select::columns(&[\"id\"]))\n .limit(5)\n .execute()\n .await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n"; + +export const RsVectorSearchPrefilter = "// Prefiltering is the default: the filter is applied before vector search.\nlet mut results = table\n .vector_search(&query_vector)?\n .only_if(\"id > 100\")\n .select(Select::columns(&[\"id\"]))\n .limit(5)\n .execute()\n .await?;\nwhile let Some(batch) = results.try_next().await? {\n println!(\"{:?}\", batch);\n}\n"; + diff --git a/docs/web/snippets/sentence-transformers.mdx b/docs/web/snippets/sentence-transformers.mdx new file mode 100644 index 000000000..bf4aec126 --- /dev/null +++ b/docs/web/snippets/sentence-transformers.mdx @@ -0,0 +1,14 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const TsQuickstartConnect = "const db = await lancedb.connect(\"data/sample-lancedb\");\n"; + +export const TsQuickstartCreateTable = "const table = await db.createEmptyTable(\"words\", wordsSchema, {\n mode: \"overwrite\",\n});\nawait table.add([{ text: \"hello world\" }, { text: \"goodbye world\" }]);\n"; + +export const TsQuickstartImports = "import * as lancedb from \"@lancedb/lancedb\";\nimport \"@lancedb/lancedb/embedding/transformers\";\nimport { Utf8 } from \"apache-arrow\";\n"; + +export const TsQuickstartInitModel = "const model = (await lancedb.embedding\n .getRegistry()\n .get(\"huggingface\")\n ?.create()) as lancedb.embedding.EmbeddingFunction;\n"; + +export const TsQuickstartQuery = "const query = \"greetings\";\nconst actual = (await table.search(query).limit(1).toArray())[0];\nconsole.log(actual.text);\n"; + +export const TsQuickstartSchema = "const wordsSchema = lancedb.embedding.LanceSchema({\n text: model.sourceField(new Utf8()),\n vector: model.vectorField(),\n});\n"; + diff --git a/docs/web/snippets/simple.mdx b/docs/web/snippets/simple.mdx new file mode 100644 index 000000000..92710bfc4 --- /dev/null +++ b/docs/web/snippets/simple.mdx @@ -0,0 +1,24 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const RsAdd = "let new_data = create_some_records()?;\ntbl.add(new_data).execute().await.unwrap();\n"; + +export const RsConnect = "#[tokio::main]\nasync fn main() -> Result<()> {\n if std::path::Path::new(\"data\").exists() {\n std::fs::remove_dir_all(\"data\").unwrap();\n }\n // --8<-- [start:connect_uri]\n let uri = \"data/sample-lancedb\";\n let db = connect(uri).execute().await?;\n // --8<-- [end:connect_uri]\n\n // --8<-- [start:list_names]\n println!(\"{:?}\", db.table_names().execute().await?);\n // --8<-- [end:list_names]\n let tbl = create_table(&db).await?;\n create_index(&tbl).await?;\n let batches = search(&tbl).await?;\n println!(\"{:?}\", batches);\n\n create_empty_table(&db).await.unwrap();\n\n // --8<-- [start:delete]\n tbl.delete(\"id > 24\").await.unwrap();\n // --8<-- [end:delete]\n\n // --8<-- [start:drop_table]\n db.drop_table(\"my_table\").await.unwrap();\n // --8<-- [end:drop_table]\n Ok(())\n}\n"; + +export const RsConnectUri = "let uri = \"data/sample-lancedb\";\nlet db = connect(uri).execute().await?;\n"; + +export const RsCreateEmptyTable = "let schema = Arc::new(Schema::new(vec![\n Field::new(\"id\", DataType::Int32, false),\n Field::new(\"item\", DataType::Utf8, true),\n]));\ndb.create_empty_table(\"empty_table\", schema).execute().await\n"; + +export const RsCreateIndex = "table.create_index(&[\"vector\"], Index::Auto).execute().await\n"; + +export const RsCreateTable = "let initial_data = create_some_records()?;\nlet tbl = db\n .create_table(\"my_table\", initial_data)\n .execute()\n .await\n .unwrap();\n"; + +export const RsDelete = "tbl.delete(\"id > 24\").await.unwrap();\n"; + +export const RsDropTable = "db.drop_table(\"my_table\").await.unwrap();\n"; + +export const RsListNames = "println!(\"{:?}\", db.table_names().execute().await?);\n"; + +export const RsOpenExistingTbl = "let table = db.open_table(\"my_table\").execute().await.unwrap();\n"; + +export const RsSearch = "table\n .query()\n .limit(2)\n .nearest_to(&[1.0; 128])?\n .execute()\n .await?\n .try_collect::>()\n .await\n"; + diff --git a/docs/web/snippets/storage.mdx b/docs/web/snippets/storage.mdx new file mode 100644 index 000000000..942fd8aec --- /dev/null +++ b/docs/web/snippets/storage.mdx @@ -0,0 +1,64 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyStorageAzureAccount = "db = lancedb.connect(\n \"az://my-container/my-database\",\n storage_options={\n \"account_name\": \"some-account\",\n \"account_key\": \"some-key\",\n },\n)\n"; + +export const PyStorageAzureSas = "db = lancedb.connect(\n \"az://my-container/my-database\",\n storage_options={\n \"azure_storage_account_name\": \"some-account\",\n \"azure_storage_sas_token\": \"\",\n },\n)\n"; + +export const PyStorageConnectAzure = "db = lancedb.connect(\"az://bucket/path\")\n"; + +export const PyStorageConnectGcs = "db = lancedb.connect(\"gs://bucket/path\")\n"; + +export const PyStorageConnectS3 = "db = lancedb.connect(\"s3://bucket/path\")\n"; + +export const PyStorageConnectTimeout = "db = lancedb.connect(\n \"s3://bucket/path\",\n storage_options={\"timeout\": \"60s\"},\n)\n"; + +export const PyStorageGcsServiceAccount = "db = lancedb.connect(\n \"gs://my-bucket/my-database\",\n storage_options={\n \"service_account\": \"path/to/service-account.json\",\n },\n)\n"; + +export const PyStorageS3Ddb = "db = lancedb.connect(\n \"s3+ddb://bucket/path?ddbTableName=my-dynamodb-table\",\n)\n"; + +export const PyStorageS3DdbLocal = "db = lancedb.connect(\n \"s3+ddb://bucket/path?ddbTableName=my-dynamodb-table\",\n storage_options={\n \"endpoint\": \"http://localhost:4566\",\n \"dynamodb_endpoint\": \"http://localhost:4566\",\n \"allow_http\": \"true\",\n },\n)\n"; + +export const PyStorageS3Express = "db = lancedb.connect(\n \"s3://my-bucket--use1-az4--x-s3/path\",\n storage_options={\n \"region\": \"us-east-1\",\n \"s3_express\": \"true\",\n },\n)\n"; + +export const PyStorageS3Minio = "db = lancedb.connect(\n \"s3://bucket/path\",\n storage_options={\n \"region\": \"us-east-1\",\n \"endpoint\": \"http://minio:9000\",\n },\n)\n"; + +export const PyStorageS3SseKms = "db = lancedb.connect(\n \"s3://bucket/path\",\n storage_options={\n \"aws_server_side_encryption\": \"aws:kms\",\n \"aws_sse_kms_key_id\": \"\",\n },\n)\n"; + +export const PyStorageTableTimeout = "table = db.create_table(\n \"table\",\n [{\"a\": 1, \"b\": 2}],\n storage_options={\"timeout\": \"60s\"},\n)\n"; + +export const PyStorageTigrisConnect = "db = lancedb.connect(\n \"s3://your-bucket/path\",\n storage_options={\n \"endpoint\": \"https://t3.storage.dev\",\n \"region\": \"auto\",\n },\n)\n"; + +export const PyStorageCosConnect = "db = lancedb.connect(\n \"cos://my-bucket/my-database\",\n storage_options={\n \"secret_id\": \"\",\n \"secret_key\": \"\",\n \"region\": \"ap-guangzhou\",\n },\n)\n"; + +export const PyStorageGoosefsConnect = "db = lancedb.connect(\"goosefs://my-namespace/my-database\")\n"; + +export const TsStorageAzureAccount = "async function storageAzureAccount() {\n const db = await lancedb.connect(\n \"az://my-container/my-database\",\n {\n storageOptions: {\n accountName: \"some-account\",\n accountKey: \"some-key\",\n },\n },\n );\n return db;\n}\n"; + +export const TsStorageAzureSas = "async function storageAzureSas() {\n const db = await lancedb.connect(\n \"az://my-container/my-database\",\n {\n storageOptions: {\n azureStorageAccountName: \"some-account\",\n azureStorageSasToken: \"\",\n },\n },\n );\n return db;\n}\n"; + +export const TsStorageConnectAzure = "async function storageConnectAzure() {\n const db = await lancedb.connect(\"az://bucket/path\");\n return db;\n}\n"; + +export const TsStorageConnectGcs = "async function storageConnectGcs() {\n const db = await lancedb.connect(\"gs://bucket/path\");\n return db;\n}\n"; + +export const TsStorageConnectS3 = "async function storageConnectS3() {\n const db = await lancedb.connect(\"s3://bucket/path\");\n return db;\n}\n"; + +export const TsStorageConnectTimeout = "async function storageConnectTimeout() {\n const db = await lancedb.connect(\"s3://bucket/path\", {\n storageOptions: { timeout: \"60s\" },\n });\n return db;\n}\n"; + +export const TsStorageGcsServiceAccount = "async function storageGcsServiceAccount() {\n const db = await lancedb.connect(\n \"gs://my-bucket/my-database\",\n {\n storageOptions: {\n serviceAccount: \"path/to/service-account.json\",\n },\n },\n );\n return db;\n}\n"; + +export const TsStorageS3Ddb = "async function storageS3Ddb() {\n const db = await lancedb.connect(\n \"s3+ddb://bucket/path?ddbTableName=my-dynamodb-table\",\n );\n return db;\n}\n"; + +export const TsStorageS3DdbLocal = "async function storageS3DdbLocal() {\n const db = await lancedb.connect(\n \"s3+ddb://bucket/path?ddbTableName=my-dynamodb-table\",\n {\n storageOptions: {\n endpoint: \"http://localhost:4566\",\n dynamodbEndpoint: \"http://localhost:4566\",\n allowHttp: \"true\",\n },\n },\n );\n return db;\n}\n"; + +export const TsStorageS3Express = "async function storageS3Express() {\n const db = await lancedb.connect(\n \"s3://my-bucket--use1-az4--x-s3/path\",\n {\n storageOptions: {\n region: \"us-east-1\",\n s3Express: \"true\",\n },\n },\n );\n return db;\n}\n"; + +export const TsStorageS3Minio = "async function storageS3Minio() {\n const db = await lancedb.connect(\"s3://bucket/path\", {\n storageOptions: {\n region: \"us-east-1\",\n endpoint: \"http://minio:9000\",\n },\n });\n return db;\n}\n"; + +export const TsStorageS3SseKms = "async function storageS3SseKms() {\n const db = await lancedb.connect(\"s3://bucket/path\", {\n storageOptions: {\n awsServerSideEncryption: \"aws:kms\",\n awsSseKmsKeyId: \"\",\n },\n });\n return db;\n}\n"; + +export const TsStorageTableTimeout = "async function storageTableTimeout() {\n const db = await lancedb.connect(\"s3://bucket/path\");\n const table = await db.createTable(\n \"table\",\n [{ a: 1, b: 2 }],\n { storageOptions: { timeout: \"60s\" } },\n );\n return table;\n}\n"; + +export const TsStorageTigrisConnect = "async function storageTigrisConnect() {\n const db = await lancedb.connect(\n \"s3://your-bucket/path\",\n {\n storageOptions: {\n endpoint: \"https://t3.storage.dev\",\n region: \"auto\",\n },\n },\n );\n return db;\n}\n"; + +export const TsStorageGoosefsConnect = "async function storageGoosefsConnect() {\n const db = await lancedb.connect(\"goosefs://my-namespace/my-database\");\n return db;\n}\n"; + diff --git a/docs/web/snippets/tables.mdx b/docs/web/snippets/tables.mdx new file mode 100644 index 000000000..6b9b98280 --- /dev/null +++ b/docs/web/snippets/tables.mdx @@ -0,0 +1,372 @@ +{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */} + +export const PyAddColumnsCalculated = "# Add a discounted price column (10% discount)\ntable.add_columns({\"discounted_price\": \"cast((price * 0.9) as float)\"})\n"; + +export const PyAddColumnsDefaultValues = "# Add a stock status column with default value\ntable.add_columns({\"in_stock\": \"cast(true as boolean)\"})\n"; + +export const PyAddColumnsNullable = "# Add a nullable timestamp column\ntable.add_columns({\"last_ordered\": \"cast(NULL as timestamp)\"})\n"; + +export const PyAddDataNestedModel = "from lancedb.pydantic import LanceModel, Vector\nfrom pydantic import BaseModel\n\nclass Document(BaseModel):\n content: str\n source: str\n\nclass NestedSchema(LanceModel):\n id: str\n vector: Vector(128)\n document: Document\n\n# Create table with nested schema\ntable_name = \"nested_model_example\"\ntable = db.create_table(table_name, schema=NestedSchema, mode=\"overwrite\")\n"; + +export const PyAddDataPydanticModel = "from lancedb.pydantic import LanceModel, Vector\n\n# Define a Pydantic model\nclass Content(LanceModel):\n movie_id: int\n vector: Vector(128)\n genres: str\n title: str\n imdb_id: int\n\n @property\n def imdb_url(self) -> str:\n return f\"https://www.imdb.com/title/tt{self.imdb_id}\"\n\n# Create table with Pydantic model schema\ntable_name = \"pydantic_example\"\ntable = db.create_table(table_name, schema=Content, mode=\"overwrite\")\n"; + +export const PyAddDataToTable = "import pyarrow as pa\n\n# create an empty table with schema\ndata = [\n {\"vector\": [3.1, 4.1], \"item\": \"foo\", \"price\": 10.0},\n {\"vector\": [5.9, 26.5], \"item\": \"bar\", \"price\": 20.0},\n {\"vector\": [10.2, 100.8], \"item\": \"baz\", \"price\": 30.0},\n {\"vector\": [1.4, 9.5], \"item\": \"fred\", \"price\": 40.0},\n]\n\nschema = pa.schema(\n [\n pa.field(\"vector\", pa.list_(pa.float32(), 2)),\n pa.field(\"item\", pa.utf8()),\n pa.field(\"price\", pa.float32()),\n ]\n)\n\ntable_name = \"basic_ingestion_example\"\ntable = db.create_table(table_name, schema=schema, mode=\"overwrite\")\n# Add data\ntable.add(data)\n"; + +export const PyAddFeatureColumnsSql = "table.add_columns(\n {\n \"price_per_id\": \"cast(price / id as float)\",\n \"price_log\": \"ln(price)\",\n \"price_score\": \"cast(price / (price + 100.0) as float)\",\n }\n)\n"; + +export const PyAddFromDataset = "import pyarrow.dataset as ds\n\ndataset = ds.dataset(data_path, format=\"parquet\")\ndb = tmp_db\ntable = db.create_table(\"my_table\", schema=dataset.schema, mode=\"overwrite\")\ntable.add(dataset)\n"; + +export const PyAlterColumnsDataType = "# Change price from int32 to int64 for larger numbers\ntable.alter_columns({\"path\": \"price\", \"data_type\": pa.int64()})\n"; + +export const PyAlterColumnsMultiple = "# Rename, change type, and make nullable in one operation\ntable.alter_columns(\n {\n \"path\": \"sale_price\",\n \"rename\": \"final_price\",\n \"data_type\": pa.float64(),\n \"nullable\": True,\n }\n)\n"; + +export const PyAlterColumnsNullable = "# Make the name column nullable\ntable.alter_columns({\"path\": \"name\", \"nullable\": True})\n"; + +export const PyAlterColumnsRename = "# Rename discount_price to sale_price\ntable.alter_columns({\"path\": \"discount_price\", \"rename\": \"sale_price\"})\n"; + +export const PyAlterColumnsWithExpression = "# For custom transforms, create a new column from a SQL expression.\nexpression_table = tmp_db.create_table(\n \"schema_evolution_expression_example\",\n [{\"id\": 1, \"price_text\": \"$100\"}],\n mode=\"overwrite\",\n)\n\nexpression_table.add_columns(\n {\"price_numeric\": \"cast(replace(price_text, '$', '') as int)\"}\n)\nexpression_table.drop_columns([\"price_text\"])\nexpression_table.alter_columns({\"path\": \"price_numeric\", \"rename\": \"price\"})\n"; + +export const PyAlterVectorColumn = "vector_dim = 768 # Your embedding dimension\ntable_name = \"vector_alter_example\"\ndb = tmp_db\ndata = [\n {\n \"id\": 1,\n \"embedding\": np.random.random(vector_dim).tolist(),\n },\n]\ntable = db.create_table(table_name, data, mode=\"overwrite\")\n\ntable.alter_columns(\n dict(path=\"embedding\", data_type=pa.list_(pa.float32(), vector_dim))\n)\n"; + +export const PyBatchDataInsertion = "import pyarrow as pa\n\ndef make_batches():\n for i in range(5): # Create 5 batches\n yield pa.RecordBatch.from_arrays(\n [\n pa.array([[3.1, 4.1], [5.9, 26.5]], pa.list_(pa.float32(), 2)),\n pa.array([f\"item{i * 2 + 1}\", f\"item{i * 2 + 2}\"]),\n pa.array([float((i * 2 + 1) * 10), float((i * 2 + 2) * 10)]),\n ],\n [\"vector\", \"item\", \"price\"],\n )\n\nschema = pa.schema(\n [\n pa.field(\"vector\", pa.list_(pa.float32(), 2)),\n pa.field(\"item\", pa.utf8()),\n pa.field(\"price\", pa.float32()),\n ]\n)\n# Create table with batches\ntable_name = \"batch_ingestion_example\"\ntable = db.create_table(table_name, make_batches(), schema=schema, mode=\"overwrite\")\n"; + +export const PyBranchCreate = "# Fork an isolated, writable branch from main's latest version.\n# `create` returns a table handle scoped to the new branch.\nbranch = table.branches.create(\"exp\")\n"; + +export const PyBranchDelete = "# Delete the branch and its branch-local history. Data on main is safe.\ntable.branches.delete(\"exp\")\n"; + +export const PyBranchIndex = "# Build and validate indexes on a branch before using the configuration on\n# main.\ndev = products.branches.create(\"index-dev\")\n\n# A vector (ANN) index and a full-text search index, both branch-scoped.\ndev.create_index(\n \"vector\",\n config=IvfPq(distance_type=\"cosine\", num_partitions=1, num_sub_vectors=2),\n)\ndev.create_index(\"text\", config=FTS())\n\n# Both indexes live only on the branch; main still has none.\nprint([ix.name for ix in dev.list_indices()]) # branch: two indexes\nprint([ix.name for ix in products.list_indices()]) # main: [] (untouched)\n"; + +export const PyBranchReopen = "# Reopen an existing branch by name from the table handle...\nchecked_out = table.branches.checkout(\"exp\")\n# ...or open it directly from the database connection.\nbranch_handle = db.open_table(\"quotes_branches_example\", branch=\"exp\")\nprint(checked_out.count_rows(), branch_handle.count_rows()) # both 4\n"; + +export const PyBranchUpsertToMain = "# This is a row-level upsert, not a merge of branch histories.\n# `merge_insert` updates matching rows and inserts new rows using a stable\n# unique key. Filter the branch read if you only want to apply some results.\nrows_to_apply = candidate.to_arrow()\n(\n table.merge_insert(\"id\")\n .when_matched_update_all() # update rows that already exist on main\n .when_not_matched_insert_all() # insert rows that are new on the branch\n .execute(rows_to_apply)\n)\n"; + +export const PyBranchWrite = "# Writes land on the branch handle only; main is left untouched.\nbranch.add([{\"id\": 4, \"author\": \"Lancelot\", \"quote\": \"For the realm!\"}])\nprint(branch.count_rows()) # 4 rows on the branch\nprint(table.count_rows()) # 3 rows; main is unaffected\n\n# List every branch, each mapped to its metadata (including its fork point).\nprint(table.branches.list())\n"; + +export const PyConsistencyCheckoutLatest = "uri = str(tmp_db.uri)\nwriter_db = lancedb.connect(uri)\nreader_db = lancedb.connect(uri)\nwriter_table = writer_db.create_table(\n \"consistency_checkout_latest_table\", [{\"id\": 1}], mode=\"overwrite\"\n)\nreader_table = reader_db.open_table(\"consistency_checkout_latest_table\")\n\nwriter_table.add([{\"id\": 2}])\nrows_before_refresh = reader_table.count_rows()\nprint(f\"Rows before checkout_latest: {rows_before_refresh}\")\n\nreader_table.checkout_latest()\nrows_after_refresh = reader_table.count_rows()\nprint(f\"Rows after checkout_latest: {rows_after_refresh}\")\n"; + +export const PyConsistencyEventual = "from datetime import timedelta\n\nuri = str(tmp_db.uri)\nwriter_db = lancedb.connect(uri)\nreader_db = lancedb.connect(uri, read_consistency_interval=timedelta(seconds=3600))\nwriter_table = writer_db.create_table(\n \"consistency_eventual_table\", [{\"id\": 1}], mode=\"overwrite\"\n)\nreader_table = reader_db.open_table(\"consistency_eventual_table\")\nwriter_table.add([{\"id\": 2}])\nrows_after_write = reader_table.count_rows()\nprint(f\"Rows visible before eventual refresh interval: {rows_after_write}\")\n"; + +export const PyConsistencyStrong = "from datetime import timedelta\n\nuri = str(tmp_db.uri)\nwriter_db = lancedb.connect(uri)\nreader_db = lancedb.connect(uri, read_consistency_interval=timedelta(0))\nwriter_table = writer_db.create_table(\n \"consistency_strong_table\", [{\"id\": 1}], mode=\"overwrite\"\n)\nreader_table = reader_db.open_table(\"consistency_strong_table\")\nwriter_table.add([{\"id\": 2}])\nrows_after_write = reader_table.count_rows()\nprint(f\"Rows visible with strong consistency: {rows_after_write}\")\n"; + +export const PyCreateEmptyTable = "import pyarrow as pa\n\nschema = pa.schema(\n [\n pa.field(\"vector\", pa.list_(pa.float32(), 2)),\n pa.field(\"item\", pa.string()),\n pa.field(\"price\", pa.float32()),\n ]\n)\ndb = tmp_db\ntbl = db.create_table(\"test_empty_table\", schema=schema, mode=\"overwrite\")\n"; + +export const PyCreateEmptyTablePydantic = "from lancedb.pydantic import LanceModel, Vector\n\nclass Item(LanceModel):\n vector: Vector(2)\n item: str\n price: float\n\ndb = tmp_db\ntbl = db.create_table(\n \"test_empty_table_new\", schema=Item.to_arrow_schema(), mode=\"overwrite\"\n)\n"; + +export const PyCreateTableConflictHandling = "# Idempotent open: reuse the existing table if it exists.\n# The provided data is ignored; the schema is validated against the\n# existing table and a mismatch raises an error.\ntbl = db.create_table(\"conflict_table\", data, exist_ok=True)\n\n# Overwrite: drop the existing table and create a new one with the\n# provided data. This permanently discards the old table's data.\ntbl = db.create_table(\"conflict_table\", data, mode=\"overwrite\")\n"; + +export const PyCreateTableCustomSchema = "import pyarrow as pa\n\ncustom_schema = pa.schema(\n [\n pa.field(\"vector\", pa.list_(pa.float32(), 4)),\n pa.field(\"lat\", pa.float32()),\n pa.field(\"long\", pa.float32()),\n ]\n)\n\ndata = [\n {\"vector\": [1.1, 1.2, 1.3, 1.4], \"lat\": 45.5, \"long\": -122.7},\n {\"vector\": [0.2, 1.8, 0.4, 3.6], \"lat\": 40.1, \"long\": -74.1},\n]\ndb = tmp_db\ntbl = db.create_table(\n \"my_table_custom_schema\", data, schema=custom_schema, mode=\"overwrite\"\n)\n"; + +export const PyCreateTableFromArrow = "import numpy as np\nimport pyarrow as pa\n\ndim = 16\ntotal = 2\nschema = pa.schema(\n [pa.field(\"vector\", pa.list_(pa.float16(), dim)), pa.field(\"text\", pa.string())]\n)\ndata = pa.Table.from_arrays(\n [\n pa.array(\n [np.random.randn(dim).astype(np.float16) for _ in range(total)],\n pa.list_(pa.float16(), dim),\n ),\n pa.array([\"foo\", \"bar\"]),\n ],\n [\"vector\", \"text\"],\n)\ndb = tmp_db\ntbl = db.create_table(\"f16_tbl\", data, schema=schema, mode=\"overwrite\")\n"; + +export const PyCreateTableFromDicts = "data = [\n {\"vector\": [1.1, 1.2], \"lat\": 45.5, \"long\": -122.7},\n {\"vector\": [0.2, 1.8], \"lat\": 40.1, \"long\": -74.1},\n]\ndb = tmp_db\ndb.create_table(\"test_table\", data, mode=\"overwrite\")\ntbl = db[\"test_table\"]\ntbl.head()\n"; + +export const PyCreateTableFromIterator = "import pyarrow as pa\n\nschema = pa.schema(\n [\n pa.field(\"vector\", pa.list_(pa.float32(), 4)),\n pa.field(\"item\", pa.utf8()),\n pa.field(\"price\", pa.float32()),\n ]\n)\n\ndef make_batches():\n for i in range(5):\n yield pa.RecordBatch.from_arrays(\n [\n pa.array(\n [[3.1, 4.1, 5.1, 6.1], [5.9, 26.5, 4.7, 32.8]],\n pa.list_(pa.float32(), 4),\n ),\n pa.array([\"foo\", \"bar\"]),\n pa.array([10.0, 20.0]),\n ],\n [\"vector\", \"item\", \"price\"],\n )\n\ndb = tmp_db\ndb.create_table(\"batched_table\", make_batches(), schema=schema, mode=\"overwrite\")\n"; + +export const PyCreateTableFromPandas = "import pandas as pd\n\ndata = pd.DataFrame(\n {\n \"vector\": [[1.1, 1.2, 1.3, 1.4], [0.2, 1.8, 0.4, 3.6]],\n \"lat\": [45.5, 40.1],\n \"long\": [-122.7, -74.1],\n }\n)\ndb = tmp_db\ndb.create_table(\"my_table_pandas\", data, mode=\"overwrite\")\ndb[\"my_table_pandas\"].head()\n"; + +export const PyCreateTableFromPolars = "import polars as pl\n\ndata = pl.DataFrame(\n {\n \"vector\": [[3.1, 4.1], [5.9, 26.5]],\n \"item\": [\"foo\", \"bar\"],\n \"price\": [10.0, 20.0],\n }\n)\ndb = tmp_db\ntbl = db.create_table(\"my_table_pl\", data, mode=\"overwrite\")\n"; + +export const PyCreateTableFromPydantic = "from lancedb.pydantic import LanceModel, Vector\n\nclass Content(LanceModel):\n movie_id: int\n vector: Vector(128)\n genres: str\n title: str\n imdb_id: int\n\n @property\n def imdb_url(self) -> str:\n return f\"https://www.imdb.com/title/tt{self.imdb_id}\"\n\ndb = tmp_db\ntbl = db.create_table(\"movielens_small\", schema=Content, mode=\"overwrite\")\n"; + +export const PyCreateTableNestedSchema = "from lancedb.pydantic import LanceModel, Vector\n\n# --8<-- [start:tables_document_model]\nfrom pydantic import BaseModel\n\nclass Document(BaseModel):\n content: str\n source: str\n\n# --8<-- [end:tables_document_model]\n\nclass NestedSchema(LanceModel):\n id: str\n vector: Vector(1536)\n document: Document\n\ndb = tmp_db\ntbl = db.create_table(\"nested_table\", schema=NestedSchema, mode=\"overwrite\")\n"; + +export const PyDeleteOperation = "# delete data\npredicate = \"id = 3\"\ntable.delete(predicate)\n"; + +export const PyDropColumnsMultiple = "# Remove the second temporary column\ntable.drop_columns([\"temp_col2\"])\n"; + +export const PyDropColumnsSingle = "# Remove the first temporary column\ntable.drop_columns([\"temp_col1\"])\n"; + +export const PyDropTable = "db = tmp_db\n# Create a table first\ndata = [{\"vector\": [1.1, 1.2], \"lat\": 45.5}]\ndb.create_table(\"my_table\", data, mode=\"overwrite\")\n\n# Drop the table\ndb.drop_table(\"my_table\")\n"; + +export const PyInsertIfNotExists = "import pyarrow as pa\n\ntable = db.create_table(\n \"users_example\",\n data=pa.table(\n {\n \"id\": [1, 2],\n \"name\": [\"Alice\", \"Bob\"],\n \"login_count\": [10, 20],\n }\n ),\n mode=\"overwrite\",\n)\n\nincoming_users = pa.table(\n {\n \"id\": [2, 3],\n \"name\": [\"Bobby\", \"Charlie\"],\n \"login_count\": [21, 5],\n }\n)\n\n(table.merge_insert(\"id\").when_not_matched_insert_all().execute(incoming_users))\n"; + +export const PyMergeDeleteMissingBySource = "import pyarrow as pa\n\ntable = db.create_table(\n \"users_example\",\n data=pa.table(\n {\n \"id\": [1, 2, 3],\n \"name\": [\"Alice\", \"Bob\", \"Charlie\"],\n \"login_count\": [10, 20, 5],\n }\n ),\n mode=\"overwrite\",\n)\n\nincoming_users = pa.table(\n {\n \"id\": [2, 3],\n \"name\": [\"Bobby\", \"Charlie\"],\n \"login_count\": [21, 5],\n }\n)\n\n(\n table.merge_insert(\"id\")\n .when_matched_update_all()\n .when_not_matched_insert_all()\n .when_not_matched_by_source_delete()\n .execute(incoming_users)\n)\n"; + +export const PyMergeMatchedUpdateOnly = "import pyarrow as pa\n\ntable = db.create_table(\n \"users_example\",\n data=pa.table(\n {\n \"id\": [1, 2],\n \"name\": [\"Alice\", \"Bob\"],\n \"login_count\": [10, 20],\n }\n ),\n mode=\"overwrite\",\n)\n\nincoming_users = pa.table(\n {\n \"id\": [2, 3],\n \"name\": [\"Bobby\", \"Charlie\"],\n \"login_count\": [21, 5],\n }\n)\n\n(table.merge_insert(\"id\").when_matched_update_all().execute(incoming_users))\n"; + +export const PyMergePartialColumns = "import pyarrow as pa\n\ntable = db.create_table(\n \"users_example\",\n data=pa.table(\n {\n \"id\": [1, 2],\n \"name\": [\"Alice\", \"Bob\"],\n \"login_count\": [10, 20],\n }\n ),\n mode=\"overwrite\",\n)\n\nincoming_users = pa.table(\n {\n \"id\": [2, 3],\n \"name\": [\"Bobby\", \"Charlie\"],\n }\n)\n\n(\n table.merge_insert(\"id\")\n .when_matched_update_all()\n .when_not_matched_insert_all()\n .execute(incoming_users)\n)\n"; + +export const PyMergeUpdateInsert = "import pyarrow as pa\n\ntable = db.create_table(\n \"users_example\",\n data=pa.table(\n {\n \"id\": [1, 2],\n \"name\": [\"Alice\", \"Bob\"],\n \"login_count\": [10, 20],\n }\n ),\n mode=\"overwrite\",\n)\n\nincoming_users = pa.table(\n {\n \"id\": [2, 3],\n \"name\": [\"Bobby\", \"Charlie\"],\n \"login_count\": [21, 5],\n }\n)\n\n(\n table.merge_insert(\"id\")\n .when_matched_update_all()\n .when_not_matched_insert_all()\n .execute(incoming_users)\n)\n"; + +export const PyOpenExistingTable = "db = tmp_db\n# Create a table first\ndata = [{\"vector\": [1.1, 1.2], \"lat\": 45.5, \"long\": -122.7}]\ndb.create_table(\"test_table\", data, mode=\"overwrite\")\n\n# List table names\nprint(db.list_tables().tables)\n\n# Open existing table\ntbl = db.open_table(\"test_table\")\n"; + +export const PySchemaAddSetup = "table_name = \"schema_evolution_add_example\"\nif data is None:\n data = [\n {\n \"id\": 1,\n \"name\": \"Laptop\",\n \"price\": 1200.00,\n \"vector\": np.random.random(128).tolist(),\n },\n {\n \"id\": 2,\n \"name\": \"Smartphone\",\n \"price\": 800.00,\n \"vector\": np.random.random(128).tolist(),\n },\n {\n \"id\": 3,\n \"name\": \"Headphones\",\n \"price\": 150.00,\n \"vector\": np.random.random(128).tolist(),\n },\n ]\ntable = tmp_db.create_table(table_name, data, mode=\"overwrite\")\n"; + +export const PySchemaAlterSetup = "table_name = \"schema_evolution_alter_example\"\nif data is None:\n data = [\n {\n \"id\": 1,\n \"name\": \"Laptop\",\n \"price\": 1200,\n \"discount_price\": 1080.0,\n \"vector\": np.random.random(128).tolist(),\n },\n {\n \"id\": 2,\n \"name\": \"Smartphone\",\n \"price\": 800,\n \"discount_price\": 720.0,\n \"vector\": np.random.random(128).tolist(),\n },\n ]\nschema = pa.schema(\n {\n \"id\": pa.int64(),\n \"name\": pa.string(),\n \"price\": pa.int32(),\n \"discount_price\": pa.float64(),\n \"vector\": pa.list_(pa.float32(), 128),\n }\n)\ntable = tmp_db.create_table(table_name, data, schema=schema, mode=\"overwrite\")\n"; + +export const PySchemaDropSetup = "if data is None:\n data = [\n {\n \"id\": 1,\n \"name\": \"Laptop\",\n \"price\": 1200.00,\n \"temp_col1\": \"X\",\n \"temp_col2\": 100,\n \"vector\": np.random.random(128).tolist(),\n },\n {\n \"id\": 2,\n \"name\": \"Smartphone\",\n \"price\": 800.00,\n \"temp_col1\": \"Y\",\n \"temp_col2\": 200,\n \"vector\": np.random.random(128).tolist(),\n },\n {\n \"id\": 3,\n \"name\": \"Headphones\",\n \"price\": 150.00,\n \"temp_col1\": \"Z\",\n \"temp_col2\": 300,\n \"vector\": np.random.random(128).tolist(),\n },\n ]\ntable = tmp_db.create_table(\"schema_evolution_drop_example\", data, mode=\"overwrite\")\n"; + +export const PySchemaFieldMetadataMerge = "# Set two metadata keys on the `category` field.\nres = table.update_field_metadata(\n {\"path\": \"category\", \"metadata\": {\"unit\": \"label\", \"pii\": \"false\"}}\n)\nprint(res.version)\n\n# Merge: add a new key, delete one with None, keep the rest.\ntable.update_field_metadata(\n {\"path\": \"category\", \"metadata\": {\"source\": \"import\", \"pii\": None}}\n)\n\n# Arrow stores field metadata as bytes.\nassert table.schema.field(\"category\").metadata == {\n b\"unit\": b\"label\",\n b\"source\": b\"import\",\n}\n"; + +export const PySchemaFieldMetadataReplace = "table.update_field_metadata(\n {\n \"path\": \"category\",\n \"metadata\": {\"owner\": \"search-team\"},\n \"replace\": True,\n }\n)\n"; + +export const PyTablesBasicConnect = "import lancedb\n\nuri = \"data/sample-lancedb\"\ndb = lancedb.connect(uri)\n"; + +export const PyTablesDocumentModel = "from pydantic import BaseModel\n\nclass Document(BaseModel):\n content: str\n source: str\n"; + +export const PyTablesImports = "import lancedb\nimport numpy as np\nimport pandas as pd\nimport pyarrow as pa\nimport pytest\nfrom numpy.random import randint, random\n"; + +export const PyTablesTzValidator = "from datetime import datetime\nfrom zoneinfo import ZoneInfo\n\nfrom lancedb.pydantic import LanceModel\nfrom pydantic import Field, ValidationError, ValidationInfo, field_validator\n\ntzname = \"America/New_York\"\ntz = ZoneInfo(tzname)\n\nclass TestModel(LanceModel):\n dt_with_tz: datetime = Field(json_schema_extra={\"tz\": tzname})\n\n @field_validator(\"dt_with_tz\")\n @classmethod\n def tz_must_match(cls, dt: datetime) -> datetime:\n assert dt.tzinfo == tz\n return dt\n\nok = TestModel(dt_with_tz=datetime.now(tz))\n\ntry:\n TestModel(dt_with_tz=datetime.now(ZoneInfo(\"Asia/Shanghai\")))\n assert 0 == 1, \"this should raise ValidationError\"\nexcept ValidationError:\n print(\"A ValidationError was raised.\")\n pass\n"; + +export const PyUpdateConnectEnterprise = "import lancedb\n\ndb = lancedb.connect(\n uri=\"db://your-project-slug\",\n api_key=\"your-api-key\",\n region=\"us-east-1\",\n)\n"; + +export const PyUpdateConnectLocal = "import lancedb\n\ndb = lancedb.connect(\"./data\")\n"; + +export const PyUpdateExampleTableSetup = "import pyarrow as pa\n\ntable = db.create_table(\n \"users_example\",\n data=pa.table(\n {\n \"id\": [1, 2],\n \"name\": [\"Alice\", \"Bob\"],\n \"login_count\": [10, 20],\n }\n ),\n mode=\"overwrite\",\n)\n"; + +export const PyUpdateOperation = "import pyarrow as pa\n\ntable = db.create_table(\n \"users_example\",\n data=pa.table(\n {\n \"id\": [1, 2],\n \"name\": [\"Alice\", \"Bob\"],\n \"login_count\": [10, 20],\n }\n ),\n mode=\"overwrite\",\n)\ntable.update(where=\"id = 2\", values={\"name\": \"Bobby\"})\n"; + +export const PyUpdateOptimizeCleanup = "from datetime import timedelta\n\ntable.optimize(cleanup_older_than=timedelta(days=1))\n"; + +export const PyUpdateUsingSql = "import pyarrow as pa\n\ntable = db.create_table(\n \"users_example\",\n data=pa.table(\n {\n \"id\": [1, 2],\n \"name\": [\"Alice\", \"Bob\"],\n \"login_count\": [10, 20],\n }\n ),\n mode=\"overwrite\",\n)\ntable.update(where=\"id = 2\", values_sql={\"login_count\": \"login_count + 1\"})\n"; + +export const PyVersioningAddData = "more_data = [\n {\n \"id\": 4,\n \"author\": \"Richard Daniel Sanchez\",\n \"quote\": \"That's the way the news goes!\",\n },\n {\"id\": 5, \"author\": \"Morty\", \"quote\": \"Aww geez, Rick!\"},\n]\ntable.add(more_data)\n"; + +export const PyVersioningBasicSetup = "import pyarrow as pa\n\ndb = tmp_db\n\ntable_name = \"quotes_versioning_example\"\ndata = [\n {\"id\": 1, \"author\": \"Richard\", \"quote\": \"Wubba Lubba Dub Dub!\"},\n {\"id\": 2, \"author\": \"Morty\", \"quote\": \"Rick, what's going on?\"},\n {\n \"id\": 3,\n \"author\": \"Richard\",\n \"quote\": \"I turned myself into a pickle, Morty!\",\n },\n]\n\n# Define schema\nschema = pa.schema(\n [\n pa.field(\"id\", pa.int64()),\n pa.field(\"author\", pa.string()),\n pa.field(\"quote\", pa.string()),\n ]\n)\n\ntable = db.create_table(table_name, data, schema=schema, mode=\"overwrite\")\n"; + +export const PyVersioningCheckInitialVersion = "versions = table.list_versions()\ncurrent_version = table.version\nprint(f\"Number of versions after creation: {len(versions)}\")\nprint(f\"Current version: {current_version}\")\n"; + +export const PyVersioningCheckVersionsAfterMod = "versions = table.list_versions()\nversion_count_after_mod = len(versions)\nversion_after_mod = table.version\nprint(f\"Number of versions after modifications: {version_count_after_mod}\")\nprint(f\"Current version: {version_after_mod}\")\n"; + +export const PyVersioningCheckoutLatest = "table.checkout_latest()\n"; + +export const PyVersioningDeleteData = "table.delete(\"author = 'Morty'\")\nrows_after_deletion = table.count_rows()\nprint(f\"Number of rows after deletion: {rows_after_deletion}\")\n"; + +export const PyVersioningListAllVersions = "versions = table.list_versions()\nfor v in versions:\n print(f\"Version {v['version']}, created at {v['timestamp']}\")\n"; + +export const PyVersioningRollback = "table.restore(version_after_mod)\nversions = table.list_versions()\nversion_count_after_rollback = len(versions)\nprint(f\"Total number of versions after rollback: {version_count_after_rollback}\")\n"; + +export const PyVersioningTags = "# Create a tag pointing at a specific version\ntable.tags.create(\"baseline\", 1)\ntable.tags.create(\"with-edits\", table.version)\n\n# List all tags on this table\nprint(table.tags.list())\n\n# Look up the version a tag points at\nprint(table.tags.get_version(\"baseline\"))\n\n# Move an existing tag to a different version\ntable.tags.update(\"baseline\", 2)\n\n# Check out a version by tag name\ntable.checkout(\"baseline\")\nprint(table.version)\n\n# Delete a tag (does not delete the underlying version)\ntable.tags.delete(\"with-edits\")\n\n# Return to the latest version\ntable.checkout_latest()\n"; + +export const PyVersioningUpdateData = "table.update(where=\"author='Richard'\", values={\"author\": \"Richard Daniel Sanchez\"})\nrows_after_update = table.count_rows(\"author = 'Richard Daniel Sanchez'\")\nprint(f\"Rows updated to Richard Daniel Sanchez: {rows_after_update}\")\n"; + +export const TsAddColumnsCalculated = "// Add a discounted price column (10% discount)\nawait schemaAddTable.addColumns([\n {\n name: \"discounted_price\",\n valueSql: \"cast((price * 0.9) as float)\",\n },\n]);\n"; + +export const TsAddColumnsDefaultValues = "// Add a stock status column with default value\nawait schemaAddTable.addColumns([\n {\n name: \"in_stock\",\n valueSql: \"cast(true as boolean)\",\n },\n]);\n"; + +export const TsAddColumnsNullable = "// Add a nullable timestamp column\nawait schemaAddTable.addColumns([\n {\n name: \"last_ordered\",\n valueSql: \"cast(NULL as timestamp)\",\n },\n]);\n"; + +export const TsAddFeatureColumnsSql = "await schemaAddTable.addColumns([\n {\n name: \"price_per_id\",\n valueSql: \"cast(price / id as float)\",\n },\n {\n name: \"price_log\",\n valueSql: \"ln(price)\",\n },\n {\n name: \"price_score\",\n valueSql: \"cast(price / (price + 100.0) as float)\",\n },\n]);\n"; + +export const TsAddProgress = "// Track ingestion progress as batches are written.\nawait table.add(moreData, {\n progress: (p) => {\n const rows = `${p.outputRows}/${p.totalRows ?? \"?\"} rows`;\n const size = `${p.outputBytes} bytes, ${p.elapsedSeconds.toFixed(1)}s`;\n const tasks = `${p.activeTasks}/${p.totalTasks} tasks active`;\n const done = p.done ? \" \\u2014 done\" : \"\";\n console.log(`wrote ${rows} (${size}, ${tasks})${done}`);\n },\n});\n"; + +export const TsAlterColumnsDataType = "// Change price from int32 to int64 for larger numbers\nawait schemaAlterTable.alterColumns([\n { path: \"price\", dataType: new arrow.Int64() },\n]);\n"; + +export const TsAlterColumnsMultiple = "// Rename, change type, and make nullable in one operation\nawait schemaAlterTable.alterColumns([\n {\n path: \"sale_price\",\n rename: \"final_price\",\n dataType: new arrow.Float64(),\n nullable: true,\n },\n]);\n"; + +export const TsAlterColumnsNullable = "// Make the name column nullable\nawait schemaAlterTable.alterColumns([{ path: \"name\", nullable: true }]);\n"; + +export const TsAlterColumnsRename = "// Rename discount_price to sale_price\nawait schemaAlterTable.alterColumns([\n { path: \"discount_price\", rename: \"sale_price\" },\n]);\n"; + +export const TsAlterColumnsWithExpression = "// For custom transforms, create a new column from a SQL expression.\nconst expressionTable = await db.createTable(\n \"schema_evolution_expression_example\",\n [{ id: 1, price_text: \"$100\" }],\n { mode: \"overwrite\" },\n);\n\nawait expressionTable.addColumns([\n {\n name: \"price_numeric\",\n valueSql: \"cast(replace(price_text, '$', '') as int)\",\n },\n]);\nawait expressionTable.dropColumns([\"price_text\"]);\nawait expressionTable.alterColumns([\n { path: \"price_numeric\", rename: \"price\" },\n]);\n"; + +export const TsAlterVectorColumn = "const oldDim = 384;\nconst newDim = 1024;\nconst vectorSchema = new arrow.Schema([\n new arrow.Field(\"id\", new arrow.Int64()),\n new arrow.Field(\n \"embedding\",\n new arrow.FixedSizeList(\n oldDim,\n new arrow.Field(\"item\", new arrow.Float16(), true),\n ),\n true,\n ),\n]);\nconst vectorData = lancedb.makeArrowTable(\n [{ id: 1, embedding: Array.from({ length: oldDim }, () => Math.random()) }],\n { schema: vectorSchema },\n);\nconst vectorTable = await db.createTable(\"vector_alter_example\", vectorData, {\n mode: \"overwrite\",\n});\n\n// Changing FixedSizeList dimensions (384 -> 1024) is not supported via alterColumns.\n// Use addColumns + dropColumns + alterColumns(rename) to replace the column.\nawait vectorTable.addColumns([\n {\n name: \"embedding_v2\",\n valueSql: `arrow_cast(NULL, 'FixedSizeList(${newDim}, Float16)')`,\n },\n]);\nawait vectorTable.dropColumns([\"embedding\"]);\nawait vectorTable.alterColumns([{ path: \"embedding_v2\", rename: \"embedding\" }]);\n"; + +export const TsBranchCreate = "// Fork an isolated, writable branch from main's latest version.\n// `create` returns a table handle scoped to the new branch.\nconst branch = await branches.create(\"exp\");\n"; + +export const TsBranchDelete = "// Delete the branch and its branch-local history. Data on main is safe.\nawait branches.delete(\"exp\");\n"; + +export const TsBranchIndex = "// Build and validate indexes on a branch before using the configuration on\n// main.\nconst dev = await productBranches.create(\"index-dev\");\n\n// A vector (ANN) index and a full-text search index, both branch-scoped.\nawait dev.createIndex(\"vector\", {\n config: lancedb.Index.ivfPq({\n distanceType: \"cosine\",\n numPartitions: 1,\n numSubVectors: 2,\n }),\n});\nawait dev.createIndex(\"text\", { config: lancedb.Index.fts() });\n\n// Both indexes live only on the branch; main still has none.\nconsole.log((await dev.listIndices()).map((ix) => ix.name)); // branch: two indexes\nconsole.log((await products.listIndices()).map((ix) => ix.name)); // main: [] (untouched)\n"; + +export const TsBranchReopen = "// Reopen an existing branch by name from the table handle...\nconst checkedOut = await branches.checkout(\"exp\");\n// ...or open it directly from the database connection.\nconst branchHandle = await db.openTable(\n \"quotes_branches_example\",\n undefined,\n { branch: \"exp\" },\n);\nconsole.log(await checkedOut.countRows(), await branchHandle.countRows()); // both 4\n"; + +export const TsBranchUpsertToMain = "// This is a row-level upsert, not a merge of branch histories.\n// `mergeInsert` updates matching rows and inserts new rows using a stable\n// unique key. Filter the branch read if you only want to apply some results.\nconst rowsToApply = await candidate.toArrow();\nawait table\n .mergeInsert(\"id\")\n .whenMatchedUpdateAll() // update rows that already exist on main\n .whenNotMatchedInsertAll() // insert rows that are new on the branch\n .execute(rowsToApply);\n"; + +export const TsBranchWrite = "// Writes land on the branch handle only; main is left untouched.\nawait branch.add([{ id: 4, author: \"Lancelot\", quote: \"For the realm!\" }]);\nconsole.log(await branch.countRows()); // 4 rows on the branch\nconsole.log(await table.countRows()); // 3 rows; main is unaffected\n\n// List every branch, each mapped to its metadata (including its fork point).\nconsole.log(await branches.list());\n"; + +export const TsConsistencyCheckoutLatest = "const checkoutWriterDb = await lancedb.connect(databaseDir);\nconst checkoutReaderDb = await lancedb.connect(databaseDir);\nconst checkoutWriterTable = await checkoutWriterDb.createTable(\n \"consistency_checkout_latest_table\",\n [{ id: 1 }],\n { mode: \"overwrite\" },\n);\nconst checkoutReaderTable = await checkoutReaderDb.openTable(\n \"consistency_checkout_latest_table\",\n);\nawait checkoutWriterTable.add([{ id: 2 }]);\nconst rowsBeforeRefresh = await checkoutReaderTable.countRows();\nconsole.log(`Rows before checkoutLatest: ${rowsBeforeRefresh}`);\nawait checkoutReaderTable.checkoutLatest();\nconst rowsAfterRefresh = await checkoutReaderTable.countRows();\nconsole.log(`Rows after checkoutLatest: ${rowsAfterRefresh}`);\n"; + +export const TsConsistencyEventual = "const eventualWriterDb = await lancedb.connect(databaseDir);\nconst eventualReaderDb = await lancedb.connect(databaseDir, {\n readConsistencyInterval: 3600,\n});\nconst eventualWriterTable = await eventualWriterDb.createTable(\n \"consistency_eventual_table\",\n [{ id: 1 }],\n { mode: \"overwrite\" },\n);\nconst eventualReaderTable = await eventualReaderDb.openTable(\n \"consistency_eventual_table\",\n);\nawait eventualWriterTable.add([{ id: 2 }]);\nconst eventualRowsAfterWrite = await eventualReaderTable.countRows();\nconsole.log(\n `Rows visible before eventual refresh interval: ${eventualRowsAfterWrite}`,\n);\n"; + +export const TsConsistencyStrong = "const strongWriterDb = await lancedb.connect(databaseDir);\nconst strongReaderDb = await lancedb.connect(databaseDir, {\n readConsistencyInterval: 0,\n});\nconst strongWriterTable = await strongWriterDb.createTable(\n \"consistency_strong_table\",\n [{ id: 1 }],\n { mode: \"overwrite\" },\n);\nconst strongReaderTable = await strongReaderDb.openTable(\n \"consistency_strong_table\",\n);\nawait strongWriterTable.add([{ id: 2 }]);\nconst strongRowsAfterWrite = await strongReaderTable.countRows();\nconsole.log(`Rows visible with strong consistency: ${strongRowsAfterWrite}`);\n"; + +export const TsCreateEmptyTable = "const emptySchema = new arrow.Schema([\n new arrow.Field(\n \"vector\",\n new arrow.FixedSizeList(\n 2,\n new arrow.Field(\"item\", new arrow.Float32(), true),\n ),\n ),\n new arrow.Field(\"item\", new arrow.Utf8()),\n new arrow.Field(\"price\", new arrow.Float32()),\n]);\nconst emptyTable = await db.createEmptyTable(\n \"test_empty_table\",\n emptySchema,\n {\n mode: \"overwrite\",\n },\n);\n"; + +export const TsCreateTableConflictHandling = "// Idempotent open: reuse the existing table if it exists.\n// The provided data is ignored; the schema is validated against the\n// existing table and a mismatch raises an error.\nlet conflictTable = await db.createTable(\"conflict_table\", data, {\n existOk: true,\n});\n\n// Overwrite: drop the existing table and create a new one with the\n// provided data. This permanently discards the old table's data.\nconflictTable = await db.createTable(\"conflict_table\", data, {\n mode: \"overwrite\",\n});\n"; + +export const TsCreateTableCustomSchema = "const customSchema = new arrow.Schema([\n new arrow.Field(\n \"vector\",\n new arrow.FixedSizeList(\n 4,\n new arrow.Field(\"item\", new arrow.Float32(), true),\n ),\n ),\n new arrow.Field(\"lat\", new arrow.Float32()),\n new arrow.Field(\"long\", new arrow.Float32()),\n]);\n\nconst customSchemaData = lancedb.makeArrowTable(\n [\n { vector: [1.1, 1.2, 1.3, 1.4], lat: 45.5, long: -122.7 },\n { vector: [0.2, 1.8, 0.4, 3.6], lat: 40.1, long: -74.1 },\n ],\n { schema: customSchema },\n);\nconst customSchemaTable = await db.createTable(\n \"my_table_custom_schema\",\n customSchemaData,\n { mode: \"overwrite\" },\n);\n"; + +export const TsCreateTableFromArrow = "const arrowSchema = new arrow.Schema([\n new arrow.Field(\n \"vector\",\n new arrow.FixedSizeList(\n 16,\n new arrow.Field(\"item\", new arrow.Float32(), true),\n ),\n ),\n new arrow.Field(\"text\", new arrow.Utf8()),\n]);\nconst arrowData = lancedb.makeArrowTable(\n [\n { vector: Array(16).fill(0.1), text: \"foo\" },\n { vector: Array(16).fill(0.2), text: \"bar\" },\n ],\n { schema: arrowSchema },\n);\nconst arrowTable = await db.createTable(\"f32_tbl\", arrowData, {\n mode: \"overwrite\",\n});\n"; + +export const TsCreateTableFromDicts = "type Location = {\n vector: number[];\n lat: number;\n long: number;\n};\n\nconst data: Location[] = [\n { vector: [1.1, 1.2], lat: 45.5, long: -122.7 },\n { vector: [0.2, 1.8], lat: 40.1, long: -74.1 },\n];\nconst table = await db.createTable(\"test_table\", data, {\n mode: \"overwrite\",\n});\n"; + +export const TsCreateTableFromIterator = "const batchSchema = new arrow.Schema([\n new arrow.Field(\n \"vector\",\n new arrow.FixedSizeList(\n 4,\n new arrow.Field(\"item\", new arrow.Float32(), true),\n ),\n ),\n new arrow.Field(\"item\", new arrow.Utf8()),\n new arrow.Field(\"price\", new arrow.Float32()),\n]);\n\nconst tableForBatches = await db.createEmptyTable(\n \"batched_table\",\n batchSchema,\n {\n mode: \"overwrite\",\n },\n);\n\nconst rows = Array.from({ length: 10 }, (_, i) => ({\n vector: [i + 0.1, i + 0.2, i + 0.3, i + 0.4],\n item: `item-${i + 1}`,\n price: (i + 1) * 10,\n}));\n\nconst chunkSize = 2;\nfor (let i = 0; i < rows.length; i += chunkSize) {\n const batch = lancedb.makeArrowTable(rows.slice(i, i + chunkSize), {\n schema: batchSchema,\n });\n await tableForBatches.add(batch);\n}\n"; + +export const TsDeleteOperation = "// delete data\nconst predicate = \"id = 3\";\nawait table.delete(predicate);\n"; + +export const TsDropColumnsMultiple = "// Remove the second temporary column\nawait schemaDropTable.dropColumns([\"temp_col2\"]);\n"; + +export const TsDropColumnsSingle = "// Remove the first temporary column\nawait schemaDropTable.dropColumns([\"temp_col1\"]);\n"; + +export const TsDropTable = "await db.createTable(\"my_table\", [{ vector: [1.1, 1.2], lat: 45.5 }], {\n mode: \"overwrite\",\n});\n\nawait db.dropTable(\"my_table\");\n"; + +export const TsInsertIfNotExists = "const table = await db.createTable(\n \"users_example\",\n [\n { id: 1, name: \"Alice\", login_count: 10 },\n { id: 2, name: \"Bob\", login_count: 20 },\n ],\n { mode: \"overwrite\" },\n);\n\nconst incomingUsers = [\n { id: 2, name: \"Bobby\", login_count: 21 },\n { id: 3, name: \"Charlie\", login_count: 5 },\n];\n\nawait table\n .mergeInsert(\"id\")\n .whenNotMatchedInsertAll()\n .execute(incomingUsers);\n"; + +export const TsMergeDeleteMissingBySource = "const table = await db.createTable(\n \"users_example\",\n [\n { id: 1, name: \"Alice\", login_count: 10 },\n { id: 2, name: \"Bob\", login_count: 20 },\n { id: 3, name: \"Charlie\", login_count: 5 },\n ],\n { mode: \"overwrite\" },\n);\n\nconst incomingUsers = [\n { id: 2, name: \"Bobby\", login_count: 21 },\n { id: 3, name: \"Charlie\", login_count: 5 },\n];\n\nawait table\n .mergeInsert(\"id\")\n .whenMatchedUpdateAll()\n .whenNotMatchedInsertAll()\n .whenNotMatchedBySourceDelete()\n .execute(incomingUsers);\n"; + +export const TsMergeMatchedUpdateOnly = "const table = await db.createTable(\n \"users_example\",\n [\n { id: 1, name: \"Alice\", login_count: 10 },\n { id: 2, name: \"Bob\", login_count: 20 },\n ],\n { mode: \"overwrite\" },\n);\n\nconst incomingUsers = [\n { id: 2, name: \"Bobby\", login_count: 21 },\n { id: 3, name: \"Charlie\", login_count: 5 },\n];\n\nawait table\n .mergeInsert(\"id\")\n .whenMatchedUpdateAll()\n .execute(incomingUsers);\n"; + +export const TsMergePartialColumns = "const table = await db.createTable(\n \"users_example\",\n [\n { id: 1, name: \"Alice\", login_count: 10 },\n { id: 2, name: \"Bob\", login_count: 20 },\n ],\n { mode: \"overwrite\" },\n);\n\nconst incomingUsers = [\n { id: 2, name: \"Bobby\" },\n { id: 3, name: \"Charlie\" },\n];\n\nawait table\n .mergeInsert(\"id\")\n .whenMatchedUpdateAll()\n .whenNotMatchedInsertAll()\n .execute(incomingUsers);\n"; + +export const TsMergeUpdateInsert = "const table = await db.createTable(\n \"users_example\",\n [\n { id: 1, name: \"Alice\", login_count: 10 },\n { id: 2, name: \"Bob\", login_count: 20 },\n ],\n { mode: \"overwrite\" },\n);\n\nconst incomingUsers = [\n { id: 2, name: \"Bobby\", login_count: 21 },\n { id: 3, name: \"Charlie\", login_count: 5 },\n];\n\nawait table\n .mergeInsert(\"id\")\n .whenMatchedUpdateAll()\n .whenNotMatchedInsertAll()\n .execute(incomingUsers);\n"; + +export const TsOpenExistingTable = "const openTableData = [{ vector: [1.1, 1.2], lat: 45.5, long: -122.7 }];\nawait db.createTable(\"test_table_open\", openTableData, {\n mode: \"overwrite\",\n});\n\nconsole.log(await db.tableNames());\n\nconst openedTable = await db.openTable(\"test_table_open\");\n"; + +export const TsSchemaAddSetup = "const schemaAddData = [\n {\n id: 1,\n name: \"Laptop\",\n price: 1200.0,\n vector: Array.from({ length: 128 }, () => Math.random()),\n },\n {\n id: 2,\n name: \"Smartphone\",\n price: 800.0,\n vector: Array.from({ length: 128 }, () => Math.random()),\n },\n {\n id: 3,\n name: \"Headphones\",\n price: 150.0,\n vector: Array.from({ length: 128 }, () => Math.random()),\n },\n];\nconst schemaAddTable = await db.createTable(\n \"schema_evolution_add_example\",\n schemaAddData,\n { mode: \"overwrite\" },\n);\n"; + +export const TsSchemaAlterSetup = "const schemaAlter = new arrow.Schema([\n new arrow.Field(\"id\", new arrow.Int64()),\n new arrow.Field(\"name\", new arrow.Utf8()),\n new arrow.Field(\"price\", new arrow.Int32()),\n new arrow.Field(\"discount_price\", new arrow.Float64()),\n new arrow.Field(\n \"vector\",\n new arrow.FixedSizeList(\n 128,\n new arrow.Field(\"item\", new arrow.Float32(), true),\n ),\n ),\n]);\nconst schemaAlterData = lancedb.makeArrowTable(\n [\n {\n id: 1,\n name: \"Laptop\",\n price: 1200,\n discount_price: 1080.0,\n vector: Array.from({ length: 128 }, () => Math.random()),\n },\n {\n id: 2,\n name: \"Smartphone\",\n price: 800,\n discount_price: 720.0,\n vector: Array.from({ length: 128 }, () => Math.random()),\n },\n ],\n { schema: schemaAlter },\n);\nconst schemaAlterTable = await db.createTable(\n \"schema_evolution_alter_example\",\n schemaAlterData,\n { mode: \"overwrite\" },\n);\n"; + +export const TsSchemaDropSetup = "const schemaDropData = [\n {\n id: 1,\n name: \"Laptop\",\n price: 1200.0,\n temp_col1: \"X\",\n temp_col2: 100,\n vector: Array.from({ length: 128 }, () => Math.random()),\n },\n {\n id: 2,\n name: \"Smartphone\",\n price: 800.0,\n temp_col1: \"Y\",\n temp_col2: 200,\n vector: Array.from({ length: 128 }, () => Math.random()),\n },\n {\n id: 3,\n name: \"Headphones\",\n price: 150.0,\n temp_col1: \"Z\",\n temp_col2: 300,\n vector: Array.from({ length: 128 }, () => Math.random()),\n },\n];\nconst schemaDropTable = await db.createTable(\n \"schema_evolution_drop_example\",\n schemaDropData,\n { mode: \"overwrite\" },\n);\n"; + +export const TsSchemaFieldMetadataMerge = "// Set two metadata keys on the `category` field.\nconst res = await fieldMetadataTable.updateFieldMetadata([\n { path: \"category\", metadata: { unit: \"label\", pii: \"false\" } },\n]);\nconsole.log(res.version);\n\n// Merge: add a new key, delete one via null, keep the rest.\nawait fieldMetadataTable.updateFieldMetadata([\n { path: \"category\", metadata: { source: \"import\", pii: null } },\n]);\n"; + +export const TsSchemaFieldMetadataReplace = "await fieldMetadataTable.updateFieldMetadata([\n {\n path: \"category\",\n metadata: { owner: \"search-team\" },\n replace: true,\n },\n]);\n"; + +export const TsUpdateConnectEnterprise = "const db = await lancedb.connect(\"db://your-project-slug\", {\n apiKey: \"your-api-key\",\n region: \"us-east-1\",\n});\n"; + +export const TsUpdateConnectLocal = "const db = await lancedb.connect(\"./data\");\n"; + +export const TsUpdateExampleTableSetup = "const table = await db.createTable(\n \"users_example\",\n [\n { id: 1, name: \"Alice\", login_count: 10 },\n { id: 2, name: \"Bob\", login_count: 20 },\n ],\n { mode: \"overwrite\" },\n);\n"; + +export const TsUpdateOperation = "const table = await db.createTable(\n \"users_example\",\n [\n { id: 1, name: \"Alice\", login_count: 10 },\n { id: 2, name: \"Bob\", login_count: 20 },\n ],\n { mode: \"overwrite\" },\n);\nawait table.update({ where: \"id = 2\", values: { name: \"Bobby\" } });\n"; + +export const TsUpdateOptimizeCleanup = "const olderThan = new Date();\nolderThan.setDate(olderThan.getDate() - 1);\nawait table.optimize({ cleanupOlderThan: olderThan });\n"; + +export const TsUpdateUsingSql = "const table = await db.createTable(\n \"users_example\",\n [\n { id: 1, name: \"Alice\", login_count: 10 },\n { id: 2, name: \"Bob\", login_count: 20 },\n ],\n { mode: \"overwrite\" },\n);\nawait table.update({\n where: \"id = 2\",\n valuesSql: { login_count: \"login_count + 1\" },\n});\n"; + +export const TsVersioningAddData = "const moreData = [\n {\n id: 4,\n author: \"Richard Daniel Sanchez\",\n quote: \"That's the way the news goes!\",\n },\n { id: 5, author: \"Morty\", quote: \"Aww geez, Rick!\" },\n];\nawait table.add(moreData);\n"; + +export const TsVersioningBasicSetup = "const tableName = \"quotes_versioning_example\";\nconst data = [\n { id: 1, author: \"Richard\", quote: \"Wubba Lubba Dub Dub!\" },\n { id: 2, author: \"Morty\", quote: \"Rick, what's going on?\" },\n {\n id: 3,\n author: \"Richard\",\n quote: \"I turned myself into a pickle, Morty!\",\n },\n];\nconst table = await db.createTable(tableName, data, { mode: \"overwrite\" });\n"; + +export const TsVersioningCheckInitialVersion = "const versions = await table.listVersions();\nconst currentVersion = await table.version();\nconsole.log(`Number of versions after creation: ${versions.length}`);\nconsole.log(`Current version: ${currentVersion}`);\n"; + +export const TsVersioningCheckVersionsAfterMod = "const versionsAfterMod = await table.listVersions();\nconst versionCountAfterMod = versionsAfterMod.length;\nconst versionAfterMod = await table.version();\nconsole.log(\n `Number of versions after modifications: ${versionCountAfterMod}`,\n);\nconsole.log(`Current version: ${versionAfterMod}`);\n"; + +export const TsVersioningCheckoutLatest = "await table.checkoutLatest();\n"; + +export const TsVersioningDeleteData = "await table.delete(\"author = 'Morty'\");\nconst rowsAfterDeletion = await table.countRows();\nconsole.log(`Number of rows after deletion: ${rowsAfterDeletion}`);\n"; + +export const TsVersioningListAllVersions = "const allVersions = await table.listVersions();\nfor (const v of allVersions) {\n console.log(`Version ${v.version}, created at ${v.timestamp}`);\n}\n"; + +export const TsVersioningRollback = "await table.checkout(versionAfterMod);\nawait table.restore();\nconst versionsAfterRollback = await table.listVersions();\nconst versionCountAfterRollback = versionsAfterRollback.length;\nconsole.log(\n `Total number of versions after rollback: ${versionCountAfterRollback}`,\n);\n"; + +export const TsVersioningTags = "const tags = await tagsTable.tags();\n\n// Create a tag pointing at a specific version\nawait tags.create(\"baseline\", 1);\nawait tags.create(\"with-edits\", await tagsTable.version());\n\n// List all tags on this table\nconsole.log(await tags.list());\n\n// Look up the version a tag points at\nconsole.log(await tags.getVersion(\"baseline\"));\n\n// Move an existing tag to a different version\nawait tags.update(\"baseline\", 2);\n\n// Check out a version by tag name\nawait tagsTable.checkout(\"baseline\");\nconsole.log(await tagsTable.version());\n\n// Delete a tag (does not delete the underlying version)\nawait tags.delete(\"with-edits\");\n\n// Return to the latest version\nawait tagsTable.checkoutLatest();\n"; + +export const TsVersioningUpdateData = "await table.update({\n where: \"author = 'Richard'\",\n values: { author: \"Richard Daniel Sanchez\" },\n});\nconst rowsAfterUpdate = await table.countRows(\n \"author = 'Richard Daniel Sanchez'\",\n);\nconsole.log(`Rows updated to Richard Daniel Sanchez: ${rowsAfterUpdate}`);\n"; + +export const RsAddColumnsCalculated = "// Add a discounted price column (10% discount)\nschema_add_table\n .add_columns(\n NewColumnTransform::SqlExpressions(vec![(\n \"discounted_price\".to_string(),\n \"cast((price * 0.9) as float)\".to_string(),\n )]),\n None,\n )\n .await\n .unwrap();\n"; + +export const RsAddColumnsDefaultValues = "// Add a stock status column with default value\nschema_add_table\n .add_columns(\n NewColumnTransform::SqlExpressions(vec![(\n \"in_stock\".to_string(),\n \"cast(true as boolean)\".to_string(),\n )]),\n None,\n )\n .await\n .unwrap();\n"; + +export const RsAddColumnsNullable = "// Add a nullable timestamp column\nschema_add_table\n .add_columns(\n NewColumnTransform::SqlExpressions(vec![(\n \"last_ordered\".to_string(),\n \"cast(NULL as timestamp)\".to_string(),\n )]),\n None,\n )\n .await\n .unwrap();\n"; + +export const RsAddFeatureColumnsSql = "schema_add_table\n .add_columns(\n NewColumnTransform::SqlExpressions(vec![\n (\n \"price_per_id\".to_string(),\n \"cast(price / id as float)\".to_string(),\n ),\n (\"price_log\".to_string(), \"ln(price)\".to_string()),\n (\n \"price_score\".to_string(),\n \"cast(price / (price + 100.0) as float)\".to_string(),\n ),\n ]),\n None,\n )\n .await\n .unwrap();\n"; + +export const RsAlterColumnsDataType = "// Change price from int32 to int64 for larger numbers\nschema_alter_table\n .alter_columns(&[ColumnAlteration::new(\"price\".to_string()).cast_to(DataType::Int64)])\n .await\n .unwrap();\n"; + +export const RsAlterColumnsMultiple = "// Rename, change type, and make nullable in one operation\nschema_alter_table\n .alter_columns(&[ColumnAlteration::new(\"sale_price\".to_string())\n .rename(\"final_price\".to_string())\n .cast_to(DataType::Float64)\n .set_nullable(true)])\n .await\n .unwrap();\n"; + +export const RsAlterColumnsNullable = "// Make the name column nullable\nschema_alter_table\n .alter_columns(&[ColumnAlteration::new(\"name\".to_string()).set_nullable(true)])\n .await\n .unwrap();\n"; + +export const RsAlterColumnsRename = "// Rename discount_price to sale_price\nschema_alter_table\n .alter_columns(&[ColumnAlteration::new(\"discount_price\".to_string())\n .rename(\"sale_price\".to_string())])\n .await\n .unwrap();\n"; + +export const RsAlterColumnsWithExpression = "// For custom transforms, create a new column from a SQL expression.\nlet expression_schema = Arc::new(Schema::new(vec![\n Field::new(\"id\", DataType::Int64, false),\n Field::new(\"price_text\", DataType::Utf8, false),\n]));\nlet expression_batch = RecordBatch::try_new(\n expression_schema.clone(),\n vec![\n Arc::new(Int64Array::from(vec![1])),\n Arc::new(StringArray::from(vec![\"$100\"])),\n ],\n)\n.unwrap();\nlet expression_reader: Box = Box::new(RecordBatchIterator::new(\n vec![Ok(expression_batch)].into_iter(),\n expression_schema.clone(),\n));\nlet expression_table = db\n .create_table(\"schema_evolution_expression_example\", expression_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n\nexpression_table\n .add_columns(\n NewColumnTransform::SqlExpressions(vec![(\n \"price_numeric\".to_string(),\n \"cast(replace(price_text, '$', '') as int)\".to_string(),\n )]),\n None,\n )\n .await\n .unwrap();\nexpression_table.drop_columns(&[\"price_text\"]).await.unwrap();\nexpression_table\n .alter_columns(&[ColumnAlteration::new(\"price_numeric\".to_string())\n .rename(\"price\".to_string())])\n .await\n .unwrap();\n"; + +export const RsAlterVectorColumn = "let old_dim = 384;\nlet new_dim = 1024;\nlet vector_schema = Arc::new(Schema::new(vec![\n Field::new(\"id\", DataType::Int64, false),\n Field::new(\n \"embedding\",\n DataType::FixedSizeList(\n Arc::new(Field::new(\"item\", DataType::Float32, true)),\n old_dim,\n ),\n true,\n ),\n]));\nlet vector_batch = RecordBatch::try_new(\n vector_schema.clone(),\n vec![\n Arc::new(Int64Array::from(vec![1])),\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n vec![Some(vec![Some(0.1_f32); old_dim as usize])],\n old_dim,\n ),\n ),\n ],\n)\n.unwrap();\nlet vector_reader: Box =\n Box::new(RecordBatchIterator::new(vec![Ok(vector_batch)].into_iter(), vector_schema.clone()));\nlet vector_table = db\n .create_table(\"vector_alter_example\", vector_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n\n// Changing FixedSizeList dimensions (384 -> 1024) is not supported via alter_columns.\n// Use add_columns + drop_columns + alter_columns(rename) to replace the column.\nvector_table\n .add_columns(\n NewColumnTransform::SqlExpressions(vec![(\n \"embedding_v2\".to_string(),\n format!(\"arrow_cast(NULL, 'FixedSizeList({}, Float32)')\", new_dim),\n )]),\n None,\n )\n .await\n .unwrap();\nvector_table.drop_columns(&[\"embedding\"]).await.unwrap();\nvector_table\n .alter_columns(&[ColumnAlteration::new(\"embedding_v2\".to_string())\n .rename(\"embedding\".to_string())])\n .await\n .unwrap();\n"; + +export const RsBranchCreate = "// Fork an isolated, writable branch from main's latest version.\n// `create_branch` returns a table handle scoped to the new branch.\nlet branch = branches_table\n .create_branch(\"exp\", Ref::Version(None, None))\n .await\n .unwrap();\n"; + +export const RsBranchDelete = "// Delete the branch and its branch-local history. Data on main is safe.\nbranches_table.delete_branch(\"exp\").await.unwrap();\n"; + +export const RsBranchIndex = "use lancedb::index::scalar::FtsIndexBuilder;\nuse lancedb::index::Index;\n\n// Build and validate indexes on a branch before using the configuration on\n// main.\nlet dev = products\n .create_branch(\"index-dev\", Ref::Version(None, None))\n .await\n .unwrap();\n\n// A vector (ANN) index and a full-text search index, both branch-scoped.\ndev.create_index(&[\"vector\"], Index::Auto)\n .execute()\n .await\n .unwrap();\ndev.create_index(&[\"text\"], Index::FTS(FtsIndexBuilder::default()))\n .execute()\n .await\n .unwrap();\n\n// Both indexes live only on the branch; main still has none.\nprintln!(\"Branch indexes: {}\", dev.list_indices().await.unwrap().len()); // 2\nprintln!(\"Main indexes: {}\", products.list_indices().await.unwrap().len()); // 0\n"; + +export const RsBranchReopen = "// Reopen an existing branch by name from the table handle...\nlet checked_out = branches_table.checkout_branch(\"exp\", None).await.unwrap();\n// ...or open it directly via the connection's builder.\nlet opened = db\n .open_table(\"quotes_branches_example\")\n .branch(\"exp\")\n .execute()\n .await\n .unwrap();\nprintln!(\n \"Reopened rows: {}, {}\",\n checked_out.count_rows(None).await.unwrap(),\n opened.count_rows(None).await.unwrap()\n); // both 4\n"; + +export const RsBranchUpsertToMain = "// This is a row-level upsert, not a merge of branch histories.\n// `merge_insert` updates matching rows and inserts new rows using a stable\n// unique key. Filter the branch read if you only want to apply some results.\nlet schema = candidate.schema().await.unwrap();\nlet batches = candidate\n .query()\n .execute()\n .await\n .unwrap()\n .try_collect::>()\n .await\n .unwrap();\nlet rows_to_apply = RecordBatchIterator::new(batches.into_iter().map(Ok), schema);\n\nlet mut merge = branches_table.merge_insert(&[\"id\"]);\nmerge\n .when_matched_update_all(None) // update rows that already exist on main\n .when_not_matched_insert_all(); // insert rows that are new on the branch\nmerge.execute(Box::new(rows_to_apply)).await.unwrap();\n"; + +export const RsBranchWrite = "// Writes land on the branch handle only; main is left untouched.\nbranch\n .add(make_quotes_reader(vec![(4, \"Lancelot\", \"For the realm!\")]))\n .execute()\n .await\n .unwrap();\nprintln!(\"Branch rows: {}\", branch.count_rows(None).await.unwrap()); // 4\nprintln!(\"Main rows: {}\", branches_table.count_rows(None).await.unwrap()); // 3\n\n// List every branch, each mapped to its metadata (including its fork point).\nprintln!(\"Branches: {:?}\", branches_table.list_branches().await.unwrap());\n"; + +export const RsConsistencyCheckoutLatest = "let checkout_writer_db = connect(&db_uri).execute().await.unwrap();\nlet checkout_reader_db = connect(&db_uri).execute().await.unwrap();\nlet checkout_writer_table = checkout_writer_db\n .create_table(\n \"consistency_checkout_latest_table\",\n make_users_reader(vec![1], vec![\"Alice\"], None),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\nlet checkout_reader_table = checkout_reader_db\n .open_table(\"consistency_checkout_latest_table\")\n .execute()\n .await\n .unwrap();\ncheckout_writer_table\n .add(make_users_reader(vec![2], vec![\"Bob\"], None))\n .execute()\n .await\n .unwrap();\nlet rows_before_refresh = checkout_reader_table.count_rows(None).await.unwrap();\nprintln!(\"Rows before checkout_latest: {}\", rows_before_refresh);\ncheckout_reader_table.checkout_latest().await.unwrap();\nlet rows_after_refresh = checkout_reader_table.count_rows(None).await.unwrap();\nprintln!(\"Rows after checkout_latest: {}\", rows_after_refresh);\n"; + +export const RsConsistencyEventual = "let eventual_writer_db = connect(&db_uri).execute().await.unwrap();\nlet eventual_reader_db = connect(&db_uri)\n .read_consistency_interval(StdDuration::from_secs(3600))\n .execute()\n .await\n .unwrap();\nlet eventual_writer_table = eventual_writer_db\n .create_table(\n \"consistency_eventual_table\",\n make_users_reader(vec![1], vec![\"Alice\"], None),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\nlet eventual_reader_table = eventual_reader_db\n .open_table(\"consistency_eventual_table\")\n .execute()\n .await\n .unwrap();\neventual_writer_table\n .add(make_users_reader(vec![2], vec![\"Bob\"], None))\n .execute()\n .await\n .unwrap();\nlet eventual_rows_after_write = eventual_reader_table.count_rows(None).await.unwrap();\nprintln!(\n \"Rows visible before eventual refresh interval: {}\",\n eventual_rows_after_write\n);\n"; + +export const RsConsistencyStrong = "let strong_writer_db = connect(&db_uri).execute().await.unwrap();\nlet strong_reader_db = connect(&db_uri)\n .read_consistency_interval(StdDuration::from_secs(0))\n .execute()\n .await\n .unwrap();\nlet strong_writer_table = strong_writer_db\n .create_table(\n \"consistency_strong_table\",\n make_users_reader(vec![1], vec![\"Alice\"], None),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\nlet strong_reader_table = strong_reader_db\n .open_table(\"consistency_strong_table\")\n .execute()\n .await\n .unwrap();\nstrong_writer_table\n .add(make_users_reader(vec![2], vec![\"Bob\"], None))\n .execute()\n .await\n .unwrap();\nlet strong_rows_after_write = strong_reader_table.count_rows(None).await.unwrap();\nprintln!(\n \"Rows visible with strong consistency: {}\",\n strong_rows_after_write\n);\n"; + +export const RsCreateEmptyTable = "let empty_schema = Arc::new(Schema::new(vec![\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 2),\n false,\n ),\n Field::new(\"item\", DataType::Utf8, false),\n Field::new(\"price\", DataType::Float32, false),\n]));\nlet empty_table = db\n .create_empty_table(\"test_empty_table\", empty_schema)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsCreateTableConflictHandling = "// Idempotent open: reuse the existing table if it exists.\n// The provided data is ignored; the schema is validated against the\n// existing table and a mismatch raises an error.\nlet _conflict_table = db\n .create_table(\"conflict_table\", exist_ok_reader)\n .mode(CreateTableMode::exist_ok(|req| req))\n .execute()\n .await\n .unwrap();\n\n// Overwrite: drop the existing table and create a new one with the\n// provided data. This permanently discards the old table's data.\nlet conflict_table = db\n .create_table(\"conflict_table\", overwrite_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsCreateTableCustomSchema = "let custom_schema = Arc::new(Schema::new(vec![\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 4),\n false,\n ),\n Field::new(\"lat\", DataType::Float32, false),\n Field::new(\"long\", DataType::Float32, false),\n]));\n\nlet custom_batch = RecordBatch::try_new(\n custom_schema.clone(),\n vec![\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n vec![\n Some(vec![Some(1.1), Some(1.2), Some(1.3), Some(1.4)]),\n Some(vec![Some(0.2), Some(1.8), Some(0.4), Some(3.6)]),\n ],\n 4,\n ),\n ),\n Arc::new(Float32Array::from(vec![45.5, 40.1])),\n Arc::new(Float32Array::from(vec![-122.7, -74.1])),\n ],\n)\n.unwrap();\nlet custom_reader: Box =\n Box::new(RecordBatchIterator::new(vec![Ok(custom_batch)].into_iter(), custom_schema.clone()));\nlet custom_table = db\n .create_table(\"my_table_custom_schema\", custom_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsCreateTableFromArrow = "let arrow_schema = Arc::new(Schema::new(vec![\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 16),\n false,\n ),\n Field::new(\"text\", DataType::Utf8, false),\n]));\n\nlet arrow_batch = RecordBatch::try_new(\n arrow_schema.clone(),\n vec![\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n vec![Some(vec![Some(0.1); 16]), Some(vec![Some(0.2); 16])],\n 16,\n ),\n ),\n Arc::new(StringArray::from(vec![\"foo\", \"bar\"])),\n ],\n)\n.unwrap();\nlet arrow_reader: Box =\n Box::new(RecordBatchIterator::new(vec![Ok(arrow_batch)].into_iter(), arrow_schema.clone()));\nlet arrow_table = db\n .create_table(\"arrow_table_example\", arrow_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsCreateTableFromDicts = "struct Location {\n vector: [f32; 2],\n lat: f32,\n long: f32,\n}\n\nlet data = vec![\n Location {\n vector: [1.1, 1.2],\n lat: 45.5,\n long: -122.7,\n },\n Location {\n vector: [0.2, 1.8],\n lat: 40.1,\n long: -74.1,\n },\n];\n\nlet schema = Arc::new(Schema::new(vec![\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 2),\n false,\n ),\n Field::new(\"lat\", DataType::Float32, false),\n Field::new(\"long\", DataType::Float32, false),\n]));\n\nlet batch = RecordBatch::try_new(\n schema.clone(),\n vec![\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n data.iter()\n .map(|row| Some(row.vector.iter().copied().map(Some).collect::>())),\n 2,\n ),\n ),\n Arc::new(Float32Array::from_iter_values(\n data.iter().map(|row| row.lat),\n )),\n Arc::new(Float32Array::from_iter_values(\n data.iter().map(|row| row.long),\n )),\n ],\n)\n.unwrap();\nlet reader: Box =\n Box::new(RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema.clone()));\nlet table = db\n .create_table(\"test_table\", reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsCreateTableFromIterator = "let batch_schema = Arc::new(Schema::new(vec![\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 4),\n false,\n ),\n Field::new(\"item\", DataType::Utf8, false),\n Field::new(\"price\", DataType::Float32, false),\n]));\n\nlet batches = (0..5)\n .map(|i| {\n RecordBatch::try_new(\n batch_schema.clone(),\n vec![\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n vec![\n Some(vec![Some(3.1 + i as f32), Some(4.1), Some(5.1), Some(6.1)]),\n Some(vec![\n Some(5.9),\n Some(26.5 + i as f32),\n Some(4.7),\n Some(32.8),\n ]),\n ],\n 4,\n ),\n ),\n Arc::new(StringArray::from(vec![\n format!(\"item{}\", i * 2 + 1),\n format!(\"item{}\", i * 2 + 2),\n ])),\n Arc::new(Float32Array::from(vec![\n ((i * 2 + 1) * 10) as f32,\n ((i * 2 + 2) * 10) as f32,\n ])),\n ],\n )\n .unwrap()\n })\n .collect::>();\n\nlet batch_reader: Box =\n Box::new(RecordBatchIterator::new(batches.into_iter().map(Ok), batch_schema.clone()));\nlet batch_table = db\n .create_table(\"batched_table\", batch_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsDeleteOperation = "// delete data\nlet predicate = \"id = 3\";\ntable.delete(predicate).await.unwrap();\n"; + +export const RsDropColumnsMultiple = "// Remove the second temporary column\nschema_drop_table.drop_columns(&[\"temp_col2\"]).await.unwrap();\n"; + +export const RsDropColumnsSingle = "// Remove the first temporary column\nschema_drop_table.drop_columns(&[\"temp_col1\"]).await.unwrap();\n"; + +export const RsDropTable = "let drop_schema = Arc::new(Schema::new(vec![\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 2),\n false,\n ),\n Field::new(\"lat\", DataType::Float32, false),\n]));\nlet drop_batch = RecordBatch::try_new(\n drop_schema.clone(),\n vec![\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n vec![Some(vec![Some(1.1), Some(1.2)])],\n 2,\n ),\n ),\n Arc::new(Float32Array::from(vec![45.5])),\n ],\n)\n.unwrap();\nlet drop_reader: Box =\n Box::new(RecordBatchIterator::new(vec![Ok(drop_batch)].into_iter(), drop_schema.clone()));\ndb.create_table(\"my_table\", drop_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n\ndb.drop_table(\"my_table\", &[]).await.unwrap();\n"; + +export const RsInsertIfNotExists = "let table = db\n .create_table(\n \"users_example\",\n make_users_reader(vec![1, 2], vec![\"Alice\", \"Bob\"], Some(vec![10, 20])),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n\nlet mut merge_insert = table.merge_insert(&[\"id\"]);\nmerge_insert.when_not_matched_insert_all();\nmerge_insert\n .execute(make_users_reader(\n vec![2, 3],\n vec![\"Bobby\", \"Charlie\"],\n Some(vec![21, 5]),\n ))\n .await\n .unwrap();\n"; + +export const RsMergeDeleteMissingBySource = "let table = db\n .create_table(\n \"users_example\",\n make_users_reader(\n vec![1, 2, 3],\n vec![\"Alice\", \"Bob\", \"Charlie\"],\n Some(vec![10, 20, 5]),\n ),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n\nlet mut merge_insert = table.merge_insert(&[\"id\"]);\nmerge_insert\n .when_matched_update_all(None)\n .when_not_matched_insert_all()\n .when_not_matched_by_source_delete(None);\nmerge_insert\n .execute(make_users_reader(\n vec![2, 3],\n vec![\"Bobby\", \"Charlie\"],\n Some(vec![21, 5]),\n ))\n .await\n .unwrap();\n"; + +export const RsMergeMatchedUpdateOnly = "let table = db\n .create_table(\n \"users_example\",\n make_users_reader(vec![1, 2], vec![\"Alice\", \"Bob\"], Some(vec![10, 20])),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n\nlet mut merge_insert = table.merge_insert(&[\"id\"]);\nmerge_insert.when_matched_update_all(None);\nmerge_insert\n .execute(make_users_reader(\n vec![2, 3],\n vec![\"Bobby\", \"Charlie\"],\n Some(vec![21, 5]),\n ))\n .await\n .unwrap();\n"; + +export const RsMergePartialColumns = "let table = db\n .create_table(\n \"users_example\",\n make_users_reader(vec![1, 2], vec![\"Alice\", \"Bob\"], Some(vec![10, 20])),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n\nlet mut merge_insert = table.merge_insert(&[\"id\"]);\nmerge_insert\n .when_matched_update_all(None)\n .when_not_matched_insert_all();\nmerge_insert\n .execute(make_users_reader(vec![2, 3], vec![\"Bobby\", \"Charlie\"], None))\n .await\n .unwrap();\n"; + +export const RsMergeUpdateInsert = "let table = db\n .create_table(\n \"users_example\",\n make_users_reader(vec![1, 2], vec![\"Alice\", \"Bob\"], Some(vec![10, 20])),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n\nlet mut merge_insert = table.merge_insert(&[\"id\"]);\nmerge_insert\n .when_matched_update_all(None)\n .when_not_matched_insert_all();\nmerge_insert\n .execute(make_users_reader(\n vec![2, 3],\n vec![\"Bobby\", \"Charlie\"],\n Some(vec![21, 5]),\n ))\n .await\n .unwrap();\n"; + +export const RsOpenExistingTable = "let open_schema = Arc::new(Schema::new(vec![\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 2),\n false,\n ),\n Field::new(\"lat\", DataType::Float32, false),\n Field::new(\"long\", DataType::Float32, false),\n]));\nlet open_batch = RecordBatch::try_new(\n open_schema.clone(),\n vec![\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n vec![Some(vec![Some(1.1), Some(1.2)])],\n 2,\n ),\n ),\n Arc::new(Float32Array::from(vec![45.5])),\n Arc::new(Float32Array::from(vec![-122.7])),\n ],\n)\n.unwrap();\nlet open_reader: Box =\n Box::new(RecordBatchIterator::new(vec![Ok(open_batch)].into_iter(), open_schema.clone()));\ndb.create_table(\"test_table\", open_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n\nprintln!(\"{:?}\", db.table_names().execute().await.unwrap());\n\nlet opened_table = db.open_table(\"test_table\").execute().await.unwrap();\n"; + +export const RsSchemaAddSetup = "let schema_add_schema = Arc::new(Schema::new(vec![\n Field::new(\"id\", DataType::Int64, false),\n Field::new(\"name\", DataType::Utf8, false),\n Field::new(\"price\", DataType::Float64, false),\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 128),\n false,\n ),\n]));\nlet schema_add_batch = RecordBatch::try_new(\n schema_add_schema.clone(),\n vec![\n Arc::new(Int64Array::from(vec![1, 2, 3])),\n Arc::new(StringArray::from(vec![\"Laptop\", \"Smartphone\", \"Headphones\"])),\n Arc::new(Float64Array::from(vec![1200.0, 800.0, 150.0])),\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n vec![\n Some(vec![Some(0.1_f32); 128]),\n Some(vec![Some(0.2_f32); 128]),\n Some(vec![Some(0.3_f32); 128]),\n ],\n 128,\n ),\n ),\n ],\n)\n.unwrap();\nlet schema_add_reader: Box = Box::new(RecordBatchIterator::new(\n vec![Ok(schema_add_batch)].into_iter(),\n schema_add_schema.clone(),\n));\nlet schema_add_table = db\n .create_table(\"schema_evolution_add_example\", schema_add_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsSchemaAlterSetup = "let schema_alter_schema = Arc::new(Schema::new(vec![\n Field::new(\"id\", DataType::Int64, false),\n Field::new(\"name\", DataType::Utf8, false),\n Field::new(\"price\", DataType::Int32, false),\n Field::new(\"discount_price\", DataType::Float64, false),\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 128),\n false,\n ),\n]));\nlet schema_alter_batch = RecordBatch::try_new(\n schema_alter_schema.clone(),\n vec![\n Arc::new(Int64Array::from(vec![1, 2])),\n Arc::new(StringArray::from(vec![\"Laptop\", \"Smartphone\"])),\n Arc::new(Int32Array::from(vec![1200, 800])),\n Arc::new(Float64Array::from(vec![1080.0, 720.0])),\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n vec![Some(vec![Some(0.1_f32); 128]), Some(vec![Some(0.2_f32); 128])],\n 128,\n ),\n ),\n ],\n)\n.unwrap();\nlet schema_alter_reader: Box = Box::new(RecordBatchIterator::new(\n vec![Ok(schema_alter_batch)].into_iter(),\n schema_alter_schema.clone(),\n));\nlet schema_alter_table = db\n .create_table(\"schema_evolution_alter_example\", schema_alter_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsSchemaDropSetup = "let schema_drop_schema = Arc::new(Schema::new(vec![\n Field::new(\"id\", DataType::Int64, false),\n Field::new(\"name\", DataType::Utf8, false),\n Field::new(\"price\", DataType::Float64, false),\n Field::new(\"temp_col1\", DataType::Utf8, false),\n Field::new(\"temp_col2\", DataType::Int32, false),\n Field::new(\n \"vector\",\n DataType::FixedSizeList(Arc::new(Field::new(\"item\", DataType::Float32, true)), 128),\n false,\n ),\n]));\nlet schema_drop_batch = RecordBatch::try_new(\n schema_drop_schema.clone(),\n vec![\n Arc::new(Int64Array::from(vec![1, 2, 3])),\n Arc::new(StringArray::from(vec![\"Laptop\", \"Smartphone\", \"Headphones\"])),\n Arc::new(Float64Array::from(vec![1200.0, 800.0, 150.0])),\n Arc::new(StringArray::from(vec![\"X\", \"Y\", \"Z\"])),\n Arc::new(Int32Array::from(vec![100, 200, 300])),\n Arc::new(\n FixedSizeListArray::from_iter_primitive::(\n vec![\n Some(vec![Some(0.1_f32); 128]),\n Some(vec![Some(0.2_f32); 128]),\n Some(vec![Some(0.3_f32); 128]),\n ],\n 128,\n ),\n ),\n ],\n)\n.unwrap();\nlet schema_drop_reader: Box = Box::new(RecordBatchIterator::new(\n vec![Ok(schema_drop_batch)].into_iter(),\n schema_drop_schema.clone(),\n));\nlet schema_drop_table = db\n .create_table(\"schema_evolution_drop_example\", schema_drop_reader)\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsSchemaFieldMetadataMerge = "// Set two metadata keys on the `category` field.\nlet res = field_metadata_table\n .update_field_metadata(&[FieldMetadataUpdate::new(\"category\")\n .set(\"unit\", \"label\")\n .set(\"pii\", \"false\")])\n .await\n .unwrap();\nprintln!(\"version: {}\", res.version);\n\n// Merge: add a new key, delete one with `.remove`, keep the rest.\nfield_metadata_table\n .update_field_metadata(&[FieldMetadataUpdate::new(\"category\")\n .set(\"source\", \"import\")\n .remove(\"pii\")])\n .await\n .unwrap();\n"; + +export const RsSchemaFieldMetadataReplace = "field_metadata_table\n .update_field_metadata(&[FieldMetadataUpdate::new(\"category\")\n .set(\"owner\", \"search-team\")\n .replace()])\n .await\n .unwrap();\n"; + +export const RsUpdateConnectEnterprise = "let uri = \"db://your-project-slug\";\nlet api_key = \"your-api-key\";\nlet region = \"us-east-1\";\n"; + +export const RsUpdateConnectLocal = "let db = connect(\"./data\").execute().await.unwrap();\n"; + +export const RsUpdateExampleTableSetup = "let table = db\n .create_table(\n \"users_example\",\n make_users_reader(vec![1, 2], vec![\"Alice\", \"Bob\"], Some(vec![10, 20])),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsUpdateMakeUsersReader = "fn make_users_reader(\n ids: Vec,\n names: Vec<&str>,\n login_counts: Option>,\n) -> Box {\n let mut fields = vec![\n Field::new(\"id\", DataType::Int64, false),\n Field::new(\"name\", DataType::Utf8, false),\n ];\n let mut columns: Vec> =\n vec![Arc::new(Int64Array::from(ids)), Arc::new(StringArray::from(names))];\n\n if let Some(login_counts) = login_counts {\n fields.push(Field::new(\"login_count\", DataType::Int64, true));\n columns.push(Arc::new(Int64Array::from(login_counts)));\n }\n\n let schema = Arc::new(Schema::new(fields));\n let batch = RecordBatch::try_new(schema.clone(), columns).unwrap();\n let reader = RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema);\n Box::new(reader)\n}\n"; + +export const RsUpdateOperation = "let table = db\n .create_table(\n \"users_example\",\n make_users_reader(vec![1, 2], vec![\"Alice\", \"Bob\"], Some(vec![10, 20])),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\ntable\n .update()\n .only_if(\"id = 2\")\n .column(\"name\", \"'Bobby'\")\n .execute()\n .await\n .unwrap();\n"; + +export const RsUpdateOptimizeCleanup = "table\n .optimize(OptimizeAction::Prune {\n older_than: Some(Duration::days(1)),\n delete_unverified: None,\n error_if_tagged_old_versions: None,\n })\n .await\n .unwrap();\n"; + +export const RsUpdateUsingSql = "let table = db\n .create_table(\n \"users_example\",\n make_users_reader(vec![1, 2], vec![\"Alice\", \"Bob\"], Some(vec![10, 20])),\n )\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\ntable\n .update()\n .only_if(\"id = 2\")\n .column(\"login_count\", \"login_count + 1\")\n .execute()\n .await\n .unwrap();\n"; + +export const RsVersioningAddData = "let more_data = vec![\n (4, \"Richard Daniel Sanchez\", \"That's the way the news goes!\"),\n (5, \"Morty\", \"Aww geez, Rick!\"),\n];\ntable\n .add(make_quotes_reader(more_data))\n .execute()\n .await\n .unwrap();\n"; + +export const RsVersioningBasicSetup = "let table_name = \"quotes_versioning_example\";\nlet data = vec![\n (1, \"Richard\", \"Wubba Lubba Dub Dub!\"),\n (2, \"Morty\", \"Rick, what's going on?\"),\n (3, \"Richard\", \"I turned myself into a pickle, Morty!\"),\n];\n\nlet table = db\n .create_table(table_name, make_quotes_reader(data))\n .mode(CreateTableMode::Overwrite)\n .execute()\n .await\n .unwrap();\n"; + +export const RsVersioningCheckInitialVersion = "let versions = table.list_versions().await.unwrap();\nlet current_version = table.version().await.unwrap();\nprintln!(\"Number of versions after creation: {}\", versions.len());\nprintln!(\"Current version: {}\", current_version);\n"; + +export const RsVersioningCheckVersionsAfterMod = "let versions_after_mod = table.list_versions().await.unwrap();\nlet version_count_after_mod = versions_after_mod.len();\nlet version_after_mod = table.version().await.unwrap();\nprintln!(\n \"Number of versions after modifications: {}\",\n version_count_after_mod\n);\nprintln!(\"Current version: {}\", version_after_mod);\n"; + +export const RsVersioningCheckoutLatest = "table.checkout_latest().await.unwrap();\n"; + +export const RsVersioningDeleteData = "table.delete(\"author = 'Morty'\").await.unwrap();\nlet rows_after_deletion = table.count_rows(None).await.unwrap();\nprintln!(\"Number of rows after deletion: {}\", rows_after_deletion);\n"; + +export const RsVersioningListAllVersions = "let all_versions = table.list_versions().await.unwrap();\nfor v in &all_versions {\n println!(\"Version {}, created at {}\", v.version, v.timestamp);\n}\n"; + +export const RsVersioningMakeQuotesReader = "fn make_quotes_reader(rows: Vec<(i64, &str, &str)>) -> Box {\n let ids: Vec = rows.iter().map(|(id, _, _)| *id).collect();\n let authors: Vec<&str> = rows.iter().map(|(_, author, _)| *author).collect();\n let quotes: Vec<&str> = rows.iter().map(|(_, _, quote)| *quote).collect();\n\n let schema = Arc::new(Schema::new(vec![\n Field::new(\"id\", DataType::Int64, false),\n Field::new(\"author\", DataType::Utf8, false),\n Field::new(\"quote\", DataType::Utf8, false),\n ]));\n\n let batch = RecordBatch::try_new(\n schema.clone(),\n vec![\n Arc::new(Int64Array::from(ids)),\n Arc::new(StringArray::from(authors)),\n Arc::new(StringArray::from(quotes)),\n ],\n )\n .unwrap();\n let reader = RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema);\n Box::new(reader)\n}\n"; + +export const RsVersioningRollback = "table.checkout(version_after_mod).await.unwrap();\ntable.restore().await.unwrap();\nlet versions_after_rollback = table.list_versions().await.unwrap();\nlet version_count_after_rollback = versions_after_rollback.len();\nprintln!(\n \"Total number of versions after rollback: {}\",\n version_count_after_rollback\n);\n"; + +export const RsVersioningTags = "let mut tags = tags_table.tags().await.unwrap();\n\n// Create a tag pointing at a specific version\ntags.create(\"baseline\", 1).await.unwrap();\nlet current_version = tags_table.version().await.unwrap();\ntags.create(\"with-edits\", current_version).await.unwrap();\n\n// List all tags on this table\nlet all_tags = tags.list().await.unwrap();\nprintln!(\"Tags: {:?}\", all_tags);\n\n// Look up the version a tag points at\nlet baseline_version = tags.get_version(\"baseline\").await.unwrap();\nprintln!(\"baseline -> v{}\", baseline_version);\n\n// Move an existing tag to a different version\ntags.update(\"baseline\", 2).await.unwrap();\n\n// Check out a version by tag name (separate method in Rust)\ntags_table.checkout_tag(\"baseline\").await.unwrap();\nprintln!(\"Current version: {}\", tags_table.version().await.unwrap());\n\n// Delete a tag (does not delete the underlying version)\ntags.delete(\"with-edits\").await.unwrap();\n\n// Return to the latest version\ntags_table.checkout_latest().await.unwrap();\n"; + +export const RsVersioningUpdateData = "table\n .update()\n .only_if(\"author = 'Richard'\")\n .column(\"author\", \"'Richard Daniel Sanchez'\")\n .execute()\n .await\n .unwrap();\nlet rows_after_update = table\n .count_rows(Some(\"author = 'Richard Daniel Sanchez'\".to_string()))\n .await\n .unwrap();\nprintln!(\n \"Rows updated to Richard Daniel Sanchez: {}\",\n rows_after_update\n);\n"; + diff --git a/docs/web/sql/data-types.mdx b/docs/web/sql/data-types.mdx new file mode 100644 index 000000000..70bafdb96 --- /dev/null +++ b/docs/web/sql/data-types.mdx @@ -0,0 +1,154 @@ +--- +title: "SQL data types" +sidebarTitle: "Data Types" +description: "How Lance columns appear to SQL, including the types SQL has no name for." +icon: shapes +keywords: ["sql types", "arrow", "cast", "pseudo-columns", "vector", "blob", "json"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +There is no SQL type layer over Lance storage. The table provider hands +DataFusion the dataset's Arrow schema directly, so a column is the same object +to the query engine as it is to the format. + +The consequence is that SQL type names here are **spellings of Arrow types**, +not a system that maps onto them. [Data types](/sql/data-types) documents the +storage side; this page covers what changes when you reach a column from SQL. + +## The Arrow types a column can be {#supported} + +| Category | Types | +| --- | --- | +| Null | `null` | +| Boolean | `bool` | +| Integer | `int8`, `int16`, `int32`, `int64` and their `uint` counterparts | +| Floating point | `float16`, `float32`, `float64` | +| String | `utf8`, `large_utf8` | +| Binary | `binary`, `large_binary`, `fixed_size_binary(n)` | +| Date and time | `date32`, `date64`, `time32`, `time64`, `timestamp` (with or without timezone), `duration` | +| Nested | `struct`, `list`, `large_list`, `fixed_size_list`, `map` | +| Dictionary | `dictionary(key, value)` | + +Two limits are worth stating because they surface as schema errors rather than +as documentation: + +- A `map` with `keys_sorted = true` is rejected. Sorted-key maps do not survive + the round trip through the Python bindings, so they are refused rather than + silently unsorted. +- Not every type is legal as a `fixed_size_list` child. The vector case is, + which is what matters in practice. + +## SQL names for Arrow types {#names} + +| SQL | Arrow | +| --- | --- | +| `BOOLEAN` | `Boolean` | +| `TINYINT` `SMALLINT` `INT` `BIGINT` | `Int8` `Int16` `Int32` `Int64` | +| `UTINYINT` … `UBIGINT` | the unsigned counterparts | +| `REAL` / `FLOAT` | `Float32` | +| `DOUBLE` | `Float64` | +| `VARCHAR` / `TEXT` / `STRING` | `Utf8` | +| `BYTEA` / `BINARY` | `Binary` | +| `DATE` | `Date32` | +| `TIME` | `Time64` | +| `TIMESTAMP` | `Timestamp`, with or without a timezone | +| `INTERVAL` | `Interval` | +| `STRUCT` / `LIST` / `MAP` | the nested equivalents | + +DataFusion's [type reference](https://datafusion.apache.org/user-guide/sql/data_types.html) +describes the full mapping. + +## Types SQL has no name for {#no-name} + +Three column kinds you will meet that no SQL type name covers. + +### Vectors {#vectors} + +A dense vector column has a fixed dimension, commonly represented as +`FixedSizeList`. Other supported vector element types are +possible; inspect the column with [`arrow_typeof`](/sql/scalar-functions#type) rather than assuming Float32. +It is selectable and testable for nullness: + +```sql +SELECT id FROM documents WHERE vector IS NOT NULL +``` + + +A list representation does not imply that ordinary arithmetic operators +perform vector arithmetic. Use supported distance functions for similarity +calculations. SQL vector search depends on the deployment version and query +plan; see [SQL](/basics/sql#no-vector-sql). + + +### JSON {#json} + +Stored as JSONB in `large_binary` with the `lance.json` extension name. It reads +through accessor functions rather than a `->` operator: + +```sql +SELECT json_get_string(data, 'name') FROM events +WHERE json_get_int(data, 'status') = 200 +``` + +`json_get`, its typed variants, and `json_extract` are **index-accelerated** by a +JSON scalar index. See [Functions](/sql/scalar-functions#json). + +### Blobs {#blobs} + +A blob column is read either as its binary contents or as a **description** — a +pointer plus metadata, without fetching the bytes. That choice changes the +column's Arrow type, and therefore the schema your query sees. + + +The default reads blob columns as descriptions and other binary columns as +binary. A query selecting a blob column and expecting bytes gets a struct. + + +### bfloat16 {#bfloat16} + +Sixteen-bit floats in the bfloat16 layout, stored as `fixed_size_binary(2)`. +Useful as a vector element type when halving the memory matters more than the +precision does. + +## Geospatial {#geospatial} + + +Lance defines geospatial types and `st_*` functions on +[GeoArrow](https://geoarrow.org), but they sit behind an optional build feature +that **LanceDB does not ship**. Calling `st_intersects` and friends returns an +error naming the missing feature rather than an unknown-function error, and the +spatial index is gated the same way. + + +Geometry can still be stored -- it is binary, and binary columns work -- but +nothing interprets it. Filter on a bounding box held in ordinary numeric +columns if you need spatial predicates today. + +## Pseudo-columns {#pseudo-columns} + +Availability depends on the SQL table provider. The ordinary table provider +exposes the stored dataset schema; naming a Lance scan metadata field does +not make it available as a SQL column. The `fts()` provider adds `_score` to +its schema, so `_score` is included in `SELECT * FROM fts(...)`. + +| Column | Type | | +| --- | --- | --- | +| `_rowid` | `UInt64` | Lance scan row identifier; stability requires stable row ids | +| `_rowaddr` | `UInt64` | physical row address | +| `_rowoffset` | `UInt64` | offset within the fragment | +| `_score` | `Float32` | relevance, from [`fts()`](/sql/functions#fts) | + +`_rowid`, `_rowaddr`, and `_rowoffset` describe Lance scan metadata, not +columns automatically registered on an ordinary SQL table. Select `_score` +from an `fts()` relation explicitly when you want a projection containing it. + +## Casting {#casting} + +```sql +SELECT CAST(identifier AS VARCHAR) FROM documents +SELECT TRY_CAST(maybe_number AS BIGINT) FROM raw +``` + +`TRY_CAST` yields null rather than failing the query on a value that will not +convert. See [Operators](/sql/operators#casting). diff --git a/docs/web/sql/ddl/function.mdx b/docs/web/sql/ddl/function.mdx new file mode 100644 index 000000000..39a661fc3 --- /dev/null +++ b/docs/web/sql/ddl/function.mdx @@ -0,0 +1,86 @@ +--- +title: "Function" +sidebarTitle: "Function" +description: "CREATE FUNCTION, SHOW FUNCTIONS, and DROP FUNCTION." +icon: function +keywords: ["create function", "drop function", "show functions", "sql", "udf"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +## CREATE FUNCTION {#create} + +```sql +CREATE FUNCTION f(x BIGINT) RETURNS BIGINT +LANGUAGE python +AS 'def f(x): return x * 2' +WITH (pip = 'numpy==1.26.4', python_version = '3.11') +``` + +The body is Python source. Its dependencies are declared with it and are part +of the **immutable function version** -- changing one produces a new version +rather than altering the registered one, so a column filled by an earlier +version is unaffected. + +| Option | | +| --- | --- | +| `pip` | pip requirements for the worker environment | +| `conda` | conda packages instead of pip | +| `conda_channels` | channels in priority order; requires `conda` | +| `python_version` | worker Python version, defaulting to yours | +| `gpu` | `'true'` when every execution needs a GPU | + +**Errors and limits** + +| | | +| --- | --- | +| `LANGUAGE` other than `python` | refused | +| `pip` and `conda` together | refused -- pick one | +| `conda_channels` without `conda` | refused | +| `gpu` set to anything but `'true'` | refused | +| An empty body | refused | +| Any other option | refused, and the message lists what is supported | + + +**Enterprise job type:** `create_function`. Registration returns a job because +the function is baked into an image before the version becomes usable. + + +## SHOW FUNCTIONS {#show} + +```sql +SHOW FUNCTIONS +``` + +Lists registered functions and their versions. Since a version is immutable +and a column pins the version that fills it, this is how you find which +versions are still in use before dropping one. + +## DROP FUNCTION {#drop} + +```sql +DROP FUNCTION embed WITH (version = '1') +DROP FUNCTION IF EXISTS embed WITH (version = '1') +DROP FUNCTION embed WITH (version = '1', cascade = 'true') +``` + +`version` is required -- a function is a set of immutable versions, and +dropping is always of one specific version, never "the function" as a whole. + + +Without `cascade = 'true'`, dropping a version that a column still pins fails +rather than breaking that column silently. Passing it drops the version and +lets columns pinned to it stop refreshing -- they keep their last-filled +values, per [Guarantees](/basics/guarantees#jobs). + + +## What to read next {#next} + + + + Registering one from the SDK, and diagnosing environment mismatches. + + + Filling a column with a function instead of an expression. + + diff --git a/docs/web/sql/ddl/index.mdx b/docs/web/sql/ddl/index.mdx new file mode 100644 index 000000000..2ac18dd4c --- /dev/null +++ b/docs/web/sql/ddl/index.mdx @@ -0,0 +1,49 @@ +--- +title: "Index" +sidebarTitle: "Index" +description: "CREATE INDEX and DROP INDEX — one column, server-chosen name." +icon: bolt +keywords: ["create index", "drop index", "sql"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +## CREATE INDEX {#create} + +```sql +CREATE INDEX ON documents (title) +``` + + +SQL `CREATE INDEX` covers **one column** and takes **no user-chosen name** -- +the server names it. For a multi-column or explicitly-named index, or to +choose the algorithm, use the SDK's +[`create_index`](/indexing). + + + +**Enterprise job type:** `create_ivf_pq`, `create_btree`, `create_fts`, and one +more per algorithm -- the type names which algorithm is building, not a +different feature. See [Build & Repair](/indexing/reindexing). + + +## DROP INDEX {#drop} + +```sql +DROP INDEX documents.title_idx +``` + +Takes the qualified `table.index` name. Find it with the SDK's +[`list_indices`](/indexing) if you created the index without +naming it yourself. + +## What to read next {#next} + + + + Choosing an index type from the SDK. + + + Keeping an index current as data arrives. + + diff --git a/docs/web/sql/ddl/job.mdx b/docs/web/sql/ddl/job.mdx new file mode 100644 index 000000000..ae99b014e --- /dev/null +++ b/docs/web/sql/ddl/job.mdx @@ -0,0 +1,90 @@ +--- +title: "Job" +sidebarTitle: "Job" +description: "SHOW JOBS, SHOW ERRORS, and controlling a running job with PAUSE, RESUME and CANCEL." +icon: hourglass-half +keywords: ["show jobs", "show errors", "pause job", "resume job", "cancel job", "sql"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +A [job](/basics/jobs) is not a catalog object -- it has no schema and is not +namespaced -- but it has its own SQL statements, grouped here the same way the +catalog objects are. + +## SHOW JOBS {#show} + +```sql +SHOW JOBS +``` + +Every job the server knows about -- id, type, state, creation time. The SQL +counterpart of `list_jobs`. + +## SHOW ERRORS {#errors} + +```sql +SHOW ERRORS ON
[FOR JOB ''] [LIMIT ] +``` + +```sql +SHOW ERRORS ON documents +SHOW ERRORS ON documents FOR JOB 'job-abc' LIMIT 100 +``` + +Why rows failed. Reach for this when a [refresh](/sql/dml/refresh) reports +fewer filled rows than you expected. + +**Errors and limits** + +| | | +| --- | --- | +| `LIMIT` | must be between 1 and 100,000 | +| Failed jobs scanned without `FOR JOB` | 32 most recent | +| Tables the server does not open directly | not supported | + +Without `FOR JOB` this surveys recent failures and stops at 32 jobs. When the +listing stops at your `LIMIT`, the message says so -- narrow with `FOR JOB` +rather than raising the limit blindly. + +## PAUSE JOB {#pause} + +```sql +PAUSE JOB 'j-ci-abc-def' +``` + +## RESUME JOB {#resume} + +```sql +RESUME JOB 'j-ci-abc-def' +``` + +## CANCEL JOB {#cancel} + +```sql +CANCEL JOB 'j-ci-abc-def' +``` + +The job id is always a quoted string literal. All three take exactly one and +nothing after it -- a second token on the line is a syntax error rather than +being ignored. + + +Cancellation is a request, matching the SDK's +[`cancel()`](/basics/jobs#managing): it does not guarantee the job stops before +doing more work, and asking twice or cancelling a finished job does nothing. +`CANCEL JOB` itself can be unavailable on a deployment with no job registry +configured, which is a deployment condition rather than something you can work +around from SQL. + + +## What to read next {#next} + + + + The handle these statements are the SQL view of. + + + Job types you did not start yourself. + + diff --git a/docs/web/sql/ddl/materialized-view.mdx b/docs/web/sql/ddl/materialized-view.mdx new file mode 100644 index 000000000..51b5ef55b --- /dev/null +++ b/docs/web/sql/ddl/materialized-view.mdx @@ -0,0 +1,73 @@ +--- +title: "Materialized View" +sidebarTitle: "Materialized View" +description: "CREATE MATERIALIZED VIEW and SHOW MATERIALIZED VIEWS; refreshing is a REFRESH statement." +icon: table +keywords: ["materialized view", "create materialized view", "show materialized views", "sql"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +A materialized view stores a query's result as rows, kept current by a refresh +-- the counterpart to a [view](/sql/ddl/view), which stores only the query. + + +Materialized views are off by default on a deployment and have to be enabled +there. Which statements and query shapes a deployment accepts, whether it needs +the source table to have stable row ids, and how it refreshes a view depend on +its LanceDB Enterprise version and configuration. + + +## CREATE MATERIALIZED VIEW {#create} + +```sql +CREATE MATERIALIZED VIEW AS SELECT ... +``` + +On a `db://` connection, the SDK's +[`create_materialized_view`](/management/materialized-view#create) sends this +statement. Once created, a view is queried like a table, refreshed with SQL and +listed with SQL. + +## SHOW MATERIALIZED VIEWS {#show} + +```sql +SHOW MATERIALIZED VIEWS +``` + + +Cheaper than the SDK equivalent. `list_materialized_views` finds views by +reading every table's schema, costing one open per table; this asks the server. + + +## Refreshing one {#refresh} + +```sql +REFRESH MATERIALIZED VIEW chunks +``` + +See [REFRESH](/sql/dml/refresh#refresh-materialized-view) for the full syntax, +including `CASCADE` and `full = true`. + +## Dropping one {#drop} + +A materialized view **is** a table, so it is dropped with +[`DROP TABLE`](/sql/ddl/table#drop) -- which is also why dropping one removes +stored rows, unlike dropping a [view](/sql/ddl/view#drop). + + +**Enterprise job type:** `mat_view_refresh` for a refresh, or `sql` on a +deployment that rebuilds views through SQL; `drop_table` for dropping one, same +as any table. + + +## What to read next {#next} + + + + Creating one from the SDK. + + + How a refresh works, locally and on a deployment. + + diff --git a/docs/web/sql/ddl/namespace.mdx b/docs/web/sql/ddl/namespace.mdx new file mode 100644 index 000000000..daedf9720 --- /dev/null +++ b/docs/web/sql/ddl/namespace.mdx @@ -0,0 +1,53 @@ +--- +title: "Namespace" +sidebarTitle: "Namespace" +description: "CREATE SCHEMA and DROP SCHEMA — the SQL spelling of namespace DDL." +icon: folder-tree +keywords: ["namespace", "schema", "create schema", "drop schema", "sql"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +A [namespace](/management/namespace) is addressed in SQL as a `SCHEMA` -- the +standard's name for the same concept. + +## CREATE SCHEMA {#create} + +```sql +CREATE SCHEMA public +CREATE SCHEMA db1.ns1.ns2 +CREATE SCHEMA IF NOT EXISTS `ns1$ns2` +``` + +A dotted path creates the whole chain: `db1.ns1.ns2` creates `ns2` inside +`ns1` inside `db1`, provided the parents already exist. A name containing the +namespace path delimiter itself is backtick-quoted, as in `` `ns1$ns2` `` above. + +`IF NOT EXISTS` makes the statement a no-op rather than an error when the +namespace is already there. + +## DROP SCHEMA {#drop} + +```sql +DROP SCHEMA db1.public +DROP SCHEMA IF EXISTS `a$b` +DROP SCHEMA `a$b` RESTRICT +``` + + +`CASCADE` is rejected: **"DROP SCHEMA CASCADE is not supported; drop the +objects inside first."** A namespace's tables, views and functions must be +dropped explicitly before the namespace itself can go. `RESTRICT` -- the +default -- states that requirement rather than changing it. + + +## What to read next {#next} + + + + The SDK equivalent, and how a namespace fits the catalog hierarchy. + + + Creating tables inside a namespace. + + diff --git a/docs/web/sql/ddl/table.mdx b/docs/web/sql/ddl/table.mdx new file mode 100644 index 000000000..bd64458be --- /dev/null +++ b/docs/web/sql/ddl/table.mdx @@ -0,0 +1,178 @@ +--- +title: "Table" +sidebarTitle: "Table" +description: "CREATE, DESCRIBE, ALTER, CLONE and DROP TABLE." +icon: table +keywords: ["create table", "alter table", "drop table", "describe", "clone", "ddl"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +## CREATE TABLE {#create} + +```sql +CREATE TABLE documents (id BIGINT, title VARCHAR, created_at TIMESTAMP) +CREATE TABLE recent AS SELECT * FROM documents WHERE created_at > now() - INTERVAL '7 days' +``` + +Column types are Arrow types under SQL names -- see [Data types](/sql/data-types). +A table inside a [namespace](/sql/ddl/namespace) is written as a dotted path: +`prod.events.clicks`. + +## DESCRIBE {#describe} + +```sql +DESCRIBE documents +``` + +The table's columns and types, without a plan -- the SQL counterpart of +`table.schema`. + +## ALTER TABLE {#alter} + +`ALTER TABLE ... ADD COLUMN` covers three different things, distinguished by +what follows the column name. + +### A plain column {#plain-column} + +```sql +ALTER TABLE t ADD COLUMN c INT +``` + +Added empty and nullable. Nothing is computed. + +### A computed column {#computed-column} + +```sql +ALTER TABLE t ADD COLUMN doubled AS value * 2 +``` + +`AS ` declares a [computed column](/feature-engineering/add-computed-column). +The column is committed empty and the expression is stored, not evaluated -- +declaring one on a billion-row table costs what declaring one on an empty table +costs. [`REFRESH COLUMN`](/sql/dml/refresh) fills it. + +### A function column {#function-column} + +```sql +ALTER TABLE t ADD COLUMN embedded AS embed(name) WITH (version = 'fv_1') +``` + +When the expression calls a [registered function](/sql/ddl/function), the +column is filled by running that function on workers. `version` pins which +immutable function version fills it, so a later registration does not silently +change what the column means. + +Several modifiers narrow what gets computed: + +| | | +| --- | --- | +| `FILTER (WHERE pred)` | only rows matching `pred` are computed | +| `IGNORE NULLS` | rows with null inputs are skipped | +| `OVER (PARTITION BY col)` | the function sees rows grouped by `col` | + +A function returning several values fills several columns at once: + +```sql +ALTER TABLE t ADD COLUMN (label, score) AS classify(text) WITH (version = 'fv_1') +``` + +```sql +ALTER TABLE t ADD COLUMN c AS f(x) FILTER (WHERE keep) WITH (version = 'fv_1') +ALTER TABLE t ADD COLUMN c AS f(x) WITH (num_workers = '2') +``` + + +`WITH` on a plain computed column accepts only `version`. Resource options such +as `num_workers` apply to function columns, where there are workers to size. + + +### Table properties {#properties} + +```sql +ALTER TABLE t SET UNENFORCED PRIMARY KEY (id) +``` + +Records which column identifies a row. **Unenforced** is not a hedge in the +naming: uniqueness is not checked on write, and declaring it does not make +duplicates impossible. It tells the planner what to assume. + +```sql +ALTER TABLE t SET WAL +ALTER TABLE t SET WAL WITH (sharding = 'bucket', buckets = 4) +ALTER TABLE t SET WAL WITH (maintained_indexes = '') +ALTER TABLE t UNSET WAL +``` + +A write-ahead log makes small frequent writes cheap by batching them out of the +commit path. `sharding` and `buckets` spread the log so concurrent writers do +not contend on one place; `maintained_indexes` chooses which indexes are kept +current against WAL rows. See +[Background jobs](/maintenance/background-jobs#wal) for how the log gets +folded back into the table. + + +[`TABLESAMPLE`](/sql/dql/sampling) is refused on a WAL-backed table, because +rows a sample would draw from are not all in the fragments yet. + + +**Errors and limits** + +| | | +| --- | --- | +| `ALTER TABLE ... DROP COLUMN` | not supported | + +Dropping, renaming, or retyping a column is an SDK operation -- +[`drop_columns`](/management/table#schema) and +[`alter_columns`](/management/table#schema) -- not a SQL statement. + +## CLONE {#clone} + +```sql +CREATE TABLE docs_snapshot CLONE documents +CREATE TABLE docs_at_v42 CLONE documents AT 42 +CREATE TABLE docs_at_release CLONE documents AT TAG 'release-1' +CREATE TABLE docs_yesterday CLONE documents AT TIMESTAMP '2026-09-01T00:00:00Z' +``` + +A **shallow clone**: the new table shares the source's data files rather than +copying them, so it costs one metadata commit regardless of the source's size. + +`AT` pins the point in the source's history to clone. A bare integer is a +version and a bare quoted string is a timestamp; `VERSION`, `TAG` and +`TIMESTAMP` say so explicitly, and a tag always needs its keyword -- a bare +quoted string cannot be both a tag name and a timestamp. + + +The clone depends on the source's files continuing to exist. If the source is +[compacted](/maintenance/cleanup) past the retention window, the version the +clone points at can be pruned. The SDK's +[`clone_table`](/management/table#rename-clone) also offers a deep clone for +when the copy has to outlive the source's history. + + +## DROP TABLE {#drop} + +```sql +DROP TABLE documents +``` + + +Removes the table and every version of it. There is no undrop. + + + +**Enterprise job type:** `drop_table`. See +[Background jobs](/maintenance/background-jobs#teardown). + + +## What to read next {#next} + + + + The SDK equivalent, and what a table carries. + + + Renaming, retyping and dropping columns from the SDK. + + diff --git a/docs/web/sql/ddl/view.mdx b/docs/web/sql/ddl/view.mdx new file mode 100644 index 000000000..fc0807058 --- /dev/null +++ b/docs/web/sql/ddl/view.mdx @@ -0,0 +1,98 @@ +--- +title: "View" +sidebarTitle: "View" +description: "CREATE VIEW and DROP VIEW — a saved query, evaluated when read." +icon: eye +keywords: ["create view", "drop view", "sql", "catalog"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +A view stores a **query**, not rows. Reading it runs the query against current +data, so a view is never stale -- and never faster than the query it wraps. It +is the counterpart to a [materialized view](/sql/ddl/materialized-view), which +stores rows and is brought up to date by a refresh; both are +[catalog objects](/basics/catalog) defined by a query, differing only in when +that query runs. + +There is no SDK method for a view, because the thing it stores *is* a SQL +statement. + +## CREATE VIEW {#create} + +```sql +CREATE VIEW "recent_matches" AS +SELECT t1.floats +FROM "documents" AS t1 +INNER JOIN "categories" AS t2 ON t1.ints = t2.ints +``` + +The statement returns the view's schema -- column name, data type, nullability +-- resolved from the query at creation time. Anything the +[dialect](/sql/functions) supports can define one: joins, predicates, +aggregates, window functions. + +## Reading one {#reading} + +A view is queried like a table: + +```sql +SELECT * FROM "recent_matches" WHERE floats > 0.5 +``` + +Predicates applied to the view compose with the query inside it rather than +filtering its output afterwards, so a selective filter does not pay for rows +the view would have produced and then discarded. + +## DROP VIEW {#drop} + +```sql +DROP VIEW "recent_matches" +``` + +Removes the saved query. Unlike dropping a table, no data is removed -- there +was none to begin with. + +## Listing {#listing} + +Views and tables share a namespace, and both are things you can query. The +catalog distinguishes them: + +| | | +| --- | --- | +| view names | the views in the database | +| table names | the tables | +| relation names | everything queryable -- both | + +Use the relation listing when the question is "what can I select from," and the +separate listings when the distinction matters -- before dropping, for +instance, since dropping a view removes only the saved query while dropping a +table removes data. + +## Choosing between a view and a materialized view {#choosing} + +| | View | Materialized view | +| --- | --- | --- | +| Stores | the query | the rows | +| Freshness | always current | as of the last refresh | +| Read cost | the full query, every read | a table scan | +| Write cost | none | a refresh | + +Reach for a **view** when the query is cheap, or when reading stale rows would +be wrong. Reach for a **materialized view** when the query is expensive and the +data changes less often than it is read. + +The privileges are separate too: `CREATE_VIEW` and `CREATE_MATERIALIZED_VIEW` +are distinct grants on a namespace, so the ability to create one does not imply +the other. See [Authorization](/governance/authorization). + +## What to read next {#next} + + + + The stored-rows counterpart. + + + Where views fit among the other catalog objects. + + diff --git a/docs/web/sql/dml/copy.mdx b/docs/web/sql/dml/copy.mdx new file mode 100644 index 000000000..837b92285 --- /dev/null +++ b/docs/web/sql/dml/copy.mdx @@ -0,0 +1,23 @@ +--- +title: "Copy" +sidebarTitle: "Copy" +description: "COPY ... TO — writing a query result to object storage as files." +icon: file-export +keywords: ["copy", "export", "parquet", "sql"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +```sql +COPY (SELECT id, title FROM documents) TO 's3://bucket/export/' STORED AS PARQUET +``` + +Writes a query result to object storage. This is the export path when the +consumer is another engine rather than an application -- results land as files +rather than streaming to a client. + +## What to read next {#next} + + + Bringing rows in, rather than exporting them. + diff --git a/docs/web/sql/dml/delete.mdx b/docs/web/sql/dml/delete.mdx new file mode 100644 index 000000000..da71dd9eb --- /dev/null +++ b/docs/web/sql/dml/delete.mdx @@ -0,0 +1,27 @@ +--- +title: "Delete" +sidebarTitle: "Delete" +description: "DELETE FROM ... WHERE — rows are marked, not erased." +icon: trash +keywords: ["delete", "sql"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +```sql +DELETE FROM documents WHERE created_at < '2025-01-01' +``` + +Deletion marks rows rather than erasing them, so the data remains recoverable +for as long as the version holding it survives +[retention](/maintenance/cleanup). + +Commits a new table version, so [versioning](/management/table#versioning) and +the [write guarantees](/basics/guarantees#writes) apply -- including that the +version advances even when the statement affects zero rows. + +## What to read next {#next} + + + The SDK equivalent. + diff --git a/docs/web/sql/dml/insert.mdx b/docs/web/sql/dml/insert.mdx new file mode 100644 index 000000000..2be017f06 --- /dev/null +++ b/docs/web/sql/dml/insert.mdx @@ -0,0 +1,33 @@ +--- +title: "Insert" +sidebarTitle: "Insert" +description: "INSERT INTO, including INSERT ... SELECT to move rows server-side." +icon: plus +keywords: ["insert", "insert into", "sql"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +```sql +INSERT INTO documents (id, title) VALUES (1, 'Merlin'), (2, 'Arthur') + +INSERT INTO archive SELECT * FROM documents WHERE created_at < '2026-01-01' +``` + +`INSERT ... SELECT` is the form worth knowing: it moves rows between tables +server-side, without a round trip through your process. + +Commits a new table version, so [versioning](/management/table#versioning) and +the [write guarantees](/basics/guarantees#writes) apply -- including that the +version advances even when the statement affects zero rows. + +## What to read next {#next} + + + + The SDK equivalent. + + + Upsert -- matching incoming rows against existing keys -- has no SQL form. + + diff --git a/docs/web/sql/dml/refresh.mdx b/docs/web/sql/dml/refresh.mdx new file mode 100644 index 000000000..2d00c1104 --- /dev/null +++ b/docs/web/sql/dml/refresh.mdx @@ -0,0 +1,98 @@ +--- +title: "REFRESH" +sidebarTitle: "REFRESH" +description: "Filling computed columns and bringing materialized views up to date." +icon: arrows-rotate +keywords: ["refresh column", "refresh materialized view", "backfill", "cascade"] +--- + +Enterprise [SQL runs on a deployment.](/sql) + +LanceDB extensions with no DataFusion equivalent. They are what makes +[feature engineering](/feature-engineering) reachable from SQL: a column +declared by [DDL](/sql/ddl/table#computed-column) starts empty, and a refresh fills it. + +## REFRESH COLUMN {#refresh-column} + +```sql +REFRESH COLUMN ON [TABLE]
+ [WHERE ] + [WITH (
()?; diff --git a/python/src/listing.rs b/python/src/listing.rs new file mode 100644 index 000000000..2d1a3916e --- /dev/null +++ b/python/src/listing.rs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use crate::error::PythonErrorExt; +use crate::runtime::future_into_py; +use futures::{StreamExt, future::poll_fn}; +use lancedb::listing::{Listing, ListingOptions}; +use pyo3::exceptions::PyStopAsyncIteration; +use pyo3::{Bound, PyAny, PyRef, PyResult, Python, pyclass, pymethods}; +use std::sync::{Arc, Mutex}; + +pub fn options(page_token: Option, page_limit: Option) -> ListingOptions { + let mut options = ListingOptions::default(); + options.page_token = page_token; + options.page_limit = page_limit; + options +} + +#[pyclass] +pub struct NameListing { + inner: Arc>>, + next_lock: Arc>, +} +impl NameListing { + pub fn new(inner: Listing) -> Self { + Self { + inner: Arc::new(Mutex::new(inner)), + next_lock: Arc::new(tokio::sync::Mutex::new(())), + } + } +} +#[pymethods] +impl NameListing { + fn num_page_results(&self, py: Python<'_>) -> usize { + py.detach(|| self.inner.lock().unwrap().num_page_results()) + } + fn page_token(&self, py: Python<'_>) -> Option { + py.detach(|| self.inner.lock().unwrap().page_token().map(str::to_owned)) + } + fn __aiter__(self_: PyRef<'_, Self>) -> PyRef<'_, Self> { + self_ + } + fn __anext__(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner.clone(); + let next_lock = self_.next_lock.clone(); + future_into_py(self_.py(), async move { + let _guard = next_lock.lock().await; + let value = poll_fn(|cx| inner.lock().unwrap().poll_next_unpin(cx)) + .await + .ok_or_else(|| PyStopAsyncIteration::new_err(""))? + .infer_error()?; + Ok(value) + }) + } +} + +#[pyclass] +pub struct FunctionListing { + inner: Arc>>, + next_lock: Arc>, +} +impl FunctionListing { + pub fn new(inner: Listing) -> Self { + Self { + inner: Arc::new(Mutex::new(inner)), + next_lock: Arc::new(tokio::sync::Mutex::new(())), + } + } +} +#[pymethods] +impl FunctionListing { + fn num_page_results(&self, py: Python<'_>) -> usize { + py.detach(|| self.inner.lock().unwrap().num_page_results()) + } + fn page_token(&self, py: Python<'_>) -> Option { + py.detach(|| self.inner.lock().unwrap().page_token().map(str::to_owned)) + } + fn __aiter__(self_: PyRef<'_, Self>) -> PyRef<'_, Self> { + self_ + } + fn __anext__(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner.clone(); + let next_lock = self_.next_lock.clone(); + future_into_py(self_.py(), async move { + let _guard = next_lock.lock().await; + let value = poll_fn(|cx| inner.lock().unwrap().poll_next_unpin(cx)) + .await + .ok_or_else(|| PyStopAsyncIteration::new_err(""))? + .infer_error()?; + value.to_canonical_json().infer_error() + }) + } +} + +#[pyclass] +pub struct JobListing { + inner: Arc>>, + next_lock: Arc>, +} +impl JobListing { + pub fn new(inner: Listing) -> Self { + Self { + inner: Arc::new(Mutex::new(inner)), + next_lock: Arc::new(tokio::sync::Mutex::new(())), + } + } +} +#[pymethods] +impl JobListing { + fn num_page_results(&self, py: Python<'_>) -> usize { + py.detach(|| self.inner.lock().unwrap().num_page_results()) + } + fn page_token(&self, py: Python<'_>) -> Option { + py.detach(|| self.inner.lock().unwrap().page_token().map(str::to_owned)) + } + fn __aiter__(self_: PyRef<'_, Self>) -> PyRef<'_, Self> { + self_ + } + fn __anext__(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner.clone(); + let next_lock = self_.next_lock.clone(); + future_into_py(self_.py(), async move { + let _guard = next_lock.lock().await; + let value = poll_fn(|cx| inner.lock().unwrap().poll_next_unpin(cx)) + .await + .ok_or_else(|| PyStopAsyncIteration::new_err(""))? + .infer_error()?; + Ok(crate::job::JobInfo::from(value)) + }) + } +} diff --git a/python/src/table.rs b/python/src/table.rs index d7519bdab..d82ebbaaf 100644 --- a/python/src/table.rs +++ b/python/src/table.rs @@ -267,7 +267,7 @@ fn fmt_maintained(maintained: &Option>) -> String { /// Constructed via the `bucket(...)`, `identity(...)`, or `unsharded()` /// classmethods, then optionally chain `with_maintained_indexes(...)` and /// `with_writer_config_defaults(...)`. A fresh spec maintains every index the -/// MemWAL supports, resolved on install. +/// table has, including ones created later. #[pyclass(module = "lancedb._lancedb", from_py_object)] #[derive(Clone, Debug)] pub struct LsmWriteSpec { @@ -307,9 +307,9 @@ impl LsmWriteSpec { } } - /// Set which indexes the MemWAL maintains. `None` (the default) - /// resolves every supported index on install; a list is verbatim, - /// and an empty list maintains nothing. + /// Set which indexes the MemWAL maintains. `None` (the default) is + /// every index the table has, including ones created later; a list is + /// verbatim, and an empty list maintains nothing. #[pyo3(signature = (indexes))] pub fn with_maintained_indexes(&self, indexes: Option>) -> Self { Self { @@ -592,8 +592,6 @@ impl From for RefreshMaterializedViewRes fn from(result: lancedb::RefreshMaterializedViewResult) -> Self { let mode = match result.mode { lancedb::RefreshMode::Rebuild => "rebuild", - lancedb::RefreshMode::Incremental => "incremental", - lancedb::RefreshMode::NoOp => "no_op", }; Self { mode: mode.to_string(), @@ -825,8 +823,8 @@ impl From for FtsToken { language = "English".to_string(), max_token_length = Some(40), lower_case = true, - stem = true, - remove_stop_words = true, + stem = None, + remove_stop_words = None, custom_stop_words = None, ascii_folding = true, ngram_min_length = 3, @@ -840,15 +838,15 @@ pub fn tokenize( language: String, max_token_length: Option, lower_case: bool, - stem: bool, - remove_stop_words: bool, + stem: Option, + remove_stop_words: Option, custom_stop_words: Option>, ascii_folding: bool, ngram_min_length: u32, ngram_max_length: u32, prefix_only: bool, ) -> PyResult> { - let params = FtsIndexBuilder::default() + let mut params = FtsIndexBuilder::default() .base_tokenizer(base_tokenizer) .language(&language) .map_err(|_| { @@ -859,13 +857,17 @@ pub fn tokenize( })? .max_token_length(max_token_length.map(|value| value as usize)) .lower_case(lower_case) - .stem(stem) - .remove_stop_words(remove_stop_words) .ascii_folding(ascii_folding) .ngram_min_length(ngram_min_length) .ngram_max_length(ngram_max_length) .ngram_prefix_only(prefix_only) .custom_stop_words(custom_stop_words); + if let Some(stem) = stem { + params = params.stem(stem); + } + if let Some(remove_stop_words) = remove_stop_words { + params = params.remove_stop_words(remove_stop_words); + } let tokens = lancedb_tokenize(&query, ¶ms).infer_error()?; Ok(tokens.into_iter().map(FtsToken::from).collect()) } @@ -915,6 +917,11 @@ impl Table { self.inner.is_some() } + /// Whether this table has a local Lance dataset that the client may open. + pub fn _is_native(&self) -> PyResult { + Ok(self.inner_ref()?.as_native().is_some()) + } + /// Closes the table, releasing any resources associated with it. pub fn close(&mut self) { self.inner.take(); @@ -1268,6 +1275,7 @@ impl Table { let dict = PyDict::new(py); dict.set_item("total_bytes", stats.total_bytes)?; dict.set_item("num_rows", stats.num_rows)?; + dict.set_item("num_deleted_rows", stats.num_deleted_rows)?; dict.set_item("num_indices", stats.num_indices)?; let fragment_stats = PyDict::new(py); @@ -1709,7 +1717,10 @@ impl Table { future_into_py(self_.py(), async move { inner .as_native() - .ok_or_else(|| PyValueError::new_err("This cannot be run on a remote table"))? + .ok_or_else(|| lancedb::Error::NotSupported { + message: "uses_v2_manifest_paths is not supported for remote tables.".into(), + }) + .infer_error()? .uses_v2_manifest_paths() .await .infer_error() @@ -1721,7 +1732,10 @@ impl Table { future_into_py(self_.py(), async move { inner .as_native() - .ok_or_else(|| PyValueError::new_err("This cannot be run on a remote table"))? + .ok_or_else(|| lancedb::Error::NotSupported { + message: "migrate_manifest_paths_v2 is not supported for remote tables.".into(), + }) + .infer_error()? .migrate_manifest_paths_v2() .await .infer_error() @@ -1817,10 +1831,9 @@ impl Table { }) } - #[pyo3(signature = (full=false, source_version=None))] + #[pyo3(signature = (source_version=None))] pub fn refresh_materialized_view( self_: PyRef<'_, Self>, - full: bool, source_version: Option, ) -> PyResult> { let inner = self_.inner_ref()?.clone(); @@ -1828,7 +1841,7 @@ impl Table { let view = lancedb::MaterializedView::from_table(inner) .await .infer_error()?; - let mut builder = view.refresh().full(full); + let mut builder = view.refresh(); if let Some(version) = source_version { builder = builder.source_version(version); } @@ -1837,10 +1850,9 @@ impl Table { }) } - #[pyo3(signature = (full=false, source_version=None))] + #[pyo3(signature = (source_version=None))] pub fn refresh_materialized_view_async( self_: PyRef<'_, Self>, - full: bool, source_version: Option, ) -> PyResult> { let inner = self_.inner_ref()?.clone(); @@ -1848,7 +1860,7 @@ impl Table { let view = lancedb::MaterializedView::from_table(inner) .await .infer_error()?; - let mut builder = view.refresh().full(full); + let mut builder = view.refresh(); if let Some(version) = source_version { builder = builder.source_version(version); } @@ -1863,11 +1875,9 @@ impl Table { let view = lancedb::MaterializedView::from_table(inner) .await .infer_error()?; - view.definition().to_json().map_err(|err| { - PyRuntimeError::new_err(format!( - "failed to serialize materialized-view definition: {err}" - )) - }) + Ok(lancedb::materialized_view::definition_metadata_from_sql( + view.definition_sql(), + )) }) } diff --git a/python/uv.lock b/python/uv.lock index 8366dd397..f80541711 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1849,19 +1849,19 @@ wheels = [ [[package]] name = "lance-namespace" -version = "0.8.6" +version = "0.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lance-namespace-urllib3-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/12/f7ab93b29be3edbf5fc3610714bf2d06088e7f4524bfb38dfd6852458b08/lance_namespace-0.8.6.tar.gz", hash = "sha256:18232e721c8188145f4ec9389cc2dfbeeabf54a619d94885ea1b3375bee9f4af", size = 11529, upload-time = "2026-06-12T17:36:41.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/93/da5f7fcac690db9b282a3439ed9e34960c147619a0d6e1f4eb8cd240e7a5/lance_namespace-0.11.1.tar.gz", hash = "sha256:f67cfbbe0647b7cb42f23b673e7edf8a75b7d8a047265a916492f8d247ee1bc2", size = 11631, upload-time = "2026-08-18T17:40:06.294Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1b/5b1668ee2dc8910965f390640359112a31157092fcf8e000b89c79b58708/lance_namespace-0.8.6-py3-none-any.whl", hash = "sha256:571eae34f9aad70e5b05020416c2860889b9ec82993ccd0eb015e7b39c3ea309", size = 13383, upload-time = "2026-06-12T17:36:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/fa/bc/601f2b3cc4cfa0070d858a33223bc823fffdd7981a25c45984a5216ca952/lance_namespace-0.11.1-py3-none-any.whl", hash = "sha256:07643fce9a42ad4d58cc8bf91e3f592bc7f4cbd8d0ad5233223506debf67551c", size = 13507, upload-time = "2026-08-18T17:40:03.561Z" }, ] [[package]] name = "lance-namespace-urllib3-client" -version = "0.8.6" +version = "0.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -1869,9 +1869,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/80/fb224b4a89c1c1638cde949cb6cce6c3aca7759effbfea46a3d9c3960b21/lance_namespace_urllib3_client-0.8.6.tar.gz", hash = "sha256:b6fb1d306e74a7576e5309919020be744527de484a63dbf5eed10f8b368548df", size = 228772, upload-time = "2026-06-12T17:36:42.609Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/c5/2bdd0ff98b469894c8a73be809d26ffdad5402517b0e5f9e758026cba29e/lance_namespace_urllib3_client-0.11.1.tar.gz", hash = "sha256:145a9e9424d7597487249b5b95ee274423bf2910e1a9160b6a07b676b61ea46a", size = 237345, upload-time = "2026-08-18T17:40:07.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/90/1e27de15cd1b16785a1c7312beb0a59e75c8344a815f600f58173a565bd1/lance_namespace_urllib3_client-0.8.6-py3-none-any.whl", hash = "sha256:9d78249c3fb15aa3d15d668f78f04a275af3d08d800a7027492f37996ac4968b", size = 369950, upload-time = "2026-06-12T17:36:40.438Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2a/eaaefd55d1190291207049fedc6b3eb22b506e57d6de91bae46bbaaa9c60/lance_namespace_urllib3_client-0.11.1-py3-none-any.whl", hash = "sha256:36537f529294da6d884ba0fe783704483f0a75463497c7705fd083a4d0257990", size = 406311, upload-time = "2026-08-18T17:40:04.842Z" }, ] [[package]] @@ -2005,7 +2005,7 @@ requires-dist = [ { name = "pyarrow-stubs", marker = "extra == 'tests'", specifier = ">=16.0" }, { name = "pydantic", specifier = ">=2.7.4,<3" }, { name = "pylance", marker = "extra == 'pylance'", specifier = ">=5.0.0b5" }, - { name = "pylance", marker = "extra == 'tests'", specifier = "==9.0.0" }, + { name = "pylance", marker = "extra == 'tests'", specifier = "==14.0.0b6" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.350" }, { name = "pytest", marker = "extra == 'tests'", specifier = ">=7.0" }, { name = "pytest-asyncio", marker = "extra == 'tests'", specifier = ">=0.21" }, @@ -3912,8 +3912,8 @@ crypto = [ [[package]] name = "pylance" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } +version = "14.0.0b6" +source = { registry = "https://pypi.fury.io/lance-format/" } dependencies = [ { name = "lance-namespace" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3922,12 +3922,12 @@ dependencies = [ { name = "pyarrow" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/be/45733acd64801991852aac8e658601fd8fc12f76ceb81e57fca690896b90/pylance-9.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8257213501d3298c5b6a344d60938e4bbe4de9f00cd3265371a56d1dc3dd15ca", size = 68377982, upload-time = "2026-07-24T16:53:45.247Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3e/1ef707cb215cc7268c63ad84a91344ad6313d3984b343eeb19a9b708698e/pylance-9.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:804eedfa1fda2e703cca8580c76f0b44a1b849b725a8908c06f8acfca811732f", size = 71844362, upload-time = "2026-07-24T16:56:07.6Z" }, - { url = "https://files.pythonhosted.org/packages/c8/fb/a499e5c53ddb75c7de44100fd2bb1f7cc735966200d20292eed7af9ef552/pylance-9.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a0b75595e3766c1d5f4c90abdc52337b4de60f1a52d123a4f8c4e5bcbbbfa8f", size = 75663088, upload-time = "2026-07-24T17:10:31.283Z" }, - { url = "https://files.pythonhosted.org/packages/e9/80/0714e09f64a68dbdf62558955e737a7436df763b9834a6aba506861b5352/pylance-9.0.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d2f69c5c390ae3710c35a429905fe15f769951777fafb1b72a359e035ec121f7", size = 71866858, upload-time = "2026-07-24T16:56:35.937Z" }, - { url = "https://files.pythonhosted.org/packages/4b/3c/78d3a6d6ca0d843b7c3ac0c30d9cd2cf4635b0c2cabe6ec66583d5bfc1a1/pylance-9.0.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:836268a7832d62f3d5ccbe1c3fca239621971d90297bcfff14a70b3cb6842aa8", size = 75642656, upload-time = "2026-07-24T17:13:11.879Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c1/dc9c9a31e171530ec0add024d922488c046437d32a7087c29e254a7eacc7/pylance-9.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:96441d27a5ed3805300388ccf8f31835cd280c98751f80b6a1a11dcd6808fc43", size = 81668288, upload-time = "2026-07-24T17:05:38.707Z" }, + { url = "https://pypi.fury.io/lance-format/-/ver_DuQEn/pylance-14.0.0b6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3eade86fa30d9ff3248ee7ffb0b27ce29495394d12327da4d22b5c54bc2b59fe" }, + { url = "https://pypi.fury.io/lance-format/-/ver_K5sqj/pylance-14.0.0b6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10e291974ee738a1d5d7e9dad17a634394a91cb60080b5ceac209077fbaec43c" }, + { url = "https://pypi.fury.io/lance-format/-/ver_EnbkT/pylance-14.0.0b6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61bac10f5081c1210edd017f81c8c784d800b89446bd0ed05d4a24e7001965bc" }, + { url = "https://pypi.fury.io/lance-format/-/ver_19ffYg/pylance-14.0.0b6-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c627ad4a9870611becf84409d8e5ecddc6be64e1dd53bee4bd5fb10ab561e5c" }, + { url = "https://pypi.fury.io/lance-format/-/ver_2eLLY5/pylance-14.0.0b6-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bc603a7dd690233f1fe000600171e1c092f153bc9060cdf198825c4bbe1418aa" }, + { url = "https://pypi.fury.io/lance-format/-/ver_t8ggc/pylance-14.0.0b6-cp310-abi3-win_amd64.whl", hash = "sha256:1d32df32a3d157611450def07eae8ace3a7401286eac68cad9a24e0d5deed2db" }, ] [[package]] diff --git a/rust/lancedb/Cargo.toml b/rust/lancedb/Cargo.toml index cdb0609d3..2d7299a0e 100644 --- a/rust/lancedb/Cargo.toml +++ b/rust/lancedb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lancedb" -version = "0.40.0-beta.12" +version = "0.41.0-beta.1" edition.workspace = true description = "LanceDB: A serverless, low-latency vector database for AI applications" license.workspace = true diff --git a/rust/lancedb/src/catalog.rs b/rust/lancedb/src/catalog.rs index 5eaa1d49e..576eb8e88 100644 --- a/rust/lancedb/src/catalog.rs +++ b/rust/lancedb/src/catalog.rs @@ -15,7 +15,12 @@ //! ``` use std::fmt; +use std::pin::Pin; use std::sync::Arc; +use std::task::{Context, Poll}; + +use futures::future::BoxFuture; +use futures::{FutureExt, Stream}; use crate::Result; use crate::connection::Connection; @@ -118,6 +123,73 @@ pub struct ListDatabasesResponse { pub page_token: Option, } +/// A lazy stream of database names with inspectable pagination state. +/// +/// The continuation token advances when a page is fetched, so resuming from it +/// skips any names still cached in this stream. Drain the cache before saving a +/// token if those names must be included. Errors terminate the stream; the token +/// for the failed request remains available for resuming with a new stream. +pub struct DatabaseNames { + catalog: Arc, + names: std::vec::IntoIter, + page_token: Option, + page_limit: Option, + done: bool, + pending: Option>>, +} + +impl DatabaseNames { + /// Number of names available without another REST request. + pub fn num_page_results(&self) -> usize { + self.names.len() + } + + /// Token for the next REST request, or `None` after the last page is fetched. + /// + /// Before the first request, returns the supplied starting token (`None` + /// means start from the beginning). A final page can still have cached names. + pub fn page_token(&self) -> Option<&str> { + self.page_token.as_deref() + } +} + +impl Stream for DatabaseNames { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + loop { + if let Some(name) = this.names.next() { + return Poll::Ready(Some(Ok(name))); + } + if this.done { + return Poll::Ready(None); + } + let pending = this.pending.get_or_insert_with(|| { + let catalog = this.catalog.clone(); + let request = ListDatabasesRequest { + page_token: this.page_token.clone(), + limit: this.page_limit, + }; + async move { catalog.list_databases(request).await }.boxed() + }); + let response = futures::ready!(pending.as_mut().poll(cx)); + this.pending = None; + match response { + Ok(response) => { + this.page_token = response.page_token.filter(|token| !token.is_empty()); + this.done = this.page_token.is_none(); + this.names = response.databases.into_iter(); + } + Err(error) => { + this.done = true; + return Poll::Ready(Some(Err(error))); + } + } + } + } +} + /// A backend that manages databases. Implementations own database lifecycle semantics. #[async_trait::async_trait] pub trait Catalog: Send + Sync + std::fmt::Debug + 'static { @@ -188,12 +260,35 @@ impl CatalogConnection { self.catalog.drop_database(request.into()).await } - /// List a page of databases. Pass [`ListDatabasesRequest::default`] for the first page. - pub async fn list_databases( + /// Iterate lazily over all database names, fetching more results as needed. + /// + /// `page_token` starts at a saved continuation token; `None` starts at the + /// beginning. `page_limit` limits each REST response (not the whole stream); + /// `None` uses the server default. Errors terminate the listing. + /// + /// ``` + /// # async fn example(catalog: &lancedb::catalog::CatalogConnection) -> lancedb::Result<()> { + /// use futures::TryStreamExt; + /// let mut names = catalog.list_databases(None, None); + /// while let Some(name) = names.try_next().await? { + /// println!("{name}"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn list_databases( &self, - request: ListDatabasesRequest, - ) -> Result { - self.catalog.list_databases(request).await + page_token: Option, + page_limit: Option, + ) -> DatabaseNames { + DatabaseNames { + catalog: self.catalog.clone(), + names: Vec::new().into_iter(), + page_token, + page_limit, + done: false, + pending: None, + } } /// Connect to a database without creating it if it is missing. diff --git a/rust/lancedb/src/connection.rs b/rust/lancedb/src/connection.rs index 1d0a4a0ab..ff01f4109 100644 --- a/rust/lancedb/src/connection.rs +++ b/rust/lancedb/src/connection.rs @@ -23,11 +23,12 @@ use crate::connection::create_table::CreateTableBuilder; use crate::data::scannable::Scannable; use crate::database::listing::ListingDatabase; use crate::database::{ - CloneTableRequest, Database, DatabaseOptions, JobInfo, OpenTableRequest, PauseJobStatus, - ReadConsistency, ResumeJobStatus, TableNamesRequest, + CloneTableRequest, Database, DatabaseOptions, ExecuteQueryRequest, JobInfo, OpenTableRequest, + PauseJobStatus, ReadConsistency, ResumeJobStatus, TableNamesRequest, }; use crate::embeddings::{EmbeddingRegistry, MemoryRegistry}; use crate::error::{Error, Result}; +use crate::listing::{Listing, ListingOptions}; #[cfg(feature = "remote")] use crate::remote::{ client::ClientConfig, @@ -105,7 +106,9 @@ impl TableNamesBuilder { self } - /// The maximum number of table names to return + /// The maximum number of table names to return. + /// + /// Without a limit, all names are returned. Zero returns an empty list. pub fn limit(mut self, limit: u32) -> Self { self.request.limit = Some(limit); self @@ -334,16 +337,14 @@ pub struct CloneTableBuilder { /// Builder for asynchronously executing a SQL statement on a remote database. pub struct ExecuteQueryAsyncBuilder { parent: Arc, - query: String, - default_namespace_path: Vec, + request: ExecuteQueryRequest, } impl ExecuteQueryAsyncBuilder { fn new(parent: Arc, query: String) -> Self { Self { parent, - query, - default_namespace_path: vec!["public".to_string()], + request: ExecuteQueryRequest::new(query), } } @@ -356,15 +357,63 @@ impl ExecuteQueryAsyncBuilder { I: IntoIterator, S: Into, { - self.default_namespace_path = path.into_iter().map(Into::into).collect(); + self.request.default_namespace_path = path.into_iter().map(Into::into).collect(); + self + } + + /// Bind values to the statement's placeholders. + /// + /// `parameters` is a single-row batch. Column `i` binds `$`, and a + /// column whose name is not a number also binds `$`, so + /// `WHERE id = $1` and `WHERE id = $id` both work. The values travel as + /// Arrow rather than as SQL text, so a float keeps its exact bits and type + /// and a vector stays a compact `FixedSizeList`. + /// + /// Every placeholder needs a value and every parameter must be used by a + /// placeholder. Parameters are supported in queries (`SELECT`, and + /// `EXPLAIN` of one), not in DDL or DML, nor in table-function arguments. + /// Do not follow a named placeholder directly with `$`: `$a$` starts a + /// dollar-quoted string. + /// + /// A parameterized statement runs on the call that returns its rows rather + /// than detached from it: cancelling the query, or dropping its handle + /// before reading it, stops the statement on the server. Its status follows + /// that stream too -- it reports `Running` until the reader has received + /// every row -- so open the reader directly rather than waiting for + /// `Finished`. + /// + /// # Example + /// + /// ```no_run + /// # use std::sync::Arc; + /// # use arrow_array::{ArrayRef, FixedSizeListArray, Int64Array, RecordBatch}; + /// # use arrow_array::types::Float32Type; + /// # async fn query(db: &lancedb::Connection) -> lancedb::Result<()> { + /// let vector = FixedSizeListArray::from_iter_primitive::( + /// [Some([0.1_f32, 0.2, 0.3].map(Some))], + /// 3, + /// ); + /// let parameters = RecordBatch::try_from_iter([ + /// ("vector", Arc::new(vector) as ArrayRef), + /// ("k", Arc::new(Int64Array::from(vec![10])) as ArrayRef), + /// ])?; + /// let query = db + /// .execute_query_async("SELECT id FROM docs ORDER BY distance(vector, $vector) LIMIT $k") + /// .parameters(parameters) + /// .execute() + /// .await?; + /// let rows = query.reader().await?; + /// # Ok(()) + /// # } + /// ``` + pub fn parameters(mut self, parameters: RecordBatch) -> Self { + self.request.parameters = Some(parameters); self } /// Start the statement and return its asynchronous query handle. pub async fn execute(self) -> Result { - self.parent - .execute_query_async(&self.query, &self.default_namespace_path) - .await + self.parent.execute_query_async(self.request).await } } @@ -455,8 +504,9 @@ impl Connection { /// /// The query can reference tables in other databases with SQL dot notation. /// Use [`ExecuteQueryAsyncBuilder::default_namespace_path`] to avoid qualifying - /// tables in the default namespace. Local connections return - /// [`Error::NotSupported`]. + /// tables in the default namespace, and [`ExecuteQueryAsyncBuilder::parameters`] + /// to bind Arrow values to `$1` / `$name` placeholders instead of writing them + /// into the SQL text. Local connections return [`Error::NotSupported`]. /// /// # Example /// @@ -505,7 +555,8 @@ impl Connection { /// under creation, may contain only uncommitted storage, or may be concurrently /// dropped before it is opened. /// - /// The parameters `page_token` and `limit` can be used to paginate the results + /// Without a limit, all names are returned. The parameters `start_after` and + /// `limit` can be used to paginate the results. pub fn table_names(&self) -> TableNamesBuilder { TableNamesBuilder::new(self.internal.clone()) } @@ -625,8 +676,7 @@ impl Connection { /// List every published immutable Function version in `namespace_path` of /// the remote catalog. Functions in child namespaces are not included. /// - /// Results are ordered by Function name then version. The client walks all - /// server pages before returning. Local databases return + /// Results are ordered by Function name then version. Local databases return /// [`Error::NotSupported`]. /// /// # Example @@ -635,17 +685,30 @@ impl Connection { /// # async fn list_functions( /// # connection: &lancedb::Connection, /// # ) -> Result<(), Box> { - /// for function in connection.list_functions(&[]).await? { + /// use futures::TryStreamExt; + /// let mut functions = connection.list_functions(&[], Default::default()); + /// while let Some(function) = functions.try_next().await? { /// println!("{} {}", function.name(), function.version()); /// } /// # Ok(()) /// # } /// ``` - pub async fn list_functions( + /// + /// Results are fetched lazily, one page at a time. See [`Listing`] + /// for cached-result and continuation-token semantics. Errors terminate iteration. + /// + pub fn list_functions( &self, namespace_path: &[String], - ) -> Result> { - self.internal.list_functions(namespace_path).await + options: ListingOptions, + ) -> Listing { + let database = self.database().clone(); + let namespace_path = namespace_path.to_vec(); + Listing::new(options, move |options| { + let database = database.clone(); + let namespace_path = namespace_path.clone(); + async move { database.list_functions(&namespace_path, options).await } + }) } /// Remove the current Function name binding, retaining the object history. @@ -723,11 +786,37 @@ impl Connection { /// Names only. No path in this API returns a stored credential, by /// construction rather than by policy. Local databases return /// [`Error::NotSupported`]. - pub async fn list_secrets(&self, namespace_path: &[String]) -> Result> { - for segment in namespace_path { - validate_secret_component("Secret namespace path segment", segment)?; - } - self.internal.list_secrets(namespace_path).await + /// + /// Results are fetched lazily, one page at a time. See [`Listing`] + /// for cached-result and continuation-token semantics. Errors terminate iteration. + /// + /// ``` + /// # async fn example(connection: &lancedb::Connection) -> lancedb::Result<()> { + /// use futures::TryStreamExt; + /// let mut items = connection.list_secrets(&[], Default::default()); + /// while let Some(item) = items.try_next().await? { + /// println!("{item:?}"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn list_secrets( + &self, + namespace_path: &[String], + options: ListingOptions, + ) -> Listing { + let database = self.database().clone(); + let namespace_path = namespace_path.to_vec(); + Listing::new(options, move |options| { + let database = database.clone(); + let namespace_path = namespace_path.clone(); + async move { + for segment in &namespace_path { + validate_secret_component("Secret namespace path segment", segment)?; + } + database.list_secrets(&namespace_path, options).await + } + }) } /// Drop a Secret. @@ -795,7 +884,8 @@ impl Connection { /// let described = connection.describe_view("recent_orders", &namespace).await?; /// println!("{} in {:?}", described.query, described.default_namespace_path); /// - /// let names = connection.list_views(&namespace).await?; + /// use futures::TryStreamExt; + /// let names: Vec<_> = connection.list_views(&namespace, Default::default()).try_collect().await?; /// assert!(names.iter().any(|name| name == "recent_orders")); /// /// // Dropping the view leaves `orders` untouched. @@ -871,11 +961,36 @@ impl Connection { /// The names of the views in one namespace. /// /// Names only; a definition is query metadata and comes from - /// [`Self::describe_view`]. The client walks all server pages before - /// returning. Local databases return [`Error::NotSupported`]. - pub async fn list_views(&self, namespace_path: &[String]) -> Result> { - validate_namespace(namespace_path)?; - self.internal.list_views(namespace_path).await + /// [`Self::describe_view`]. Local databases return [`Error::NotSupported`]. + /// + /// Results are fetched lazily, one page at a time. See [`Listing`] + /// for cached-result and continuation-token semantics. Errors terminate iteration. + /// + /// ``` + /// # async fn example(connection: &lancedb::Connection) -> lancedb::Result<()> { + /// use futures::TryStreamExt; + /// let mut items = connection.list_views(&[], Default::default()); + /// while let Some(item) = items.try_next().await? { + /// println!("{item:?}"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn list_views( + &self, + namespace_path: &[String], + options: ListingOptions, + ) -> Listing { + let database = self.database().clone(); + let namespace_path = namespace_path.to_vec(); + Listing::new(options, move |options| { + let database = database.clone(); + let namespace_path = namespace_path.clone(); + async move { + validate_namespace(&namespace_path)?; + database.list_views(&namespace_path, options).await + } + }) } /// Rename a table in the database. @@ -934,8 +1049,26 @@ impl Connection { } /// List server-side jobs across the database's tables. - pub async fn list_jobs(&self) -> Result> { - self.internal.list_jobs().await + /// + /// Results are fetched lazily, one page at a time. See [`Listing`] + /// for cached-result and continuation-token semantics. Errors terminate iteration. + /// + /// ``` + /// # async fn example(connection: &lancedb::Connection) -> lancedb::Result<()> { + /// use futures::TryStreamExt; + /// let mut items = connection.list_jobs(Default::default()); + /// while let Some(item) = items.try_next().await? { + /// println!("{item:?}"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn list_jobs(&self, options: ListingOptions) -> Listing { + let database = self.database().clone(); + Listing::new(options, move |options| { + let database = database.clone(); + async move { database.list_jobs(options).await } + }) } /// Request cancellation of a server-side job by id. Returns true if the @@ -1055,7 +1188,21 @@ impl Connection { } /// List tables with pagination support - pub async fn list_tables(&self, request: ListTablesRequest) -> Result { + /// + /// The default limit is 100 tables per page for both local and remote connections, + /// including namespaces. Zero returns an empty page without a continuation token. + /// Follow the response's opaque `page_token` until it is absent to retrieve every table; + /// a page can contain fewer than the limit even when more tables remain. + pub async fn list_tables(&self, mut request: ListTablesRequest) -> Result { + // Apply the SDK's page size here so every backend receives the same request. + let limit = request.limit.get_or_insert(100); + if *limit <= 0 { + return Ok(ListTablesResponse { + context: None, + tables: Vec::new(), + page_token: None, + }); + } self.internal.list_tables(request).await } @@ -2141,6 +2288,61 @@ mod tests { assert_eq!(seen, names); } + #[tokio::test] + async fn test_table_listing_defaults_and_zero_limit() { + let tempdir = tempfile::tempdir().unwrap(); + // Listing discovers physical entries without opening the datasets. + let names: Vec<_> = (0..130).map(|i| format!("t{i:03}")).collect(); + for name in &names { + std::fs::create_dir(tempdir.path().join(format!("{name}.lance"))).unwrap(); + } + let db = connect(tempdir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + assert_eq!(db.table_names().execute().await.unwrap(), names); + assert!( + db.table_names() + .limit(0) + .execute() + .await + .unwrap() + .is_empty() + ); + + let first = db.list_tables(ListTablesRequest::default()).await.unwrap(); + assert_eq!(first.tables, names[..100]); + assert!(first.page_token.is_some()); + let second = db + .list_tables(ListTablesRequest { + page_token: first.page_token, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(second.tables, names[100..]); + assert!(second.page_token.is_none()); + + let all = db + .list_tables(ListTablesRequest { + limit: Some(200), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(all.tables, names); + assert!(all.page_token.is_none()); + let empty = db + .list_tables(ListTablesRequest { + limit: Some(0), + ..Default::default() + }) + .await + .unwrap(); + assert!(empty.tables.is_empty()); + assert!(empty.page_token.is_none()); + } + #[tokio::test] async fn test_open_table() { let tc = new_test_connection().await.unwrap(); diff --git a/rust/lancedb/src/database.rs b/rust/lancedb/src/database.rs index 2518edf13..05386e0b7 100644 --- a/rust/lancedb/src/database.rs +++ b/rust/lancedb/src/database.rs @@ -14,10 +14,11 @@ //! * Tables may be managed by a database system (e.g. Postgres) //! * A custom table implementation (e.g. remote table, etc.) may be used -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +use arrow_array::RecordBatch; use lance::dataset::ReadParams; use lance_namespace::LanceNamespace; use lance_namespace::models::{ @@ -29,6 +30,7 @@ use lance_namespace::models::{ use crate::data::scannable::Scannable; use crate::error::Result; use crate::job::Job; +use crate::listing::{ListingOptions, ListingPage}; use crate::materialized_view::CreateMaterializedViewRequest; use crate::secrets::SecretInfo; use crate::table::{BaseTable, WriteOptions}; @@ -190,6 +192,32 @@ impl CloneTableRequest { } } +/// A request to start a SQL statement on a remote database. +#[derive(Clone, Debug)] +pub struct ExecuteQueryRequest { + /// The SQL text. + pub query: String, + /// The namespace unqualified table names resolve in. Empty is `public`, + /// the SQL name of the root namespace. + pub default_namespace_path: Vec, + /// Values for the statement's placeholders, as a single-row batch. + /// + /// Column `i` binds `$`, and a column whose name is not a number + /// also binds `$`. See + /// [`ExecuteQueryAsyncBuilder::parameters`](crate::connection::ExecuteQueryAsyncBuilder::parameters). + pub parameters: Option, +} + +impl ExecuteQueryRequest { + pub fn new(query: impl Into) -> Self { + Self { + query: query.into(), + default_namespace_path: vec!["public".to_string()], + parameters: None, + } + } +} + /// How long until a change is reflected from one Table instance to another /// /// Tables are always internally consistent. If a write method is called on @@ -362,52 +390,49 @@ pub trait Database: ) -> Result { job_op_not_supported("remote materialized-view drop") } - /// List materialized-view names in a namespace. + /// Fetch one page of materialized-view names in a namespace. + /// Local backends filter a page of table names, so an empty result may + /// still have a continuation token. #[doc(hidden)] - async fn list_materialized_views(&self, namespace_path: &[String]) -> Result> { + async fn list_materialized_views( + &self, + namespace_path: &[String], + options: ListingOptions, + ) -> Result> { + let response = self + .list_tables(ListTablesRequest { + id: Some(namespace_path.to_vec()), + page_token: options.page_token, + limit: options.page_limit.map(|limit| limit as i32), + ..Default::default() + }) + .await?; let mut names = Vec::new(); - let mut page_token = None; - let mut seen_page_tokens = HashSet::new(); - loop { - let response = self - .list_tables(ListTablesRequest { - id: Some(namespace_path.to_vec()), - page_token: page_token.clone(), - ..Default::default() + for name in response.tables { + let Ok(table) = self + .open_table(OpenTableRequest { + name: name.clone(), + namespace_path: namespace_path.to_vec(), + index_cache_size: None, + lance_read_params: None, + location: None, + namespace_client: None, + managed_versioning: None, }) - .await?; - for name in response.tables { - let Ok(table) = self - .open_table(OpenTableRequest { - name: name.clone(), - namespace_path: namespace_path.to_vec(), - index_cache_size: None, - lance_read_params: None, - location: None, - namespace_client: None, - managed_versioning: None, - }) - .await - else { - continue; - }; - let schema = table.schema().await?; - if crate::materialized_view::read_definition(schema.metadata())?.is_some() { - names.push(name); - } - } - let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty()) + .await else { - break; + continue; }; - if !seen_page_tokens.insert(next_page_token.clone()) { - return Err(crate::Error::Runtime { - message: "materialized-view listing repeated a page token".into(), - }); + let schema = table.schema().await?; + if crate::materialized_view::read_definition(schema.metadata())?.is_some() { + names.push(name); } - page_token = Some(next_page_token); } - Ok(names) + + Ok(ListingPage { + items: names, + page_token: response.page_token, + }) } /// Look up one exact immutable Function version. async fn get_function( @@ -418,12 +443,12 @@ pub trait Database: ) -> Result { function_catalog_not_supported() } - /// List every published immutable Function version in one namespace of the - /// remote catalog. + /// Fetch one page of published immutable Function versions in one namespace. async fn list_functions( &self, _namespace_path: &[String], - ) -> Result> { + _options: ListingOptions, + ) -> Result> { function_catalog_not_supported() } /// Remove the current Function name binding, retaining the object history. @@ -469,11 +494,15 @@ pub trait Database: ) -> Result<()> { secret_catalog_not_supported() } - /// The names of every Secret in this database. + /// Fetch one page of Secret names in this database. /// /// Names only. No API path returns a stored credential, by construction /// rather than by policy. - async fn list_secrets(&self, _namespace_path: &[String]) -> Result> { + async fn list_secrets( + &self, + _namespace_path: &[String], + _options: ListingOptions, + ) -> Result> { secret_catalog_not_supported() } /// Drop a Secret. Functions bound to it fail at their next job, which is @@ -516,8 +545,12 @@ pub trait Database: async fn drop_view_async(&self, _name: &str, _namespace_path: &[String]) -> Result { view_ops_not_supported() } - /// The names of the views in one namespace. - async fn list_views(&self, _namespace_path: &[String]) -> Result> { + /// Fetch one page of view names in one namespace. + async fn list_views( + &self, + _namespace_path: &[String], + _options: ListingOptions, + ) -> Result> { view_ops_not_supported() } /// Open a job by id, returning a handle with its record already @@ -526,8 +559,8 @@ pub trait Database: async fn open_job(&self, _job_id: &str) -> Result { job_op_not_supported("open_job") } - /// List server-side jobs across the database's tables. - async fn list_jobs(&self) -> Result> { + /// Fetch one page of server-side jobs across the database's tables. + async fn list_jobs(&self, _options: ListingOptions) -> Result> { job_op_not_supported("list_jobs") } /// Request cancellation of a job by id. Returns true if the server @@ -549,8 +582,7 @@ pub trait Database: /// Start executing a SQL statement on a remote database. async fn execute_query_async( &self, - _query: &str, - _default_namespace_path: &[String], + _request: ExecuteQueryRequest, ) -> Result { Err(crate::error::Error::NotSupported { message: "SQL is not supported by this database".to_string(), diff --git a/rust/lancedb/src/database/namespace.rs b/rust/lancedb/src/database/namespace.rs index 6bca29476..d93f73c64 100644 --- a/rust/lancedb/src/database/namespace.rs +++ b/rust/lancedb/src/database/namespace.rs @@ -288,16 +288,44 @@ impl Database for LanceNamespaceDatabase { } async fn table_names(&self, request: TableNamesRequest) -> Result> { - let ns_request = ListTablesRequest { - id: Some(request.namespace_path), - page_token: request.start_after, - limit: request.limit.map(|l| l as i32), - ..Default::default() - }; + if request.limit == Some(0) { + return Ok(Vec::new()); + } - let response = self.namespace.list_tables(ns_request).await?; + // Namespace tokens are opaque, and pages need not arrive in name order. + // Collect the listing before applying the name cursor and overall limit. + let mut names = Vec::new(); + let mut page_token = None; + let mut seen_tokens = HashSet::new(); + loop { + let response = self + .namespace + .list_tables(ListTablesRequest { + id: Some(request.namespace_path.clone()), + page_token, + ..Default::default() + }) + .await?; + names.extend(response.tables); + let Some(token) = response.page_token.filter(|token| !token.is_empty()) else { + break; + }; + if !seen_tokens.insert(token.clone()) { + return Err(Error::Runtime { + message: "Namespace table listing response repeated a page_token".into(), + }); + } + page_token = Some(token); + } - Ok(response.tables) + names.sort(); + if let Some(ref start_after) = request.start_after { + names.retain(|name| name > start_after); + } + if let Some(limit) = request.limit { + names.truncate(limit as usize); + } + Ok(names) } async fn list_tables(&self, request: ListTablesRequest) -> Result { @@ -589,6 +617,135 @@ mod tests { use futures::TryStreamExt; use tempfile::tempdir; + #[derive(Debug)] + struct ScriptedNamespace { + pages: Vec, + requests: Mutex>, + } + + #[async_trait] + impl LanceNamespace for ScriptedNamespace { + fn namespace_id(&self) -> String { + "scripted".into() + } + + async fn list_tables( + &self, + request: ListTablesRequest, + ) -> lance_core::Result { + let mut requests = self.requests.lock().unwrap(); + let page = self.pages[requests.len()].clone(); + requests.push(request); + Ok(page) + } + } + + fn namespace_with_pages( + pages: Vec, + ) -> (LanceNamespaceDatabase, Arc) { + let namespace = Arc::new(ScriptedNamespace { + pages, + requests: Mutex::new(Vec::new()), + }); + let db = LanceNamespaceDatabase::from_namespace_client( + namespace.clone(), + "scripted".into(), + HashMap::new(), + HashMap::new(), + None, + None, + HashSet::new(), + ); + (db, namespace) + } + + fn listing_page(names: &[&str], token: Option<&str>) -> ListTablesResponse { + ListTablesResponse { + tables: names.iter().map(|name| (*name).into()).collect(), + page_token: token.map(String::from), + ..Default::default() + } + } + + #[tokio::test] + #[allow(deprecated)] + async fn test_table_names_collects_namespace_pages() { + for namespace_path in [vec![], vec!["child".into()]] { + for (start_after, limit, expected) in [ + (None, None, vec!["a", "b", "c", "d"]), + (Some("a".into()), Some(2), vec!["b", "c"]), + ] { + let (db, namespace) = namespace_with_pages(vec![ + listing_page(&["d", "b"], Some("opaque-1")), + listing_page(&[], Some("opaque-2")), + listing_page(&["c", "a"], None), + ]); + let names = db + .table_names(TableNamesRequest { + namespace_path: namespace_path.clone(), + start_after, + limit, + }) + .await + .unwrap(); + assert_eq!(names, expected); + + let requests = namespace.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + for request in requests.iter() { + assert_eq!(request.id.as_ref(), Some(&namespace_path)); + assert_eq!(request.limit, None); + } + assert_eq!(requests[0].page_token, None); + assert_eq!(requests[1].page_token.as_deref(), Some("opaque-1")); + assert_eq!(requests[2].page_token.as_deref(), Some("opaque-2")); + } + } + } + + #[tokio::test] + #[allow(deprecated)] + async fn test_table_names_zero_limit_skips_namespace_request() { + let (db, namespace) = namespace_with_pages(vec![]); + assert!( + db.table_names(TableNamesRequest { + limit: Some(0), + ..Default::default() + }) + .await + .unwrap() + .is_empty() + ); + assert!(namespace.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + #[allow(deprecated)] + async fn test_table_names_stops_on_empty_namespace_token() { + let (db, namespace) = namespace_with_pages(vec![listing_page(&["a"], Some(""))]); + assert_eq!( + db.table_names(TableNamesRequest::default()).await.unwrap(), + vec!["a"] + ); + assert_eq!(namespace.requests.lock().unwrap().len(), 1); + } + + #[tokio::test] + #[allow(deprecated)] + async fn test_table_names_rejects_namespace_token_cycle() { + let (db, namespace) = namespace_with_pages(vec![ + listing_page(&["a"], Some("one")), + listing_page(&["b"], Some("two")), + listing_page(&["c"], Some("one")), + ]); + let error = db + .table_names(TableNamesRequest::default()) + .await + .unwrap_err(); + assert!(error.to_string().contains("repeated a page_token")); + assert_eq!(namespace.requests.lock().unwrap().len(), 3); + } + /// Helper function to create test data fn create_test_data() -> RecordBatch { let schema = Arc::new(Schema::new(vec![ diff --git a/rust/lancedb/src/job.rs b/rust/lancedb/src/job.rs index 54b46fc11..711a73033 100644 --- a/rust/lancedb/src/job.rs +++ b/rust/lancedb/src/job.rs @@ -3,6 +3,8 @@ //! Handles to operations a server may run asynchronously. +use std::future::Future; +use std::pin::Pin; use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; use arrow_array::RecordBatch; @@ -250,6 +252,18 @@ where pub(crate) fn spawned(task: JoinHandle>) -> Self { Self::new_typed(Box::new(SpawnedJob::new(task))) } + + pub(crate) fn spawned_with_cancellation(task: JoinHandle>, cancel: F) -> Self + where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let cancellation: Cancellation = Arc::new(move || Box::pin(cancel())); + Self::new_typed(Box::new(SpawnedJob::new_with_cancellation( + task, + cancellation, + ))) + } } impl Job @@ -501,10 +515,28 @@ impl Outcome { struct SpawnedJob { outcome: watch::Receiver>, abort: AbortHandle, + cancellation: Option, } +type CancellationFuture = Pin> + Send>>; +type Cancellation = Arc CancellationFuture + Send + Sync>; + impl SpawnedJob { fn new(task: JoinHandle>) -> Self + where + T: Serialize + Send + 'static, + { + Self::new_inner(task, None) + } + + fn new_with_cancellation(task: JoinHandle>, cancellation: Cancellation) -> Self + where + T: Serialize + Send + 'static, + { + Self::new_inner(task, Some(cancellation)) + } + + fn new_inner(task: JoinHandle>, cancellation: Option) -> Self where T: Serialize + Send + 'static, { @@ -526,7 +558,11 @@ impl SpawnedJob { }; let _ = tx.send(Some(outcome)); }); - Self { outcome, abort } + Self { + outcome, + abort, + cancellation, + } } } @@ -556,6 +592,19 @@ impl JobHandle for SpawnedJob { } async fn cancel(&self) -> Result<()> { + if let Some(cancellation) = &self.cancellation { + let mut outcome = self.outcome.clone(); + tokio::select! { + biased; + settled = outcome.wait_for(Option::is_some) => { + settled.map_err(|_| Error::Runtime { + message: "job outcome was dropped before cancellation completed".to_string(), + })?; + return Ok(()); + } + result = cancellation() => result?, + } + } self.abort.abort(); Ok(()) } diff --git a/rust/lancedb/src/lib.rs b/rust/lancedb/src/lib.rs index 530328cbb..e215f3c6b 100644 --- a/rust/lancedb/src/lib.rs +++ b/rust/lancedb/src/lib.rs @@ -187,6 +187,7 @@ pub mod index; pub mod io; pub mod ipc; pub mod job; +pub mod listing; pub mod materialized_view; #[cfg(feature = "metrics-otel")] pub mod metrics_otel; diff --git a/rust/lancedb/src/listing.rs b/rust/lancedb/src/listing.rs new file mode 100644 index 000000000..710202d9d --- /dev/null +++ b/rust/lancedb/src/listing.rs @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Lazy, resumable listings of database resources. + +use std::collections::HashSet; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use futures::future::BoxFuture; +use futures::{Future, FutureExt, Stream}; + +use crate::{Error, Result}; + +/// Pagination controls for a resource listing. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct ListingOptions { + /// Starting continuation token; `None` starts at the beginning. + pub page_token: Option, + /// Maximum results per request, not a limit on the entire listing. + /// `None` uses the backend default. Must be between 1 and `i32::MAX`. + pub page_limit: Option, +} + +impl ListingOptions { + /// Resume at a saved continuation token. + pub fn page_token(mut self, token: impl Into) -> Self { + self.page_token = Some(token.into()); + self + } + + /// Set the maximum number of results requested per page. + pub fn page_limit(mut self, limit: u32) -> Self { + self.page_limit = Some(limit); + self + } +} + +/// One backend page of a resource listing. +#[derive(Debug)] +pub struct ListingPage { + /// Items returned on this page, possibly empty even when more pages exist. + pub items: Vec, + /// Token for the next page; `None` or an empty string ends the listing. + pub page_token: Option, +} + +type FetchPage = + Box BoxFuture<'static, Result>> + Send + Sync>; + +/// A lazy stream of resources with inspectable pagination state. +/// +/// No requests are made until the stream is polled. Empty pages with continuation +/// tokens are skipped. Errors terminate the stream and retain the failed request's +/// token, allowing a new stream to retry it. Repeated tokens are rejected. +/// +/// A token advances when its page is fetched. Drain [`Self::num_page_results`] +/// before saving [`Self::page_token`] to avoid skipping cached items on resumption. +/// After the final page is fetched the token is `None`, even with cached items. +/// Use [`futures::TryStreamExt::try_collect`] to collect a listing into a vector. +pub struct Listing { + fetch: FetchPage, + items: std::vec::IntoIter, + options: ListingOptions, + done: bool, + seen_tokens: HashSet, + pending: Option>>>, +} + +impl Listing { + pub(crate) fn new(options: ListingOptions, fetch: F) -> Self + where + F: Fn(ListingOptions) -> Fut + Send + Sync + 'static, + Fut: Future>> + Send + 'static, + { + let seen_tokens = options + .page_token + .iter() + .filter(|t| !t.is_empty()) + .cloned() + .collect(); + Self { + fetch: Box::new(move |options| fetch(options).boxed()), + items: Vec::new().into_iter(), + options, + done: false, + seen_tokens, + pending: None, + } + } + + /// Number of items available without another backend request. + pub fn num_page_results(&self) -> usize { + self.items.len() + } + + /// Token for the next request. Initially the supplied token; `None` after + /// fetching the final page. Drain cached items before saving this token. + pub fn page_token(&self) -> Option<&str> { + self.options.page_token.as_deref() + } +} + +impl Stream for Listing { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + loop { + if let Some(item) = this.items.next() { + return Poll::Ready(Some(Ok(item))); + } + if this.done { + return Poll::Ready(None); + } + if this + .options + .page_limit + .is_some_and(|limit| limit == 0 || limit > i32::MAX as u32) + { + this.done = true; + return Poll::Ready(Some(Err(Error::InvalidInput { + message: "Listing page limit must be between 1 and 2147483647".into(), + }))); + } + let pending = this + .pending + .get_or_insert_with(|| (this.fetch)(this.options.clone())); + let response = futures::ready!(pending.as_mut().poll(cx)); + this.pending = None; + match response { + Ok(response) => { + let token = response.page_token.filter(|token| !token.is_empty()); + if token + .as_ref() + .is_some_and(|token| !this.seen_tokens.insert(token.clone())) + { + this.done = true; + return Poll::Ready(Some(Err(Error::Runtime { + message: "Listing response repeated a page_token".into(), + }))); + } + this.done = token.is_none(); + this.options.page_token = token; + this.items = response.items.into_iter(); + } + Err(error) => { + this.done = true; + return Poll::Ready(Some(Err(error))); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::{StreamExt, TryStreamExt}; + use std::sync::{Arc, Mutex}; + + #[tokio::test] + async fn lazy_pages_empty_pages_and_resume() { + let requests = Arc::new(Mutex::new(Vec::new())); + let fetch = { + let requests = requests.clone(); + move |options: ListingOptions| { + requests.lock().unwrap().push(options.clone()); + async move { + let (items, token) = match options.page_token.as_deref() { + Some("start") => (vec![1, 2], Some("empty")), + Some("empty") => (vec![], Some("last")), + Some("last") => (vec![3, 4], Some("")), + _ => panic!("unexpected request"), + }; + Ok(ListingPage { + items, + page_token: token.map(str::to_owned), + }) + } + } + }; + let mut listing = Listing::new( + ListingOptions::default().page_token("start").page_limit(2), + fetch.clone(), + ); + assert!(requests.lock().unwrap().is_empty()); + assert_eq!(listing.page_token(), Some("start")); + assert_eq!(listing.num_page_results(), 0); + assert_eq!(listing.try_next().await.unwrap(), Some(1)); + assert_eq!(listing.num_page_results(), 1); + assert_eq!(listing.page_token(), Some("empty")); + assert_eq!(listing.try_next().await.unwrap(), Some(2)); + assert_eq!(requests.lock().unwrap().len(), 1); + let mut resumed = Listing::new( + ListingOptions::default() + .page_token(listing.page_token().unwrap()) + .page_limit(2), + fetch, + ); + assert_eq!(resumed.try_next().await.unwrap(), Some(3)); + assert_eq!(resumed.page_token(), None); + assert_eq!(resumed.num_page_results(), 1); + assert_eq!(resumed.try_next().await.unwrap(), Some(4)); + assert_eq!(resumed.try_next().await.unwrap(), None); + assert_eq!(resumed.try_next().await.unwrap(), None); + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert!(requests.iter().all(|request| request.page_limit == Some(2))); + } + + #[tokio::test] + async fn failure_retains_token_and_terminates() { + let mut listing = + Listing::::new(ListingOptions::default().page_token("retry"), |_| async { + Err(Error::Runtime { + message: "failed".into(), + }) + }); + assert!(listing.next().await.unwrap().is_err()); + assert_eq!(listing.page_token(), Some("retry")); + assert_eq!(listing.num_page_results(), 0); + assert!(listing.next().await.is_none()); + } + + #[tokio::test] + async fn invalid_limits_do_not_fetch() { + for limit in [0, u32::MAX] { + let mut listing = + Listing::::new(ListingOptions::default().page_limit(limit), |_| async { + panic!("invalid limits must not reach the backend") + }); + assert!(matches!( + listing.next().await, + Some(Err(Error::InvalidInput { .. })) + )); + assert!(listing.next().await.is_none()); + } + } + + #[tokio::test] + async fn jobs_can_exceed_the_old_page_cap() { + let listing = Listing::new(ListingOptions::default(), |options| async move { + let n: usize = options + .page_token + .as_deref() + .unwrap_or("0") + .parse() + .unwrap(); + Ok(ListingPage { + items: vec![n], + page_token: (n < 110).then(|| (n + 1).to_string()), + }) + }); + assert_eq!( + listing.try_collect::>().await.unwrap(), + (0..=110).collect::>() + ); + } +} diff --git a/rust/lancedb/src/materialized_view.rs b/rust/lancedb/src/materialized_view.rs index 7dec300a2..c04a6cbbe 100644 --- a/rust/lancedb/src/materialized_view.rs +++ b/rust/lancedb/src/materialized_view.rs @@ -7,102 +7,28 @@ //! one source table and maintained by refresh rather than by writes. Creation //! commits an empty table carrying the defining query in schema metadata; a //! query this version cannot maintain reads back as unrefreshable, not as a -//! plain table. Queries, indexes and search work on the view unchanged. +//! plain table. Queries, indexes and search work on the view unchanged; +//! writes to its rows or stored columns are refused. -mod duplicate_pairs; mod grouped; -mod grouped_units; -mod partitioned; -pub(crate) mod vector_dedup; -pub use duplicate_pairs::{VectorSource, VectorSourceKind}; pub use grouped::IVF_PARTITION; -/// Refreshing a view grouped by [`IVF_PARTITION`] in units, one per index -/// partition, so the work can be spread over several processes. The caller -/// plans once, computes every unit of that plan wherever it likes, and -/// commits the whole set; a unit is bound to the view and plan it was -/// computed for, and the commit publishes all of them or none. -/// -/// ```no_run -/// # #![recursion_limit = "256"] -/// # use lancedb::materialized_view::{ -/// # commit_grouped_refresh, plan_grouped_refresh, write_grouped_unit, WrittenUnit, -/// # }; -/// # use lancedb::materialized_view::MaterializedView; -/// # async fn refresh_in_units(view: &MaterializedView) -> Result<(), Box> { -/// // The view this refresh was requested for. Every path below carries it, -/// // including the fallbacks: a view dropped and recreated meanwhile is a -/// // different view, and refreshing it in place of this one is the identity -/// // crossing the units path refuses. -/// let incarnation = view.incarnation().map(str::to_string); -/// -/// async fn in_one_pass(view: &MaterializedView, incarnation: Option<&str>) -> lancedb::Result<()> { -/// let mut refresh = view.refresh(); -/// if let Some(token) = incarnation { -/// refresh = refresh.expect_incarnation(token); -/// } -/// refresh.execute().await?; -/// Ok(()) -/// } -/// -/// // No plan means this view cannot be refreshed in units: it is not -/// // grouped by the partition of an indexed column, its index no longer -/// // says which fragments it covers, or it carries no incarnation to bind -/// // the units to. -/// let Some(plan) = plan_grouped_refresh(view.table(), None).await? else { -/// in_one_pass(view, incarnation.as_deref()).await?; -/// return Ok(()); -/// }; -/// -/// // Each unit is independent; this loop stands in for dispatching them. -/// // A unit whose view was dropped and recreated, or whose plan is stale, -/// // fails here rather than producing rows for the wrong view. -/// let mut units: Vec = Vec::new(); -/// for unit in 0..plan.units { -/// units.push(write_grouped_unit(view.table(), unit, &plan).await?); -/// } -/// -/// match commit_grouped_refresh(view.table(), &plan, units, incarnation.as_deref()).await { -/// Ok(result) => println!("{} rows at version {}", result.rows_written, result.version), -/// Err(err) => { -/// // The refresh is unrecorded, but it is not necessarily inert: a -/// // commit that raced a concurrent write can have landed without a -/// // watermark. Refresh from scratch rather than assuming either. -/// eprintln!("refresh in units unrecorded ({err}); refreshing in one pass"); -/// in_one_pass(view, incarnation.as_deref()).await?; -/// } -/// } -/// # Ok(()) -/// # } -/// ``` -pub use grouped_units::{ - GroupedRefreshPlan, WrittenUnit, commit_grouped_refresh, plan_grouped_refresh, - write_grouped_unit, -}; -pub use partitioned::{ - PartitionedRefreshPlan, WrittenPartition, commit_partitioned_refresh, plan_partitioned_refresh, - write_refresh_partition, write_refresh_partition_with_inputs, -}; mod query; pub mod refresh; -#[cfg(test)] -mod differential; - use std::collections::HashMap; use std::sync::Arc; use arrow_schema::{DataType, Field as ArrowField, FieldRef, Schema as ArrowSchema, SchemaRef}; use datafusion_common::ScalarValue; -use lance_core::ROW_ID; use lance_datafusion::planner::Planner; use serde::{Deserialize, Serialize}; use crate::connection::Connection; -use crate::database::listing::OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS; use crate::database::{CreateTableRequest, Database, OpenTableRequest}; use crate::embeddings::EmbeddingDefinition; use crate::function::FunctionBinding; use crate::job::Job; +use crate::listing::{Listing, ListingOptions}; use crate::table::Table; use crate::table::computed_columns::{ FUNCTION_BINDINGS_META_KEY, computed_column_from_field, computed_columns, @@ -116,26 +42,6 @@ pub use refresh::{RefreshMaterializedViewResult, RefreshMode}; /// Schema metadata key holding the view definition; see [`DEFINITION_FORMAT`]. pub const DEFINITION_META_KEY: &str = "mv.definition"; -/// Schema metadata key holding the view's incarnation: a token minted at each -/// physical creation of a view table, so a view dropped and recreated under -/// the same name and definition is still told apart from the one a caller -/// captured. A view whose metadata was replaced wholesale, or one declared -/// before tokens existed, carries none until its next refresh mints one. -pub const INCARNATION_META_KEY: &str = "mv.incarnation"; - -/// Schema metadata key holding the source table version the view was last -/// refreshed to. Absent until the first refresh. -pub const SOURCE_VERSION_META_KEY: &str = "mv.source_version"; - -/// Schema metadata key holding the wall-clock time of the last refresh, -/// in milliseconds since the epoch. -pub const REFRESHED_AT_MS_META_KEY: &str = "mv.refreshed_at_ms"; - -/// Column recording which source row produced each view row: the source's -/// stable `_rowid` at refresh time, which is why sources must keep stable -/// row ids. -pub const SOURCE_ROW_ID_COLUMN: &str = "__source_row_id"; - /// Field metadata namespace for declarations about schema structure, such as /// an unenforced primary key. const SCHEMA_DECLARATION_META_PREFIX: &str = "lance-schema:"; @@ -154,26 +60,16 @@ const COLUMN_DEFINITIONS_META_KEY: &str = "lancedb::column_definitions"; /// The newest layout this version reads under [`DEFINITION_META_KEY`]: /// `{"format": N, "query": ""}`, the query as -/// [`MaterializedViewDefinition::to_sql`] renders it. Format 3 identifies a -/// native duplicate-pair source, format 4 a dedup result source, format 2 a query with `GROUP BY`, and -/// format 1 an ordinary query. A reader refuses a newer format rather than +/// [`MaterializedViewDefinition::to_sql`] renders it. Format 2 identifies a +/// query with `GROUP BY`, and format 1 an ordinary query. A reader refuses a newer format rather than /// guess at it. The layout also carries `"kind": "query"`, which readers /// older than the format number report as an unrefreshable view instead of /// failing to read the metadata. -pub const DEFINITION_FORMAT: u64 = 4; +pub const DEFINITION_FORMAT: u64 = 2; /// The format `definition` is written in; see [`DEFINITION_FORMAT`]. fn format_of(definition: &MaterializedViewDefinition) -> u64 { - if let Some(source) = &definition.vector_source { - match source.kind { - VectorSourceKind::Pairs => 3, - VectorSourceKind::Dedup => 4, - } - } else if definition.is_grouped() { - 2 - } else { - 1 - } + if definition.is_grouped() { 2 } else { 1 } } /// Legacy `kind` tag of the structured layout written before @@ -236,9 +132,8 @@ pub enum LateralSource { /// The list column. column: String, }, - /// `name(args)`: a Function in `FROM` position, returning rows. The - /// server stages its output in a hidden table, recorded under - /// [`STAGING_META_KEY`]; a local database cannot refresh this form. + /// `name(args)`: a Function in `FROM` position, returning rows. Local + /// databases do not execute table functions during refresh. Function { /// The Function's name. name: String, @@ -254,37 +149,17 @@ pub(crate) struct ViewUnnest { pub alias: String, } -/// Schema metadata key holding a [`StagingBinding`], present only on a view -/// whose query calls a Function in `FROM` position. -pub const STAGING_META_KEY: &str = "mv.staging"; - -/// Where a Function in `FROM` position has its output staged: a hidden -/// table carrying every source column plus `column`, the Function's list -/// output. Refresh scans this table in place of the query's source. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct StagingBinding { - /// The staging table's name. - pub table: String, - /// Its namespace path; empty is the root namespace. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub namespace: Vec, - /// The list column holding the Function's output. - pub column: String, -} - -/// The list column refresh unnests for `definition`, given the staging its -/// Function output lives in. `None` when the query has no lateral item. +/// The list column refresh unnests for `definition`. `None` when the query +/// has no lateral item. pub(crate) fn physical_unnest( definition: &MaterializedViewDefinition, - staging: Option<&StagingBinding>, ) -> Result> { let Some(lateral) = &definition.lateral else { return Ok(None); }; - let column = match (&lateral.source, staging) { - (LateralSource::Unnest { column }, _) => column.clone(), - (LateralSource::Function { .. }, Some(staging)) => staging.column.clone(), - (LateralSource::Function { name, .. }, None) => { + let column = match &lateral.source { + LateralSource::Unnest { column } => column.clone(), + LateralSource::Function { name, .. } => { return Err(Error::NotSupported { message: format!( "'{name}' in FROM position is a Function; views over Function rows are \ @@ -299,25 +174,11 @@ pub(crate) fn physical_unnest( })) } -/// Read the staging binding off a view's schema metadata, if it has one. -pub fn read_staging(metadata: &HashMap) -> Result> { - metadata - .get(STAGING_META_KEY) - .map(|raw| { - serde_json::from_str(raw).map_err(|e| Error::Runtime { - message: format!("unreadable materialized view staging binding: {e}"), - }) - }) - .transpose() -} - /// The query that defines a materialized view, in the relational shape /// refresh maintains. Stored as SQL; see [`MaterializedViewDefinition::from_sql`] /// for the shape. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MaterializedViewDefinition { - /// A table-bound native vector source. It is never evaluated per source row. - pub vector_source: Option>, /// Name of the source table, in the same database as the view. pub source_table: String, /// Namespace path holding the source table; empty is the root namespace. @@ -408,10 +269,10 @@ pub enum StoredDefinition { #[doc(hidden)] #[derive(Debug, Clone, PartialEq, Eq)] pub struct MaterializedViewInfo { - /// The parsed view definition. - pub definition: MaterializedViewDefinition, - /// The current physical incarnation, when one has been minted. - pub incarnation: Option, + /// The defining SQL as stored. + pub definition_sql: String, + /// The definition parsed into the local refresh grammar, when supported. + pub parsed_definition: Option, } /// The backend-independent request used to create a remote materialized view. @@ -456,12 +317,78 @@ struct LegacyDefinition { /// Serialize `definition` into the layout stored under /// [`DEFINITION_META_KEY`]. pub(crate) fn definition_to_metadata(definition: &MaterializedViewDefinition) -> Result { - Ok(serde_json::json!({ + Ok(definition_metadata_from_sql_with_format( + &definition.to_sql(), + format_of(definition), + )) +} + +/// Serialize an arbitrary SQL definition into the materialized-view metadata +/// envelope. Sophon uses this for SQL queries that are broader than the local +/// refresh engine's supported shape. +pub fn definition_metadata_from_sql(sql: &str) -> String { + definition_metadata_from_sql_with_format(sql, 1) +} + +/// Serialize an arbitrary SQL definition together with the resolution +/// defaults needed to plan unqualified identifiers on a later refresh. +pub fn definition_metadata_from_sql_with_defaults( + sql: &str, + default_database: &str, + default_namespace_path: &[String], +) -> String { + serde_json::json!({ "kind": "query", - "format": format_of(definition), - "query": definition.to_sql(), + "format": 1, + "query": sql, + "default_database": default_database, + "default_namespace_path": default_namespace_path, }) - .to_string()) + .to_string() +} + +fn definition_metadata_from_sql_with_format(sql: &str, format: u64) -> String { + serde_json::json!({ + "kind": "query", + "format": format, + "query": sql, + }) + .to_string() +} + +/// Return the defining SQL stored in [`DEFINITION_META_KEY`], without +/// requiring the local refresh parser to understand it. +pub fn read_definition_sql(metadata: &HashMap) -> Result> { + Ok(read_definition_sql_with_defaults(metadata)?.map(|definition| definition.query)) +} + +/// A stored SQL definition and the optional name-resolution defaults recorded +/// by a remote SQL engine. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct MaterializedViewSqlDefinition { + /// The SQL query that defines the materialized view. + pub query: String, + /// The database used to resolve unqualified references when the view was created. + #[serde(default)] + pub default_database: Option, + /// The namespace used to resolve unqualified references when the view was created. + #[serde(default)] + pub default_namespace_path: Option>, +} + +/// Read the SQL definition and its optional resolution defaults from +/// [`DEFINITION_META_KEY`]. +pub fn read_definition_sql_with_defaults( + metadata: &HashMap, +) -> Result> { + let Some(raw) = metadata.get(DEFINITION_META_KEY) else { + return Ok(None); + }; + serde_json::from_str(raw) + .map(Some) + .map_err(|e| Error::Runtime { + message: format!("unreadable materialized view definition: {e}"), + }) } /// Read a view definition off a schema metadata map, if it carries one. @@ -516,7 +443,6 @@ pub fn read_definition(metadata: &HashMap) -> Result) -> Result, + operation: &str, +) -> Result<()> { + if metadata.contains_key(DEFINITION_META_KEY) { + return Err(Error::NotSupported { + message: format!( + "cannot {operation} '{name}': it is a materialized view, which only refresh writes" + ), + }); + } + Ok(()) +} + pub(crate) fn materialized_view_info_from_metadata( name: &str, metadata: &HashMap, ) -> Result { - let incarnation = metadata.get(INCARNATION_META_KEY).cloned(); match read_definition(metadata)? { Some(StoredDefinition::Query(definition)) => Ok(MaterializedViewInfo { - definition, - incarnation, + definition_sql: definition.to_sql(), + parsed_definition: Some(definition), }), Some(StoredDefinition::Newer { format }) => Err(Error::NotSupported { message: format!( @@ -563,19 +506,14 @@ pub(crate) struct Planned { pub fields: Vec, pub lineage: Lineage, /// Source columns the query reads; a read through the unnest alias is - /// recorded as the list column, which is what the source has and what - /// incremental refresh watches. + /// recorded as the list column present in the source schema. pub inputs: Vec, } pub(crate) fn plan( source_schema: SchemaRef, definition: &MaterializedViewDefinition, - staging: Option<&StagingBinding>, ) -> Result { - if definition.vector_source.is_some() { - return duplicate_pairs::plan(source_schema, definition); - } let filter = definition .filter .as_deref() @@ -593,7 +531,7 @@ pub(crate) fn plan( // Projections are typed against the source, or for an unnested view // against the source with the list column replaced by its element // under the alias, where `c.chunk` is an ordinary nested path. - let unnest = physical_unnest(definition, staging)?; + let unnest = physical_unnest(definition)?; let source_schema = match &unnest { None => source_schema, Some(unnest) => { @@ -607,12 +545,9 @@ pub(crate) fn plan( } }; let projections: Vec<(String, String)> = if definition.selects_star() { - // `SELECT *`. A source that is itself a view carries its own - // provenance column; the new view records its own, not a copy. source_schema .fields() .iter() - .filter(|f| f.name() != SOURCE_ROW_ID_COLUMN) .map(|f| (f.name().clone(), query::ident_sql(f.name()))) .collect() } else { @@ -653,25 +588,12 @@ pub(crate) fn plan( name: output.clone(), }); } - if output == SOURCE_ROW_ID_COLUMN || output == ROW_ID { - return Err(Error::InvalidInput { - message: format!("view column name '{output}' is reserved"), - }); - } - let parsed = planner .parse_expr(expression) .map_err(|e| Error::InvalidExpression { column: output.clone(), message: e.to_string(), })?; - // Before optimization: the simplifier folds a stable-but-not-immutable - // call like now() into a literal, hiding it from the check while the - // stored definition keeps the call. - ensure_immutable(&parsed, |message| Error::InvalidExpression { - column: output.clone(), - message, - })?; let expr = planner .optimize_expr(parsed) .map_err(|e| Error::InvalidExpression { @@ -734,7 +656,6 @@ pub(crate) fn plan( } let definition = MaterializedViewDefinition { - vector_source: None, source_table: definition.source_table.clone(), source_namespace: definition.source_namespace.clone(), lateral: definition.lateral.clone(), @@ -760,17 +681,14 @@ pub(crate) fn plan( }) } -/// Check that `filter` is an immutable boolean predicate over `source_schema`, -/// returning the columns it reads. +/// Check that `filter` is a boolean predicate over `source_schema`, returning +/// the columns it reads. pub(crate) fn plan_filter(source_schema: &SchemaRef, filter: &str) -> Result> { let expr = Planner::new(source_schema.clone()) .parse_filter(filter) .map_err(|e| Error::InvalidInput { message: format!("invalid view filter: {e}"), })?; - ensure_immutable(&expr, |message| Error::InvalidInput { - message: format!("invalid view filter: {message}"), - })?; let filter_inputs = resolve_inputs(source_schema, &expr, |message| Error::InvalidInput { message: format!("invalid view filter: {message}"), })?; @@ -839,41 +757,6 @@ pub(crate) fn flattened_schema( Ok(Arc::new(ArrowSchema::new(fields))) } -/// Reject any function that is not immutable: a view definition has to -/// evaluate identically across refreshes, or incremental maintenance would -/// mix rows from different evaluations of the same definition. -fn ensure_immutable(expr: &datafusion_expr::Expr, error: impl Fn(String) -> Error) -> Result<()> { - use datafusion_common::tree_node::{TreeNode, TreeNodeRecursion}; - use datafusion_expr::Volatility; - - // Labeled immutable but not determined by row values alone: version() - // depends on the build, the arrow_* introspectors on schema state. - const NOT_VALUE_DETERMINED: &[&str] = - &["version", "arrow_typeof", "arrow_field", "arrow_metadata"]; - - let mut offending: Option = None; - expr.apply(|node| { - if let datafusion_expr::Expr::ScalarFunction(function) = node { - let name = function.func.name(); - if function.func.signature().volatility != Volatility::Immutable - || NOT_VALUE_DETERMINED.contains(&name) - { - offending = Some(name.to_string()); - return Ok(TreeNodeRecursion::Stop); - } - } - Ok(TreeNodeRecursion::Continue) - }) - .map_err(|e| error(e.to_string()))?; - match offending { - Some(name) => Err(error(format!( - "function '{name}' is not immutable and would evaluate differently \ - across refreshes" - ))), - None => Ok(()), - } -} - /// The root of a possibly-dotted column path: `metadata.age` -> `metadata`. fn root(path: &str) -> &str { path.split('.').next().unwrap_or(path) @@ -1125,7 +1008,7 @@ fn project_schema(schema: &ArrowSchema, columns: &[String]) -> SchemaRef { } /// A validated view declaration, ready to become a table: the projected -/// fields plus [`SOURCE_ROW_ID_COLUMN`], definition stamped in metadata. +/// fields with the definition stamped in metadata. /// Produced only by [`prepare_declaration`]. #[derive(Clone)] pub struct PreparedDeclaration { @@ -1158,8 +1041,7 @@ impl PreparedDeclaration { } /// The schema the view will have: the declared columns in order, any - /// internal projections added by [`PreparedDeclaration::input_column`], - /// then [`SOURCE_ROW_ID_COLUMN`]. + /// internal projections added by [`PreparedDeclaration::input_column`]. pub fn schema(&self) -> &SchemaRef { &self.schema } @@ -1168,11 +1050,6 @@ impl PreparedDeclaration { /// read: the column the view projects it to, if any, otherwise an /// internal projection added here, named by [`input_column_name`]. pub fn input_column(&mut self, source_column: &str) -> Result { - if self.definition.vector_source.is_some() { - return Err(Error::InvalidInput { - message: "native vector views have no per-source-row inputs".into(), - }); - } // A grouped view's rows are groups; no source row carries a value into one. if self.definition.is_grouped() { return Err(Error::InvalidInput { @@ -1194,14 +1071,13 @@ impl PreparedDeclaration { if self.schema.field_with_name(&name).is_ok() { return Err(Error::ColumnAlreadyExists { name }); } - let row_id = self.row_id_index()?; let mut fields: Vec = self .schema .fields() .iter() .map(|f| f.as_ref().clone()) .collect(); - fields.insert(row_id, without_declarations(&field.with_name(name.clone()))); + fields.push(without_declarations(&field.with_name(name.clone()))); self.definition.projections.push(ViewProjection { output: name.clone(), expression: source_column @@ -1273,11 +1149,6 @@ impl PreparedDeclaration { columns: Vec<(usize, ArrowField)>, bindings: &[FunctionBinding], ) -> Result { - if self.definition.vector_source.is_some() { - return Err(Error::InvalidInput { - message: "computed columns on native vector views are not supported".into(), - }); - } let invalid = |message: String| Error::InvalidInput { message }; if columns.is_empty() { return Err(invalid("at least one computed column is needed".into())); @@ -1303,10 +1174,7 @@ impl PreparedDeclaration { columns.sort_by_key(|(position, _)| *position); for (inserted, (position, field)) in columns.iter().enumerate() { let name = field.name().as_str(); - if name == SOURCE_ROW_ID_COLUMN - || name == ROW_ID - || name.starts_with(INPUT_COLUMN_PREFIX) - { + if name.starts_with(INPUT_COLUMN_PREFIX) { return Err(invalid(format!("view column name '{name}' is reserved"))); } if fields.iter().any(|f| f.name() == name) { @@ -1348,16 +1216,8 @@ impl PreparedDeclaration { Ok(self) } - fn row_id_index(&self) -> Result { - self.schema - .index_of(SOURCE_ROW_ID_COLUMN) - .map_err(|e| Error::Runtime { - message: e.to_string(), - }) - } - /// Columns the declaration lists: everything before the internal - /// projections and the provenance column. + /// projections. fn visible_count(&self) -> usize { self.definition.projections.len() - self.internal_inputs + computed_columns(&self.schema).len() @@ -1366,9 +1226,7 @@ impl PreparedDeclaration { /// Create the view table and verify it, consuming the declaration. /// /// The view goes at the root of the source's own database, where refresh - /// resolves the recorded source coordinate. Stable row ids are requested - /// at both levels and verified rather than trusted; nothing is rolled - /// back on failure. + /// resolves the recorded source coordinate. pub async fn create(self, name: &str) -> Result { self.create_in(&[], name).await } @@ -1382,53 +1240,16 @@ impl PreparedDeclaration { ) -> Result { let empty: Vec> = vec![]; - // Minted here, not at preparation: a declaration can be cloned and - // create more than one physical table, and each needs its own token. - let incarnation = uuid::Uuid::new_v4().to_string(); - let mut metadata = self.schema.metadata().clone(); - metadata.insert(INCARNATION_META_KEY.to_string(), incarnation.clone()); - let schema = Arc::new(ArrowSchema::new_with_metadata( - self.schema.fields().clone(), - metadata, - )); let reader: Box = - Box::new(arrow_array::RecordBatchIterator::new(empty, schema)); + Box::new(arrow_array::RecordBatchIterator::new(empty, self.schema)); let mut request = CreateTableRequest::new(name.to_string(), Box::new(reader)); request.namespace_path = namespace_path.to_vec(); - let write_params = request - .write_options - .lance_write_params - .get_or_insert_with(Default::default); - write_params.enable_stable_row_ids = true; - let store_params = write_params - .store_params - .get_or_insert_with(Default::default); - crate::connection::merge_storage_options( - store_params, - [( - OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS.to_string(), - "true".to_string(), - )], - ); let table = self.database.clone().create_table(request).await?; let table = Table::new(table, self.database); - let stable = match table.as_native() { - Some(native) => native.dataset.get().await?.manifest.uses_stable_row_ids(), - None => false, - }; - if !stable { - return Err(Error::Runtime { - message: format!( - "view '{name}' was created without stable row ids: the database \ - ignored the creation option; the table remains and is not \ - usable as a materialized view" - ), - }); - } Ok(MaterializedView { table, - definition: self.definition, - incarnation: Some(incarnation), + definition_sql: self.definition.to_sql(), + parsed_definition: Some(self.definition), }) } } @@ -1484,7 +1305,6 @@ pub async fn prepare_declaration( limit: Option, ) -> Result { let definition = MaterializedViewDefinition { - vector_source: None, source_table: source.name().to_string(), source_namespace: source.namespace().to_vec(), lateral: None, @@ -1508,7 +1328,7 @@ pub async fn prepare_declaration( /// Validate `definition` over `source`, the table it names. The declaration /// is planned against the source as refresh will reach it, and the result /// creates the view with [`PreparedDeclaration::create`]. A query calling a -/// Function in `FROM` position needs [`prepare_staged_definition`]. +/// Function in `FROM` position is not supported by local refresh. /// /// ``` /// # #![recursion_limit = "256"] @@ -1546,27 +1366,6 @@ pub async fn prepare_declaration( /// # Ok(()) /// # } /// ``` -/// -/// An indexed, pinned source can materialize retained original rows using the -/// default greedy direct-representative policy. A row is removed only when it -/// directly matches a retained row; an A-B-C chain keeps A and C. The source is -/// unchanged. Hosts distribute this through [`plan_partitioned_refresh`] and -/// [`write_refresh_partition_with_inputs`], with a barrier between selection -/// tasks and source-fragment materialization tasks. -/// -/// ``` -/// # #![recursion_limit = "256"] -/// use lancedb::materialized_view::{MaterializedViewDefinition, prepare_definition}; -/// # async fn dedup(images: &lancedb::Table) -> Result<(), Box> { -/// let version = images.version().await?; -/// let definition = MaterializedViewDefinition::from_sql(&format!( -/// "SELECT * FROM vector_dedup('images', {version}, 'phash', 4)" -/// ))?; -/// let clean = prepare_definition(images, definition).await?.create("images_clean").await?; -/// clean.refresh().execute().await?; -/// # Ok(()) -/// # } -/// ``` pub async fn prepare_definition( source: &Table, definition: MaterializedViewDefinition, @@ -1583,59 +1382,12 @@ pub async fn prepare_definition( ), }); } - prepare_with(source, definition, None).await -} - -/// Validate a `definition` whose query calls a Function in `FROM` position, -/// planned over `staging`: a table carrying every column of the query's -/// source plus `column`, the Function's list output for that row. The view -/// records the query as written and the staging under [`STAGING_META_KEY`]; -/// refresh scans the staging table and unnests `column`. -/// -/// ``` -/// # #![recursion_limit = "256"] -/// use lancedb::materialized_view::{MaterializedViewDefinition, prepare_staged_definition}; -/// -/// // `staging` holds every column of `docs` plus `chunks`, the list -/// // `chunk(body)` returned for each row. -/// # async fn declare(staging: &lancedb::Table) -> Result<(), Box> { -/// let definition = MaterializedViewDefinition::from_sql( -/// "SELECT id, c.text, c.ordinal FROM docs, chunk(body) AS c", -/// )?; -/// let view = prepare_staged_definition(staging, definition, "chunks") -/// .await? -/// .create("chunks") -/// .await?; -/// view.refresh().execute().await?; -/// # Ok(()) -/// # } -/// ``` -pub async fn prepare_staged_definition( - staging: &Table, - definition: MaterializedViewDefinition, - column: impl Into, -) -> Result { - if !matches!( - definition.lateral.as_ref().map(|l| &l.source), - Some(LateralSource::Function { .. }) - ) { - return Err(Error::InvalidInput { - message: "only a query calling a Function in FROM position takes a staging table" - .into(), - }); - } - let binding = StagingBinding { - table: staging.name().to_string(), - namespace: staging.namespace().to_vec(), - column: column.into(), - }; - prepare_with(staging, definition, Some(binding)).await + prepare_with(source, definition).await } async fn prepare_with( source: &Table, definition: MaterializedViewDefinition, - staging: Option, ) -> Result { let Some(caller_native) = source.as_native() else { return Err(Error::NotSupported { @@ -1682,17 +1434,6 @@ async fn prepare_with( ), }); } - if definition.vector_source.is_none() - && !native.dataset.get().await?.manifest.uses_stable_row_ids() - { - return Err(Error::InvalidInput { - message: format!( - "materialized views require stable row ids on the source table; \ - create '{}' with storage option new_table_enable_stable_row_ids=true", - source.name() - ), - }); - } refresh::ensure_no_mem_wal( native.dataset.get().await?.as_ref(), "source table", @@ -1710,45 +1451,23 @@ async fn prepare_with( message: format!("view column name '{}' is reserved", reserved.output), }); } - let source_schema = if let Some(pairs) = &definition.vector_source { - let ds = native - .dataset - .get() - .await? - .checkout_version(pairs.dataset_version) - .await?; - crate::table::datafusion::udtf::duplicate_pairs::plan_duplicate_pairs( - Arc::new(ds.clone()), - &pairs.config()?, - ) - .await?; - Arc::new(ArrowSchema::from(ds.schema())) - } else { - resolved.schema().await? - }; + let source_schema = resolved.schema().await?; let source_metadata = source_schema.metadata().clone(); let Planned { definition, - mut fields, + fields, lineage, .. - } = plan(source_schema.clone(), &definition, staging.as_ref())?; + } = plan(source_schema.clone(), &definition)?; if definition.is_grouped() { grouped::check(native.dataset.get().await?.as_ref(), &definition).await?; } // What later projections (`input_column`) are planned against: for an // unnested view the flattened schema, where the alias is a column. - let planning_schema = match physical_unnest(&definition, staging.as_ref())? { + let planning_schema = match physical_unnest(&definition)? { None => source_schema.clone(), Some(unnest) => flattened_schema(&source_schema, &unnest)?, }; - if definition.vector_source.is_none() { - fields.push(ArrowField::new( - SOURCE_ROW_ID_COLUMN, - DataType::UInt64, - false, - )); - } // Only column-describing metadata comes along: structural declarations // describe how a table is written, and a view is written by refresh alone. let mut metadata: HashMap = HashMap::new(); @@ -1766,14 +1485,6 @@ async fn prepare_with( DEFINITION_META_KEY.to_string(), definition_to_metadata(&definition)?, ); - if let Some(staging) = &staging { - metadata.insert( - STAGING_META_KEY.to_string(), - serde_json::to_string(staging).map_err(|e| Error::Runtime { - message: format!("failed to serialize the staging binding: {e}"), - })?, - ); - } Ok(PreparedDeclaration { schema: Arc::new(ArrowSchema::new_with_metadata(fields, metadata)), definition, @@ -1792,13 +1503,31 @@ pub struct CreateMaterializedViewBuilder { namespace: Vec, source: String, source_namespace: Vec, - vector_source: Option, projections: Vec<(String, String)>, filter: Option, limit: Option, with_no_data: bool, } +fn source_sql_identifier( + namespace_path: &[String], + source: &str, + namespace_in_schema_position: bool, +) -> String { + fn quote(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) + } + + let namespace = if namespace_in_schema_position && !namespace_path.is_empty() { + vec![namespace_path.join("$")] + } else { + namespace_path.to_vec() + }; + let mut parts = namespace.iter().map(|part| quote(part)).collect::>(); + parts.push(quote(source)); + parts.join(".") +} + impl CreateMaterializedViewBuilder { pub(crate) fn new(connection: Connection, name: String, source: String) -> Self { Self { @@ -1807,7 +1536,6 @@ impl CreateMaterializedViewBuilder { namespace: Vec::new(), source, source_namespace: Vec::new(), - vector_source: None, projections: Vec::new(), filter: None, limit: None, @@ -1828,38 +1556,6 @@ impl CreateMaterializedViewBuilder { self } - /// Read native pairs or retained original rows from a fixed indexed snapshot. - /// This source selects its full output schema and cannot be combined with - /// projections, filters or limits. Remote creation uses the ordinary MV - /// endpoint and its registry job; it does not execute a SQL query client-side. - pub fn vector_source(mut self, source: VectorSource) -> Self { - self.vector_source = Some(source); - self - } - - fn native_definition(&self) -> Result> { - let Some(source) = &self.vector_source else { - return Ok(None); - }; - source.config()?; - if !self.projections.is_empty() || self.filter.is_some() || self.limit.is_some() { - return Err(Error::InvalidInput { - message: "native vector sources cannot be combined with select, where or limit" - .into(), - }); - } - Ok(Some(MaterializedViewDefinition { - vector_source: Some(Box::new(source.clone())), - source_table: self.source.clone(), - source_namespace: self.source_namespace.clone(), - projections: vec![ViewProjection::star()], - filter: None, - lateral: None, - group_by: Vec::new(), - limit: None, - })) - } - /// The view's columns, as `(name, SQL expression)` pairs. Not calling /// this selects every source column, expanded at creation time. pub fn select( @@ -1893,9 +1589,6 @@ impl CreateMaterializedViewBuilder { } fn query(&self) -> Result { - if let Some(definition) = self.native_definition()? { - return Ok(definition.to_sql()); - } fn quote(name: &str) -> String { format!("\"{}\"", name.replace('"', "\"\"")) } @@ -1909,13 +1602,11 @@ impl CreateMaterializedViewBuilder { .collect::>() .join(", ") }; - let source = self - .source_namespace - .iter() - .chain(std::iter::once(&self.source)) - .map(|part| quote(part)) - .collect::>() - .join("."); + let source = source_sql_identifier( + &self.source_namespace, + &self.source, + self.connection.uri().starts_with("db://"), + ); let mut query = format!("SELECT {projection} FROM {source}"); if let Some(filter) = &self.filter { query.push_str(" WHERE "); @@ -1928,9 +1619,7 @@ impl CreateMaterializedViewBuilder { } /// Submit creation and initial population, returning a [`Job`] that - /// settles when the view is ready. Ordinary sources must keep stable row - /// ids for provenance across compaction. Native vector sources also support - /// physical row IDs because their definitions pin the dataset version. + /// settles when the view is ready. pub async fn execute_async(self) -> Result { if self.connection.uri().starts_with("db://") { return self @@ -1967,24 +1656,19 @@ impl CreateMaterializedViewBuilder { } async fn execute_native(self) -> Result { - let native_definition = self.native_definition()?; let source = self .connection .open_table(&self.source) .namespace(self.source_namespace.clone()) .execute() .await?; - let prepared = if let Some(definition) = native_definition { - prepare_definition(&source, definition).await? - } else { - prepare_declaration( - &source, - (!self.projections.is_empty()).then_some(self.projections.as_slice()), - self.filter.as_deref(), - self.limit, - ) - .await? - }; + let prepared = prepare_declaration( + &source, + (!self.projections.is_empty()).then_some(self.projections.as_slice()), + self.filter.as_deref(), + self.limit, + ) + .await?; let view = prepared.create_in(&self.namespace, &self.name).await?; if !self.with_no_data { view.refresh().execute().await?; @@ -1993,28 +1677,29 @@ impl CreateMaterializedViewBuilder { } } -/// A handle on a materialized view: the view table plus its parsed definition. +/// A handle on a materialized view: the view table plus its defining SQL. #[derive(Debug, Clone)] pub struct MaterializedView { table: Table, - definition: MaterializedViewDefinition, - incarnation: Option, + definition_sql: String, + parsed_definition: Option, } impl MaterializedView { /// Interpret `table` as a materialized view: [`Error::NotAMaterializedView`] - /// for a plain table, [`Error::NotSupported`] for a query this version - /// cannot refresh. + /// for a plain table. A remote definition broader than the local refresh + /// grammar remains available through [`Self::definition_sql`]. pub async fn from_table(table: Table) -> Result { let info = table.base_table().materialized_view_info().await?; Ok(Self { table, - definition: info.definition, - incarnation: info.incarnation, + definition_sql: info.definition_sql, + parsed_definition: info.parsed_definition, }) } - /// The view, as the table it is. Queries, indexes and search all apply. + /// The view, as the table it is. Queries, indexes and search all apply; + /// writes such as `add`, `update`, `delete` and `merge_insert` do not. pub fn table(&self) -> &Table { &self.table } @@ -2024,23 +1709,28 @@ impl MaterializedView { self.table.name() } - /// The query that defines the view. - pub fn definition(&self) -> &MaterializedViewDefinition { - &self.definition + /// The query parsed into the local refresh grammar. + /// + /// Sophon may store SQL broader than the local grammar; use + /// [`Self::definition_sql`] when the original SQL is sufficient. + pub fn definition(&self) -> Result<&MaterializedViewDefinition> { + self.parsed_definition + .as_ref() + .ok_or_else(|| Error::NotSupported { + message: + "this materialized-view definition is supported only by the remote SQL engine" + .to_string(), + }) } - /// The view's incarnation token as of when this handle was opened; see - /// [`RefreshMaterializedViewBuilder::expect_incarnation`]. `None` for a - /// view that has none yet (see [`INCARNATION_META_KEY`]). - pub fn incarnation(&self) -> Option<&str> { - self.incarnation.as_deref() + /// The complete SQL query that defines the view. + pub fn definition_sql(&self) -> &str { + &self.definition_sql } /// Recompute the view from its source. /// - /// By default the refresh is incremental when the source's changes can be - /// reconciled into the view, and otherwise rebuilds; see - /// [`RefreshMaterializedViewBuilder`]. + /// Every refresh rebuilds the view from the selected source version. /// /// ```no_run /// # #![recursion_limit = "256"] @@ -2054,9 +1744,7 @@ impl MaterializedView { pub fn refresh(&self) -> RefreshMaterializedViewBuilder { RefreshMaterializedViewBuilder { view: self.clone(), - full: false, source_version: None, - expected_incarnation: None, } } } @@ -2064,38 +1752,17 @@ impl MaterializedView { /// Builds a refresh. Created by [`MaterializedView::refresh`]. pub struct RefreshMaterializedViewBuilder { view: MaterializedView, - full: bool, source_version: Option, - expected_incarnation: Option, } impl RefreshMaterializedViewBuilder { - /// Rebuild the view even where an incremental refresh would do. - pub fn full(mut self, full: bool) -> Self { - self.full = full; - self - } - - /// Refresh to this source table version instead of the latest. + /// Refresh a local view to this source table version instead of the latest. + /// Remote SQL refreshes do not support source-version pinning. pub fn source_version(mut self, version: u64) -> Self { self.source_version = Some(version); self } - /// Refresh only if the view is still the incarnation that minted `token` - /// (see [`MaterializedView::incarnation`]): a refresh requested against - /// one declaration must not land in a view dropped and recreated since, - /// even under the same name and definition. - /// - /// Best effort. The token is read from the latest stored manifest before - /// planning and again immediately before every commit, but it is not part - /// of the commit's own condition, so a recreation that lands between that - /// final read and the commit is not caught. - pub fn expect_incarnation(mut self, token: impl Into) -> Self { - self.expected_incarnation = Some(token.into()); - self - } - /// Submit the refresh and return a job that settles with its result. pub async fn execute_async(self) -> Result> { if self.view.table.as_native().is_none() { @@ -2103,34 +1770,18 @@ impl RefreshMaterializedViewBuilder { .view .table .base_table() - .refresh_materialized_view_async( - self.full, - self.source_version, - self.expected_incarnation.as_deref(), - ) + .refresh_materialized_view_async(self.source_version) .await; } Ok(Job::spawned(tokio::spawn(async move { - refresh::execute_refresh( - &self.view.table, - self.full, - self.source_version, - self.expected_incarnation.as_deref(), - ) - .await + refresh::execute_refresh(&self.view.table, self.source_version).await }))) } /// Refresh the view, waiting for the job to finish. pub async fn execute(self) -> Result { if self.view.table.as_native().is_some() { - return refresh::execute_refresh( - &self.view.table, - self.full, - self.source_version, - self.expected_incarnation.as_deref(), - ) - .await; + return refresh::execute_refresh(&self.view.table, self.source_version).await; } self.execute_async().await?.wait().await } @@ -2175,8 +1826,26 @@ impl Connection { } /// The names of materialized views in the root namespace. - pub async fn list_materialized_views(&self) -> Result> { - self.database().list_materialized_views(&[]).await + /// + /// Results are fetched lazily, one page at a time. See [`Listing`] + /// for cached-result and continuation-token semantics. Errors terminate iteration. + /// + /// ``` + /// # async fn example(connection: &lancedb::Connection) -> lancedb::Result<()> { + /// use futures::TryStreamExt; + /// let mut items = connection.list_materialized_views(Default::default()); + /// while let Some(item) = items.try_next().await? { + /// println!("{item:?}"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn list_materialized_views(&self, options: ListingOptions) -> Listing { + let database = self.database().clone(); + Listing::new(options, move |options| { + let database = database.clone(); + async move { database.list_materialized_views(&[], options).await } + }) } /// Drop a materialized view. @@ -2245,2037 +1914,332 @@ impl Connection { #[cfg(test)] mod tests { - use arrow_array::record_batch; + use arrow_array::{RecordBatch, record_batch}; use super::*; use crate::connect; - use crate::table::WriteOptions; - async fn people_db() -> Connection { + #[test] + fn remote_source_namespace_uses_one_sql_schema_position() { + let namespace = vec!["analytics".to_string(), "daily".to_string()]; + assert_eq!( + source_sql_identifier(&namespace, "source", true), + "\"analytics$daily\".\"source\"" + ); + assert_eq!( + source_sql_identifier(&namespace, "source", false), + "\"analytics\".\"daily\".\"source\"" + ); + } + + #[test] + fn sql_definition_defaults_share_one_metadata_value() { + let metadata = HashMap::from([( + DEFINITION_META_KEY.to_string(), + definition_metadata_from_sql_with_defaults( + "SELECT * FROM source", + "db", + &["analytics".to_string()], + ), + )]); + + let definition = read_definition_sql_with_defaults(&metadata) + .unwrap() + .unwrap(); + assert_eq!(definition.query, "SELECT * FROM source"); + assert_eq!(definition.default_database.as_deref(), Some("db")); + assert_eq!( + definition.default_namespace_path, + Some(vec!["analytics".to_string()]) + ); + } + + #[tokio::test] + async fn refresh_always_rebuilds_without_source_row_ids() { let conn = connect("memory://").execute().await.unwrap(); - let batch = record_batch!( - ("name", Utf8, ["ada", "grace", "alan"]), - ("age", Int32, [36, 85, 41]) - ) - .unwrap(); - conn.create_table("people", batch) - .write_options(stable_row_ids()) + let source = conn + .create_table("src", record_batch!(("x", Int32, [1, 2])).unwrap()) .execute() .await .unwrap(); - conn - } - - /// Sources must keep stable row ids; see the create-time gate. - pub(super) fn stable_row_ids() -> WriteOptions { - WriteOptions { - lance_write_params: Some(lance::dataset::WriteParams { - enable_stable_row_ids: true, - ..Default::default() - }), - } - } - - /// The error a doomed declaration against `people` produces. - async fn declare_err( - cfg: impl FnOnce(CreateMaterializedViewBuilder) -> CreateMaterializedViewBuilder, - ) -> Error { - let conn = people_db().await; - cfg(conn.create_materialized_view("bad", "people")) - .execute() - .await - .unwrap_err() - } - - #[tokio::test] - async fn test_create_records_the_definition() { - let conn = people_db().await; let view = conn - .create_materialized_view("adults", "people") - .select([("name", "name"), ("shout", "upper(name)")]) - .only_if("age >= 18") - .limit(10) + .create_materialized_view("copy", "src") .execute() .await .unwrap(); - assert_eq!(view.name(), "adults"); - assert_eq!( - view.definition(), - &MaterializedViewDefinition { - vector_source: None, - source_table: "people".into(), - source_namespace: Vec::new(), - projections: vec![ - ViewProjection { - output: "name".into(), - expression: "name".into() - }, - ViewProjection { - output: "shout".into(), - expression: "upper(name)".into() - }, - ], - filter: Some("age >= 18".into()), - group_by: Vec::new(), - limit: Some(10), - lateral: None, - } - ); - - // The definition round-trips off the stored schema, not the handle. - let reopened = conn.open_materialized_view("adults").await.unwrap(); - assert_eq!(reopened.definition(), view.definition()); - } - - #[tokio::test] - async fn test_view_schema_is_derived_from_the_query() { - let conn = people_db().await; - let view = conn - .create_materialized_view("shapes", "people") - .select([("shout", "upper(name)"), ("next_age", "age + 1")]) - .execute() - .await - .unwrap(); - - let schema = view.table().schema().await.unwrap(); - assert_eq!( - schema.field_with_name("shout").unwrap().data_type(), - &DataType::Utf8 - ); - assert_eq!( - schema.field_with_name("next_age").unwrap().data_type(), - &DataType::Int32 - ); - assert_eq!( - schema - .field_with_name(SOURCE_ROW_ID_COLUMN) - .unwrap() - .data_type(), - &DataType::UInt64 - ); - assert_eq!(view.table().count_rows(None).await.unwrap(), 3); - } - - #[tokio::test] - async fn test_with_no_data_skips_initial_refresh() { - let conn = people_db().await; - let view = conn - .create_materialized_view("empty", "people") - .with_no_data(true) - .execute() - .await - .unwrap(); - assert_eq!(view.table().count_rows(None).await.unwrap(), 0); - } - - #[tokio::test] - async fn test_create_and_refresh_async_jobs() { - let conn = people_db().await; - let create_job = conn - .create_materialized_view("async_view", "people") - .with_no_data(true) - .execute_async() - .await - .unwrap(); - assert!(create_job.id().is_none()); - create_job.wait().await.unwrap(); - - let view = conn.open_materialized_view("async_view").await.unwrap(); - assert_eq!(view.table().count_rows(None).await.unwrap(), 0); - - let refresh_job = view.refresh().execute_async().await.unwrap(); - assert!(refresh_job.id().is_none()); - let result = refresh_job.wait().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(result.rows_written, 3); - assert_eq!(view.table().count_rows(None).await.unwrap(), 3); - - let drop_job = conn - .drop_materialized_view_async("async_view", &[]) - .await - .unwrap(); - assert!(drop_job.id().is_none()); - drop_job.wait().await.unwrap(); - assert!(conn.open_table("async_view").execute().await.is_err()); - } - - #[tokio::test] - async fn test_drop_materialized_view_rejects_plain_tables() { - let conn = people_db().await; - let error = conn - .drop_materialized_view("people", &[]) - .await - .unwrap_err(); - assert!(matches!(error, Error::NotAMaterializedView { .. })); - - conn.create_materialized_view("drop_me", "people") - .with_no_data(true) - .execute() - .await - .unwrap(); - conn.drop_materialized_view("drop_me", &[]).await.unwrap(); - assert!(conn.open_table("drop_me").execute().await.is_err()); - } - - #[tokio::test] - async fn test_remote_query_quotes_resource_identifiers() { - let conn = people_db().await; - let query = conn - .create_materialized_view("unused", "odd\"source") - .source_namespace(vec!["raw data".into()]) - .select([("double\"age", "age * 2")]) - .only_if("age >= 18") - .limit(10) - .query() - .unwrap(); - assert_eq!( - query, - "SELECT age * 2 AS \"double\"\"age\" FROM \"raw data\".\"odd\"\"source\" WHERE age >= 18 LIMIT 10" - ); - } - - #[tokio::test] - async fn native_vector_builder_preserves_source_and_rejects_extra_clauses() { - let conn = people_db().await; - let source = VectorSource { - kind: VectorSourceKind::Dedup, - dataset_version: 7, - column: "vector'field".into(), - distance_threshold: "4.0".into(), - }; - let builder = || { - conn.create_materialized_view("clean", "odd.source'name") - .source_namespace(vec!["raw space".into()]) - .vector_source(source.clone()) - }; - let query = builder().query().unwrap(); - let definition = MaterializedViewDefinition::from_sql(&query).unwrap(); - assert_eq!(definition.source_table, "odd.source'name"); - assert_eq!(definition.source_namespace, ["raw space"]); - assert_eq!(definition.vector_source.as_deref(), Some(&source)); - assert!(definition.selects_star()); - assert!(builder().select([("id", "id")]).query().is_err()); - assert!(builder().only_if("true").query().is_err()); - assert!(builder().limit(1).query().is_err()); - let invalid_source = VectorSource { - dataset_version: 0, - ..source - }; + assert_eq!(view.table().count_rows(None).await.unwrap(), 2); assert!( - conn.create_materialized_view("bad", "people") - .vector_source(invalid_source) - .query() + view.table() + .schema() + .await + .unwrap() + .field_with_name("__source_row_id") .is_err() ); - } - /// No projection selects every source column, expanded now: the schema - /// captured at creation is the definition. - #[tokio::test] - async fn test_default_projection_captures_the_source_schema() { - let conn = people_db().await; - let view = conn - .create_materialized_view("copy", "people") + source + .add(record_batch!(("x", Int32, [3])).unwrap()) .execute() .await .unwrap(); - assert_eq!( - view.definition() - .projections - .iter() - .map(|p| p.output.as_str()) - .collect::>(), - vec!["name", "age"] - ); + let refreshed = view.refresh().execute().await.unwrap(); + assert_eq!(refreshed.mode, RefreshMode::Rebuild); + assert_eq!(refreshed.rows_written, 3); + assert_eq!(view.table().count_rows(None).await.unwrap(), 3); + + let unchanged = view.refresh().execute().await.unwrap(); + assert_eq!(unchanged.mode, RefreshMode::Rebuild); + assert_eq!(unchanged.rows_written, 3); + + source.delete("true").await.unwrap(); + let emptied = view.refresh().execute().await.unwrap(); + assert_eq!(emptied.mode, RefreshMode::Rebuild); + assert_eq!(emptied.rows_written, 0); + assert_eq!(view.table().count_rows(None).await.unwrap(), 0); } - #[tokio::test] - async fn test_unknown_column_fails_at_create_time() { - let conn = people_db().await; - let err = conn - .create_materialized_view("bad", "people") - .select([("x", "missing + 1")]) - .execute() - .await - .unwrap_err(); - assert!(matches!(err, Error::InvalidExpression { column, .. } if column == "x")); - let names = conn.table_names().execute().await.unwrap(); - assert!(!names.contains(&"bad".to_string())); + fn reader(batch: RecordBatch) -> Box { + let schema = batch.schema(); + Box::new(arrow_array::RecordBatchIterator::new( + vec![Ok(batch)], + schema, + )) } - #[tokio::test] - async fn test_unknown_filter_column_fails_at_create_time() { - let err = declare_err(|b| b.only_if("missing > 1")).await; - assert!(matches!(err, Error::InvalidInput { message } if message.contains("missing"))); - } - - #[tokio::test] - async fn test_duplicate_output_is_rejected() { - let err = declare_err(|b| b.select([("dup", "age"), ("dup", "age + 1")])).await; - assert!(matches!(err, Error::ColumnAlreadyExists { name } if name == "dup")); - } - - #[tokio::test] - async fn test_reserved_output_name_is_rejected() { - let err = declare_err(|b| b.select([(SOURCE_ROW_ID_COLUMN, "age")])).await; - assert!(matches!(err, Error::InvalidInput { message } if message.contains("reserved"))); - } - - #[tokio::test] - async fn test_missing_source_fails() { - let conn = connect("memory://").execute().await.unwrap(); - let err = conn - .create_materialized_view("v", "nope") - .execute() - .await - .unwrap_err(); - assert!(matches!(err, Error::TableNotFound { .. })); - } - - /// Provenance has to survive source compactions and updates, and stable - /// row ids cannot be enabled after a table exists -- so the requirement - /// is checked at the last moment the caller can still act on it. - #[tokio::test] - async fn test_source_without_stable_row_ids_is_refused() { - let conn = connect("memory://").execute().await.unwrap(); - let batch = record_batch!(("x", Int32, [1, 2])).unwrap(); - conn.create_table("plain", batch).execute().await.unwrap(); - - let err = conn - .create_materialized_view("v", "plain") - .execute() - .await - .unwrap_err(); - assert!( - matches!(err, Error::InvalidInput { message } if message.contains("stable row ids")) - ); - assert!( - !conn - .table_names() - .execute() - .await - .unwrap() - .contains(&"v".to_string()) - ); - } - - #[tokio::test] - async fn test_name_collision_fails() { - let conn = people_db().await; - let err = conn - .create_materialized_view("people", "people") - .execute() - .await - .unwrap_err(); - assert!(matches!(err, Error::TableAlreadyExists { .. })); - } - - #[tokio::test] - async fn test_a_plain_table_is_not_a_view() { - let conn = people_db().await; - let table = conn.open_table("people").execute().await.unwrap(); - let err = MaterializedView::from_table(table).await.unwrap_err(); - assert!(matches!(err, Error::NotAMaterializedView { name } if name == "people")); - - let err = conn.open_materialized_view("people").await.unwrap_err(); - assert!(matches!(err, Error::NotAMaterializedView { .. })); - } - - /// The reason the kind is tagged: a definition written by a newer version - /// reads back as a view this one cannot refresh, not as a plain table. - #[tokio::test] - async fn test_unrecognized_kind_is_refused_by_name() { - let conn = people_db().await; - conn.create_materialized_view("v", "people") - .execute() - .await - .unwrap(); - let table = conn.open_table("v").execute().await.unwrap(); - table - .as_native() - .unwrap() - .replace_schema_metadata(HashMap::from([( - DEFINITION_META_KEY.to_string(), - r#"{"kind": "join"}"#.to_string(), - )])) - .await - .unwrap(); - - let err = conn.open_materialized_view("v").await.unwrap_err(); - assert!(matches!(err, Error::NotSupported { message } if message.contains("join"))); - } - - #[tokio::test] - async fn test_list_reports_views_and_only_views() { - let conn = people_db().await; - conn.create_materialized_view("adults", "people") - .only_if("age >= 18") - .execute() - .await - .unwrap(); - - let views = conn.list_materialized_views().await.unwrap(); - assert_eq!(views, vec!["adults"]); - let view = conn.open_materialized_view("adults").await.unwrap(); - assert_eq!(view.definition().filter.as_deref(), Some("age >= 18")); - } - - /// The creation option outranks a connection configured to create - /// unstable tables: the view still gets stable row ids, on the same - /// store (no fork -- the table must be reachable through the - /// connection afterwards). - #[tokio::test] - async fn test_view_is_stable_despite_connection_override() { - let conn = connect("memory://") - .storage_options([("new_table_enable_stable_row_ids", "false")]) - .execute() - .await - .unwrap(); - let batch = record_batch!(("x", Int32, [1])).unwrap(); - conn.create_table("src", batch) - .storage_option("new_table_enable_stable_row_ids", "true") - .execute() - .await - .unwrap(); - - let view = conn - .create_materialized_view("v", "src") - .execute() - .await - .unwrap(); - let stable = view - .table() - .as_native() - .unwrap() - .dataset - .get() - .await - .unwrap() - .manifest - .uses_stable_row_ids(); - assert!(stable); - conn.open_materialized_view("v").await.unwrap(); - } - - /// A committed filter has to be usable as a predicate. - #[tokio::test] - async fn test_non_boolean_filter_is_rejected() { - let err = declare_err(|b| b.only_if("age + 1")).await; - assert!(matches!(err, Error::InvalidInput { message } if message.contains("boolean"))); - } - - /// Nested references stay dotted paths; resolution is by root field. - #[tokio::test] - async fn test_struct_columns_can_be_declared() { - use arrow_array::{ArrayRef, Int32Array, StructArray}; - - let conn = connect("memory://").execute().await.unwrap(); - let ages = StructArray::from(vec![( - Arc::new(ArrowField::new("age", DataType::Int32, false)), - Arc::new(Int32Array::from(vec![36, 17])) as ArrayRef, - )]); - let batch = - arrow_array::RecordBatch::try_from_iter(vec![("metadata", Arc::new(ages) as ArrayRef)]) - .unwrap(); - conn.create_table("people", batch) - .write_options(stable_row_ids()) - .execute() - .await - .unwrap(); - - let view = conn - .create_materialized_view("ages", "people") - .select([("age", "metadata.age")]) - .only_if("metadata.age >= 18") - .execute() - .await - .unwrap(); - let schema = view.table().schema().await.unwrap(); - assert_eq!( - schema.field_with_name("age").unwrap().data_type(), - &DataType::Int32 - ); - } - - /// A newer-kind view must not disappear from the listing. - #[tokio::test] - async fn test_unrecognized_kind_is_listed_by_name() { - let conn = people_db().await; - conn.create_materialized_view("v", "people") - .execute() - .await - .unwrap(); - let table = conn.open_table("v").execute().await.unwrap(); - table - .as_native() - .unwrap() - .replace_schema_metadata(HashMap::from([( - DEFINITION_META_KEY.to_string(), - r#"{"kind": "join"}"#.to_string(), - )])) - .await - .unwrap(); - - let views = conn.list_materialized_views().await.unwrap(); - assert_eq!(views, vec!["v"]); - } - - /// A definition must evaluate identically across refreshes; anything - /// less makes incremental maintenance a mix of evaluations. - #[tokio::test] - async fn test_volatile_and_unstable_expressions_are_rejected() { - let conn = people_db().await; - for expression in [ - "random()", - "now()", - "version()", - "arrow_typeof(age)", - "arrow_metadata(age, 'k')", - ] { - let err = conn - .create_materialized_view("bad", "people") - .select([("x", expression)]) - .execute() - .await - .unwrap_err(); - assert!( - matches!(err, Error::InvalidExpression { message, .. } - if message.contains("not immutable")), - "{expression} was not rejected" - ); - } - for filter in ["age > random() * 100", "age >= 0 and now() is not null"] { - let err = conn - .create_materialized_view("bad", "people") - .only_if(filter) - .execute() - .await - .unwrap_err(); - assert!( - matches!(err, Error::InvalidInput { message } if message.contains("not immutable")), - "{filter} was not rejected" - ); - } - } - - /// A column projected as itself stays the column it was: blob discovery - /// and the blob APIs key off field metadata, which a bare rebuild of the - /// field would drop. - #[tokio::test] - async fn test_identity_projection_keeps_field_metadata() { - let conn = connect("memory://").execute().await.unwrap(); - let schema = Arc::new(ArrowSchema::new_with_metadata( - vec![ - ArrowField::new("id", DataType::Int32, true), - crate::blob("payload", true), - ], - HashMap::new(), - )); - conn.create_empty_table("src", schema) - .write_options(stable_row_ids()) - .execute() - .await - .unwrap(); - - let view = conn - .create_materialized_view("v", "src") - .execute() - .await - .unwrap(); - let view_schema = view.table().schema().await.unwrap(); - - let payload = view_schema.field_with_name("payload").unwrap(); - assert!( - crate::blob::is_blob(payload), - "default projection dropped the blob marker: {:?}", - payload.metadata() - ); - assert_eq!( - view.table().blob_columns().await.unwrap(), - vec!["payload".to_string()], - "blob discovery no longer finds the projected column" - ); - assert!(view_schema.metadata().contains_key(DEFINITION_META_KEY)); - // Structural declarations describe how a table is written; a view is - // written by refresh, and its fields are always nullable. - assert!(!view_schema.metadata().contains_key("lance:primary_key")); - - // A computed column is a new value and carries no source metadata. - let computed = conn - .create_materialized_view("c", "src") - .select([("payload", "payload"), ("n", "id + 1")]) - .execute() - .await - .unwrap(); - let computed_schema = computed.table().schema().await.unwrap(); - assert!(crate::blob::is_blob( - computed_schema.field_with_name("payload").unwrap() - )); - assert!( - computed_schema - .field_with_name("n") - .unwrap() - .metadata() - .is_empty() - ); - } - - /// A nested column projected straight through is still that column, and a - /// declaration buried in a struct child binds as hard as one on top. - #[tokio::test] - async fn test_nested_projection_metadata_and_declarations() { - // The schema below carries the legacy v1 blob marker, which Lance only - // allows writing at file version <= 2.1. - let conn = connect("memory://") - .storage_options([( - crate::database::listing::OPT_NEW_TABLE_STORAGE_VERSION, - "2.1", - )]) - .execute() - .await - .unwrap(); - let payload = crate::blob("payload", true).with_metadata(HashMap::from([ - ("lance-encoding:blob".to_string(), "true".to_string()), - ( - "lance-schema:unenforced-primary-key".to_string(), - "0".to_string(), + fn assert_refused(result: Result, operation: &str) { + match result { + Err(Error::NotSupported { message }) => assert!( + message.contains("is a materialized view, which only refresh writes"), + "{operation}: {message}" ), - ])); - let schema = Arc::new(ArrowSchema::new(vec![ - ArrowField::new("id", DataType::Int32, true), - ArrowField::new("meta", DataType::Struct(vec![payload].into()), true), - ])); - conn.create_empty_table("src", schema) - .write_options(stable_row_ids()) - .execute() - .await - .unwrap(); - - // A nested path is a direct projection: the leaf's metadata comes with - // it, so the blob stays a blob rather than a plain struct. - let lifted = conn - .create_materialized_view("lifted", "src") - .select([("payload", "meta.payload")]) - .execute() - .await - .unwrap(); - let field = lifted.table().schema().await.unwrap(); - let field = field.field_with_name("payload").unwrap().clone(); - assert_eq!( - field.metadata().get("lance-encoding:blob"), - Some(&"true".to_string()), - "nested projection lost the leaf's metadata" - ); - assert!( - !field - .metadata() - .contains_key("lance-schema:unenforced-primary-key"), - "a structural declaration rode along" - ); - - // Projecting the struct whole must not carry the child's declaration - // out to a view whose fields are nullable. - let whole = conn - .create_materialized_view("whole", "src") - .select([("meta", "meta")]) - .execute() - .await - .unwrap(); - let schema = whole.table().schema().await.unwrap(); - let DataType::Struct(children) = schema.field_with_name("meta").unwrap().data_type() else { - panic!("meta is not a struct"); - }; - let child = children.iter().find(|c| c.name() == "payload").unwrap(); - assert!( - !child - .metadata() - .contains_key("lance-schema:unenforced-primary-key"), - "a nested declaration survived: {:?}", - child.metadata() - ); - assert_eq!( - child.metadata().get("lance-encoding:blob"), - Some(&"true".to_string()) - ); + other => panic!("{operation} on a view was not refused: {other:?}"), + } } - /// A map's entries are fields like any other, and a declaration on one - /// binds the view's writes just as hard as one on top. #[tokio::test] - async fn test_map_declarations_are_stripped() { - let conn = connect("memory://").execute().await.unwrap(); - let value = - ArrowField::new("value", DataType::Utf8, false).with_metadata(HashMap::from([( - "lance-schema:unenforced-clustering-key:position".to_string(), - "1".to_string(), - )])); - let entries = ArrowField::new( - "entries", - DataType::Struct(vec![ArrowField::new("key", DataType::Utf8, false), value].into()), - false, - ); - let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new( - "props", - DataType::Map(Arc::new(entries), false), - true, - )])); - conn.create_empty_table("src", schema) - .write_options(stable_row_ids()) - .execute() - .await - .unwrap(); + async fn direct_writes_to_a_view_are_refused() { + use arrow_schema::Schema; + use datafusion::prelude::SessionContext; + use lance::dataset::{ColumnAlteration, NewColumnTransform}; - let view = conn - .create_materialized_view("view", "src") - .execute() - .await - .unwrap(); - let schema = view.table().schema().await.unwrap(); - let DataType::Map(entries, _) = schema.field_with_name("props").unwrap().data_type() else { - panic!("props is not a map"); - }; - let DataType::Struct(children) = entries.data_type() else { - panic!("map entries are not a struct"); - }; - let value = children.iter().find(|c| c.name() == "value").unwrap(); - assert!( - !value - .metadata() - .contains_key("lance-schema:unenforced-clustering-key:position"), - "a declaration survived inside a map: {:?}", - value.metadata() - ); - } + use crate::table::AddDataMode; + use crate::table::datafusion::BaseTableAdapter; - /// A computed column is declared by field metadata. Projecting one -- - /// as itself or under an alias -- must carry its description without its - /// declaration, which the target table would reject as foreign. - #[tokio::test] - async fn test_view_over_a_computed_column() { let conn = connect("memory://").execute().await.unwrap(); - let batch = record_batch!(("id", Int32, [1, 2])).unwrap(); let source = conn - .create_table("src", batch) - .write_options(stable_row_ids()) + .create_table("src", record_batch!(("x", Int32, [1, 2])).unwrap()) .execute() .await .unwrap(); + let view = conn + .create_materialized_view("m", "src") + .execute() + .await + .unwrap(); + let downstream = conn + .create_materialized_view("m2", "m") + .execute() + .await + .unwrap(); + let table = view.table(); + let row = || record_batch!(("x", Int32, [99])).unwrap(); + + assert_refused(table.add(row()).execute().await, "add"); + assert_refused( + table + .add(row()) + .mode(AddDataMode::Overwrite) + .execute() + .await, + "overwrite", + ); + assert_refused( + table.update().column("x", "x + 1").execute().await, + "update", + ); + assert_refused(table.delete("x = 1").await, "delete"); + let mut merge = table.merge_insert(&["x"]); + merge.when_not_matched_insert_all(); + assert_refused(merge.execute(reader(row())).await, "merge_insert"); + assert_refused( + table + .add_columns() + .transform(NewColumnTransform::SqlExpressions(vec![( + "y".into(), + "x * 2".into(), + )])) + .execute() + .await, + "add_columns", + ); + assert_refused( + table + .add_columns() + .transform(NewColumnTransform::AllNulls(Arc::new(Schema::new(vec![ + ArrowField::new("y", DataType::Int32, true), + ])))) + .execute() + .await, + "add_columns (nulls)", + ); + assert_refused( + table + .alter_columns(&[ColumnAlteration::new("x".into()).rename("z".into())]) + .await, + "alter_columns", + ); + assert_refused(table.drop_columns(&["x"]).await, "drop_columns"); + assert_refused( + downstream.table().add(row()).execute().await, + "add to a chained view", + ); + + let ctx = SessionContext::new(); + let adapter = BaseTableAdapter::try_new(table.base_table().clone()) + .await + .unwrap(); + ctx.register_table("m", Arc::new(adapter)).unwrap(); + let insert = match ctx.sql("INSERT INTO m VALUES (99)").await { + Ok(frame) => frame.collect().await.map(|_| ()), + Err(error) => Err(error), + }; + let message = insert.unwrap_err().to_string(); + assert!(message.contains("is a materialized view"), "{message}"); + + assert_eq!(table.count_rows(None).await.unwrap(), 2); + assert_eq!(table.schema().await.unwrap().fields().len(), 1); + + // Refresh, chained, is the writer. + source.add(row()).execute().await.unwrap(); + assert_eq!(view.refresh().execute().await.unwrap().rows_written, 3); + assert_eq!( + downstream.refresh().execute().await.unwrap().rows_written, + 3 + ); + assert_eq!( + downstream + .table() + .count_rows(Some("x = 99".into())) + .await + .unwrap(), + 1 + ); + + // The source is a plain table and still takes every write. + source + .update() + .column("x", "x + 1") + .execute() + .await + .unwrap(); + source.delete("x = 2").await.unwrap(); + let mut merge = source.merge_insert(&["x"]); + merge.when_not_matched_insert_all(); + merge + .execute(reader(record_batch!(("x", Int32, [7])).unwrap())) + .await + .unwrap(); source .add_columns() - .computed("doubled", "id * 2") - .execute() - .await - .unwrap(); - - // Default projection reaches the computed column too. - let whole = conn - .create_materialized_view("whole", "src") - .execute() - .await - .unwrap(); - let schema = whole.table().schema().await.unwrap(); - let field = schema.field_with_name("doubled").unwrap(); - assert!( - !field - .metadata() - .keys() - .any(|k| k.starts_with("computed_column")), - "a computed-column declaration rode along: {:?}", - field.metadata() - ); - - // And under an alias. - conn.create_materialized_view("aliased", "src") - .select([("twice", "doubled")]) + .transform(NewColumnTransform::SqlExpressions(vec![( + "y".into(), + "x * 2".into(), + )])) .execute() .await .unwrap(); + source.drop_columns(&["y"]).await.unwrap(); + assert_eq!(view.refresh().execute().await.unwrap().rows_written, 3); } - /// Embedding configuration names columns. It comes along only for the - /// columns a view actually projects, under the names the view gives them. #[tokio::test] - async fn test_embedding_config_follows_the_projection() { - let config = r#"[{"name":"f","model":{},"source_column":"text","vector_column":"vec"}]"#; + async fn indexes_and_optimize_still_apply_to_a_view() { + use crate::index::Index; + use crate::table::{CompactionOptions, OptimizeAction}; + let conn = connect("memory://").execute().await.unwrap(); - let schema = Arc::new(ArrowSchema::new_with_metadata( - vec![ - ArrowField::new("text", DataType::Utf8, true), - ArrowField::new("vec", DataType::Float32, true), - ], - HashMap::from([("embedding_functions".to_string(), config.to_string())]), - )); - conn.create_empty_table("src", schema) - .write_options(stable_row_ids()) - .execute() - .await - .unwrap(); - - let carried = |view: &MaterializedView| { - let view = view.table().clone(); - async move { - view.schema() - .await - .unwrap() - .metadata() - .get("embedding_functions") - .cloned() - } - }; - - // Both columns projected as themselves: kept as it stands. - let whole = conn - .create_materialized_view("whole", "src") - .execute() - .await - .unwrap(); - let kept = carried(&whole).await.expect("config dropped"); - assert!(kept.contains(r#""source_column":"text""#), "{kept}"); - assert!(kept.contains(r#""vector_column":"vec""#), "{kept}"); - - // Only the source column: the configuration names a vector column the - // view does not have, so it describes nothing and goes. - let partial = conn - .create_materialized_view("partial", "src") - .select([("text", "text")]) - .execute() - .await - .unwrap(); - assert_eq!(carried(&partial).await, None); - - // Renamed: the configuration follows the names the view uses. - let renamed = conn - .create_materialized_view("renamed", "src") - .select([("body", "text"), ("embedding", "vec")]) - .execute() - .await - .unwrap(); - let remapped = carried(&renamed).await.expect("config dropped"); - assert!(remapped.contains(r#""source_column":"body""#), "{remapped}"); - assert!( - remapped.contains(r#""vector_column":"embedding""#), - "{remapped}" - ); - - // The Node bindings spell the same configuration in camelCase, and - // the Rust definition names the destination `dest_column`. - for (config, source_key, dest_key) in [ - ( - r#"[{"name":"f","model":{},"sourceColumn":"text","vectorColumn":"vec"}]"#, - "sourceColumn", - "vectorColumn", - ), - ( - r#"[{"name":"f","model":{},"source_column":"text","dest_column":"vec"}]"#, - "source_column", - "dest_column", - ), - ] { - let schema = Arc::new(ArrowSchema::new_with_metadata( - vec![ - ArrowField::new("text", DataType::Utf8, true), - ArrowField::new("vec", DataType::Float32, true), - ], - HashMap::from([("embedding_functions".to_string(), config.to_string())]), - )); - let name = format!("src_{source_key}"); - conn.create_empty_table(&name, schema) - .write_options(stable_row_ids()) - .execute() - .await - .unwrap(); - let view = conn - .create_materialized_view(format!("v_{source_key}"), &name) - .select([("body", "text"), ("embedding", "vec")]) - .execute() - .await - .unwrap(); - let carried = carried(&view).await.expect("config dropped"); - assert!( - carried.contains(&format!(r#""{source_key}":"body""#)), - "{carried}" - ); - assert!( - carried.contains(&format!(r#""{dest_key}":"embedding""#)), - "{carried}" - ); - } - - // A computed column is not the source column under another name. - let computed = conn - .create_materialized_view("computed", "src") - .select([("body", "upper(text)"), ("embedding", "vec")]) - .execute() - .await - .unwrap(); - assert_eq!(carried(&computed).await, None); - - // One column projected twice is two columns in the view, and the - // configuration has to describe both rather than whichever came last. - let twice = conn - .create_materialized_view("twice", "src") - .select([("body", "text"), ("a", "vec"), ("b", "vec")]) - .execute() - .await - .unwrap(); - let carried = carried(&twice).await.expect("config dropped"); - let entries: Vec = serde_json::from_str(&carried).unwrap(); - let mut vectors: Vec<&str> = entries - .iter() - .filter_map(|e| e["vector_column"].as_str()) - .collect(); - vectors.sort_unstable(); - assert_eq!(vectors, ["a", "b"], "{carried}"); - } - - /// The native Rust producer records embeddings as column definitions - /// rather than as `embedding_functions`, and a query embeds its own text - /// through them. They are positional, so the view's list covers every one - /// of its fields. - #[tokio::test] - async fn test_native_column_definitions_follow_the_projection() { - let conn = connect("memory://").execute().await.unwrap(); - let rich = crate::table::TableDefinition::new( - Arc::new(ArrowSchema::new(vec![ - ArrowField::new("text", DataType::Utf8, true), - ArrowField::new("vector", DataType::Float32, true), - ])), - vec![ - ColumnDefinition { - kind: ColumnKind::Physical, - }, - ColumnDefinition { - kind: ColumnKind::Embedding(EmbeddingDefinition::new( - "text", - "model", - Some("vector"), - )), - }, - ], - ) - .into_rich_schema(); - conn.create_empty_table("src", rich) - .write_options(stable_row_ids()) - .execute() - .await - .unwrap(); - - let view = conn - .create_materialized_view("view", "src") - .select([("body", "text"), ("embedding", "vector")]) - .execute() - .await - .unwrap(); - let schema = view.table().schema().await.unwrap(); - let raw = schema - .metadata() - .get(COLUMN_DEFINITIONS_META_KEY) - .expect("the view dropped the native column definitions"); - let definitions: Vec = serde_json::from_str(raw).unwrap(); - assert_eq!( - definitions.len(), - schema.fields().len(), - "column definitions are positional" - ); - let ColumnKind::Embedding(embedding) = &definitions[1].kind else { - panic!("the embedding column came back physical: {raw}"); - }; - assert_eq!(embedding.source_column, "body"); - assert_eq!(embedding.dest_column.as_deref(), Some("embedding")); - assert_eq!(embedding.embedding_name, "model"); - assert!(matches!(definitions[0].kind, ColumnKind::Physical)); - assert!(matches!(definitions[2].kind, ColumnKind::Physical)); - - // Without the column the function reads, the view cannot recompute - // the embedding, so it carries no definition for it. - let partial = conn - .create_materialized_view("partial", "src") - .select([("embedding", "vector")]) - .execute() - .await - .unwrap(); - assert_eq!( - partial - .table() - .schema() - .await - .unwrap() - .metadata() - .get(COLUMN_DEFINITIONS_META_KEY), - None - ); - } - - /// A scan takes the cap as i64, so a larger one is refused where it is - /// declared rather than at the refresh that cannot run it. What a cap of - /// zero means is a refresh question, tested there. - #[tokio::test] - async fn test_limit_above_i64_max_is_refused_at_creation() { - let conn = people_db().await; - let err = conn - .create_materialized_view("too_big", "people") - .limit(i64::MAX as u64 + 1) - .execute() - .await - .unwrap_err(); - assert!( - matches!(&err, Error::InvalidInput { message } if message.contains("exceeds the maximum")), - "got {err:?}" - ); - - // The boundary itself is accepted. - conn.create_materialized_view("at_max", "people") - .limit(i64::MAX as u64) - .execute() - .await - .unwrap(); - } - - #[tokio::test] - async fn test_drop_is_drop_table() { - let conn = people_db().await; - conn.create_materialized_view("v", "people") - .execute() - .await - .unwrap(); - conn.drop_table("v", &[]).await.unwrap(); - assert!(conn.list_materialized_views().await.unwrap().is_empty()); - } - - /// The public declaration contract: prepare validates the source and - /// create consumes the declaration into a verified view table; a - /// source that cannot anchor refresh and a target outside the - /// source's database are both refused. - #[tokio::test] - async fn prepare_and_create_bind_the_declaration_lifecycle() { - let conn = connect("memory://").execute().await.unwrap(); - let batch = record_batch!(("id", Int32, [1, 2]), ("value", Int32, [3, 4])).unwrap(); let source = conn - .create_table("src", batch.clone()) - .write_options(stable_row_ids()) + .create_table("src", record_batch!(("x", Int32, [1, 2, 3])).unwrap()) .execute() .await .unwrap(); - - let projections = [ - ("id".to_string(), "id".to_string()), - ("double".to_string(), "value * 2".to_string()), - ]; - let prepared = prepare_declaration(&source, Some(&projections), Some("value > 0"), None) - .await - .unwrap(); - assert_eq!(prepared.definition().source_table, "src"); - - let view = prepared.create("v").await.unwrap(); - let schema = view.table().schema().await.unwrap(); - let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect(); - assert_eq!(names, ["id", "double", SOURCE_ROW_ID_COLUMN]); - assert!(schema.metadata().contains_key(DEFINITION_META_KEY)); - - // The same call rejects a source without stable row ids, so an - // external creation path cannot skip the check. - conn.create_table("plain", batch).execute().await.unwrap(); - let plain = conn.open_table("plain").execute().await.unwrap(); - let err = prepare_declaration(&plain, None, None, None) - .await - .unwrap_err(); - assert!(err.to_string().contains("stable row ids"), "{err}"); - - // A handle whose location does not resolve back through its name is - // refused: the definition would record a name reaching other data. - let plain_uri = plain - .as_native() - .unwrap() - .dataset - .get() - .await - .unwrap() - .uri() - .to_string(); - let masquerade = conn - .open_table("src") - .location(plain_uri) - .execute() - .await - .unwrap(); - let err = prepare_declaration(&masquerade, None, None, None) - .await - .unwrap_err(); - assert!( - err.to_string().contains("does not resolve to itself"), - "{err}" - ); - - // A table created at a custom location is refused outright: its - // recorded name reaches nothing at the database root, so the - // canonical reopen fails before any URI comparison. - let custom = conn - .create_table( - "custom_loc", - record_batch!(("id", Int32, [1, 2]), ("value", Int32, [3, 4])).unwrap(), - ) - .location("memory://elsewhere/custom_loc") - .write_options(stable_row_ids()) - .execute() - .await - .unwrap(); - let err = prepare_declaration(&custom, None, None, None) - .await - .unwrap_err(); - assert!(err.to_string().contains("custom_loc"), "{err}"); - } - - /// A view declared over a namespaced source records that namespace, and - /// refresh resolves the source through it -- the coordinate round-trips. - #[tokio::test] - async fn a_namespaced_source_round_trips_through_refresh() { - use lance_namespace::models::CreateNamespaceRequest; - - let tmp = tempfile::tempdir().unwrap(); - let mut properties = std::collections::HashMap::new(); - properties.insert("root".to_string(), tmp.path().to_str().unwrap().to_string()); - let conn = crate::connect_namespace("dir", properties) - .execute() - .await - .unwrap(); - conn.create_namespace(CreateNamespaceRequest { - id: Some(vec!["ns".into()]), - ..Default::default() - }) - .await - .unwrap(); - - let batch = record_batch!( - ("name", Utf8, ["ada", "grace", "alan"]), - ("age", Int32, [36, 85, 41]) - ) - .unwrap(); - conn.create_table("people", batch) - .namespace(vec!["ns".to_string()]) - .write_options(stable_row_ids()) - .execute() - .await - .unwrap(); - - // A decoy of the same name at the root: resolving the source at the - // wrong namespace materializes one row here instead of three. - let decoy = record_batch!(("name", Utf8, ["mallory"]), ("age", Int32, [42])).unwrap(); - conn.create_table("people", decoy) - .write_options(stable_row_ids()) - .execute() - .await - .unwrap(); - let view = conn - .create_materialized_view("adults", "people") - .with_no_data(true) - .namespace(vec!["ns".to_string()]) - .source_namespace(vec!["ns".to_string()]) - .select([("name", "name")]) - .only_if("age >= 18") + .create_materialized_view("m", "src") .execute() .await .unwrap(); - - assert_eq!(view.definition().source_table, "people"); - assert_eq!(view.definition().source_namespace, vec!["ns".to_string()]); - assert_eq!(view.table().namespace(), &["ns"]); - - // Refresh resolves the source at the recorded namespace, not at root. - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.rows_written, 3); - } - - fn definition(source_namespace: Vec) -> MaterializedViewDefinition { - MaterializedViewDefinition { - vector_source: None, - source_table: "people".to_string(), - source_namespace, - lateral: None, - projections: vec![ViewProjection { - output: "name".to_string(), - expression: "name".to_string(), - }], - filter: None, - group_by: Vec::new(), - limit: None, - } - } - - fn read(stored: impl Into) -> Result> { - read_definition(&HashMap::from([( - DEFINITION_META_KEY.to_string(), - stored.into(), - )])) - } - - /// What is stored is the query, under a format number; the same query - /// reads back whatever namespace the source sits in. - #[test] - fn the_stored_layout_is_the_canonical_query() { - for (namespace, query) in [ - (Vec::new(), "SELECT name FROM people"), - (vec!["ns".to_string()], "SELECT name FROM ns.people"), - ] { - let stored = definition_to_metadata(&definition(namespace.clone())).unwrap(); - let value: serde_json::Value = serde_json::from_str(&stored).unwrap(); - assert_eq!( - value, - serde_json::json!({"kind": "query", "format": 1, "query": query}) - ); - assert_eq!( - read(stored).unwrap(), - Some(StoredDefinition::Query(definition(namespace))) - ); - } - } - - /// The structured layout written before the format number still reads, - /// under both of its kind tags, and a tag that disagrees with its - /// namespace is an error: under `select` old readers resolved it at root. - #[test] - fn legacy_layouts_read_back() { - let legacy = |kind: &str, namespace: Vec<&str>| { - serde_json::json!({ - "kind": kind, - "source_table": "people", - "source_namespace": namespace, - "projections": [{"output": "name", "expression": "name"}], - "inputs": ["name"], - }) - .to_string() - }; - assert_eq!( - read(legacy(SELECT_KIND, vec![])).unwrap(), - Some(StoredDefinition::Query(definition(Vec::new()))) - ); - assert_eq!( - read(legacy(NAMESPACED_SELECT_KIND, vec!["ns"])).unwrap(), - Some(StoredDefinition::Query(definition(vec!["ns".into()]))) - ); - assert!( - read(r#"{"kind":"select","source_table":"people","projections":[]}"#) - .unwrap() - .is_some(), - "a pre-namespace definition carries no namespace key" - ); - for (kind, namespace) in [(SELECT_KIND, vec!["ns"]), (NAMESPACED_SELECT_KIND, vec![])] { - let err = read(legacy(kind, namespace)).unwrap_err(); - assert!( - err.to_string() - .contains("does not match its source namespace"), - "kind '{kind}': {err}" - ); - } - } - - /// A newer writer's definition is reported as such, never guessed at, - /// and a definition that is not a definition at all is an error rather - /// than a plain table. - #[test] - fn a_newer_format_is_reported_not_guessed() { - assert_eq!( - read(r#"{"format":5,"query":"SELECT name FROM people"}"#).unwrap(), - Some(StoredDefinition::Newer { format: "5".into() }) - ); - assert_eq!( - read(r#"{"kind":"join"}"#).unwrap(), - Some(StoredDefinition::Newer { - format: "kind 'join'".into() - }) - ); - for stored in [ - "{}", - r#"{"format":"one"}"#, - r#"{"format":1}"#, - r#"{"format":1,"query":"SELECT name FROM people GROUP BY name"}"#, - r#"{"format":2,"query":"SELECT name FROM people ORDER BY name"}"#, - ] { - assert!(read(stored).is_err(), "{stored}"); - } - } - - /// Planning records what the query reads of the source: a nested path - /// as itself, a read through an unnest alias as the list column. - #[test] - fn planning_records_the_source_columns_read() { - let element = DataType::Struct( - vec![ - ArrowField::new("chunk", DataType::Utf8, true), - ArrowField::new("ordinal", DataType::Int32, true), - ] - .into(), - ); - let schema = Arc::new(ArrowSchema::new(vec![ - ArrowField::new("id", DataType::Int64, false), - ArrowField::new( - "meta", - DataType::Struct(vec![ArrowField::new("title", DataType::Utf8, true)].into()), - true, - ), - ArrowField::new( - "chunks", - DataType::List(Arc::new(ArrowField::new("item", element, true))), - true, - ), - ])); - let planned = plan( - schema.clone(), - &MaterializedViewDefinition::from_sql( - "SELECT id, meta.title, c.chunk FROM docs, UNNEST(chunks) AS c WHERE c.ordinal < 3", - ) - .unwrap(), - None, - ) - .unwrap(); - assert_eq!(planned.inputs, ["chunks", "id", "meta.title"]); - assert_eq!( - planned - .fields - .iter() - .map(|f| f.name().as_str()) - .collect::>(), - ["id", "title", "chunk"] - ); - assert_eq!( - planned.fields[2].data_type(), - &DataType::Utf8, - "the element's field is read through the alias" - ); - - let star = plan( - schema, - &MaterializedViewDefinition::from_sql("SELECT * FROM docs, UNNEST(chunks) AS c") - .unwrap(), - None, - ) - .unwrap(); - assert_eq!( - star.definition.to_sql(), - "SELECT id, meta, c FROM docs, UNNEST(chunks) AS c" - ); - let err = plan( - Arc::new(ArrowSchema::new(vec![ArrowField::new( - "id", - DataType::Int64, - false, - )])), - &MaterializedViewDefinition::from_sql("SELECT id FROM docs, UNNEST(id) AS c").unwrap(), - None, - ) - .unwrap_err(); - assert!(err.to_string().contains("not a list"), "{err}"); - } - - /// A binding as the server records it: one Utf8 input over `input` - /// bound to a nullable parameter, one Int32 output named `output`, with - /// the exact schemas the durable contract requires. - pub fn test_binding(binding_id: &str, input: &str, output: &str) -> FunctionBinding { - let input_schema = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ - ArrowField::new("text", DataType::Utf8, true), - ])) - .unwrap(); - let output_schema = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ - ArrowField::new(output, DataType::Int32, true), - ])) - .unwrap(); - let input_type = input_schema.fields[0].r#type.r#type.clone(); - let output_type = output_schema.fields[0].r#type.r#type.clone(); - FunctionBinding::from_json( - &serde_json::json!({ - "binding_id": binding_id, - "function": {"name": "embed", "version": "1", "object_id": "fixture", "location": "memory:///fixture", - "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, - "inputs": [{ - "parameter": "text", "field_id": -1, "field_path": input, - "arrow_type": input_type, "nullable": true, - }], - "outputs": [{ - "result_field": "$value", "output_name": output, "output_field_id": -1, - "output_ordinal": 0, "arrow_type": output_type, "nullable": false, - }], - "input_schema": serde_json::to_value(input_schema).unwrap(), - "output_schema": serde_json::to_value(output_schema).unwrap(), - }) - .to_string(), - ) - .unwrap() - } - - /// A computed column as the server declares it on a table: bound to a - /// registered Function. - pub fn computed_field(name: &str, binding_id: &str, input: &str) -> ArrowField { - ArrowField::new(name, DataType::Int32, true).with_metadata( - crate::table::computed_columns::function_computed_column_metadata( - binding_id, - 0, - &[input.to_string()], - ), - ) - } - - pub async fn people(conn: &Connection) -> Table { - let batch = - record_batch!(("id", Int32, [1, 2, 3]), ("name", Utf8, ["a", "b", "c"])).unwrap(); - conn.create_table("people", batch) - .write_options(stable_row_ids()) + view.table() + .create_index(&["x"], Index::Auto) .execute() .await - .unwrap() - } + .unwrap(); + assert_eq!(view.table().list_indices().await.unwrap().len(), 1); + view.table() + .optimize(OptimizeAction::Compact { + options: CompactionOptions::default(), + remap_options: None, + }) + .await + .unwrap(); + view.table().optimize(OptimizeAction::All).await.unwrap(); - /// `people` with both columns non-nullable, for nullability cases. - pub async fn strict_people(conn: &Connection) -> Table { - let schema = Arc::new(ArrowSchema::new(vec![ - ArrowField::new("id", DataType::Int32, false), - ArrowField::new("name", DataType::Utf8, false), - ])); - let batch = arrow_array::RecordBatch::try_new( - schema, - vec![ - Arc::new(arrow_array::Int32Array::from(vec![1, 2, 3])), - Arc::new(arrow_array::StringArray::from(vec!["a", "b", "c"])), - ], - ) - .unwrap(); - conn.create_table("people", batch) - .write_options(stable_row_ids()) + source + .add(record_batch!(("x", Int32, [4])).unwrap()) .execute() .await - .unwrap() - } - - async fn prepared_people(conn: &Connection) -> PreparedDeclaration { - let source = people(conn).await; - prepare_declaration( - &source, - Some(&[ - ("id".to_string(), "id".to_string()), - ("name".to_string(), "name".to_string()), - ]), - None, - None, - ) - .await - .unwrap() - } - - #[tokio::test] - async fn a_computed_column_is_declared_null_with_its_binding() { - let conn = connect("memory://").execute().await.unwrap(); - let view = prepared_people(&conn) - .await - .with_computed_columns( - vec![(2, computed_field("emb", "fb_1", "name"))], - &[test_binding("fb_1", "name", "emb")], - ) - .unwrap() - .create("v") - .await .unwrap(); - - let schema = view.table().schema().await.unwrap(); - let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect(); - assert_eq!(names, ["id", "name", "emb", SOURCE_ROW_ID_COLUMN]); - let declared: Vec = computed_columns(&schema) - .into_iter() - .map(|c| c.name) - .collect(); - assert_eq!(declared, ["emb"]); - let bindings = crate::table::computed_columns::function_bindings(&schema).unwrap(); - assert_eq!(bindings.len(), 1); - assert_eq!(bindings[0].binding_id(), "fb_1"); - // The stored definition is the query alone; bindings live beside it. - let stored: serde_json::Value = - serde_json::from_str(&schema.metadata()[DEFINITION_META_KEY]).unwrap(); - assert_eq!(stored["format"], 1); - assert_eq!(view.definition().projections.len(), 2); - assert_eq!(view.table().count_rows(None).await.unwrap(), 0); - assert_eq!(conn.open_materialized_view("v").await.unwrap().name(), "v"); - } - - #[tokio::test] - async fn computed_column_declarations_are_validated() { - let conn = connect("memory://").execute().await.unwrap(); - let prepared = prepared_people(&conn).await; - let binding = test_binding("fb_1", "name", "emb"); - let fails = |prepared: PreparedDeclaration, - columns: Vec<(usize, ArrowField)>, - bindings: &[FunctionBinding]| { - prepared - .with_computed_columns(columns, bindings) - .err() - .map(|e| e.to_string()) - .expect("the declaration should be refused") - }; - let emb = |binding_id: &str| computed_field("emb", binding_id, "name"); - - let err = fails( - prepared.clone(), - vec![(2, emb("fb_1").with_nullable(false))], - std::slice::from_ref(&binding), - ); - assert!(err.contains("must be nullable"), "{err}"); - - let plain = ArrowField::new("emb", DataType::Int32, true); - let err = fails( - prepared.clone(), - vec![(2, plain)], - std::slice::from_ref(&binding), - ); - assert!( - err.contains("does not carry a computed-column declaration"), - "{err}" - ); - - // The rest is the computed-column contract: a binding the field does - // not name, an output the binding does not map to this field, an - // input the view does not hold. - let err = fails( - prepared.clone(), - vec![(2, emb("fb_other"))], - std::slice::from_ref(&binding), - ); - assert!(err.contains("does not match binding 'fb_1'"), "{err}"); - let err = fails( - prepared.clone(), - vec![(2, emb("fb_1"))], - &[test_binding("fb_1", "name", "different_output")], - ); - assert!(err.contains("different_output"), "{err}"); - let err = fails( - prepared.clone(), - vec![(2, emb("fb_1"))], - &[test_binding("fb_1", "bio", "emb")], - ); - assert!(err.contains("'bio'"), "{err}"); - - let err = fails( - prepared.clone(), - vec![(2, computed_field("name", "fb_1", "name"))], - &[test_binding("fb_1", "name", "name")], - ); - assert!(err.contains("already exists"), "{err}"); - let err = fails( - prepared.clone(), - vec![(2, computed_field(SOURCE_ROW_ID_COLUMN, "fb_1", "name"))], - &[test_binding("fb_1", "name", SOURCE_ROW_ID_COLUMN)], - ); - assert!(err.contains("reserved"), "{err}"); - let err = fails( - prepared.clone(), - vec![(7, emb("fb_1"))], - std::slice::from_ref(&binding), - ); - assert!( - err.contains("placed at 7, past the view's 2 columns"), - "{err}" - ); - let err = fails(prepared, Vec::new(), std::slice::from_ref(&binding)); - assert!(err.contains("at least one computed column"), "{err}"); - } - - /// A source column a computed column reads without the view projecting - /// it becomes an internal projection before the provenance column, with - /// the source's nullability; a projected column is read from its - /// projection. - /// `Some(&[])` is a declaration that projects nothing yet: a view of - /// computed columns alone, whose inputs `input_column` places. `None` - /// is `SELECT *`. The two must not collapse into each other. - #[tokio::test] - async fn an_empty_projection_list_is_not_a_star() { - let conn = connect("memory://").execute().await.unwrap(); - let source = strict_people(&conn).await; - - let mut prepared = prepare_declaration(&source, Some(&[]), None, None) - .await - .unwrap(); - assert!(prepared.definition().projections.is_empty()); - assert_eq!(prepared.input_column("name").unwrap(), "__input_name"); - let view = prepared - .with_computed_columns( - vec![(0, computed_field("emb", "fb_1", "__input_name"))], - &[test_binding("fb_1", "__input_name", "emb")], - ) - .unwrap() - .create("only") - .await - .unwrap(); - let schema = view.table().schema().await.unwrap(); - let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect(); - assert_eq!(names, ["emb", "__input_name", SOURCE_ROW_ID_COLUMN]); + assert_eq!(view.refresh().execute().await.unwrap().rows_written, 4); + view.table().optimize(OptimizeAction::All).await.unwrap(); assert_eq!( - view.definition().to_sql(), - "SELECT name AS __input_name FROM people" + view.table().count_rows(Some("x > 2".into())).await.unwrap(), + 2 ); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.rows_written, 3); - let reopened = conn.open_materialized_view("only").await.unwrap(); - assert_eq!(reopened.definition(), view.definition()); - - let all = prepare_declaration(&source, None, None, None) - .await - .unwrap(); - assert!( - !all.definition().selects_star(), - "planning expands the star" - ); - let outputs: Vec<&str> = all - .definition() - .projections - .iter() - .map(|p| p.output.as_str()) - .collect(); - assert_eq!(outputs, ["id", "name"]); } #[tokio::test] - async fn an_unprojected_input_becomes_an_internal_projection() { - let conn = connect("memory://").execute().await.unwrap(); - let source = strict_people(&conn).await; - let mut prepared = prepare_declaration( - &source, - Some(&[("key".to_string(), "id".to_string())]), - None, - None, - ) - .await - .unwrap(); - assert_eq!(prepared.input_column("id").unwrap(), "key"); - assert_eq!(prepared.input_column("name").unwrap(), "__input_name"); - assert_eq!(prepared.input_column("name").unwrap(), "__input_name"); - let err = prepared.input_column("missing").unwrap_err().to_string(); - assert!(err.contains("no column 'missing'"), "{err}"); - - let view = prepared - .with_computed_columns( - vec![(1, computed_field("emb", "fb_1", "__input_name"))], - &[test_binding("fb_1", "__input_name", "emb")], - ) - .unwrap() - .create("v") - .await - .unwrap(); - let schema = view.table().schema().await.unwrap(); - let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect(); - assert_eq!(names, ["key", "emb", "__input_name", SOURCE_ROW_ID_COLUMN]); - let input = schema.field_with_name("__input_name").unwrap(); - assert_eq!(input.data_type(), &DataType::Utf8); - assert!( - !input.is_nullable(), - "the copy keeps the source's nullability" - ); - assert!(!schema.field_with_name("key").unwrap().is_nullable()); - let projections: Vec<(&str, &str)> = view - .definition() - .projections - .iter() - .map(|p| (p.output.as_str(), p.expression.as_str())) - .collect(); - assert_eq!(projections, [("key", "id"), ("__input_name", "name")]); - } - - /// Two outputs of one binding land at consecutive positions: each - /// insertion widens the range the next may take. - #[tokio::test] - async fn sibling_computed_columns_take_consecutive_positions() { - let conn = connect("memory://").execute().await.unwrap(); - let source = people(&conn).await; - let prepared = prepare_declaration( - &source, - Some(&[("id".to_string(), "id".to_string())]), - None, - None, - ) - .await - .unwrap(); - let metadata = |ordinal: u32| { - crate::table::computed_columns::function_computed_column_metadata( - "fb_pair", - ordinal, - &["id".to_string()], - ) - }; - let left = ArrowField::new("left", DataType::Int32, true).with_metadata(metadata(0)); - let right = ArrowField::new("right", DataType::Int32, true).with_metadata(metadata(1)); - let input_schema = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ - ArrowField::new("value", DataType::Int32, true), - ])) - .unwrap(); - let output_schema = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ - ArrowField::new("left", DataType::Int32, true), - ArrowField::new("right", DataType::Int32, true), - ])) - .unwrap(); - let int = input_schema.fields[0].r#type.r#type.clone(); - let binding = FunctionBinding::from_json( - &serde_json::json!({ - "binding_id": "fb_pair", - "function": {"name": "pair", "version": "1", "object_id": "fixture", "location": "memory:///fixture", - "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, - "inputs": [{"parameter": "value", "field_id": -1, "field_path": "id", - "arrow_type": int, "nullable": true}], - "outputs": [ - {"result_field": "left", "output_name": "left", "output_field_id": -1, - "output_ordinal": 0, "arrow_type": int, "nullable": false}, - {"result_field": "right", "output_name": "right", "output_field_id": -1, - "output_ordinal": 1, "arrow_type": int, "nullable": false}, - ], - "input_schema": serde_json::to_value(input_schema).unwrap(), - "output_schema": serde_json::to_value(output_schema).unwrap(), - }) - .to_string(), - ) - .unwrap(); - let view = prepared - .with_computed_columns(vec![(1, left), (2, right)], &[binding]) - .unwrap() - .create("v") - .await - .unwrap(); - let names: Vec = view - .table() - .schema() - .await - .unwrap() - .fields() - .iter() - .map(|f| f.name().clone()) - .collect(); - assert_eq!(names, ["id", "left", "right", SOURCE_ROW_ID_COLUMN]); - } - - /// A SQL declaration as `add_columns().computed()` records it. - pub fn sql_field( - name: &str, - data_type: DataType, - expression: &str, - inputs: &str, - ) -> ArrowField { + async fn computed_columns_of_a_view_are_still_filled_and_dropped() { use crate::table::computed_columns::{ COMPUTED_COLUMN_META_KEY, EXPRESSION_META_KEY, INPUTS_META_KEY, KIND_META_KEY, SQL_KIND, }; - ArrowField::new(name, data_type, true).with_metadata(HashMap::from([ - (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), - (KIND_META_KEY.to_string(), SQL_KIND.to_string()), - (EXPRESSION_META_KEY.to_string(), expression.to_string()), - (INPUTS_META_KEY.to_string(), inputs.to_string()), - ])) - } - /// A SQL declaration is re-planned at admission: it must parse against - /// the view, yield the declared type, and read the inputs it declares. - #[tokio::test] - async fn a_sql_declaration_is_planned_at_admission() { let conn = connect("memory://").execute().await.unwrap(); - let fails = |prepared: PreparedDeclaration, field: ArrowField| { - prepared - .with_computed_columns(vec![(1, field)], &[]) - .err() - .map(|e| e.to_string()) - .expect("the declaration should be refused") - }; - let prepared = prepared_people(&conn).await; - let err = fails( - prepared.clone(), - sql_field("bad", DataType::Int32, "missing + 1", r#"["missing"]"#), - ); - assert!(err.contains("missing"), "{err}"); - let err = fails( - prepared.clone(), - sql_field("wide", DataType::Int64, "id + 1", r#"["id"]"#), - ); - assert!( - err.contains("declared as Int64 but its expression yields Int32"), - "{err}" - ); - let err = fails( - prepared.clone(), - sql_field("lying", DataType::Int32, "id + 1", r#"["name"]"#), - ); - assert!(err.contains("declares inputs"), "{err}"); - + let source = conn + .create_table("src", record_batch!(("x", Int32, [1, 2])).unwrap()) + .execute() + .await + .unwrap(); + let prepared = prepare_declaration(&source, Some(&[("x".into(), "x".into())]), None, None) + .await + .unwrap(); + let doubled = + ArrowField::new("doubled", DataType::Int32, true).with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.into(), "true".into()), + (KIND_META_KEY.into(), SQL_KIND.into()), + (EXPRESSION_META_KEY.into(), "x * 2".into()), + (INPUTS_META_KEY.into(), "[\"x\"]".into()), + ])); let view = prepared - .with_computed_columns( - vec![(1, sql_field("next", DataType::Int32, "id + 1", r#"["id"]"#))], - &[], - ) + .with_computed_columns(vec![(1, doubled)], &[]) .unwrap() - .create("v") + .create("m") .await .unwrap(); - let names: Vec = view - .table() - .schema() - .await - .unwrap() - .fields() - .iter() - .map(|f| f.name().clone()) - .collect(); - assert_eq!(names, ["id", "next", "name", SOURCE_ROW_ID_COLUMN]); - } - - /// Creation persists a declaration only when it re-plans and the data - /// carries no values for it, whichever door created the table. - #[tokio::test] - async fn a_created_table_cannot_carry_computed_values() { - let conn = connect("memory://").execute().await.unwrap(); - let schema = Arc::new(ArrowSchema::new(vec![ - ArrowField::new("x", DataType::Int32, false), - sql_field("forged", DataType::Int32, "x + 1", r#"["x"]"#), - ])); - let filled = arrow_array::RecordBatch::try_new( - schema.clone(), - vec![ - Arc::new(arrow_array::Int32Array::from(vec![1])), - Arc::new(arrow_array::Int32Array::from(vec![999])), - ], - ) - .unwrap(); - let err = conn - .create_table("forged", filled) - .execute() - .await - .unwrap_err() - .to_string(); - assert!(err.contains("cannot be written directly"), "{err}"); - assert!( - !conn - .table_names() - .execute() + view.refresh().execute().await.unwrap(); + view.table().refresh_column("doubled").await.unwrap(); + assert_eq!( + view.table() + .count_rows(Some("doubled = x * 2".into())) .await - .unwrap() - .contains(&"forged".to_string()) + .unwrap(), + 2 ); - - let unfilled = arrow_array::RecordBatch::try_new( - schema, - vec![ - Arc::new(arrow_array::Int32Array::from(vec![1])), - Arc::new(arrow_array::Int32Array::new_null(1)), - ], - ) - .unwrap(); - let table = conn - .create_table("declared", unfilled) - .execute() - .await - .unwrap(); - assert_eq!(table.refresh_column("forged").await.unwrap().rows_filled, 1); - - // A declaration with only its marker is broken, not absent. - let half = ArrowField::new("half", DataType::Int32, true).with_metadata(HashMap::from([( - crate::table::computed_columns::COMPUTED_COLUMN_META_KEY.to_string(), - "true".to_string(), - )])); - let batch = arrow_array::RecordBatch::try_new( - Arc::new(ArrowSchema::new(vec![ - ArrowField::new("x", DataType::Int32, false), - half, - ])), - vec![ - Arc::new(arrow_array::Int32Array::from(vec![1])), - Arc::new(arrow_array::Int32Array::from(vec![999])), - ], - ) - .unwrap(); - let err = conn - .create_table("half", batch) - .execute() - .await - .unwrap_err() - .to_string(); - assert!( - err.contains("incomplete computed-column declaration"), - "{err}" + assert_refused( + view.table().drop_columns(&["x", "doubled"]).await, + "drop_columns of a stored and a computed column", ); - - let bogus = Arc::new(ArrowSchema::new(vec![ - ArrowField::new("x", DataType::Int32, false), - sql_field("bad", DataType::Int32, "missing + 1", r#"["missing"]"#), - ])); - let batch = arrow_array::RecordBatch::new_empty(bogus); - let err = conn - .create_table("bogus", batch) - .execute() - .await - .unwrap_err() - .to_string(); - assert!(err.contains("missing"), "{err}"); - } - - /// The internal-input prefix is reserved for the declaration, like the - /// provenance column, so an alias cannot masquerade as an internal input. - #[tokio::test] - async fn the_internal_input_prefix_is_reserved() { - let conn = connect("memory://").execute().await.unwrap(); - let source = people(&conn).await; - let err = prepare_declaration( - &source, - Some(&[("__input_x".to_string(), "name".to_string())]), - None, - None, - ) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("'__input_x' is reserved"), "{err}"); - } - - /// A computed column may not read another, through any path: a - /// Function bound to a child of a computed struct is refused like a SQL - /// declaration over it. - #[tokio::test] - async fn a_computed_column_cannot_read_a_computed_root() { - let conn = connect("memory://").execute().await.unwrap(); - let prepared = prepared_people(&conn).await; - let payload = sql_field( - "payload", - DataType::Struct(vec![ArrowField::new("value", DataType::Utf8, true)].into()), - "named_struct('value', name)", - r#"["name"]"#, - ); - let err = prepared - .with_computed_columns( - vec![ - (2, payload), - (3, computed_field("emb", "fb_dependent", "payload.value")), - ], - &[test_binding("fb_dependent", "payload.value", "emb")], - ) - .err() - .map(|e| e.to_string()) - .expect("a computed root as a Function input should be refused"); - assert!(err.contains("reads computed column 'payload'"), "{err}"); - } - - /// The root check uses the canonical path parser: a quoted top-level - /// name containing a dot is one root, not two segments. - #[tokio::test] - async fn a_quoted_computed_root_is_still_refused() { - let conn = connect("memory://").execute().await.unwrap(); - let prepared = prepared_people(&conn).await; - let payload = sql_field( - "payload.dot", - DataType::Struct(vec![ArrowField::new("value", DataType::Utf8, true)].into()), - "named_struct('value', name)", - r#"["name"]"#, - ); - let input = "`payload.dot`.value"; - let err = prepared - .with_computed_columns( - vec![ - (2, payload), - (3, computed_field("emb", "fb_dependent", input)), - ], - &[test_binding("fb_dependent", input, "emb")], - ) - .err() - .map(|e| e.to_string()) - .expect("a quoted computed root should be refused"); - assert!(err.contains("reads computed column 'payload.dot'"), "{err}"); - } - - /// Namespace-backed creation admits declarations by the same rule, and - /// refuses before the namespace records the table. - #[tokio::test] - async fn a_namespace_created_table_cannot_carry_computed_values() { - let tmp = tempfile::tempdir().unwrap(); - let mut properties = std::collections::HashMap::new(); - properties.insert("root".to_string(), tmp.path().to_str().unwrap().to_string()); - let conn = crate::connect_namespace("dir", properties) - .execute() - .await - .unwrap(); - let half = ArrowField::new("half", DataType::Int32, true).with_metadata(HashMap::from([( - crate::table::computed_columns::COMPUTED_COLUMN_META_KEY.to_string(), - "true".to_string(), - )])); - let batch = arrow_array::RecordBatch::try_new( - Arc::new(ArrowSchema::new(vec![ - ArrowField::new("id", DataType::Int32, false), - half, - ])), - vec![ - Arc::new(arrow_array::Int32Array::from(vec![1])), - Arc::new(arrow_array::Int32Array::from(vec![999])), - ], - ) - .unwrap(); - let err = conn - .create_table("malformed", batch) - .execute() - .await - .unwrap_err() - .to_string(); - assert!( - err.contains("incomplete computed-column declaration"), - "{err}" - ); - assert!(conn.table_names().execute().await.unwrap().is_empty()); - - let schema = Arc::new(ArrowSchema::new(vec![ - ArrowField::new("id", DataType::Int32, false), - sql_field("next", DataType::Int32, "id + 1", r#"["id"]"#), - ])); - let filled = arrow_array::RecordBatch::try_new( - schema.clone(), - vec![ - Arc::new(arrow_array::Int32Array::from(vec![1])), - Arc::new(arrow_array::Int32Array::from(vec![999])), - ], - ) - .unwrap(); - let err = conn - .create_table("forged", filled) - .execute() - .await - .unwrap_err() - .to_string(); - assert!(err.contains("cannot be written directly"), "{err}"); - - let unfilled = arrow_array::RecordBatch::try_new( - schema, - vec![ - Arc::new(arrow_array::Int32Array::from(vec![1])), - Arc::new(arrow_array::Int32Array::new_null(1)), - ], - ) - .unwrap(); - let table = conn - .create_table("declared", unfilled) - .execute() - .await - .unwrap(); - assert_eq!(table.refresh_column("next").await.unwrap().rows_filled, 1); - } - - /// A projected column keeps its nullability; a computed value is - /// nullable. - #[tokio::test] - async fn an_identity_projection_keeps_source_nullability() { - let conn = connect("memory://").execute().await.unwrap(); - let source = strict_people(&conn).await; - let prepared = prepare_declaration( - &source, - Some(&[ - ("id".to_string(), "id".to_string()), - ("n".to_string(), "name".to_string()), - ("next".to_string(), "id + 1".to_string()), - ]), - None, - None, - ) - .await - .unwrap(); - let nullable: Vec = prepared - .schema() - .fields() - .iter() - .map(|f| f.is_nullable()) - .collect(); - assert_eq!(nullable, [false, false, true, false]); + view.table().drop_columns(&["doubled"]).await.unwrap(); + view.refresh().execute().await.unwrap(); + assert_eq!(view.table().count_rows(None).await.unwrap(), 2); } } diff --git a/rust/lancedb/src/materialized_view/differential.rs b/rust/lancedb/src/materialized_view/differential.rs deleted file mode 100644 index 396b4e65b..000000000 --- a/rust/lancedb/src/materialized_view/differential.rs +++ /dev/null @@ -1,730 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The LanceDB Authors - -//! Differential refresh testing. -//! -//! The refresh contract is a property: after any sequence of source -//! mutations, a view maintained by default (incremental-where-possible) -//! refreshes equals the definition evaluated against the source directly, -//! and so does a forced rebuild. The oracle is an independent read of the -//! source -- plain column scan, filter applied in Rust -- so it shares -//! nothing with the refresh path it checks. -//! -//! The oracle runs after every step, not just at the end: a later mutation -//! that forces a rebuild would silently heal an incremental error, and those -//! transient errors are exactly the bugs this exists to catch. - -use arrow_array::{Float32Array, Int32Array, RecordBatch}; -use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; -use futures::{StreamExt, TryStreamExt}; -use lance::dataset::NewColumnTransform; -use std::sync::Arc; - -use super::MaterializedView; -use super::refresh::RefreshMode; -use crate::connect; -use crate::connection::Connection; -use crate::query::{ExecutableQuery, QueryBase, Select}; -use crate::table::{CompactionOptions, OptimizeAction, Table}; - -/// One source mutation, one per correctness-relevant class. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SrcOp { - /// Fresh non-colliding ids of both parities, so every other op has - /// view-resident rows to act on: the only op that should refresh - /// incrementally. - AppendNew, - /// Deletion in surviving fragments must break the pure-append check. - DeleteEven, - /// An in-place update; on the filtered shape it crosses the predicate, - /// so rows must leave the view. - UpdateOddScore, - /// Fragment rewrite/renumber must break the pure-append check. - Compact, - /// A column the view does not read must NOT force a rebuild. - AddColumn, - /// merge_insert commits an Update whose by-source arm deletes rows, so a - /// classifier that reads Update as "changed only" loses those deletions. - MergeDropLargest, - /// merge_insert that both changes existing rows and inserts new ones in - /// one transaction. - MergeUpsert, -} - -const ALL_OPS: [SrcOp; 7] = [ - SrcOp::AppendNew, - SrcOp::DeleteEven, - SrcOp::UpdateOddScore, - SrcOp::Compact, - SrcOp::AddColumn, - SrcOp::MergeDropLargest, - SrcOp::MergeUpsert, -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Shape { - /// SELECT id, score. - Identity, - /// SELECT id, score WHERE score > 50: additionally sensitive to rows - /// crossing the predicate. - Filtered, - /// SELECT id, score LIMIT 4. Which rows are held depends on the order - /// they were first materialized, so the oracle checks containment and - /// the cap rather than equality. - Limited, -} - -impl Shape { - fn filter(&self) -> Option<&'static str> { - match self { - Self::Identity | Self::Limited => None, - Self::Filtered => Some("score > 50"), - } - } - - fn matches(&self, score: f32) -> bool { - match self { - Self::Identity | Self::Limited => true, - Self::Filtered => score > 50.0, - } - } - - fn limit(&self) -> Option { - match self { - Self::Limited => Some(4), - _ => None, - } - } -} - -struct Case { - conn: Connection, - source: Table, - view: MaterializedView, - shape: Shape, - next_id: i32, - added_columns: u32, -} - -fn rows_batch(ids: &[i32]) -> RecordBatch { - let scores: Vec = ids.iter().map(|id| (*id * 10) as f32).collect(); - RecordBatch::try_new( - Arc::new(ArrowSchema::new(vec![ - ArrowField::new("id", DataType::Int32, true), - ArrowField::new("score", DataType::Float32, true), - ])), - vec![ - Arc::new(Int32Array::from(ids.to_vec())), - Arc::new(Float32Array::from(scores)), - ], - ) - .unwrap() -} - -fn merge_batch(ids: &[i32]) -> RecordBatch { - let scores: Vec = ids.iter().map(|id| (*id * 10 + 5) as f32).collect(); - RecordBatch::try_new( - Arc::new(ArrowSchema::new(vec![ - ArrowField::new("id", DataType::Int32, true), - ArrowField::new("score", DataType::Float32, true), - ])), - vec![ - Arc::new(Int32Array::from(ids.to_vec())), - Arc::new(Float32Array::from(scores)), - ], - ) - .unwrap() -} - -impl Case { - async fn new(shape: Shape) -> Self { - let conn = connect("memory://").execute().await.unwrap(); - let source = conn - .create_table("src", rows_batch(&[1, 2, 3, 4])) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - let mut builder = conn - .create_materialized_view("view", "src") - .select([("id", "id"), ("score", "score")]); - if let Some(filter) = shape.filter() { - builder = builder.only_if(filter); - } - if let Some(limit) = shape.limit() { - builder = builder.limit(limit as u64); - } - let view = builder.execute().await.unwrap(); - Self { - conn, - source, - view, - shape, - next_id: 100, - added_columns: 0, - } - } - - async fn apply(&mut self, op: SrcOp) { - match op { - SrcOp::AppendNew => { - // Mixed parity: the middle id is odd, so UpdateOddScore always - // has a filter-matching appended row to evict. - let ids = vec![self.next_id, self.next_id + 101, self.next_id + 202]; - self.next_id += 303; - self.source.add(rows_batch(&ids)).execute().await.unwrap(); - } - SrcOp::DeleteEven => { - self.source.delete("id % 2 = 0").await.unwrap(); - } - SrcOp::UpdateOddScore => { - self.source - .update() - .column("score", "-1.0") - .only_if("id % 2 = 1") - .execute() - .await - .unwrap(); - } - SrcOp::Compact => { - self.source - .optimize(OptimizeAction::Compact { - options: CompactionOptions::default(), - remap_options: None, - }) - .await - .unwrap(); - } - SrcOp::MergeDropLargest => { - let mut ids = self.source_ids().await; - ids.sort_unstable(); - ids.pop(); - if ids.is_empty() { - return; - } - let batch = rows_batch(&ids); - let reader = - arrow_array::RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema()); - let mut merge = self.source.merge_insert(&["id"]); - merge.when_not_matched_by_source_delete(None); - merge.execute(Box::new(reader)).await.unwrap(); - } - SrcOp::MergeUpsert => { - let mut ids = self.source_ids().await; - ids.sort_unstable(); - // One row that exists (updated in place) and one that does not. - let existing = ids.first().copied().unwrap_or(self.next_id); - let fresh = self.next_id; - self.next_id += 1; - let batch = merge_batch(&[existing, fresh]); - let reader = - arrow_array::RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema()); - let mut merge = self.source.merge_insert(&["id"]); - merge - .when_matched_update_all(None) - .when_not_matched_insert_all(); - merge.execute(Box::new(reader)).await.unwrap(); - } - SrcOp::AddColumn => { - self.added_columns += 1; - let field = ArrowField::new( - format!("extra_{}", self.added_columns), - DataType::Int32, - true, - ); - self.source - .add_columns() - .transform(NewColumnTransform::AllNulls(Arc::new(ArrowSchema::new( - vec![field], - )))) - .execute() - .await - .unwrap(); - } - } - } - - async fn source_ids(&self) -> Vec { - read_rows( - self.source - .query() - .select(Select::columns(&["id", "score"])), - ) - .await - .into_iter() - .map(|(id, _)| id) - .collect() - } - - /// The definition's result, read independently of the refresh path: - /// plain column scan, filter applied here, sorted. - async fn oracle(&self) -> Vec<(i32, i32)> { - let mut rows = read_rows( - self.source - .query() - .select(Select::columns(&["id", "score"])), - ) - .await - .into_iter() - .filter(|(_, score)| self.shape.matches(*score as f32)) - .collect::>(); - rows.sort_unstable(); - rows - } - - async fn view_rows(&self) -> Vec<(i32, i32)> { - let mut rows = read_rows( - self.view - .table() - .query() - .select(Select::columns(&["id", "score"])), - ) - .await; - rows.sort_unstable(); - rows - } - - async fn check(&self, label: &str) -> Result<(), String> { - let expected = self.oracle().await; - let actual = self.view_rows().await; - let Some(cap) = self.shape.limit() else { - if expected != actual { - return Err(format!( - "{label}: view diverged from oracle\n expected: {expected:?}\n actual: {actual:?}" - )); - } - return Ok(()); - }; - // A capped view holds some subset of the definition's result, never - // more than the cap, and never the same row twice. - if actual.len() > cap { - return Err(format!( - "{label}: view holds {} rows, over its cap of {cap}: {actual:?}", - actual.len() - )); - } - let mut unique = actual.clone(); - unique.dedup(); - if unique.len() != actual.len() { - return Err(format!("{label}: view holds a row twice: {actual:?}")); - } - if let Some(stray) = actual.iter().find(|row| !expected.contains(row)) { - return Err(format!( - "{label}: view holds {stray:?}, which the definition does not select: {expected:?}" - )); - } - // Below the cap the view must be complete, or a row was lost. - if actual.len() < cap.min(expected.len()) { - return Err(format!( - "{label}: view holds {} of {} selectable rows under a cap of {cap}: {actual:?}", - actual.len(), - expected.len() - )); - } - Ok(()) - } -} - -async fn read_rows(query: impl ExecutableQuery) -> Vec<(i32, i32)> { - let batches = query - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - batches - .iter() - .flat_map(|batch| { - let ids = batch["id"].as_any().downcast_ref::().unwrap(); - let scores = batch["score"] - .as_any() - .downcast_ref::() - .unwrap(); - // Scores are integer-valued by construction; compare exactly. - (0..batch.num_rows()) - .map(|i| (ids.value(i), scores.value(i) as i32)) - .collect::>() - }) - .collect() -} - -/// Drive one mutation sequence: refresh + oracle-check after every step, -/// then a forced rebuild checked against the same oracle. -async fn run_sequence(ops: &[SrcOp], shape: Shape) -> Result<(), String> { - let label = format!("{shape:?} {ops:?}"); - let mut case = Case::new(shape).await; - case.view - .refresh() - .execute() - .await - .map_err(|e| format!("{label}: initial refresh failed: {e}"))?; - case.check(&format!("{label} (initial)")).await?; - - for (step, op) in ops.iter().enumerate() { - case.apply(*op).await; - case.view - .refresh() - .execute() - .await - .map_err(|e| format!("{label}: refresh at step {step} failed: {e}"))?; - case.check(&format!("{label} (step {step}, {op:?})")) - .await?; - } - - case.view - .refresh() - .full(true) - .execute() - .await - .map_err(|e| format!("{label}: final full refresh failed: {e}"))?; - case.check(&format!("{label} (final rebuild)")).await?; - // Silence the unused-connection lint without dropping it mid-case. - let _ = &case.conn; - Ok(()) -} - -/// Every op sequence up to `max_len`. -fn all_sequences(max_len: u32) -> Vec> { - let mut sequences = Vec::new(); - for len in 1..=max_len { - for mut index in 0..ALL_OPS.len().pow(len) { - let mut ops = Vec::with_capacity(len as usize); - for _ in 0..len { - ops.push(ALL_OPS[index % ALL_OPS.len()]); - index /= ALL_OPS.len(); - } - sequences.push(ops); - } - } - sequences -} - -async fn run_exhaustive(max_len: u32) { - let mut cases = Vec::new(); - for shape in [Shape::Identity, Shape::Filtered, Shape::Limited] { - for ops in all_sequences(max_len) { - cases.push((ops, shape)); - } - } - let failures: Vec = futures::stream::iter(cases) - .map(|(ops, shape)| async move { run_sequence(&ops, shape).await.err() }) - .buffer_unordered(8) - .filter_map(|failure| async move { failure }) - .collect() - .await; - assert!( - failures.is_empty(), - "{} sequences diverged; first: {}", - failures.len(), - failures[0] - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn differential_exhaustive() { - run_exhaustive(3).await; -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore = "longer sweep; run manually"] -async fn differential_exhaustive_deep() { - run_exhaustive(4).await; -} - -/// Named interleavings that double as repro handles. The mode assertions pin -/// the classifier, which value comparison alone cannot: a wrongly rebuilt -/// view still matches the oracle. -#[tokio::test(flavor = "multi_thread")] -async fn differential_named_regressions() { - // An append is the one op that must stay incremental. - let mut case = Case::new(Shape::Identity).await; - case.view.refresh().execute().await.unwrap(); - case.apply(SrcOp::AppendNew).await; - let result = case.view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - case.check("append stays incremental").await.unwrap(); - - // A column the view does not read must not force a rebuild. - let mut case = Case::new(Shape::Identity).await; - case.view.refresh().execute().await.unwrap(); - case.apply(SrcOp::AddColumn).await; - let result = case.view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 0); - - // Compaction rearranges rows without changing them: the watermark - // advances and nothing rebuilds. - let mut case = Case::new(Shape::Identity).await; - case.view.refresh().execute().await.unwrap(); - case.apply(SrcOp::AppendNew).await; - case.view.refresh().execute().await.unwrap(); - case.apply(SrcOp::Compact).await; - let result = case.view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 0); - case.check("compaction alone").await.unwrap(); - - // Fragment bookkeeping stays coherent across the compaction: the next - // append is separable and computed alone. - case.apply(SrcOp::AppendNew).await; - let result = case.view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 3); - case.check("compact then append").await.unwrap(); - - // A row updated to no longer match the filter must leave the view -- - // and the fixture must prove the eviction happened, not merely that the - // end state matches: an update that never touched a view-resident row - // would also "match". - let mut case = Case::new(Shape::Filtered).await; - case.apply(SrcOp::AppendNew).await; - case.view.refresh().execute().await.unwrap(); - let before = case.view_rows().await.len(); - case.apply(SrcOp::UpdateOddScore).await; - case.view.refresh().execute().await.unwrap(); - let after = case.view_rows().await.len(); - assert!( - after < before, - "no view-resident row was evicted ({before} -> {after}); the fixture \ - no longer exercises the filtered-update transition" - ); - case.check("update crosses the filter").await.unwrap(); -} - -// --------------------------------------------------------------------------- -// Concurrency -// --------------------------------------------------------------------------- -// -// The sequential cases above cannot observe a cross-process race: the -// per-view refresh lock is process-local, so a second refresh in this -// process queues behind the first. What is missing is not more op -// sequences but a second process. These cases add one, and assert the same -// property the harness always asserts -- the view holds each row once. - -/// Rows the definition selects from the source: every id but the first, -/// read straight from the source, sharing nothing with the refresh path. -async fn concurrency_oracle(conn: &Connection) -> Vec { - let batches: Vec = conn - .open_table("src") - .execute() - .await - .unwrap() - .query() - .select(Select::columns(&["id"])) - .execute() - .await - .unwrap() - .try_collect() - .await - .unwrap(); - let mut ids = Vec::new(); - for batch in &batches { - let column = batch - .column(0) - .as_any() - .downcast_ref::() - .unwrap(); - for i in 0..batch.num_rows() { - if column.value(i) > 1 { - ids.push(column.value(i)); - } - } - } - ids.sort_unstable(); - ids -} - -/// The view's ids, sorted. -async fn concurrency_view_ids(conn: &Connection) -> Vec { - let batches: Vec = conn - .open_table("mv") - .execute() - .await - .unwrap() - .query() - .select(Select::columns(&["id"])) - .execute() - .await - .unwrap() - .try_collect() - .await - .unwrap(); - let mut ids = Vec::new(); - for batch in &batches { - let column = batch - .column(0) - .as_any() - .downcast_ref::() - .unwrap(); - for i in 0..batch.num_rows() { - ids.push(column.value(i)); - } - } - ids.sort_unstable(); - ids -} - -/// One refresh of the view at `MV_RACE_DIR`, in its own process. -/// -/// Setup happens before the start barrier so warm-up does not stagger the -/// two processes. What makes the race certain rather than likely is the -/// second barrier inside `refresh()` itself, which holds every participant -/// between staging and commit. -#[tokio::test] -#[ignore = "spawned as a child process by the concurrency cases"] -async fn cross_process_refresh_child() { - let Ok(dir) = std::env::var("MV_RACE_DIR") else { - return; - }; - let dir = std::path::PathBuf::from(dir); - let tag = std::env::var("MV_RACE_TAG").unwrap(); - - let conn = connect(dir.to_str().unwrap()).execute().await.unwrap(); - let table = conn.open_table("mv").execute().await.unwrap(); - let _ = table.schema().await.unwrap(); - let _ = table.count_rows(None).await.unwrap(); - let source = conn.open_table("src").execute().await.unwrap(); - let _ = source.count_rows(None).await.unwrap(); - let view = MaterializedView::from_table(table).await.unwrap(); - - std::fs::write(dir.join(format!("ready-{tag}")), b"1").unwrap(); - while !dir.join("START").exists() { - std::thread::sleep(std::time::Duration::from_millis(2)); - } - - let outcome = match view.refresh().execute().await { - Ok(result) => format!("committed rows={}", result.rows_written), - Err(err) if is_commit_conflict(&err) => "conflicted".to_string(), - Err(err) => format!("failed {err}"), - }; - std::fs::write(dir.join(format!("outcome-{tag}")), outcome).unwrap(); -} - -/// Whether a refresh lost its commit to a concurrent one, as opposed to -/// failing for any other reason. -fn is_commit_conflict(err: &crate::Error) -> bool { - let text = err.to_string(); - text.contains("Retryable commit conflict") || text.contains("preempted by concurrent") -} - -/// Two processes refreshing one view concurrently must leave the view -/// equal to the oracle: each selected row present exactly once. -/// -/// Both plan the same incremental delta from one watermark. A refresh is -/// meant to land on the generation it planned or leave nothing behind, so -/// at most one of them may write. -#[tokio::test] -async fn concurrent_refreshes_hold_each_row_once() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().to_str().unwrap().to_string(); - let conn = connect(&path).execute().await.unwrap(); - conn.create_table("src", rows_batch(&[1, 2, 3, 4])) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - let view = conn - .create_materialized_view("mv", "src") - .select([("id", "id"), ("score", "score")]) - .only_if("id > 1") - .execute() - .await - .unwrap(); - // Seed the watermark so the racing refreshes are both incremental. - view.refresh().execute().await.unwrap(); - - // Large enough that a refresh is real work rather than a formality. - let ids: Vec = (100..200_100).collect(); - conn.open_table("src") - .execute() - .await - .unwrap() - .add(rows_batch(&ids)) - .execute() - .await - .unwrap(); - - let tags = ["a", "b"]; - let exe = std::env::current_exe().unwrap(); - let children: Vec = tags - .iter() - .map(|tag| { - std::process::Command::new(&exe) - .args([ - "--exact", - "materialized_view::differential::cross_process_refresh_child", - "--ignored", - "--nocapture", - ]) - .env("MV_RACE_DIR", dir.path()) - .env("MV_RACE_SYNC", dir.path()) - .env("MV_RACE_PEERS", "2") - .env("MV_RACE_TAG", tag) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .unwrap() - }) - .collect(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(180); - while tags - .iter() - .any(|tag| !dir.path().join(format!("ready-{tag}")).exists()) - { - assert!( - std::time::Instant::now() < deadline, - "children never became ready" - ); - std::thread::sleep(std::time::Duration::from_millis(10)); - } - std::fs::write(dir.path().join("START"), b"1").unwrap(); - for (tag, mut child) in tags.iter().zip(children) { - let status = loop { - match child.try_wait().unwrap() { - Some(status) => break status, - None if std::time::Instant::now() >= deadline => { - child.kill().unwrap(); - panic!("child {tag} never finished"); - } - None => std::thread::sleep(std::time::Duration::from_millis(10)), - } - }; - assert!(status.success(), "child {tag} exited {status}"); - } - - // Both refreshes reached the commit boundary before either committed -- - // the in-refresh barrier guarantees it -- so exactly one may win. - let outcomes: Vec = tags - .iter() - .map(|tag| { - std::fs::read_to_string(dir.path().join(format!("outcome-{tag}"))) - .unwrap_or_else(|_| panic!("child {tag} recorded no outcome")) - }) - .collect(); - for tag in tags { - assert!( - dir.path().join(format!("planned-{tag}")).exists(), - "child {tag} never reached the commit boundary, so nothing was synchronized" - ); - } - let committed = outcomes.iter().filter(|o| o.contains("committed")).count(); - let conflicted = outcomes.iter().filter(|o| o.contains("conflicted")).count(); - assert_eq!( - (committed, conflicted), - (1, 1), - "exactly one refresh may win the generation both planned: {outcomes:?}" - ); - - let expected = concurrency_oracle(&conn).await; - let actual = concurrency_view_ids(&conn).await; - assert_eq!( - actual.len(), - expected.len(), - "the view holds {} rows, the oracle {}: a losing refresh left rows behind", - actual.len(), - expected.len() - ); - assert_eq!(actual, expected, "the view does not match the oracle"); -} diff --git a/rust/lancedb/src/materialized_view/duplicate_pairs.rs b/rust/lancedb/src/materialized_view/duplicate_pairs.rs deleted file mode 100644 index d0dc219ad..000000000 --- a/rust/lancedb/src/materialized_view/duplicate_pairs.rs +++ /dev/null @@ -1,478 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The LanceDB Authors - -//! Native pair generation as partitioned materialized-view refresh work. - -use std::sync::Arc; - -use arrow_schema::{Schema, SchemaRef}; -use datafusion_execution::{SendableRecordBatchStream, TaskContext}; -use datafusion_physical_plan::ExecutionPlan; -use datafusion_sql::sqlparser::ast::{ - Expr, FunctionArg, FunctionArgExpr, TableFunctionArgs, Value, -}; -use futures::TryStreamExt; -use lance::Dataset; -use lance::dataset::transaction::Operation; -use lance::dataset::{InsertBuilder, WriteDestination, WriteMode, WriteParams}; -use lance_table::format::Fragment; -use serde::{Deserialize, Serialize}; - -use super::grouped_units::GroupedRefreshPlan; -use super::refresh::{ - RefreshMaterializedViewResult, ensure_incarnation, ensure_no_mem_wal, open_source, -}; -use super::{MaterializedViewDefinition, Planned, ViewProjection}; -use crate::table::Table; -use crate::table::datafusion::udtf::duplicate_pairs::{ - DUPLICATE_PAIRS_OPERATOR_VERSION, DuplicatePairTask, DuplicatePairsConfig, DuplicatePairsExec, - duplicate_pair_schema, plan_duplicate_pairs, -}; -use crate::{Error, Result}; - -pub(super) const FUNCTION_NAME: &str = "vector_duplicate_pairs"; -pub(super) const DEDUP_FUNCTION_NAME: &str = "vector_dedup"; - -/// The result of a pinned native vector source. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum VectorSourceKind { - /// The qualifying pair relation. - Pairs, - /// Original source rows retained by greedy direct representatives. - Dedup, -} - -/// A native table-function source, pinned for the lifetime of the view. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct VectorSource { - pub kind: VectorSourceKind, - pub dataset_version: u64, - pub column: String, - /// Validated numeric SQL spelling; execution and task identity use f32 bits. - pub distance_threshold: String, -} - -impl VectorSource { - pub(super) fn function_name(&self) -> &'static str { - match self.kind { - VectorSourceKind::Pairs => FUNCTION_NAME, - VectorSourceKind::Dedup => DEDUP_FUNCTION_NAME, - } - } - pub(crate) fn config(&self) -> Result { - let threshold: f32 = self - .distance_threshold - .parse() - .map_err(|_| invalid("invalid pair threshold"))?; - if self.dataset_version == 0 || self.column.is_empty() || !threshold.is_finite() { - return Err(invalid( - "duplicate pairs require a positive snapshot, a column and a finite threshold", - )); - } - Ok(DuplicatePairsConfig { - dataset_version: self.dataset_version, - column: self.column.clone(), - distance_threshold: threshold, - }) - } -} - -fn invalid(message: impl Into) -> Error { - Error::InvalidInput { - message: message.into(), - } -} - -pub(super) fn source( - args: &TableFunctionArgs, - kind: VectorSourceKind, -) -> Result<(Vec, String, VectorSource)> { - if args.settings.is_some() { - return Err(invalid("duplicate pairs do not accept function settings")); - } - let exprs = args - .args - .iter() - .map(|arg| match arg { - FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) => Ok(expr), - _ => Err(invalid( - "duplicate pairs require four positional literal arguments", - )), - }) - .collect::>>()?; - let [table, version, column, threshold] = exprs.as_slice() else { - return Err(invalid( - "native vector sources require (table, dataset_version, column, distance_threshold)", - )); - }; - let string = |expr: &Expr| match expr { - Expr::Value(value) => match &value.value { - Value::SingleQuotedString(value) => Ok(value.clone()), - _ => Err(invalid("table and column must be string literals")), - }, - _ => Err(invalid("table and column must be string literals")), - }; - let table = string(table)?; - let plain = MaterializedViewDefinition::from_sql(&format!("SELECT * FROM {table}"))?; - if plain.vector_source.is_some() - || plain.lateral.is_some() - || plain.filter.is_some() - || plain.is_grouped() - || plain.limit.is_some() - { - return Err(invalid("duplicate pairs must name one source table")); - } - let dataset_version = match version { - Expr::Value(value) => match &value.value { - Value::Number(number, _) => number - .parse() - .map_err(|_| invalid("invalid source version"))?, - _ => return Err(invalid("source version must be an integer literal")), - }, - _ => return Err(invalid("source version must be an integer literal")), - }; - let config = VectorSource { - kind, - dataset_version, - column: string(column)?, - distance_threshold: threshold.to_string(), - }; - config.config()?; - Ok((plain.source_namespace, plain.source_table, config)) -} - -pub(super) fn check_shape(definition: &MaterializedViewDefinition) -> Result<()> { - if definition - .vector_source - .as_ref() - .is_some_and(|s| s.kind == VectorSourceKind::Dedup) - { - if !definition.selects_star() - || definition.filter.is_some() - || definition.limit.is_some() - || definition.lateral.is_some() - || definition.is_grouped() - { - return Err(invalid( - "a vector_dedup view must select * without other clauses", - )); - } - return Ok(()); - } - let schema = duplicate_pair_schema(); - let projections = schema - .fields() - .iter() - .map(|field| ViewProjection { - output: field.name().clone(), - expression: field.name().clone(), - }) - .collect::>(); - if (!definition.selects_star() && definition.projections != projections) - || definition.filter.is_some() - || definition.limit.is_some() - || definition.lateral.is_some() - || definition.is_grouped() - { - return Err(invalid( - "a native pair view selects *, or row_id_a, row_id_b, distance, without other clauses", - )); - } - Ok(()) -} - -pub(super) fn plan( - source_schema: SchemaRef, - definition: &MaterializedViewDefinition, -) -> Result { - check_shape(definition)?; - let config = definition.vector_source.as_ref().expect("native source"); - config.config()?; - source_schema.field_with_name(&config.column)?; - if config.kind == VectorSourceKind::Dedup { - return Ok(Planned { - definition: definition.clone(), - fields: source_schema - .fields() - .iter() - .map(|f| super::without_declarations(f)) - .collect(), - lineage: source_schema - .fields() - .iter() - .map(|f| (f.name().clone(), vec![f.name().clone()])) - .collect(), - inputs: source_schema - .fields() - .iter() - .map(|f| f.name().clone()) - .collect(), - }); - } - let schema = duplicate_pair_schema(); - let projections = schema - .fields() - .iter() - .map(|field| ViewProjection { - output: field.name().clone(), - expression: field.name().clone(), - }) - .collect(); - let mut definition = definition.clone(); - let inputs = vec![config.column.clone()]; - definition.projections = projections; - Ok(Planned { - definition, - fields: schema.fields().iter().map(|f| f.as_ref().clone()).collect(), - lineage: Default::default(), - inputs, - }) -} - -/// A saved native refresh plan, including every physical partition identity. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DuplicatePairsRefreshPlan { - pub snapshot: GroupedRefreshPlan, - pub source_uri: String, - pub definition_sql: String, - pub operator_version: u32, - pub threshold_bits: u32, - pub tasks: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WrittenPairsPartition { - pub(super) unit: u32, - pub(super) plan_id: String, - pub(super) task: DuplicatePairTask, - pub(super) fragments: Vec, - pub(super) rows: u64, -} - -impl DuplicatePairsRefreshPlan { - pub(super) fn identity(&self) -> Result { - use sha2::{Digest, Sha256}; - let encoded = serde_json::to_vec(&( - &self.snapshot, - &self.source_uri, - &self.definition_sql, - self.operator_version, - self.threshold_bits, - )) - .map_err(|e| invalid(e.to_string()))?; - Ok(format!("{:x}", Sha256::digest(encoded))) - } -} - -async fn open(view: &Table) -> Result<(Dataset, MaterializedViewDefinition, Arc)> { - let native = view - .as_native() - .ok_or_else(|| invalid("native pair refresh requires a local view"))?; - native.dataset.ensure_mutable()?; - native.dataset.reload().await?; - let view_ds = native.dataset.get().await?.as_ref().clone(); - let Some(super::StoredDefinition::Query(definition)) = - super::read_definition(&view_ds.schema().metadata)? - else { - return Err(invalid("view does not carry a supported definition")); - }; - let config = definition - .vector_source - .as_ref() - .ok_or_else(|| invalid("not a native pair view"))? - .config()?; - let source = Arc::new( - open_source(view, &definition, None) - .await? - .checkout_version(config.dataset_version) - .await?, - ); - ensure_no_mem_wal(&source, "source table", &definition.source_table).await?; - let planned = plan(Arc::new(Schema::from(source.schema())), &definition)?; - let expected = Schema::new(planned.fields); - let physical = Schema::from(view_ds.schema()); - if physical.fields().len() != expected.fields().len() - || physical - .fields() - .iter() - .zip(expected.fields()) - .any(|(a, b)| { - a.name() != b.name() - || a.data_type() != b.data_type() - || a.is_nullable() != b.is_nullable() - }) - { - return Err(invalid("native pair view schema changed since creation")); - } - Ok((view_ds, planned.definition, source)) -} - -pub(super) async fn plan_refresh( - view: &Table, - pinned: Option, -) -> Result { - let (view_ds, definition, source) = open(view).await?; - let config = definition - .vector_source - .as_ref() - .expect("native") - .config()?; - if pinned.is_some_and(|v| v != config.dataset_version) { - return Err(invalid( - "refresh source version differs from the native pair view's fixed snapshot", - )); - } - let tasks = plan_duplicate_pairs(source.clone(), &config).await?; - let units = u32::try_from(tasks.len()).map_err(|_| invalid("too many index partitions"))?; - let incarnation = view_ds - .schema() - .metadata - .get(super::INCARNATION_META_KEY) - .cloned() - .ok_or_else(|| invalid("native pair view has no incarnation"))?; - Ok(DuplicatePairsRefreshPlan { - snapshot: GroupedRefreshPlan { - units, - source_version: config.dataset_version, - view_version: view_ds.version().version, - incarnation, - }, - source_uri: source.uri().to_string(), - definition_sql: definition.to_sql(), - operator_version: DUPLICATE_PAIRS_OPERATOR_VERSION, - threshold_bits: config.distance_threshold.to_bits(), - tasks, - }) -} - -pub(super) async fn open_planned( - view: &Table, - plan: &DuplicatePairsRefreshPlan, -) -> Result<(Dataset, MaterializedViewDefinition, Arc)> { - let (view_ds, definition, source) = open(view).await?; - ensure_incarnation(&view_ds, Some(&plan.snapshot.incarnation), view.name()).await?; - let config = definition - .vector_source - .as_ref() - .expect("native") - .config()?; - if source.uri() != plan.source_uri - || source.version().version != plan.snapshot.source_version - || definition.to_sql() != plan.definition_sql - || config.distance_threshold.to_bits() != plan.threshold_bits - || plan.operator_version != DUPLICATE_PAIRS_OPERATOR_VERSION - || plan.tasks.len() != plan.snapshot.units as usize - { - return Err(invalid( - "native pair task belongs to a different snapshot, configuration or operator", - )); - } - Ok((view_ds, definition, source)) -} - -pub(super) async fn write_unit( - view: &Table, - unit: u32, - plan: &DuplicatePairsRefreshPlan, -) -> Result { - let (view_ds, definition, source) = open_planned(view, plan).await?; - let task = plan - .tasks - .get(unit as usize) - .ok_or_else(|| invalid("pair partition is outside the plan"))? - .clone(); - let config = definition - .vector_source - .as_ref() - .expect("native") - .config()?; - let exec = DuplicatePairsExec::try_new(source, config, vec![task.clone()])?; - let stream = exec.execute(0, Arc::new(TaskContext::default()))?; - let rows = Arc::new(std::sync::atomic::AtomicU64::new(0)); - let count = rows.clone(); - let stream = stream.inspect_ok(move |batch| { - count.fetch_add( - batch.num_rows() as u64, - std::sync::atomic::Ordering::Relaxed, - ); - }); - let stream: SendableRecordBatchStream = Box::pin( - datafusion_physical_plan::stream::RecordBatchStreamAdapter::new( - duplicate_pair_schema(), - stream, - ), - ); - let txn = InsertBuilder::new(WriteDestination::Dataset(Arc::new(view_ds))) - .with_params(&WriteParams { - mode: WriteMode::Append, - ..Default::default() - }) - .execute_uncommitted_stream(stream) - .await?; - let Operation::Append { fragments } = txn.operation else { - return Err(invalid( - "native pair partition did not stage append fragments", - )); - }; - Ok(WrittenPairsPartition { - unit, - plan_id: plan.identity()?, - task, - fragments, - rows: rows.load(std::sync::atomic::Ordering::Relaxed), - }) -} - -pub(super) async fn commit( - view: &Table, - plan: &DuplicatePairsRefreshPlan, - units: Vec, - expected: Option<&str>, -) -> Result { - let (_, definition, source) = open_planned(view, plan).await?; - let config = definition - .vector_source - .as_ref() - .expect("native") - .config()?; - if plan_duplicate_pairs(source.clone(), &config).await? != plan.tasks { - return Err(invalid( - "pair plan does not cover the pinned index partitions exactly", - )); - } - let identity = plan.identity()?; - let mut ordered = vec![None; plan.snapshot.units as usize]; - for written in units { - if written.plan_id != identity - || plan.tasks.get(written.unit as usize) != Some(&written.task) - { - return Err(invalid("pair partition receipt belongs to another plan")); - } - let slot = ordered - .get_mut(written.unit as usize) - .ok_or_else(|| invalid("unknown pair partition receipt"))?; - if slot.is_some() { - return Err(invalid("duplicate pair partition receipt")); - } - *slot = Some(written); - } - let units = ordered - .into_iter() - .collect::>>() - .ok_or_else(|| invalid("missing pair partition receipt"))?; - let rows = units.iter().map(|u| u.rows).sum(); - let fragments = units.into_iter().flat_map(|u| u.fragments).collect(); - super::grouped_units::commit_fragments( - view, - &plan.snapshot, - &definition, - source.manifest.timestamp_nanos, - fragments, - rows, - expected, - ) - .await -} diff --git a/rust/lancedb/src/materialized_view/grouped.rs b/rust/lancedb/src/materialized_view/grouped.rs index 095f20f11..3120e2b43 100644 --- a/rust/lancedb/src/materialized_view/grouped.rs +++ b/rust/lancedb/src/materialized_view/grouped.rs @@ -12,7 +12,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use arrow_array::cast::AsArray; use arrow_array::{Array, FixedSizeListArray, UInt32Array}; use arrow_buffer::NullBuffer; -use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema, SchemaRef}; +use arrow_schema::{DataType, Schema as ArrowSchema, SchemaRef}; use datafusion::catalog::default_table_source::provider_as_source; use datafusion::datasource::empty::EmptyTable; use datafusion::execution::FunctionRegistry; @@ -36,20 +36,16 @@ use datafusion_sql::sqlparser::parser::Parser; use futures::StreamExt; use lance::Dataset; use lance::index::{DatasetIndexExt, DatasetIndexInternalExt}; -use lance_core::ROW_ID; use lance_datafusion::exec::SessionContextExt; use lance_index::metrics::NoOpMetricsCollector; -use lance_index::vector::VectorIndex; use lance_index::vector::ivf::{IvfTransformer, new_ivf_transformer}; use lance_linalg::distance::DistanceType; use lance_linalg::kernels::normalize_fsl; -use roaring::RoaringBitmap; use uuid::Uuid; use super::refresh::to_view_batch; use super::{ - MaterializedViewDefinition, Planned, SOURCE_ROW_ID_COLUMN, ViewProjection, ensure_immutable, - plan_filter, query, without_declarations, + MaterializedViewDefinition, Planned, ViewProjection, plan_filter, query, without_declarations, }; use crate::{Error, Result}; @@ -68,50 +64,6 @@ pub(super) struct IvfBinding { pub(super) transformer: Arc, /// A cosine index assigns L2 over unit vectors, as lance's index path does. pub(super) normalize: bool, - /// Partitions of the model; the ids `ivf_partition` returns are below it. - pub(super) partitions: u32, -} - -/// The segments of the one IVF index on a column, opened, and the fragments -/// they cover between them; every other fragment's rows are unindexed. -/// `covered` is `None` when a segment does not say which fragments it holds -/// (`fragment_bitmap: None`, which lance defines as unknown rather than -/// empty): its rows would be read once by the partition reader and again as -/// unindexed, so no caller may split this index. -pub(super) struct IvfSegments { - pub(super) segments: Vec, - pub(super) covered: Option, -} - -/// One opened segment and the fragments it holds rows for. Its postings -/// still name rows of fragments it has since lost -- a column rewrite -/// attaches a new file and takes the fragment out of this bitmap -- so a -/// reader of its partitions keeps only the rows it still owns. -pub(super) struct IvfSegment { - pub(super) index: Arc, - pub(super) fragments: Option, -} - -impl IvfSegments { - pub(super) fn is_unindexed(&self, fragment_id: u64) -> Option { - Some(!self.covered.as_ref()?.contains(fragment_id as u32)) - } -} - -/// Coverage after folding in one more segment. Lance defines a segment -/// without a fragment bitmap as unknown coverage, not empty, and one such -/// segment is enough to lose the coverage of the whole index. -fn coverage( - covered: Option, - bitmap: Option<&RoaringBitmap>, -) -> Option { - match (covered, bitmap) { - (Some(mut covered), Some(bitmap)) => { - covered |= bitmap; - Some(covered) - } - _ => None, - } } /// `bindings` maps a source column to its index; planning runs unbound. @@ -278,16 +230,6 @@ pub(super) async fn bind( } async fn bind_column(source: &Dataset, column: &str) -> Result { - bind_column_with_segments(source, column) - .await - .map(|(binding, _)| binding) -} - -/// The binding plus the opened segments it was read from. -pub(super) async fn bind_column_with_segments( - source: &Dataset, - column: &str, -) -> Result<(IvfBinding, IvfSegments)> { let field = source .schema() .field(column) @@ -298,8 +240,6 @@ pub(super) async fn bind_column_with_segments( // on its own fragments carries its own centroids, and grouping every row // by one segment's model would bucket the other segments' rows wrongly. let mut found: Option<(String, Uuid, DistanceType, FixedSizeListArray)> = None; - let mut segments = Vec::new(); - let mut covered = Some(RoaringBitmap::new()); for index in source.load_indices().await?.iter() { if index.fields != [field.id] { continue; @@ -314,12 +254,6 @@ pub(super) async fn bind_column_with_segments( continue; }; let metric = vector.metric_type(); - covered = coverage(covered, index.fragment_bitmap.as_ref()); - let fragments = index.fragment_bitmap.clone(); - segments.push(IvfSegment { - index: vector.clone(), - fragments, - }); match &found { None => found = Some((index.name.clone(), index.uuid, metric, centroids)), Some((name, _, seen_metric, seen)) if *name == index.name => { @@ -369,32 +303,20 @@ pub(super) async fn bind_column_with_segments( // Lance's index path assigns cosine by L2 over unit vectors. let normalize = metric == DistanceType::Cosine; let distance = if normalize { DistanceType::L2 } else { metric }; - let partitions = u32::try_from(centroids.len()).map_err(|_| Error::InvalidInput { - message: format!("{IVF_PARTITION}({column}): too many partitions"), - })?; - Ok(( - IvfBinding { - index: uuid, - transformer: Arc::new(new_ivf_transformer(centroids, distance, vec![])), - normalize, - partitions, - }, - IvfSegments { segments, covered }, - )) + Ok(IvfBinding { + index: uuid, + transformer: Arc::new(new_ivf_transformer(centroids, distance, vec![])), + normalize, + }) } pub(super) fn empty_source(source_schema: &SchemaRef) -> Arc { - let mut fields = source_schema.fields().to_vec(); - fields.push(Arc::new(ArrowField::new(ROW_ID, DataType::UInt64, false))); - provider_as_source(Arc::new(EmptyTable::new(Arc::new(ArrowSchema::new( - fields, - ))))) + provider_as_source(Arc::new(EmptyTable::new(source_schema.clone()))) } -/// The query DataFusion runs: the view's projections plus the group's -/// smallest source row id, which stands as the row's provenance. `filtered` -/// puts the view's predicate in the query, for a caller whose rows did not -/// come from a lance scan that already applied it. +/// The query DataFusion runs. `filtered` puts the view's predicate in the +/// query for a caller whose rows did not come from a Lance scan that already +/// applied it. fn sql(definition: &MaterializedViewDefinition, filtered: bool) -> String { let items: Vec = definition .projections @@ -406,7 +328,7 @@ fn sql(definition: &MaterializedViewDefinition, filtered: bool) -> String { _ => String::new(), }; format!( - "SELECT {}, min({ROW_ID}) AS {ROW_ID} FROM {SOURCE}{predicate} GROUP BY {}", + "SELECT {} FROM {SOURCE}{predicate} GROUP BY {}", items.join(", "), definition.group_by.join(", ") ) @@ -499,11 +421,6 @@ pub(super) fn plan( }; let mut projections: Vec = Vec::with_capacity(definition.projections.len()); for p in &definition.projections { - if p.output == SOURCE_ROW_ID_COLUMN || p.output == ROW_ID { - return Err(Error::InvalidInput { - message: format!("view column name '{}' is reserved", p.output), - }); - } if projections.iter().any(|seen| seen.output == p.output) { return Err(Error::ColumnAlreadyExists { name: p.output.clone(), @@ -549,14 +466,11 @@ pub(super) fn plan( message: format!("invalid grouped view: {e}"), })?; for expr in &exprs { - ensure_immutable(expr, |message| Error::InvalidInput { - message: format!("invalid grouped view: {message}"), - })?; inputs.extend( expr.column_refs() .into_iter() .map(|c| c.name.clone()) - .filter(|name| name != ROW_ID && source_schema.field_with_name(name).is_ok()), + .filter(|name| source_schema.field_with_name(name).is_ok()), ); } inputs.sort(); @@ -566,7 +480,6 @@ pub(super) fn plan( .schema() .fields() .iter() - .filter(|f| f.name() != ROW_ID) .map(|f| without_declarations(f)) .collect(); Ok(Planned { @@ -586,7 +499,6 @@ pub(super) async fn stream( rows_written: Arc, ) -> Result { let mut scanner = source.scan(); - scanner.with_row_id(); if let Some(filter) = &definition.filter { scanner.filter(filter)?; } @@ -611,24 +523,3 @@ pub(super) async fn stream( }); Ok(Box::pin(RecordBatchStreamAdapter::new(schema, mapped))) } - -#[cfg(test)] -mod tests { - use super::*; - - /// A segment that does not name the fragments it holds leaves the whole - /// index's coverage unknown: reading "everything it does not cover" would - /// read its rows a second time. - #[test] - fn one_segment_without_a_bitmap_loses_the_coverage() { - let first = RoaringBitmap::from_iter([0u32, 1]); - let second = RoaringBitmap::from_iter([2u32]); - let both = coverage( - coverage(Some(RoaringBitmap::new()), Some(&first)), - Some(&second), - ); - assert_eq!(both, Some(RoaringBitmap::from_iter([0u32, 1, 2]))); - assert_eq!(coverage(both, None), None); - assert_eq!(coverage(None, Some(&first)), None); - } -} diff --git a/rust/lancedb/src/materialized_view/grouped_units.rs b/rust/lancedb/src/materialized_view/grouped_units.rs deleted file mode 100644 index 7f408be4d..000000000 --- a/rust/lancedb/src/materialized_view/grouped_units.rs +++ /dev/null @@ -1,1368 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The LanceDB Authors - -//! A grouped view keyed by one `ivf_partition(col)` refreshed in units, one -//! per index partition: the index already holds each partition's row ids, so -//! a unit reads only its rows, computes only its groups and writes its own -//! fragments, and a single commit replaces the view. Nothing is shuffled and -//! no worker holds more than one partition's groups. - -use std::collections::HashMap; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; - -use arrow_array::Array; -use arrow_array::RecordBatch; -use arrow_array::cast::AsArray; -use arrow_array::types::UInt64Type; -use arrow_schema::{Schema as ArrowSchema, SchemaRef}; -use datafusion::catalog::default_table_source::provider_as_source; -use datafusion::physical_plan::SendableRecordBatchStream; -use datafusion::physical_plan::stream::RecordBatchStreamAdapter; -use datafusion::prelude::SessionContext; -use futures::{StreamExt, TryStreamExt}; -use lance::Dataset; -use lance::dataset::transaction::{Operation, Transaction}; -use lance::dataset::{ - CommitBuilder, InsertBuilder, ProjectionRequest, WriteDestination, WriteMode, WriteParams, -}; -use lance_core::ROW_ID; -use lance_core::utils::address::RowAddress; -use lance_datafusion::exec::SessionContextExt; -use lance_index::metrics::NoOpMetricsCollector; -use lance_table::format::Fragment; -use serde::{Deserialize, Serialize}; - -use super::grouped::{self, IVF_PARTITION}; -use super::refresh::{ - RefreshMaterializedViewResult, RefreshMode, ensure_incarnation, ensure_no_mem_wal, open_source, - refresh_lock, stamp_watermark, to_view_batch, -}; -use super::{MaterializedViewDefinition, StoredDefinition}; -use crate::table::Table; -use crate::{Error, Result}; - -/// Row ids per `take`: bounds one unit's memory while it reads a partition. -const TAKE_CHUNK: usize = 8192; - -/// Rows per partition, and in total, the plan reads to see whether the index -/// assigns by its own centroids. A sample: [`write_grouped_unit`] proves the -/// rows it takes, and this only keeps a doomed refresh off the fleet. -const PROBE_PER_UNIT: usize = 8; -const PROBE_ROWS: usize = 256; - -/// How a grouped view can be refreshed in units. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct GroupedRefreshPlan { - /// Units `0..units`: unit `p` before the last holds index partition - /// `p`'s groups; the last holds the rows the index cannot place (null or - /// unplaceable vectors), grouped under a NULL key. One per index - /// partition, plus that one. - pub units: u32, - /// The source version every unit reads and the commit records. - pub source_version: u64, - /// The view version the plan was made against; the commit lands on it - /// or not at all. - pub view_version: u64, - /// The view this plan belongs to: its incarnation, which a drop and - /// recreate replaces. Two views of the same shape reach the same - /// counters, so without it a result written for one could be published - /// into the other. - pub incarnation: String, -} - -/// The column a grouped view's single `ivf_partition(column)` key names, if -/// that is its whole `GROUP BY`. -fn ivf_key_column(definition: &MaterializedViewDefinition) -> Option { - let [key] = definition.group_by.as_slice() else { - return None; - }; - let inner = key - .strip_prefix(&format!("{IVF_PARTITION}("))? - .strip_suffix(')')?; - let column = inner.trim().trim_matches('`'); - (!column.is_empty() && !column.contains(',')).then(|| column.to_string()) -} - -/// What a grouped refresh of `view` reads: its definition, source and binding. -struct Grouped { - view_ds: Dataset, - definition: MaterializedViewDefinition, - source: Dataset, - column: String, - inputs: Vec, - binding: grouped::IvfBinding, - segments: grouped::IvfSegments, -} - -async fn open_grouped(view: &Table, pinned: Option) -> Result> { - let view_native = view.as_native().ok_or_else(|| Error::NotSupported { - message: "materialized views are supported only on local tables".into(), - })?; - view_native.dataset.reload().await?; - let view_ds = view_native.dataset.get().await?.as_ref().clone(); - let Some(StoredDefinition::Query(definition)) = - super::read_definition(&view_ds.schema().metadata)? - else { - return Ok(None); - }; - let Some(column) = ivf_key_column(&definition) else { - return Ok(None); - }; - let staging = super::read_staging(&view_ds.schema().metadata)?; - let source = open_source(view, &definition, staging.as_ref()).await?; - let source = match pinned { - Some(version) => source.checkout_version(version).await?, - None => source, - }; - ensure_no_mem_wal(&source, "source table", &definition.source_table).await?; - let source_schema = Arc::new(ArrowSchema::from(source.schema())); - let planned = super::plan(source_schema, &definition, staging.as_ref())?; - let (binding, segments) = grouped::bind_column_with_segments(&source, &column).await?; - Ok(Some(Grouped { - view_ds, - definition: planned.definition, - source, - column, - inputs: planned.inputs, - binding, - segments, - })) -} - -/// The units `view` can be refreshed in against `source_version` (the -/// latest when `None`), or `None` when it is not a grouped view keyed by one -/// `ivf_partition(column)`. -pub async fn plan_grouped_refresh( - view: &Table, - source_version: Option, -) -> Result> { - let Some(grouped) = open_grouped(view, source_version).await? else { - return Ok(None); - }; - // The split reads indexed rows through the index and the rest by - // scanning what it does not cover, so an index that does not say what it - // covers cannot be split at all. The caller refreshes single-pass. - if grouped.segments.covered.is_none() { - return Ok(None); - } - // Without one there is nothing to bind a unit's result to, so the view - // is refreshed in one pass, where the rows never leave the process. - let Some(incarnation) = grouped - .view_ds - .schema() - .metadata - .get(super::INCARNATION_META_KEY) - .cloned() - else { - return Ok(None); - }; - // The split is the index's posting lists and the key is recomputed, so a - // view whose index assigns by something else has no units at all. - if !assigns_by_its_centroids(&grouped).await? { - return Ok(None); - } - Ok(Some(GroupedRefreshPlan { - units: grouped.binding.partitions + 1, - source_version: grouped.source.version().version, - view_version: grouped.view_ds.version().version, - incarnation, - })) -} - -/// Fragments one unit wrote, uncommitted, for [`commit_grouped_refresh`]. -/// Only [`write_grouped_unit`] makes one: it names the unit and the plan, -/// and the commit publishes a set of them as the view's whole contents, so -/// a result belonging to another plan, another view, or to a unit already -/// accounted for has to be refusable. Travels to whoever commits, so it -/// serializes, but its fields are not a caller's to write. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WrittenUnit { - unit: u32, - plan: GroupedRefreshPlan, - fragments: Vec, - rows: u64, -} - -impl WrittenUnit { - /// The unit of the plan this result holds. - pub fn unit(&self) -> u32 { - self.unit - } - - /// The plan it was computed for. - pub fn plan(&self) -> &GroupedRefreshPlan { - &self.plan - } - - /// View rows this unit wrote, its groups. - pub fn rows(&self) -> u64 { - self.rows - } - - /// The fragments it staged, uncommitted until the whole set is published. - pub fn fragments(&self) -> &[Fragment] { - &self.fragments - } -} - -/// The grouped view `plan` was made for, opened at the source version it -/// pins. Every holder of a plan asks the same two questions: is this a -/// grouped view, and is it the view the plan names. -/// -/// The pinned version is also what holds every unit to one index: indices -/// are part of the source manifest, so a rebuild lands in a later version -/// that a unit reading this one never sees, and units written on either -/// side of it still group by the same centroids. -async fn open_planned(view: &Table, plan: &GroupedRefreshPlan) -> Result { - let grouped = open_grouped(view, Some(plan.source_version)) - .await? - .ok_or_else(|| Error::InvalidInput { - message: format!( - "'{}' is not a grouped view keyed by {IVF_PARTITION}", - view.name() - ), - })?; - ensure_incarnation(&grouped.view_ds, Some(&plan.incarnation), view.name()).await?; - Ok(grouped) -} - -/// Compute unit `unit` of `plan` against source version `source_version` -/// and write its groups as uncommitted fragments of the view. -pub async fn write_grouped_unit( - view: &Table, - unit: u32, - plan: &GroupedRefreshPlan, -) -> Result { - let grouped = open_planned(view, plan).await?; - if unit >= plan.units { - return Err(Error::InvalidInput { - message: format!( - "unit {unit} is not in the plan of '{}' ({} units)", - view.name(), - plan.units - ), - }); - } - let schema: SchemaRef = Arc::new(ArrowSchema::from(grouped.view_ds.schema())); - let rows_written = Arc::new(AtomicU64::new(0)); - let rows = unit_rows(&grouped, unit).await?; - let stream = group_rows(&grouped, rows, schema.clone(), rows_written.clone()).await?; - let write_txn = InsertBuilder::new(WriteDestination::Dataset(Arc::new(grouped.view_ds))) - .with_params(&WriteParams { - mode: WriteMode::Append, - ..Default::default() - }) - .execute_uncommitted_stream(stream) - .await?; - let Operation::Append { fragments } = write_txn.operation else { - return Err(Error::Runtime { - message: "expected an append when writing a grouped unit".into(), - }); - }; - Ok(WrittenUnit { - unit, - plan: plan.clone(), - fragments, - rows: rows_written.load(Ordering::Relaxed), - }) -} - -/// The row ids the index places in partition `unit`, at most `limit` of them. -/// A segment's postings outlive its ownership -- a column rewrite attaches a -/// new file and takes the fragment out of its bitmap while leaving the rows -/// listed -- so each segment's rows are kept only while it still holds their -/// fragment. The fragment [`unit_rows`] scans them from instead is the one -/// the rewrite left them in. -async fn indexed_row_ids(grouped: &Grouped, unit: u32, limit: usize) -> Result> { - let addresses = lance::dataset::rowids::get_row_id_index(&grouped.source).await?; - let fragment_of = |row_id: u64| -> Result> { - Ok(match &addresses { - Some(index) => index.get(row_id)?.map(|address| address.fragment_id()), - None => Some(RowAddress::new_from_u64(row_id).fragment_id()), - }) - }; - let mut row_ids = Vec::new(); - for segment in &grouped.segments.segments { - let owned = segment.fragments.as_ref().ok_or_else(|| Error::Runtime { - message: "the index stopped naming the fragments it covers mid-refresh".into(), - })?; - let mut stream = segment - .index - .partition_reader(unit as usize, false, &NoOpMetricsCollector) - .await?; - while let Some(batch) = stream.try_next().await? { - for row_id in batch[ROW_ID].as_primitive::().values() { - if row_ids.len() >= limit { - return Ok(row_ids); - } - if fragment_of(*row_id)?.is_some_and(|fragment| owned.contains(fragment)) { - row_ids.push(*row_id); - } - } - } - } - Ok(row_ids) -} - -/// The first row of `batch` that `unit` holds but `ivf_partition` does not -/// put there, if there is one. `batch` carries the key column. -fn assigned_elsewhere(grouped: &Grouped, batch: &RecordBatch, unit: u32) -> Result> { - let vectors = batch[grouped.column.as_str()].as_fixed_size_list(); - let assigned = grouped.binding.assign(vectors)?; - Ok((0..batch.num_rows()).find(|&row| !(assigned.is_valid(row) && assigned.value(row) == unit))) -} - -/// Whether the index's posting lists are `ivf_partition`'s buckets, over a -/// sample of each partition. Lance also builds an index from precomputed -/// assignments, and records nowhere that it did, so the only way to ask is -/// to recompute some of them. -async fn assigns_by_its_centroids(grouped: &Grouped) -> Result { - let mut expected = HashMap::new(); - for unit in 0..grouped.binding.partitions { - if expected.len() >= PROBE_ROWS { - break; - } - for row_id in indexed_row_ids(grouped, unit, PROBE_PER_UNIT).await? { - expected.insert(row_id, unit); - } - } - if expected.is_empty() { - return Ok(true); - } - let sample: Vec = expected.keys().copied().collect(); - let columns = [ROW_ID, grouped.column.as_str()]; - let projection = ProjectionRequest::from_columns(columns, grouped.source.schema()); - let batch = grouped.source.take_rows(&sample, projection).await?; - let row_ids = batch[ROW_ID].as_primitive::().clone(); - let assigned = grouped - .binding - .assign(batch[grouped.column.as_str()].as_fixed_size_list())?; - Ok((0..batch.num_rows()).all(|row| { - assigned.is_valid(row) && Some(&assigned.value(row)) == expected.get(&row_ids.value(row)) - })) -} - -/// The source rows of one unit: the index's rows for partition `unit` plus -/// the unindexed rows that assign to it, or for the last unit every row the -/// assigner cannot place. Each batch carries the inputs and `_rowid`. -async fn unit_rows(grouped: &Grouped, unit: u32) -> Result> { - let mut columns: Vec<&str> = vec![ROW_ID]; - columns.extend(grouped.inputs.iter().map(String::as_str)); - if !grouped.inputs.contains(&grouped.column) { - columns.push(&grouped.column); - } - let source = Arc::new(grouped.source.clone()); - let mut batches = Vec::new(); - let unassigned = unit == grouped.binding.partitions; - - if !unassigned { - // Rows the index placed in this partition; a deleted row is dropped - // by the take. - let row_ids = indexed_row_ids(grouped, unit, usize::MAX).await?; - let projection = ProjectionRequest::from_columns(&columns, source.schema()); - for chunk in row_ids.chunks(TAKE_CHUNK) { - let batch = source.take_rows(chunk, projection.clone()).await?; - if batch.num_rows() > 0 { - // What the plan sampled, this unit proves: a row grouped - // here that belongs to another key would be aggregated in - // both units and published as two rows of one group. - if let Some(row) = assigned_elsewhere(grouped, &batch, unit)? { - let row_id = batch[ROW_ID].as_primitive::().value(row); - return Err(Error::NotSupported { - message: format!( - "{IVF_PARTITION}({}): the index lists row {row_id} under \ - partition {unit}, which it does not assign to; this view \ - is refreshed in one pass", - grouped.column - ), - }); - } - batches.push(in_column_order(&batch, &columns)?); - } - } - } - - // Rows the index has not seen assign here; the last unit also takes the - // rows no assignment places, from every fragment. - let mut fragments = Vec::new(); - for fragment in source.get_fragments() { - let unindexed = grouped - .segments - .is_unindexed(fragment.id() as u64) - .ok_or_else(|| Error::Runtime { - message: "the index stopped naming the fragments it covers mid-refresh".into(), - })?; - if unassigned || unindexed { - fragments.push(fragment.metadata().clone()); - } - } - if !fragments.is_empty() { - let mut scanner = source.scan(); - scanner.with_fragments(fragments); - scanner.with_row_id(); - scanner.project(&columns)?; - let mut stream = scanner.try_into_stream().await?; - while let Some(batch) = stream.try_next().await? { - let vectors = batch[grouped.column.as_str()].as_fixed_size_list(); - let assigned = grouped.binding.assign(vectors)?; - let keep: arrow_array::BooleanArray = (0..batch.num_rows()) - .map(|row| { - Some(if unassigned { - assigned.is_null(row) - } else { - assigned.is_valid(row) && assigned.value(row) == unit - }) - }) - .collect(); - let kept = arrow_select::filter::filter_record_batch(&batch, &keep)?; - if kept.num_rows() > 0 { - batches.push(in_column_order(&kept, &columns)?); - } - } - } - Ok(batches) -} - -/// `batch` with its columns in `columns` order: the take and the scan place -/// `_rowid` differently, and the query stream coalesces by position. -fn in_column_order(batch: &RecordBatch, columns: &[&str]) -> Result { - let indices = columns - .iter() - .map(|column| batch.schema().index_of(column)) - .collect::, _>>()?; - Ok(batch.project(&indices)?) -} - -/// The grouped query over `rows`, in the view's schema. -async fn group_rows( - grouped: &Grouped, - rows: Vec, - schema: SchemaRef, - rows_written: Arc, -) -> Result { - let input_schema = match rows.first() { - Some(batch) => batch.schema(), - None => { - return Ok(Box::pin(RecordBatchStreamAdapter::new( - schema, - futures::stream::empty(), - ))); - } - }; - let rows: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new( - input_schema, - futures::stream::iter(rows.into_iter().map(Ok)), - )); - let state = grouped::session(grouped::bind(&grouped.source, &grouped.definition).await?); - let ctx = SessionContext::new_with_state(state.clone()); - let source = ctx.read_one_shot(rows)?.into_view(); - let planned = grouped::logical_plan( - &state, - provider_as_source(source), - &grouped.definition, - // Neither the indexed take nor the fragment scan filters: the rows - // reach this query raw, so the predicate belongs in it. - true, - )?; - let groups = ctx - .execute_logical_plan(planned) - .await? - .execute_stream() - .await?; - let out_schema = schema.clone(); - let mapped = groups.map(move |batch| { - let batch = to_view_batch(&batch?, &out_schema)?; - rows_written.fetch_add(batch.num_rows() as u64, Ordering::Relaxed); - Ok(batch) - }); - Ok(Box::pin(RecordBatchStreamAdapter::new(schema, mapped))) -} - -/// Replace the view's rows with the fragments every unit wrote, in one -/// commit, and record `plan.source_version` as what the view reflects. -/// -/// The commit is refused if the view moved since the plan: a refresh that -/// landed meanwhile replaced rows the units never saw. Concurrent refreshes -/// of one view are serialized by the caller, as for a single-pass rebuild; -/// this check catches the ones that slipped through. -pub async fn commit_grouped_refresh( - view: &Table, - plan: &GroupedRefreshPlan, - units: Vec, - expected_incarnation: Option<&str>, -) -> Result { - let grouped = open_planned(view, plan).await?; - let units = complete_set(view.name(), plan, units)?; - let rows_written = units.iter().map(|unit| unit.rows).sum(); - let new_fragments = units.into_iter().flat_map(|unit| unit.fragments).collect(); - commit_fragments( - view, - plan, - &grouped.definition, - grouped.source.manifest.timestamp_nanos, - new_fragments, - rows_written, - expected_incarnation, - ) - .await -} - -/// The common all-or-nothing unit sink, shared by grouped and native views. -#[allow(clippy::too_many_arguments)] -pub(super) async fn commit_fragments( - view: &Table, - plan: &GroupedRefreshPlan, - definition: &MaterializedViewDefinition, - source_ts: u128, - new_fragments: Vec, - rows_written: u64, - expected_incarnation: Option<&str>, -) -> Result { - let view_native = view.as_native().ok_or_else(|| Error::NotSupported { - message: "materialized views are supported only on local tables".into(), - })?; - view_native.dataset.ensure_mutable()?; - let lock = refresh_lock(view_native.dataset.get().await?.uri()); - let _guard = lock.lock().await; - if let Some(expected) = expected_incarnation - && expected != plan.incarnation - { - return Err(Error::Runtime { - message: format!( - "the refresh of '{}' was requested for another incarnation than the one \ - its units were planned against", - view.name() - ), - }); - } - view_native.dataset.reload().await?; - let ds = view_native.dataset.get().await?.as_ref().clone(); - ensure_incarnation(&ds, Some(&plan.incarnation), view.name()).await?; - if super::read_definition(&ds.schema().metadata)? - != Some(super::StoredDefinition::Query(definition.clone())) - { - return Err(Error::InvalidInput { - message: "view definition changed while its units were written".into(), - }); - } - let ds = Arc::new(ds); - if ds.version().version != plan.view_version { - return Err(Error::Runtime { - message: format!( - "'{}' moved from version {} to {} while its units were written; the \ - refresh is unrecorded and must be replanned", - view.name(), - plan.view_version, - ds.version().version - ), - }); - } - let removed_fragment_ids: Vec = ds.get_fragments().iter().map(|f| f.id() as u64).collect(); - // Fragment ids are assigned at commit; the units' fragments all carry 0. - let transaction = Transaction::new( - plan.view_version, - Operation::Update { - removed_fragment_ids, - updated_fragments: Vec::new(), - new_fragments, - fields_modified: Vec::new(), - compacted_sstables: Vec::new(), - fields_for_preserving_frag_bitmap: Vec::new(), - update_mode: None, - inserted_rows_filter: None, - updated_fragment_offsets: None, - }, - None, - ); - #[cfg(test)] - super::refresh::tests::hold_before_publish(ds.uri()).await; - let committed = CommitBuilder::new(WriteDestination::Dataset(ds)) - .execute(transaction) - .await?; - // The version check above is not the fence: lance rebases this `Update` - // over a concurrent append rather than refusing it, which would leave - // the appended rows in a view certified as the units' whole output. - if committed.version().version != plan.view_version + 1 { - return Err(Error::Runtime { - message: format!( - "a concurrent commit raced the refresh of '{}' (view version {}); the \ - refresh is unrecorded and must be replanned", - view.name(), - committed.version().version - ), - }); - } - let version = stamp_watermark( - view_native, - committed, - plan.source_version, - source_ts, - Some(definition), - expected_incarnation, - ) - .await?; - Ok(RefreshMaterializedViewResult { - mode: RefreshMode::Rebuild, - rows_written, - source_version: plan.source_version, - version, - }) -} - -/// The units in plan order, exactly one per unit of `plan`. The commit -/// publishes them as the view's whole contents, so a missing, repeated or -/// foreign result would certify a view nobody computed. -fn complete_set( - view: &str, - plan: &GroupedRefreshPlan, - units: Vec, -) -> Result> { - let mut ordered: Vec> = vec![None; plan.units as usize]; - for written in units { - if &written.plan != plan { - return Err(Error::InvalidInput { - message: format!( - "a unit of another plan of '{view}' was offered to this refresh; the \ - refresh is unrecorded and must be replanned" - ), - }); - } - let slot = ordered - .get_mut(written.unit as usize) - .ok_or_else(|| Error::InvalidInput { - message: format!( - "unit {} is not in the plan of '{view}' ({} units)", - written.unit, plan.units - ), - })?; - if slot.is_some() { - return Err(Error::InvalidInput { - message: format!("unit {} of '{view}' was written twice", written.unit), - }); - } - // A unit holds one key, so it writes that key's one group. - if written.rows > 1 { - return Err(Error::InvalidInput { - message: format!( - "unit {} of '{view}' wrote {} groups; the split did not match the \ - grouping and the refresh must be replanned", - written.unit, written.rows - ), - }); - } - *slot = Some(written); - } - ordered - .into_iter() - .enumerate() - .map(|(unit, written)| { - written.ok_or_else(|| Error::InvalidInput { - message: format!( - "unit {unit} of '{view}' is missing; the refresh holds {} units and \ - publishes all of them or none", - plan.units - ), - }) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::super::refresh::tests::{clustered_source, declare, rows}; - use super::*; - use crate::connect; - use crate::index::Index; - use crate::index::vector::IvfFlatIndexBuilder; - use arrow_array::types::Float32Type; - use arrow_array::{ArrayRef, FixedSizeListArray, Int32Array, RecordBatchIterator}; - use lance::index::DatasetIndexExt; - use lance::index::vector::VectorIndexParams; - use lance_index::IndexType; - use lance_index::vector::ivf::IvfBuildParams; - - const BUCKETS: &str = "SELECT ivf_partition(vec) AS bucket, count(*) AS n, min(id) AS lo, \ - max(id) AS hi FROM images GROUP BY ivf_partition(vec)"; - - /// The clustered source under a two-partition cosine index, then edited - /// behind the index: a row deleted from each cluster, a row appended to - /// each, and one zero vector cosine cannot place. - async fn edited_source(conn: &crate::Connection) -> Table { - let source = clustered_source(conn).await; - source - .create_index( - &["vec"], - Index::IvfFlat( - IvfFlatIndexBuilder::default() - .num_partitions(2) - .distance_type(crate::DistanceType::Cosine), - ), - ) - .execute() - .await - .unwrap(); - source.delete("id IN (3, 13)").await.unwrap(); - let appended = FixedSizeListArray::from_iter_primitive::( - [ - Some(vec![Some(1.5), Some(0.0)]), - Some(vec![Some(101.5), Some(100.0)]), - Some(vec![Some(0.0), Some(0.0)]), - ], - 2, - ); - source - .add( - RecordBatch::try_from_iter(vec![ - ( - "id", - Arc::new(Int32Array::from(vec![20, 21, 22])) as ArrayRef, - ), - ("vec", Arc::new(appended) as ArrayRef), - ]) - .unwrap(), - ) - .execute() - .await - .unwrap(); - source - } - - async fn refresh_in_units( - view: &Table, - expected_incarnation: Option<&str>, - ) -> Result<( - GroupedRefreshPlan, - Vec, - RefreshMaterializedViewResult, - )> { - let plan = plan_grouped_refresh(view, None) - .await? - .expect("an ivf-keyed view"); - let mut units = Vec::new(); - for unit in 0..plan.units { - units.push(write_grouped_unit(view, unit, &plan).await?); - } - let result = - commit_grouped_refresh(view, &plan, units.clone(), expected_incarnation).await?; - Ok((plan, units, result)) - } - - /// Every partition's unit reads its indexed rows plus the appended rows - /// that assign to it, the deleted rows are gone, and the last unit holds - /// the unplaceable row: together they are the single-pass result. - #[tokio::test] - async fn units_compose_to_the_single_pass_result() { - let conn = connect("memory://").execute().await.unwrap(); - let source = edited_source(&conn).await; - let single = declare(&source, "single", BUCKETS).await.unwrap(); - single.refresh().execute().await.unwrap(); - let expected = rows(single.table(), &["bucket", "n", "lo", "hi"]).await; - assert_eq!(expected.len(), 3, "{expected:?}"); - assert!(expected.iter().any(|row| row == " 1 22 22"), "{expected:?}"); - - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - let (plan, units, result) = refresh_in_units(view.table(), None).await.unwrap(); - assert_eq!(plan.units, 3, "two partitions plus the unplaceable rows"); - assert_eq!( - rows(view.table(), &["bucket", "n", "lo", "hi"]).await, - expected - ); - assert_eq!(units.iter().map(|u| u.rows).sum::(), 3); - assert_eq!(units.iter().map(|u| u.rows).collect::>(), [1, 1, 1]); - assert_eq!(result.rows_written, 3); - assert_eq!(result.source_version, plan.source_version); - assert_eq!(view.table().version().await.unwrap(), result.version); - - // The commit is a refresh: the view reflects the source version it - // read and a second pass finds nothing to do. - let again = view.refresh().execute().await.unwrap(); - assert_eq!(again.rows_written, 0, "{again:?}"); - assert_eq!( - rows(view.table(), &["bucket", "n", "lo", "hi"]).await, - expected - ); - } - - /// A second refresh in units replaces the first one's rows rather than - /// appending to them. - #[tokio::test] - async fn a_refresh_in_units_replaces_the_previous_rows() { - let conn = connect("memory://").execute().await.unwrap(); - let source = edited_source(&conn).await; - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - refresh_in_units(view.table(), None).await.unwrap(); - source.delete("id = 22").await.unwrap(); - refresh_in_units(view.table(), None).await.unwrap(); - let mut groups = rows(view.table(), &["n", "lo", "hi"]).await; - groups.sort(); - assert_eq!(groups, ["10 0 20", "10 10 21"]); - } - - #[tokio::test] - async fn a_view_not_keyed_by_ivf_partition_has_no_units() { - let conn = connect("memory://").execute().await.unwrap(); - let source = clustered_source(&conn).await; - let view = declare( - &source, - "by_id", - "SELECT id % 2 AS k, count(*) AS n FROM images GROUP BY id % 2", - ) - .await - .unwrap(); - assert_eq!( - plan_grouped_refresh(view.table(), None).await.unwrap(), - None - ); - } - - #[tokio::test] - async fn a_unit_outside_the_plan_is_refused() { - let conn = connect("memory://").execute().await.unwrap(); - let source = edited_source(&conn).await; - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - let plan = plan_grouped_refresh(view.table(), None) - .await - .unwrap() - .unwrap(); - let err = write_grouped_unit(view.table(), plan.units, &plan) - .await - .unwrap_err(); - assert!(err.to_string().contains("not in the plan"), "{err}"); - } - - /// A commit that lands on a view someone else refreshed meanwhile is - /// refused rather than recorded over their rows. - #[tokio::test] - async fn a_raced_commit_is_refused() { - let conn = connect("memory://").execute().await.unwrap(); - let source = edited_source(&conn).await; - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - let plan = plan_grouped_refresh(view.table(), None) - .await - .unwrap() - .unwrap(); - let units = vec![write_grouped_unit(view.table(), 0, &plan).await.unwrap()]; - view.refresh().execute().await.unwrap(); - let before = rows(view.table(), &["bucket", "n", "lo", "hi"]).await; - let raced = commit_grouped_refresh(view.table(), &plan, units, None).await; - assert!(raced.is_err(), "{raced:?}"); - assert_eq!( - rows(view.table(), &["bucket", "n", "lo", "hi"]).await, - before - ); - } - - /// A view with a predicate holds the rows it selects however it is - /// refreshed: the units read their rows through the index and by - /// scanning fragments, neither of which filters, so the predicate has - /// to be in the grouped query itself. - #[tokio::test] - async fn a_filtered_view_refreshes_in_units_like_a_single_pass() { - const FILTERED: &str = "SELECT ivf_partition(vec) AS bucket, count(*) AS n, \ - min(id) AS lo, max(id) AS hi FROM images WHERE id < 5 \ - GROUP BY ivf_partition(vec)"; - let conn = connect("memory://").execute().await.unwrap(); - let source = edited_source(&conn).await; - let single = declare(&source, "single", FILTERED).await.unwrap(); - single.refresh().execute().await.unwrap(); - let expected = rows(single.table(), &["bucket", "n", "lo", "hi"]).await; - - let view = declare(&source, "buckets", FILTERED).await.unwrap(); - refresh_in_units(view.table(), None).await.unwrap(); - assert_eq!( - rows(view.table(), &["bucket", "n", "lo", "hi"]).await, - expected - ); - } - - /// The commit publishes its units as the view's whole contents, so it - /// takes the plan's units exactly once each and nothing else. - #[tokio::test] - async fn only_a_complete_set_of_this_plan_s_units_is_published() { - let conn = connect("memory://").execute().await.unwrap(); - let source = edited_source(&conn).await; - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - let plan = plan_grouped_refresh(view.table(), None) - .await - .unwrap() - .unwrap(); - let mut units = Vec::new(); - for unit in 0..plan.units { - units.push(write_grouped_unit(view.table(), unit, &plan).await.unwrap()); - } - - let missing = commit_grouped_refresh(view.table(), &plan, units[..2].to_vec(), None).await; - assert!( - missing.unwrap_err().to_string().contains("is missing"), - "a partial set was published" - ); - let mut twice = units.clone(); - twice[1] = units[0].clone(); - let repeated = commit_grouped_refresh(view.table(), &plan, twice, None).await; - assert!( - repeated.unwrap_err().to_string().contains("written twice"), - "a repeated unit was published" - ); - let mut foreign = units.clone(); - foreign[0].plan.source_version += 1; - let stale = commit_grouped_refresh(view.table(), &plan, foreign, None).await; - assert!( - stale.unwrap_err().to_string().contains("another plan"), - "a unit of another plan was published" - ); - assert_eq!(rows(view.table(), &["bucket"]).await, Vec::::new()); - - // The complete set still lands. - commit_grouped_refresh(view.table(), &plan, units, None) - .await - .unwrap(); - assert_eq!(rows(view.table(), &["bucket"]).await.len(), 3); - } - - /// A column rewrite attaches a new file to the row's fragment and takes - /// that fragment out of the index segment's bitmap, but the segment's - /// postings still name its rows. Taking them anyway, while the fragment - /// is also scanned as unindexed, counts those rows twice and buckets the - /// stale copy by the value the rewrite replaced. - #[tokio::test] - async fn a_rewritten_fragment_is_read_once() { - use lance::dataset::{ - MergeInsertBuilder, MergeInsertWriteMode, WhenMatched, WhenNotMatched, - }; - - let conn = connect("memory://").execute().await.unwrap(); - // A column the rewrite leaves alone: RewriteColumns is only for a - // source that covers some of the row, not all of it. - let vectors = FixedSizeListArray::from_iter_primitive::( - (0..20).map(|i| { - let base = if i < 10 { 0.0 } else { 100.0 }; - Some(vec![Some(base + 1.0 + i as f32 * 0.1), Some(base)]) - }), - 2, - ); - let source = conn - .create_table( - "images", - RecordBatch::try_from_iter(vec![ - ( - "id", - Arc::new(Int32Array::from_iter_values(0..20)) as ArrayRef, - ), - ("vec", Arc::new(vectors) as ArrayRef), - ( - "tag", - Arc::new(Int32Array::from_iter_values((0..20).map(|i| i * 10))) as ArrayRef, - ), - ]) - .unwrap(), - ) - .write_options(super::super::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - source - .create_index( - &["vec"], - Index::IvfFlat(IvfFlatIndexBuilder::default().num_partitions(2)), - ) - .execute() - .await - .unwrap(); - - // Row 0 keeps its id and its fragment; its vector is replaced in a - // file the index segment does not cover. - let batch = RecordBatch::try_from_iter(vec![ - ("id", Arc::new(Int32Array::from(vec![0])) as ArrayRef), - ( - "vec", - Arc::new( - FixedSizeListArray::from_iter_primitive::( - [Some(vec![Some(101.0), Some(100.0)])], - 2, - ), - ) as ArrayRef, - ), - ]) - .unwrap(); - let schema = batch.schema(); - let dataset = source.as_native().unwrap().dataset.get().await.unwrap(); - let mut builder = - MergeInsertBuilder::try_new(Arc::new(dataset.as_ref().clone()), vec!["id".to_string()]) - .unwrap(); - builder - .when_matched(WhenMatched::UpdateAll) - .when_not_matched(WhenNotMatched::DoNothing) - .write_mode(MergeInsertWriteMode::RewriteColumns); - builder - .try_build() - .unwrap() - .execute_reader(arrow_array::RecordBatchIterator::new([Ok(batch)], schema)) - .await - .unwrap(); - - let single = declare(&source, "single", BUCKETS).await.unwrap(); - single.refresh().execute().await.unwrap(); - let expected = rows(single.table(), &["bucket", "n", "lo", "hi"]).await; - - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - refresh_in_units(view.table(), None).await.unwrap(); - assert_eq!( - rows(view.table(), &["bucket", "n", "lo", "hi"]).await, - expected, - "the rewritten row was counted twice or bucketed by its old vector" - ); - } - - /// Units written on either side of an index rebuild still group by one - /// model: the plan pins a source version, indices live in the source - /// manifest, and a rebuild lands in a later version the units never - /// read. Without that, two units of one plan could assign their rows by - /// different centroids. - #[tokio::test] - async fn a_rebuilt_index_does_not_reach_the_units_of_a_plan() { - let conn = connect("memory://").execute().await.unwrap(); - let source = edited_source(&conn).await; - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - let plan = plan_grouped_refresh(view.table(), None) - .await - .unwrap() - .unwrap(); - let first = write_grouped_unit(view.table(), 0, &plan).await.unwrap(); - - source - .create_index( - &["vec"], - Index::IvfFlat( - IvfFlatIndexBuilder::default() - .num_partitions(2) - .distance_type(crate::DistanceType::Cosine), - ), - ) - .replace(true) - .execute() - .await - .unwrap(); - let rebuilt = plan_grouped_refresh(view.table(), None) - .await - .unwrap() - .unwrap(); - assert_eq!(rebuilt.units, plan.units, "same shape"); - assert!( - rebuilt.source_version > plan.source_version, - "the rebuild is a later source version" - ); - - // The remaining units read the version the plan pinned, so they are - // grouped by the index that version holds, not the new one. - let mut units = vec![first]; - for unit in 1..plan.units { - units.push(write_grouped_unit(view.table(), unit, &plan).await.unwrap()); - } - commit_grouped_refresh(view.table(), &plan, units, None) - .await - .unwrap(); - assert_eq!( - rows(view.table(), &["bucket", "n", "lo", "hi"]).await.len(), - 3 - ); - } - - /// Two views of the same shape over the same source reach the same plan - /// counters, so the results of one must not satisfy the other: they are - /// fragments written into a different dataset. - #[tokio::test] - async fn units_written_for_another_view_are_refused() { - let conn = connect("memory://").execute().await.unwrap(); - let source = edited_source(&conn).await; - let mine = declare(&source, "buckets", BUCKETS).await.unwrap(); - let theirs = declare(&source, "twin", BUCKETS).await.unwrap(); - let my_plan = plan_grouped_refresh(mine.table(), None) - .await - .unwrap() - .unwrap(); - let their_plan = plan_grouped_refresh(theirs.table(), None) - .await - .unwrap() - .unwrap(); - assert_eq!( - (my_plan.units, my_plan.view_version, my_plan.source_version), - ( - their_plan.units, - their_plan.view_version, - their_plan.source_version - ), - "the counters alone do not tell these views apart" - ); - - let mut mine_units = Vec::new(); - for unit in 0..my_plan.units { - mine_units.push( - write_grouped_unit(mine.table(), unit, &my_plan) - .await - .unwrap(), - ); - } - let misrouted = - commit_grouped_refresh(theirs.table(), &their_plan, mine_units.clone(), None).await; - assert!( - misrouted.unwrap_err().to_string().contains("another plan"), - "another view's units were published" - ); - assert_eq!( - rows(theirs.table(), &["bucket"]).await, - Vec::::new() - ); - - // And a unit cannot be written for a view the plan does not name. - let foreign = write_grouped_unit(theirs.table(), 0, &my_plan).await; - assert!( - foreign - .unwrap_err() - .to_string() - .contains("not the incarnation"), - "a unit was written under another view's plan" - ); - } - - /// A write that lands on the view between the plan check and the commit - /// is not a conflict lance refuses -- the update rebases over it -- so - /// the publication checks the generation it actually landed on. - #[tokio::test(flavor = "multi_thread")] - async fn a_write_racing_the_publication_is_refused() { - use super::super::refresh::tests::{ - DRIFT_LOCK, DRIFT_PLANNED, DRIFT_RELEASED, DRIFT_TARGET, - }; - let _serial = DRIFT_LOCK.lock().await; - let conn = connect("memory://").execute().await.unwrap(); - let source = edited_source(&conn).await; - // Its own name: memory:// uris repeat across tests, so a view called - // like its siblings would arm the hook for one of their commits. - let view = declare(&source, "racing_buckets", BUCKETS).await.unwrap(); - let plan = plan_grouped_refresh(view.table(), None) - .await - .unwrap() - .unwrap(); - let mut units = Vec::new(); - for unit in 0..plan.units { - units.push(write_grouped_unit(view.table(), unit, &plan).await.unwrap()); - } - let uri = view - .table() - .as_native() - .unwrap() - .dataset - .get() - .await - .unwrap() - .uri() - .to_string(); - *DRIFT_TARGET.lock().unwrap() = Some(uri); - - let publishing = { - let table = view.table().clone(); - let plan = plan.clone(); - tokio::spawn(async move { commit_grouped_refresh(&table, &plan, units, None).await }) - }; - tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified()) - .await - .expect("the commit never reached the publication boundary"); - view.table() - .add( - RecordBatch::try_from_iter(vec![ - ("bucket", Arc::new(Int32Array::from(vec![7])) as ArrayRef), - ("n", Arc::new(Int32Array::from(vec![1])) as ArrayRef), - ("lo", Arc::new(Int32Array::from(vec![0])) as ArrayRef), - ("hi", Arc::new(Int32Array::from(vec![0])) as ArrayRef), - ( - super::super::SOURCE_ROW_ID_COLUMN, - Arc::new(arrow_array::UInt64Array::from(vec![0u64])) as ArrayRef, - ), - ]) - .unwrap(), - ) - .execute() - .await - .unwrap(); - DRIFT_RELEASED.notify_one(); - - let err = publishing.await.unwrap().unwrap_err(); - assert!(err.to_string().contains("raced the refresh"), "{err}"); - } - - #[test] - fn the_key_column_is_read_off_a_single_ivf_key() { - let mut definition = MaterializedViewDefinition::from_sql( - "SELECT ivf_partition(vec) AS b, count(*) AS n FROM t GROUP BY ivf_partition(vec)", - ) - .unwrap(); - assert_eq!(ivf_key_column(&definition).as_deref(), Some("vec")); - definition.group_by = vec!["k".to_string()]; - assert_eq!(ivf_key_column(&definition), None); - definition.group_by = vec!["ivf_partition(vec)".to_string(), "k".to_string()]; - assert_eq!(ivf_key_column(&definition), None); - } - - /// A source whose index does not assign by its own centroids: lance - /// takes precomputed partitions, so row 1 is stored under partition 1 - /// and `ivf_partition` puts it in 0. - async fn precomputed_source(conn: &crate::Connection, dir: &std::path::Path) -> Table { - let vectors = FixedSizeListArray::from_iter_primitive::( - [ - Some(vec![Some(1.0), Some(0.0)]), - Some(vec![Some(2.0), Some(0.0)]), - Some(vec![Some(100.0), Some(100.0)]), - ], - 2, - ); - let source = conn - .create_table( - "images", - RecordBatch::try_from_iter(vec![ - ("id", Arc::new(Int32Array::from(vec![0, 1, 2])) as ArrayRef), - ("vec", Arc::new(vectors) as ArrayRef), - ]) - .unwrap(), - ) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - - let assignments = arrow_array::record_batch!( - ("row_id", UInt64, [0, 1, 2]), - ("partition", UInt32, [0, 1, 1]) - ) - .unwrap(); - let file = dir.join("partitions.lance"); - let file = file.to_str().unwrap(); - lance::Dataset::write( - RecordBatchIterator::new(vec![Ok(assignments.clone())], assignments.schema()), - file, - None, - ) - .await - .unwrap(); - - let centroids = FixedSizeListArray::from_iter_primitive::( - [ - Some(vec![Some(0.0), Some(0.0)]), - Some(vec![Some(100.0), Some(100.0)]), - ], - 2, - ); - let mut ivf = IvfBuildParams::try_with_centroids(2, Arc::new(centroids)).unwrap(); - ivf.precomputed_partitions_file = Some(file.to_string()); - let mut dataset = source - .as_native() - .unwrap() - .dataset - .get() - .await - .unwrap() - .as_ref() - .clone(); - dataset - .create_index( - &["vec"], - IndexType::Vector, - None, - &VectorIndexParams::with_ivf_flat_params( - lance_linalg::distance::DistanceType::L2, - ivf, - ), - true, - ) - .await - .unwrap(); - conn.open_table("images").execute().await.unwrap() - } - - async fn incarnation(view: &Table) -> String { - view.as_native() - .unwrap() - .dataset - .get() - .await - .unwrap() - .schema() - .metadata - .get(crate::materialized_view::INCARNATION_META_KEY) - .cloned() - .unwrap() - } - - /// The split is the index's posting lists and the key is - /// `ivf_partition`. Where they disagree one group is aggregated in two - /// units and published twice, so such a view has no units at all. - #[tokio::test] - async fn an_index_that_does_not_assign_by_its_centroids_has_no_units() { - let dir = tempfile::tempdir().unwrap(); - let conn = connect("memory://").execute().await.unwrap(); - let source = precomputed_source(&conn, dir.path()).await; - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - assert_eq!( - plan_grouped_refresh(view.table(), None).await.unwrap(), - None - ); - view.refresh().execute().await.unwrap(); - assert_eq!( - rows(view.table(), &["bucket", "n", "lo", "hi"]).await, - ["0 2 0 1", "1 1 2 2"] - ); - } - - /// The publication is the last place the split can be caught: a unit - /// that holds one key writes one group, and a set that says otherwise is - /// not published however it was produced. - #[tokio::test] - async fn a_unit_that_wrote_more_than_one_group_is_not_published() { - let conn = connect("memory://").execute().await.unwrap(); - let source = edited_source(&conn).await; - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - let plan = plan_grouped_refresh(view.table(), None) - .await - .unwrap() - .unwrap(); - let mut units = Vec::new(); - for unit in 0..plan.units { - units.push(write_grouped_unit(view.table(), unit, &plan).await.unwrap()); - } - units[1].rows = 2; - let err = commit_grouped_refresh(view.table(), &plan, units, None) - .await - .unwrap_err(); - assert!(err.to_string().contains("wrote 2 groups"), "{err}"); - assert_eq!(rows(view.table(), &["bucket"]).await, Vec::::new()); - } - - /// The plan reads a sample, so the unit proves the rest: it holds only - /// rows that assign to it, or it refuses rather than write a group - /// another unit holds too. - #[tokio::test] - async fn a_unit_holding_a_row_that_assigns_elsewhere_is_refused() { - let dir = tempfile::tempdir().unwrap(); - let conn = connect("memory://").execute().await.unwrap(); - let source = precomputed_source(&conn, dir.path()).await; - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - let plan = GroupedRefreshPlan { - units: 3, - source_version: source.version().await.unwrap(), - view_version: view.table().version().await.unwrap(), - incarnation: incarnation(view.table()).await, - }; - assert_eq!( - write_grouped_unit(view.table(), 0, &plan) - .await - .unwrap() - .rows, - 1 - ); - let err = write_grouped_unit(view.table(), 1, &plan) - .await - .unwrap_err(); - assert!(err.to_string().contains("does not assign to"), "{err}"); - } -} diff --git a/rust/lancedb/src/materialized_view/partitioned.rs b/rust/lancedb/src/materialized_view/partitioned.rs deleted file mode 100644 index 58192089b..000000000 --- a/rust/lancedb/src/materialized_view/partitioned.rs +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The LanceDB Authors - -//! Shared plan/write/commit surface for materialized views with independent units. - -use serde::{Deserialize, Serialize}; - -use super::duplicate_pairs::{ - self, DuplicatePairsRefreshPlan, VectorSourceKind, WrittenPairsPartition, -}; -use super::vector_dedup::{self, VectorDedupPlan, WrittenDedupUnit}; -use super::{GroupedRefreshPlan, RefreshMaterializedViewResult, StoredDefinition, WrittenUnit}; -use crate::table::Table; -use crate::{Error, Result}; - -/// Serializable work description consumed by a host's existing job workers. -/// The grouped variant preserves the previous saved-plan representation. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum PartitionedRefreshPlan { - VectorDedup(VectorDedupPlan), - DuplicatePairs(DuplicatePairsRefreshPlan), - Grouped(GroupedRefreshPlan), -} - -impl PartitionedRefreshPlan { - /// The shared source snapshot and destination generation fence. - pub fn snapshot(&self) -> &GroupedRefreshPlan { - match self { - Self::VectorDedup(plan) => &plan.snapshot, - Self::DuplicatePairs(plan) => &plan.snapshot, - Self::Grouped(plan) => plan, - } - } - /// Selection tasks that must complete before remaining tasks can start. - pub fn dependency_units(&self) -> u32 { - match self { - Self::VectorDedup(plan) => plan.dependencies(), - _ => 0, - } - } - /// Number of independent units in this immutable plan. - pub fn units(&self) -> u32 { - self.snapshot().units - } -} - -/// One completed unit's uncommitted fragments. Failed attempts produce no receipt. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum WrittenPartition { - VectorDedup(WrittenDedupUnit), - DuplicatePairs(WrittenPairsPartition), - Grouped(WrittenUnit), -} - -impl WrittenPartition { - /// Number of pair or grouped rows staged by this unit. - pub fn rows(&self) -> u64 { - match self { - Self::VectorDedup(unit) => unit.rows, - Self::DuplicatePairs(unit) => unit.rows, - Self::Grouped(unit) => unit.rows(), - } - } - /// Uncommitted output files, retained only after task completion. - pub fn fragments(&self) -> &[lance_table::format::Fragment] { - match self { - Self::VectorDedup(unit) => &unit.fragments, - Self::DuplicatePairs(unit) => &unit.fragments, - Self::Grouped(unit) => unit.fragments(), - } - } -} - -/// Plan native pair or indexed grouped work; return `None` for ordinary views. -/// `pinned`, when supplied, must match a native view's declared source version. -pub async fn plan_partitioned_refresh( - view: &Table, - pinned: Option, -) -> Result> { - if let Some(StoredDefinition::Query(definition)) = - super::read_definition(view.schema().await?.metadata())? - && definition.vector_source.is_some() - { - if definition - .vector_source - .as_ref() - .is_some_and(|s| s.kind == VectorSourceKind::Dedup) - { - return vector_dedup::plan_refresh(view, pinned) - .await - .map(|p| Some(PartitionedRefreshPlan::VectorDedup(p))); - } - return duplicate_pairs::plan_refresh(view, pinned) - .await - .map(|p| Some(PartitionedRefreshPlan::DuplicatePairs(p))); - } - super::plan_grouped_refresh(view, pinned) - .await - .map(|plan| plan.map(PartitionedRefreshPlan::Grouped)) -} - -/// Stream one unit into uncommitted fragments. Persist the returned receipt only -/// on success. A failed or interrupted call must be retried from the unit's start. -pub async fn write_refresh_partition( - view: &Table, - unit: u32, - plan: &PartitionedRefreshPlan, -) -> Result { - write_refresh_partition_with_inputs(view, unit, plan, &[]).await -} - -/// Write a unit after its durable selection dependencies have completed. -/// Supply exactly the first `plan.dependency_units()` receipts for a dependent -/// unit. A missing, duplicate or foreign receipt fails before output is staged. -pub async fn write_refresh_partition_with_inputs( - view: &Table, - unit: u32, - plan: &PartitionedRefreshPlan, - dependencies: &[WrittenPartition], -) -> Result { - match plan { - PartitionedRefreshPlan::VectorDedup(plan) => { - let dependencies = dependencies - .iter() - .map(|unit| match unit { - WrittenPartition::VectorDedup(unit) => Ok(unit.clone()), - _ => Err(Error::InvalidInput { - message: "foreign dedup dependency".into(), - }), - }) - .collect::>>()?; - vector_dedup::write_unit(view, unit, plan, &dependencies) - .await - .map(WrittenPartition::VectorDedup) - } - PartitionedRefreshPlan::DuplicatePairs(plan) => { - duplicate_pairs::write_unit(view, unit, plan) - .await - .map(WrittenPartition::DuplicatePairs) - } - PartitionedRefreshPlan::Grouped(plan) => super::write_grouped_unit(view, unit, plan) - .await - .map(WrittenPartition::Grouped), - } -} - -/// Publish a complete set of unit receipts through the common MV commit sink. -/// `expected` fences the destination incarnation. No partial set is publishable. -pub async fn commit_partitioned_refresh( - view: &Table, - plan: &PartitionedRefreshPlan, - units: Vec, - expected: Option<&str>, -) -> Result { - let foreign = || Error::InvalidInput { - message: "refresh receipt does not match the plan kind".into(), - }; - match plan { - PartitionedRefreshPlan::VectorDedup(plan) => { - let units = units - .into_iter() - .map(|unit| match unit { - WrittenPartition::VectorDedup(unit) => Ok(unit), - _ => Err(foreign()), - }) - .collect::>>()?; - vector_dedup::commit(view, plan, units, expected).await - } - PartitionedRefreshPlan::DuplicatePairs(plan) => { - let units = units - .into_iter() - .map(|unit| match unit { - WrittenPartition::DuplicatePairs(unit) => Ok(unit), - _ => Err(foreign()), - }) - .collect::>>()?; - duplicate_pairs::commit(view, plan, units, expected).await - } - PartitionedRefreshPlan::Grouped(plan) => { - let units = units - .into_iter() - .map(|unit| match unit { - WrittenPartition::Grouped(unit) => Ok(unit), - _ => Err(foreign()), - }) - .collect::>>()?; - super::commit_grouped_refresh(view, plan, units, expected).await - } - } -} diff --git a/rust/lancedb/src/materialized_view/query.rs b/rust/lancedb/src/materialized_view/query.rs index 4bfd5542e..c1a987b2c 100644 --- a/rust/lancedb/src/materialized_view/query.rs +++ b/rust/lancedb/src/materialized_view/query.rs @@ -264,36 +264,7 @@ fn extract(query: &Query) -> Result { }; let mut from = select.from.iter(); - let mut vector_source = None; let (source_namespace, source_table) = match from.next().map(|f| &f.relation) { - Some(TableFactor::Table { - name, - args: Some(args), - alias: None, - with_hints, - version: None, - with_ordinality: false, - partitions, - json_path: None, - sample: None, - index_hints, - .. - }) if matches!( - name.to_string().as_str(), - super::duplicate_pairs::FUNCTION_NAME | super::duplicate_pairs::DEDUP_FUNCTION_NAME - ) && with_hints.is_empty() - && partitions.is_empty() - && index_hints.is_empty() => - { - let kind = if name.to_string() == super::duplicate_pairs::FUNCTION_NAME { - super::VectorSourceKind::Pairs - } else { - super::VectorSourceKind::Dedup - }; - let (namespace, table, config) = super::duplicate_pairs::source(args, kind)?; - vector_source = Some(Box::new(config)); - (namespace, table) - } Some(TableFactor::Table { args: Some(_), .. }) => { return Err(invalid( "a view reads a table; a Function in FROM position follows it: \ @@ -445,7 +416,6 @@ fn extract(query: &Query) -> Result { }; let definition = MaterializedViewDefinition { - vector_source, source_table, source_namespace, lateral, @@ -454,9 +424,6 @@ fn extract(query: &Query) -> Result { group_by, limit, }; - if definition.vector_source.is_some() { - super::duplicate_pairs::check_shape(&definition)?; - } check_grouping(&definition)?; Ok(definition) } @@ -516,18 +483,7 @@ pub fn render(definition: &MaterializedViewDefinition) -> String { .chain(std::iter::once(&definition.source_table)) .map(|part| ident_sql(part)) .collect(); - if let Some(native) = &definition.vector_source { - sql.push_str(&format!( - "{}('{}', {}, '{}', {})", - native.function_name(), - table.join(".").replace('\'', "''"), - native.dataset_version, - native.column.replace('\'', "''"), - native.distance_threshold - )); - } else { - sql.push_str(&table.join(".")); - } + sql.push_str(&table.join(".")); if let Some(lateral) = &definition.lateral { match &lateral.source { LateralSource::Unnest { column } => { @@ -555,45 +511,6 @@ pub fn render(definition: &MaterializedViewDefinition) -> String { mod tests { use super::*; - #[test] - fn native_pairs_are_a_table_source_with_literal_snapshot_arguments() { - for threshold in ["4", "4.0", "-1", "0.05"] { - let sql = - format!("SELECT * FROM vector_duplicate_pairs('images', 3, 'phash', {threshold})"); - let definition = parse(&sql).unwrap(); - assert_eq!(definition.source_table, "images"); - assert!(definition.lateral.is_none()); - assert_eq!( - definition.vector_source.as_ref().unwrap().dataset_version, - 3 - ); - assert_eq!(parse(&render(&definition)).unwrap(), definition); - let metadata = std::collections::HashMap::from([( - super::super::DEFINITION_META_KEY.to_string(), - definition.to_json().unwrap(), - )]); - assert_eq!( - super::super::read_definition(&metadata).unwrap(), - Some(super::super::StoredDefinition::Query(definition)) - ); - } - for sql in [ - "SELECT row_id_a FROM vector_duplicate_pairs('images', 3, 'phash', 4)", - "SELECT * FROM vector_duplicate_pairs('images', 0, 'phash', 4)", - "SELECT * FROM vector_duplicate_pairs('images', 3, 'phash', 1e100)", - "SELECT * FROM vector_duplicate_pairs('images', 3, 'phash', cutoff)", - "SELECT * FROM vector_duplicate_pairs('images', 3, 'phash', 4) LIMIT 1", - "SELECT * FROM vector_duplicate_pairs('images', 3, 'phash', 4) WHERE distance < 2", - "SELECT * FROM vector_duplicate_pairs('images', 3, 'phash', 4) AS p", - "SELECT * FROM vector_duplicate_pairs('images', 3, 'phash', 4), f(phash) AS p", - ] { - assert!( - parse(sql).is_err(), - "accepted unsupported native view: {sql}" - ); - } - } - #[test] fn a_query_round_trips_through_its_canonical_form() { for (sql, canonical) in [ diff --git a/rust/lancedb/src/materialized_view/refresh.rs b/rust/lancedb/src/materialized_view/refresh.rs index 9f0ea2646..f9634837a 100644 --- a/rust/lancedb/src/materialized_view/refresh.rs +++ b/rust/lancedb/src/materialized_view/refresh.rs @@ -1,63 +1,37 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors -//! Refreshing materialized views. +//! Full refresh for materialized views. //! -//! A refresh pins one source version and brings the view to exactly the -//! definition's result at that version: added rows are computed and appended, -//! removed or changed rows are evicted by provenance id and recomputed in the -//! same pass. Compaction outputs cost nothing, which is only sound while -//! [`SOURCE_ROW_ID_COLUMN`] stays valid across the rewrite. Anything the -//! classifier cannot prove intact rebuilds; an indexed rebuild swaps all -//! fragments in one commit that retains index definitions. -//! -//! The watermark ([`SOURCE_VERSION_META_KEY`]) lands in a follow-up commit; a -//! crash or race between the two leaves the view visibly unstamped and the -//! next refresh rebuilds. In-process refreshes serialize on a per-view lock; -//! across processes the commit's inserted-rows filter carries a shared token, -//! so two refreshes of one view conflict and only one lands. +//! A refresh evaluates the definition at one source version, stages the +//! complete result, and atomically replaces every fragment of the view. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex as StdMutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use arrow_array::cast::AsArray; -use arrow_array::types::UInt64Type; -use arrow_array::{RecordBatch, UInt64Array, new_null_array}; -use arrow_schema::{DataType, Field as ArrowField, FieldRef, Schema as ArrowSchema, SchemaRef}; -use datafusion::common::ScalarValue; +use arrow_array::{RecordBatch, new_null_array}; +use arrow_schema::{Schema as ArrowSchema, SchemaRef}; use datafusion::error::DataFusionError; use datafusion::physical_expr::PhysicalExpr; use datafusion::physical_plan::SendableRecordBatchStream; use datafusion::physical_plan::stream::RecordBatchStreamAdapter; -use datafusion::prelude::{col, lit}; -use futures::{StreamExt, TryStreamExt}; +use futures::StreamExt; use lance::Dataset; use lance::dataset::mem_wal::DatasetMemWalExt; -use lance::dataset::transaction::{Operation, Transaction, UpdateMode}; -use lance::dataset::write::delete::DeleteBuilder; +use lance::dataset::transaction::{Operation, Transaction}; use lance::dataset::write::merge_insert::inserted_rows::{ KeyExistenceFilter, KeyExistenceFilterBuilder, KeyValue, }; use lance::dataset::{CommitBuilder, InsertBuilder, WriteDestination, WriteMode, WriteParams}; -use lance_core::{ROW_CREATED_AT_VERSION, ROW_ID, ROW_LAST_UPDATED_AT_VERSION}; - use lance_datafusion::planner::Planner; -use lance_file::version::ConcreteFileVersion; -use lance_table::format::Fragment; use serde::{Deserialize, Serialize}; -use super::{ - DEFINITION_META_KEY, INCARNATION_META_KEY, MaterializedViewDefinition, - REFRESHED_AT_MS_META_KEY, SOURCE_ROW_ID_COLUMN, SOURCE_VERSION_META_KEY, - definition_to_metadata, -}; +use super::{MaterializedViewDefinition, StoredDefinition}; use crate::database::OpenTableRequest; -use crate::table::computed_columns::{ - computed_column_from_field, computed_columns, ensure_declarations_are_planned, -}; -use crate::table::{NativeTable, NativeTableExt, Table}; +use crate::table::computed_columns::{computed_column_from_field, ensure_declarations_are_planned}; +use crate::table::{NativeTableExt, Table}; use crate::{Error, Result}; /// How a refresh brought the view up to date. @@ -66,10 +40,6 @@ use crate::{Error, Result}; pub enum RefreshMode { /// The view was recomputed from scratch. Rebuild, - /// Rows from source fragments added since the last refresh were appended. - Incremental, - /// The view was already at the requested source version. - NoOp, } /// The result of refreshing a materialized view. @@ -77,28 +47,15 @@ pub enum RefreshMode { pub struct RefreshMaterializedViewResult { /// How the view was brought up to date. pub mode: RefreshMode, - /// Rows written to the view: everything on a rebuild, and on an - /// incremental refresh both the rows added and the rows recomputed in - /// place of ones the source changed. + /// Rows written to the replacement view. pub rows_written: u64, - /// The source table version the view now reflects. + /// The source table version the view reflects. pub source_version: u64, /// The view table version after the refresh. pub version: u64, } -/// Schema metadata key holding the view table version a successful refresh -/// left behind. Any other commit on the view is drift, and refresh rebuilds. -pub const VIEW_VERSION_META_KEY: &str = "mv.view_version"; - -/// Schema metadata key holding the commit timestamp of the watermark's source -/// manifest. A dropped and recreated source reuses version numbers but never -/// their timestamps, so a mismatch means the watermark describes a different -/// incarnation and refresh rebuilds. -pub const SOURCE_VERSION_TS_META_KEY: &str = "mv.source_version_ts"; - -/// One refresh per view at a time within this process. -pub(super) fn refresh_lock(uri: &str) -> Arc> { +fn refresh_lock(uri: &str) -> Arc> { static LOCKS: OnceLock>>>> = OnceLock::new(); LOCKS @@ -110,57 +67,27 @@ pub(super) fn refresh_lock(uri: &str) -> Arc> { .clone() } -/// Internal implementation of the refresh logic. +/// Recompute a local materialized view from one source version. pub(crate) async fn execute_refresh( view: &Table, - full: bool, pinned: Option, - expected_incarnation: Option<&str>, ) -> Result { - // Native table functions use the same partition writer and commit sink in - // local execution; Sophon drives these units through its job registry. - if let Some(super::StoredDefinition::Query(definition)) = - super::read_definition(view.schema().await?.metadata())? - && definition.vector_source.is_some() - { - let plan = super::plan_partitioned_refresh(view, pinned) - .await? - .ok_or_else(|| Error::Runtime { - message: "native pair view has no partition plan".into(), - })?; - let mut units = Vec::with_capacity(plan.units() as usize); - for unit in 0..plan.units() { - let dependencies = &units[..units.len().min(plan.dependency_units() as usize)]; - units.push( - super::write_refresh_partition_with_inputs(view, unit, &plan, dependencies).await?, - ); - } - return super::commit_partitioned_refresh(view, &plan, units, expected_incarnation).await; - } - let view_native = view.as_native().ok_or_else(|| Error::NotSupported { + let native = view.as_native().ok_or_else(|| Error::NotSupported { message: "materialized views are supported only on local tables".into(), })?; - view_native.dataset.ensure_mutable()?; - let lock = refresh_lock(view_native.dataset.get().await?.uri()); + native.dataset.ensure_mutable()?; + let lock = refresh_lock(native.dataset.get().await?.uri()); let _guard = lock.lock().await; - // Force-load the latest view state under the lock: each handle caches - // lazily, and a second handle would otherwise plan from a snapshot taken - // before another handle's commit -- appending the same rows again or - // reporting NoOp over a mutated view. - view_native.dataset.reload().await?; - let view_ds = view_native.dataset.get().await?.as_ref().clone(); + native.dataset.reload().await?; + let view_ds = native.dataset.get().await?.as_ref().clone(); + ensure_no_mem_wal(&view_ds, "materialized view", view.name()).await?; - ensure_incarnation(&view_ds, expected_incarnation, view.name()).await?; - - // The definition a handle cached at open may since have been replaced; - // what refresh executes and what it stamps must be one generation. let definition = match super::read_definition(&view_ds.schema().metadata)? { - Some(super::StoredDefinition::Query(definition)) => definition, - Some(super::StoredDefinition::Newer { format }) => { + Some(StoredDefinition::Query(definition)) => definition, + Some(StoredDefinition::Newer { format }) => { return Err(Error::NotSupported { message: format!( - "materialized view '{}' is stored in format {format}, which this \ - version of lancedb cannot refresh", + "materialized view '{}' is stored in format {format}, which this version of lancedb cannot refresh", view.name() ), }); @@ -171,572 +98,216 @@ pub(crate) async fn execute_refresh( }); } }; - let definition = &definition; - let staging = super::read_staging(&view_ds.schema().metadata)?; - ensure_no_mem_wal(&view_ds, "materialized view", view.name()).await?; - - let source_ds = open_source(view, definition, staging.as_ref()).await?; - let source_ds = match pinned { - Some(version) => source_ds.checkout_version(version).await?, - None => source_ds, + let source = open_source(view, &definition).await?; + let source = match pinned { + Some(version) => source.checkout_version(version).await?, + None => source, }; - ensure_no_mem_wal(&source_ds, "source table", &definition.source_table).await?; - let source_version = source_ds.version().version; - let source_ts = source_ds.manifest.timestamp_nanos; + ensure_no_mem_wal(&source, "source table", &definition.source_table).await?; + let source_version = source.version().version; - // Re-plan the persisted definition against the current source schema and - // require its planned output to be exactly the view's physical schema: a - // definition the stored table cannot represent must not be certified. - let source_schema = Arc::new(ArrowSchema::from(source_ds.schema())); + let source_schema = Arc::new(ArrowSchema::from(source.schema())); let super::Planned { - definition: replanned, + definition, fields: planned_fields, inputs, .. - } = super::plan(source_schema.clone(), definition, staging.as_ref()).map_err(|e| match e { - // The stored query planned when the view was declared; what changed - // since is the source. + } = super::plan(source_schema, &definition).map_err(|error| match error { Error::InvalidExpression { column, message } => Error::Schema { message: format!( - "view column '{column}' no longer plans against '{}' (a source column \ - was dropped or renamed): {message}", + "view column '{column}' no longer plans against '{}' (a source column was dropped or renamed): {message}", definition.source_table ), }, Error::InvalidInput { message } => Error::Schema { message: format!( - "the stored query no longer plans against '{}' (a source column was \ - dropped or renamed): {message}", + "the stored query no longer plans against '{}' (a source column was dropped or renamed): {message}", definition.source_table ), }, - e => e, + error => error, })?; - let mut planned_fields = planned_fields; - planned_fields.push(arrow_schema::Field::new( - SOURCE_ROW_ID_COLUMN, - arrow_schema::DataType::UInt64, - false, - )); - // A computed column is not planned from the source: refresh writes it - // NULL and its declaration's owner fills it. Its declaration must still - // be complete, and it must be able to hold NULL. - let physical = ArrowSchema::from(view_ds.schema()); - let mut computed = computed_columns(&physical).into_iter().map(|c| c.name); - if let Some(name) = computed.by_ref().find(|name| { - physical - .field_with_name(name) - .is_ok_and(|f| !f.is_nullable()) - }) { + + let physical = Arc::new(ArrowSchema::from(view_ds.schema())); + ensure_declarations_are_planned(&physical)?; + if let Some(field) = physical + .fields() + .iter() + .find(|field| computed_column_from_field(field).is_some() && !field.is_nullable()) + { return Err(Error::Schema { message: format!( - "computed column '{name}' of view '{}' cannot hold NULL; recreate the view", + "computed column '{}' of view '{}' cannot hold NULL; recreate the view", + field.name(), view.name() ), }); } - ensure_declarations_are_planned(&physical)?; - let physical_planned: Vec<&FieldRef> = physical + let stored_fields: Vec<_> = physical .fields() .iter() - .filter(|f| computed_column_from_field(f).is_none()) + .filter(|field| computed_column_from_field(field).is_none()) .collect(); - // A projected column that became nullable at the source still fits the - // view's nullable field; the reverse would not. - let matches = planned_fields.len() == physical_planned.len() - && planned_fields.iter().zip(&physical_planned).all(|(e, p)| { - e.name() == p.name() - && e.data_type() == p.data_type() - && (p.is_nullable() || !e.is_nullable()) - }); + let matches = planned_fields.len() == stored_fields.len() + && planned_fields + .iter() + .zip(stored_fields) + .all(|(planned, stored)| { + planned.name() == stored.name() + && planned.data_type() == stored.data_type() + && (stored.is_nullable() || !planned.is_nullable()) + }); if !matches { return Err(Error::Schema { message: format!( - "the stored definition of view '{}' does not produce this \ - view's schema; recreate the view", + "the stored definition of view '{}' does not produce this view's schema; recreate the view", view.name() ), }); } - // The stored query is rewritten whenever its stored form differs from - // the current one: a legacy layout, or a spelling the canonicalizer no - // longer produces. Whether the rows change is a separate question: a - // legacy raw filter like `"Party" = 'D'` read the double quotes as a - // string literal, so its watermark certifies different rows than the - // canonical predicate, and only a rebuild can replace them. - let current = definition_to_metadata(&replanned)?; - let persist = view_ds.schema().metadata.get(DEFINITION_META_KEY) != Some(¤t); - let unnest = super::physical_unnest(&replanned, staging.as_ref())?; - let definition_changed = !same_meaning(&source_schema, definition, &replanned, unnest.as_ref()); - let definition = &replanned; - let persist = persist.then_some(definition); - if definition_changed { - return rebuild( - view_native, - &view_ds, - &source_ds, - source_version, - source_ts, - definition, - &inputs, - unnest.as_ref(), - true, - expected_incarnation, - ) - .await; - } - - let metadata = &view_ds.schema().metadata; - let watermark: Option = metadata - .get(SOURCE_VERSION_META_KEY) - .and_then(|raw| raw.parse().ok()); - let recorded_ts: Option = metadata - .get(SOURCE_VERSION_TS_META_KEY) - .and_then(|raw| raw.parse().ok()); - // The watermark speaks only for the view state its refresh left behind; - // any other commit on the view since then is drift, except a fill of its - // computed columns, which rewrites nothing refresh certifies. - let recorded_view_version = metadata - .get(VIEW_VERSION_META_KEY) - .and_then(|raw| raw.parse::().ok()); - let view_intact = match recorded_view_version { - Some(recorded) if recorded == view_ds.version().version => true, - Some(recorded) if recorded < view_ds.version().version => { - only_computed_rewrites_since(&view_ds, recorded).await? - } - _ => false, - }; - - if !full && watermark == Some(source_version) && view_intact && recorded_ts == Some(source_ts) { - return Ok(RefreshMaterializedViewResult { - mode: RefreshMode::NoOp, - rows_written: 0, - source_version, - version: view_ds.version().version, - }); - } - - // A group spans fragments: there is no per-fragment increment. - if definition.is_grouped() { - return rebuild( - view_native, - &view_ds, - &source_ds, - source_version, - source_ts, - definition, - &inputs, - None, - persist.is_some(), - expected_incarnation, - ) - .await; - } - let watermark = watermark.filter(|_| view_intact); - match plan_increment( - &source_ds, - source_version, - watermark, - recorded_ts, - full, - definition, + let rows_written = Arc::new(AtomicU64::new(0)); + let unnest = super::physical_unnest(&definition)?; + let stream = compute_stream( + &source, + &definition, &inputs, + unnest.as_ref(), + physical, + rows_written.clone(), ) - .await - { - Some(increment) => { - let reconciled = incremental( - view_native, - &view_ds, - &source_ds, - source_version, - source_ts, - increment, - definition, - &inputs, - unnest.as_ref(), - persist, - watermark, - expected_incarnation, - ) - .await?; - match reconciled { - Some(result) => Ok(result), - // The delta was too large to reconcile in bounded memory. - None => { - rebuild( - view_native, - &view_ds, - &source_ds, - source_version, - source_ts, - definition, - &inputs, - unnest.as_ref(), - persist.is_some(), - expected_incarnation, - ) - .await - } - } - } - None => { - rebuild( - view_native, - &view_ds, - &source_ds, - source_version, - source_ts, - definition, - &inputs, - unnest.as_ref(), - persist.is_some(), - expected_incarnation, - ) - .await - } - } -} - -/// The source fragments whose rows are new since the watermark, or `None` -/// where the view has to rebuild. Two tiers: the transaction walk is exact -/// where it applies; the fragment-signature check is the fallback for deltas -/// the walk cannot read, and under it any fragment churn rebuilds. -async fn plan_increment( - source_ds: &Dataset, - source_version: u64, - watermark: Option, - recorded_ts: Option, - full: bool, - definition: &MaterializedViewDefinition, - inputs: &[String], -) -> Option { - if full { - return None; - } - let watermark = watermark?; - if watermark > source_version { - return None; - } - let old = source_ds.checkout_version(watermark).await.ok()?; - // A recreated source reuses version numbers, never their timestamps: a - // mismatch means the watermark describes a different incarnation. - if recorded_ts != Some(old.manifest.timestamp_nanos) { - return None; - } - let old_ids: HashSet = old.get_fragments().iter().map(|f| f.id() as u64).collect(); - let live: Vec = source_ds - .get_fragments() - .iter() - .map(|f| f.metadata().clone()) - .collect(); - - if let Some(delta) = appends_and_rewrites(source_ds, watermark, source_version).await { - // A rewrite that consumed a fragment neither present at the watermark - // nor produced by an earlier rewrite swallowed a mid-delta append; - // its rows cannot be told apart from already-materialized ones. - let folded = delta - .rewritten - .iter() - .any(|id| !old_ids.contains(id) && !delta.produced.contains(id)); - if folded { - return None; - } - // An update rewrites a whole fragment. If it touched one the watermark - // never saw, that fragment holds rows appended since -- new rows the - // update did not change, which the recompute does not cover and which - // this fragment's exclusion from the append set would drop. - if delta - .updated_in_place - .iter() - .any(|id| !old_ids.contains(id)) - { - return None; - } - // Rows past the cap left every delta when the watermark advanced, so - // a capped view cannot reconcile a removal incrementally. The rebuild - // is cheap for the same reason it is capped: the scan stops there. - if definition.limit.is_some() && (delta.deleted_rows || delta.updated_rows) { - return None; - } - // Legacy storage cannot serve the row-version columns update - // discovery scans; deletes and appends need none of them. - if delta.updated_rows - && source_ds.manifest.data_storage_format.lance_file_format() == ConcreteFileVersion::V1 - { - return None; - } - // Every other fragment new at head is an append: appends and rewrites - // are the only operations in the delta that add fragments, and the - // rewrite outputs are already-materialized rows rearranged. - return Some(Increment { - appended: live - .into_iter() - .filter(|f| !old_ids.contains(&f.id) && !delta.produced.contains(&f.id)) - .collect(), - evict_deleted: delta.deleted_rows, - replace_updated: delta.updated_rows, - }); - } - - is_pure_append(&old, source_ds, &relevant_field_ids(source_ds, inputs)).then(|| Increment { - appended: live - .into_iter() - .filter(|f| !old_ids.contains(&f.id)) - .collect(), - evict_deleted: false, - replace_updated: false, + .await?; + let committed = replace_fragments(view_ds, stream).await?; + let version = committed.version().version; + native.dataset.update(committed); + Ok(RefreshMaterializedViewResult { + mode: RefreshMode::Rebuild, + rows_written: rows_written.load(Ordering::Relaxed), + source_version, + version, }) } -/// Version gap beyond which per-version transaction reads stop being cheaper -/// than one fragment scan. -const MAX_TRANSACTION_WALK: u64 = 512; - -/// Fragment ids moved by the `Rewrite` operations of the delta. Only rewrite -/// ids are real in transaction files (an Append's are placeholders assigned -/// at commit), so appends are derived as new-at-head minus rewrite outputs. -/// What an incremental refresh must do to bring the view up to date. -struct Increment { - /// Source fragments whose rows are not in the view yet. - appended: Vec, - /// Source rows left the range, so the view holds rows to evict. - evict_deleted: bool, - /// Source rows changed in the range, so the view holds rows to replace. - replace_updated: bool, -} - -struct TxnDelta { - /// Fragments consumed by `Rewrite` operations. - rewritten: HashSet, - /// Fragments produced by `Rewrite` operations. - produced: HashSet, - /// The delta removed source rows, so the view holds rows to evict. - deleted_rows: bool, - /// The delta changed source rows in place, so the view holds rows to - /// recompute. - updated_rows: bool, - /// Fragments an update modified in place, as opposed to produced. - updated_in_place: HashSet, -} - -/// Read the delta from the transaction log, `None` where it holds anything -/// but appends and rewrites or cannot be read; `None` only sends the caller -/// to a slower check. `ReserveFragments` moves no rows and rides along. -async fn appends_and_rewrites(cur: &Dataset, from: u64, to: u64) -> Option { - if to <= from || to - from > MAX_TRANSACTION_WALK { - return None; +/// Atomically replace all fragments of a materialized view with a complete +/// query result. This is shared with Sophon's SQL refresh job. +pub async fn replace_materialized_view_fragments( + view: &Table, + stream: SendableRecordBatchStream, +) -> Result { + let native = view.as_native().ok_or_else(|| Error::NotSupported { + message: "materialized views are supported only on local tables".into(), + })?; + native.dataset.ensure_mutable()?; + let lock = refresh_lock(native.dataset.get().await?.uri()); + let _guard = lock.lock().await; + native.dataset.reload().await?; + let dataset = native.dataset.get().await?.as_ref().clone(); + if super::read_definition_sql(&dataset.schema().metadata)?.is_none() { + return Err(Error::NotAMaterializedView { + name: view.name().to_string(), + }); } - let mut delta = TxnDelta { - rewritten: HashSet::new(), - produced: HashSet::new(), - deleted_rows: false, - updated_rows: false, - updated_in_place: HashSet::new(), + let committed = replace_fragments(dataset, stream).await?; + let version = committed.version().version; + native.dataset.update(committed); + Ok(version) +} + +async fn replace_fragments(dataset: Dataset, stream: SendableRecordBatchStream) -> Result { + let dataset = Arc::new(dataset); + let read_version = dataset.version().version; + let removed_fragment_ids = dataset + .get_fragments() + .iter() + .map(|fragment| fragment.id() as u64) + .collect(); + let write = InsertBuilder::new(WriteDestination::Dataset(dataset.clone())) + .with_params(&WriteParams { + mode: WriteMode::Append, + ..Default::default() + }) + .execute_uncommitted_stream(stream) + .await?; + let Operation::Append { + fragments: new_fragments, + } = write.operation + else { + return Err(Error::Runtime { + message: "expected an append while staging replacement rows".into(), + }); }; - for version in (from + 1)..=to { - let Ok(Some(txn)) = cur.read_transaction_by_version(version).await else { - return None; - }; - match txn.operation { - Operation::Append { .. } | Operation::ReserveFragments { .. } => {} - // A delete removes source rows without changing the ones that - // remain, so the view's other rows stay valid: the refresh - // evicts exactly the ids that left. - Operation::Delete { .. } => delta.deleted_rows = true, - // Update outputs carry no new rows, so they are excluded from - // the append set like rewrite outputs; the changed rows are - // replaced individually below. + let inserted_rows_filter = refresh_conflict_filter(&dataset)?; + let committed = CommitBuilder::new(WriteDestination::Dataset(dataset)) + .execute(Transaction::new( + read_version, Operation::Update { removed_fragment_ids, + updated_fragments: Vec::new(), new_fragments, - updated_fragments, - .. - } => { - delta.updated_rows = true; - // merge_insert reaches here too, and its by-source arm deletes - // rows rather than changing them. - delta.deleted_rows = true; - delta.rewritten.extend(removed_fragment_ids.iter().copied()); - // Only pre-existing fragment ids are real here; created ones - // are placeholders. Rewritten rows are excluded by creation - // version below, not by fragment identity. - delta - .produced - .extend(updated_fragments.iter().map(|f| f.id)); - delta - .updated_in_place - .extend(updated_fragments.iter().map(|f| f.id)); - let _ = new_fragments; - } - Operation::Rewrite { groups, .. } => { - for group in groups { - delta - .rewritten - .extend(group.old_fragments.iter().map(|f| f.id)); - delta - .produced - .extend(group.new_fragments.iter().map(|f| f.id)); - } - } - _ => return None, - } + fields_modified: Vec::new(), + compacted_sstables: Vec::new(), + fields_for_preserving_frag_bitmap: Vec::new(), + update_mode: None, + inserted_rows_filter: Some(inserted_rows_filter), + updated_fragment_offsets: None, + }, + None, + )) + .await?; + if committed.version().version != read_version + 1 { + return Err(Error::Runtime { + message: format!( + "a concurrent commit raced this refresh (view version {}, expected {})", + committed.version().version, + read_version + 1 + ), + }); } - Some(delta) + Ok(committed) } -/// Fallback pure-append check: every old fragment still present with an -/// identical signature over the columns the view reads. Compaction, deletes -/// and updates each break it and force a rebuild; a change to a column the -/// view does not read leaves it alone, which is what lets this tier pass -/// deltas the transaction walk cannot. -fn is_pure_append(old: &Dataset, cur: &Dataset, relevant: &HashSet) -> bool { - let signature = |fragment: &lance::dataset::fragment::FileFragment| { - fragment_signature(fragment.metadata(), relevant) - }; - let current: HashSet<(u64, String)> = cur.get_fragments().iter().map(signature).collect(); - old.get_fragments() - .iter() - .all(|fragment| current.contains(&signature(fragment))) +const REFRESH_TOKEN_ID: u64 = u64::MAX; + +fn refresh_conflict_filter(dataset: &Dataset) -> Result { + let field_id = dataset + .schema() + .fields + .first() + .map(|field| field.id) + .ok_or_else(|| Error::Runtime { + message: "a materialized view must have at least one field".into(), + })?; + let mut filter = KeyExistenceFilterBuilder::new(vec![field_id]); + filter + .insert(KeyValue::UInt64(REFRESH_TOKEN_ID)) + .map_err(|error| Error::Runtime { + message: format!( + "failed to build the materialized-view refresh conflict filter: {error}" + ), + })?; + Ok(filter.build()) } -/// A fragment's identity as the view observes it: data files and overlays -/// touching the columns it reads, plus the deletion file. Overlays change no -/// file path, so they must be part of the signature. -fn fragment_signature(metadata: &Fragment, relevant: &HashSet) -> (u64, String) { - let touches_relevant = - |fields: &[i32]| relevant.is_empty() || fields.iter().any(|id| relevant.contains(id)); - let mut files: Vec<&str> = metadata - .files - .iter() - .filter(|file| touches_relevant(&file.fields)) - .map(|file| file.path.as_str()) - .collect(); - files.sort_unstable(); - let mut overlays: Vec = metadata - .overlays - .iter() - .filter(|overlay| touches_relevant(&overlay.data_file.fields)) - .map(|overlay| format!("{}@{}", overlay.data_file.path, overlay.committed_version)) - .collect(); - overlays.sort_unstable(); - ( - metadata.id, - format!( - "{}|{}|{:?}", - files.join(","), - overlays.join(","), - metadata.deletion_file - ), - ) -} - -/// Field ids (with struct descendants) of the source columns the view reads. -fn relevant_field_ids(source: &Dataset, inputs: &[String]) -> HashSet { - fn collect(field: &lance_core::datatypes::Field, ids: &mut HashSet) { - ids.insert(field.id); - for child in &field.children { - collect(child, ids); - } - } - let mut ids = HashSet::new(); - for input in inputs { - if let Some(field) = source.schema().field(input) { - collect(field, &mut ids); - } - } - ids -} - -/// Whether two plannings of a view compute the same rows and columns: the -/// same source, unnest and limit, and expressions the planner reads as the -/// same logical expression, whatever their spelling. A definition that does -/// not plan compares as different. -fn same_meaning( - source_schema: &SchemaRef, - stored: &MaterializedViewDefinition, - replanned: &MaterializedViewDefinition, - unnest: Option<&super::ViewUnnest>, -) -> bool { - if stored.source_table != replanned.source_table - || stored.source_namespace != replanned.source_namespace - || stored.lateral != replanned.lateral - || stored.limit != replanned.limit - || stored.projections.len() != replanned.projections.len() - || stored.filter.is_some() != replanned.filter.is_some() - || stored.group_by.len() != replanned.group_by.len() - { - return false; - } - // Lance's planner does not read aggregates; a grouped definition is - // compared in its canonical spelling, which planning produced. - if stored.is_grouped() { - return stored.projections == replanned.projections - && stored.group_by == replanned.group_by - && stored.filter == replanned.filter; - } - let schema = match unnest { - None => source_schema.clone(), - Some(unnest) => match super::flattened_schema(source_schema, unnest) { - Ok(schema) => schema, - Err(_) => return false, - }, - }; - let planner = Planner::new(schema); - let same_expr = |a: &str, b: &str| match (planner.parse_expr(a), planner.parse_expr(b)) { - (Ok(a), Ok(b)) => a == b, - _ => false, - }; - stored - .projections - .iter() - .zip(&replanned.projections) - .all(|(a, b)| a.output == b.output && same_expr(&a.expression, &b.expression)) - && match (&stored.filter, &replanned.filter) { - (Some(a), Some(b)) => same_expr(a, b), - _ => true, - } -} - -/// Reject MemWAL/LSM state on a refresh participant: un-compacted tiers are -/// invisible to the fragment-planned refresh scan. An active write spec and -/// retained rows both disqualify; shard directories on storage are the -/// durable evidence of the latter. +/// Reject MemWAL/LSM state because an ordinary dataset scan cannot see its +/// un-compacted tiers. pub(crate) async fn ensure_no_mem_wal(dataset: &Dataset, role: &str, name: &str) -> Result<()> { let retained = !dataset.list_mem_wal_latest_shard_ids().await?.is_empty(); if retained || dataset.mem_wal_index_details().await?.is_some() { return Err(Error::NotSupported { - message: format!( - "{role} '{name}' has an LSM write spec or retained un-compacted \ - rows: rows in un-compacted tiers are invisible to refresh" - ), + message: format!("{role} '{name}' has an LSM write spec or retained un-compacted rows"), }); } Ok(()) } -/// The table refresh scans: the staging table when the query calls a -/// Function in FROM position, otherwise the query's source. -pub(super) async fn open_source( - view: &Table, - definition: &MaterializedViewDefinition, - staging: Option<&super::StagingBinding>, -) -> Result { +async fn open_source(view: &Table, definition: &MaterializedViewDefinition) -> Result { let database = view.database_opt().ok_or_else(|| Error::InvalidInput { message: "the view was not opened through a database connection".into(), })?; - let (name, namespace_path) = match staging { - Some(staging) => (staging.table.clone(), staging.namespace.clone()), - None => ( - definition.source_table.clone(), - definition.source_namespace.clone(), - ), - }; let source = database .open_table(OpenTableRequest { - name, - namespace_path, + name: definition.source_table.clone(), + namespace_path: definition.source_namespace.clone(), index_cache_size: None, lance_read_params: None, location: None, @@ -747,584 +318,7 @@ pub(super) async fn open_source( let native = source.as_native().ok_or_else(|| Error::NotSupported { message: "materialized views are supported only on local tables".into(), })?; - let dataset = native.dataset.get().await?.as_ref().clone(); - if definition.vector_source.is_none() && !dataset.manifest.uses_stable_row_ids() { - return Err(Error::InvalidInput { - message: format!( - "source table '{}' does not have stable row ids; it is not the \ - table this view was declared over", - definition.source_table - ), - }); - } - Ok(dataset) -} - -#[allow(clippy::too_many_arguments)] -async fn incremental( - view_native: &NativeTable, - view_ds: &Dataset, - source_ds: &Dataset, - source_version: u64, - source_ts: u128, - increment: Increment, - definition: &MaterializedViewDefinition, - inputs: &[String], - unnest: Option<&super::ViewUnnest>, - persist: Option<&MaterializedViewDefinition>, - watermark: Option, - expected_incarnation: Option<&str>, -) -> Result> { - let new_fragments = increment.appended; - let watermark_version = watermark.unwrap_or(0); - // Provenance ids this refresh removes: dropped rows, plus changed rows - // recomputed in the same commit. One view row per source row makes the - // eviction exact. Staged, not committed: removals ride with the rows - // that replace them, so a reader never sees the view without either. - let mut eviction = Eviction::new(view_ds, EVICTION_CHUNK); - let mut updated_rows = false; - if (increment.evict_deleted || increment.replace_updated) - && let Some(watermark) = watermark - { - let delta = source_ds - .delta() - .with_begin_version(watermark) - .with_end_version(source_version) - .build()?; - // Reconciling holds the delta's provenance ids in staged deletion - // vectors; past the cap, the streamed rebuild is the bounded path. - let cap = eviction_rebuild_cap(); - let mut evicted = 0usize; - if increment.evict_deleted { - let mut stream = delta.get_deleted_row_ids().await?; - while let Some(batch) = stream.try_next().await? { - let ids = row_ids_of(&batch)?; - evicted += ids.len(); - if evicted > cap { - return Ok(None); - } - eviction.push(ids).await?; - } - } - if increment.replace_updated { - // Ids only: `get_updated_rows` carries every column of every - // updated row, and discovery needs none of them. - let mut scanner = source_ds.scan(); - scanner.with_row_id().project(&[ROW_CREATED_AT_VERSION])?; - // A fixed bound: the configured default could make one discovery - // batch arbitrarily large before the fallback cap is consulted. - scanner.batch_size(8192); - scanner.filter(&format!( - "{ROW_CREATED_AT_VERSION} <= {watermark} - AND {ROW_LAST_UPDATED_AT_VERSION} > {watermark} - AND {ROW_LAST_UPDATED_AT_VERSION} <= {source_version}" - ))?; - let mut stream = scanner.try_into_stream().await?; - while let Some(batch) = stream.try_next().await? { - let ids = row_ids_of(&batch)?; - evicted += ids.len(); - if evicted > cap { - return Ok(None); - } - updated_rows |= !ids.is_empty(); - eviction.push(ids).await?; - } - } - } - let eviction = eviction.finish().await?; - - // The cap counts rows already materialized, in first-materialized order. - let remaining = match definition.limit { - Some(limit) => { - let held = view_ds.count_rows(None).await? as u64; - Some(limit.saturating_sub(held)) - } - None => None, - }; - - let mut result = RefreshMaterializedViewResult { - mode: RefreshMode::Incremental, - rows_written: 0, - source_version, - version: view_ds.version().version, - }; - let nothing_to_add = (new_fragments.is_empty() && !updated_rows) || remaining == Some(0); - if nothing_to_add && eviction.is_none() { - result.version = stamp_watermark( - view_native, - view_ds.clone(), - source_version, - source_ts, - persist, - expected_incarnation, - ) - .await?; - return Ok(Some(result)); - } - // Rows left but none arrive: the removals still have to be published. - if nothing_to_add { - let filter = refresh_filter(&empty_keys(view_ds)?)?; - let published = publish( - view_ds, - eviction, - Vec::new(), - Some(filter), - expected_incarnation, - ) - .await?; - result.version = stamp_watermark( - view_native, - published, - source_version, - source_ts, - persist, - expected_incarnation, - ) - .await?; - return Ok(Some(result)); - } - - // Appends carry the view's schema as it stands; the watermark moves in a - // follow-up commit (see the module docs for the crash window). - let schema = Arc::new(ArrowSchema::from(view_ds.schema())); - let rows_written = Arc::new(AtomicU64::new(0)); - // compute_stream counts what it produces; the truncation below can drop - // some of that, so the written count comes from the tee instead. - let computed = Arc::new(AtomicU64::new(0)); - let mut stream = compute_stream( - source_ds, - definition, - inputs, - unnest, - RowScope { - fragments: Some(new_fragments), - // An update rewrites whole fragments, so a fragment new at head - // can hold rows the view already has. Their creation version - // does not change, so it -- not fragment identity -- says which - // rows are new. - created_after: increment.replace_updated.then_some(watermark_version), - limit: remaining, - ..Default::default() - }, - schema.clone(), - computed.clone(), - ) - .await?; - - // The updated rows' current values, computed the same way and appended - // in the same commit as the new fragments' rows. - if updated_rows { - let recomputed = compute_stream( - source_ds, - definition, - inputs, - unnest, - RowScope { - updated_between: Some((watermark_version, source_version)), - ..Default::default() - }, - schema.clone(), - computed.clone(), - ) - .await?; - stream = Box::pin(RecordBatchStreamAdapter::new( - schema.clone(), - recomputed.chain(stream), - )); - } - - // Nothing survived the filter: the watermark still has to advance or the - // same fragments would be rescanned forever, but any removals still do. - let Some(first) = stream.try_next().await? else { - let published = if eviction.is_some() { - publish( - view_ds, - eviction, - Vec::new(), - Some(refresh_filter(&empty_keys(view_ds)?)?), - expected_incarnation, - ) - .await? - } else { - view_ds.clone() - }; - result.version = stamp_watermark( - view_native, - published, - source_version, - source_ts, - persist, - expected_incarnation, - ) - .await?; - return Ok(Some(result)); - }; - let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new( - schema, - futures::stream::iter([Ok(first)]).chain(stream), - )); - - // Any two refreshes of one view must not both commit: the filter's - // shared token makes lance reject the loser on key overlap however - // their planned rows relate. - let keys = Arc::new(StdMutex::new(KeyExistenceFilterBuilder::new(vec![ - source_row_id_field_id(view_ds)?, - ]))); - let stream = collect_source_row_ids(stream, keys.clone(), rows_written.clone()); - - let ds = Arc::new(view_ds.clone()); - let write_txn = InsertBuilder::new(WriteDestination::Dataset(ds.clone())) - .with_params(&WriteParams { - mode: WriteMode::Append, - ..Default::default() - }) - .execute_uncommitted_stream(stream) - .await?; - let Operation::Append { - fragments: new_fragments, - } = write_txn.operation - else { - return Err(Error::Runtime { - message: "expected an append when staging the view's new rows".into(), - }); - }; - let filter = refresh_filter(&keys)?; - let appended = publish( - view_ds, - eviction, - new_fragments, - Some(filter), - expected_incarnation, - ) - .await?; - result.rows_written = rows_written.load(Ordering::Relaxed); - result.version = stamp_watermark( - view_native, - appended, - source_version, - source_ts, - persist, - expected_incarnation, - ) - .await?; - Ok(Some(result)) -} - -#[allow(clippy::too_many_arguments)] -async fn rebuild( - view_native: &NativeTable, - view_ds: &Dataset, - source_ds: &Dataset, - source_version: u64, - source_ts: u128, - definition: &MaterializedViewDefinition, - inputs: &[String], - unnest: Option<&super::ViewUnnest>, - persist_definition: bool, - expected_incarnation: Option<&str>, -) -> Result { - let rows_written = Arc::new(AtomicU64::new(0)); - let schema = Arc::new(ArrowSchema::from(view_ds.schema())); - let stream = compute_stream( - source_ds, - definition, - inputs, - unnest, - RowScope { - limit: definition.limit, - ..Default::default() - }, - schema, - rows_written.clone(), - ) - .await?; - let keys = Arc::new(StdMutex::new(KeyExistenceFilterBuilder::new(vec![ - source_row_id_field_id(view_ds)?, - ]))); - let stream = collect_source_row_ids(stream, keys.clone(), Arc::new(AtomicU64::new(0))); - // Every rebuild is one fragment swap, indexed or not: an Update commit - // carries no schema metadata, so it cannot erase a definition update - // that raced in the way an overwrite (which adopts its stream's schema) - // durably would -- and it must land on the planned generation or abort. - let replaced = - replace_retaining_indices(view_ds.clone(), stream, keys, expected_incarnation).await?; - let version = stamp_watermark( - view_native, - replaced, - source_version, - source_ts, - persist_definition.then_some(definition), - expected_incarnation, - ) - .await?; - Ok(RefreshMaterializedViewResult { - mode: RefreshMode::Rebuild, - rows_written: rows_written.load(Ordering::Relaxed), - source_version, - version, - }) -} - -/// Replace all of the view's data in one commit that retains its index -/// definitions: new fragments staged uncommitted, one `Update` removing every -/// old fragment. `Update` prunes index bitmaps only for modified fields and -/// none are modified here, so readers never see the view unindexed or empty. -async fn replace_retaining_indices( - view_ds: Dataset, - stream: SendableRecordBatchStream, - keys: Arc>, - expected_incarnation: Option<&str>, -) -> Result { - let ds = Arc::new(view_ds); - let read_version = ds.version().version; - #[cfg(test)] - tests::hold_before_publish(ds.uri()).await; - ensure_incarnation(&ds, expected_incarnation, ds.uri()).await?; - let removed_fragment_ids: Vec = ds.get_fragments().iter().map(|f| f.id() as u64).collect(); - - let write_txn = InsertBuilder::new(WriteDestination::Dataset(ds.clone())) - .with_params(&WriteParams { - mode: WriteMode::Append, - ..Default::default() - }) - .execute_uncommitted_stream(stream) - .await?; - let Operation::Append { - fragments: new_fragments, - } = write_txn.operation - else { - return Err(Error::Runtime { - message: "expected an append when staging the view's replacement rows".into(), - }); - }; - - // Built only now: the tee fills as the staging drains the stream. - let filter = refresh_filter(&keys)?; - let transaction = Transaction::new( - read_version, - Operation::Update { - removed_fragment_ids, - updated_fragments: Vec::new(), - new_fragments, - fields_modified: Vec::new(), - compacted_sstables: Vec::new(), - fields_for_preserving_frag_bitmap: Vec::new(), - update_mode: None, - // Two refreshes that materialized the same source rows must not - // both land -- a raced first rebuild would double the view. - inserted_rows_filter: Some(filter), - updated_fragment_offsets: None, - }, - None, - ); - let committed = CommitBuilder::new(WriteDestination::Dataset(ds)) - .execute(transaction) - .await?; - if committed.version().version != read_version + 1 { - return Err(Error::Runtime { - message: format!( - "a concurrent commit raced this refresh (view version {}); the - refresh is unrecorded and the next one will rebuild", - committed.version().version - ), - }); - } - Ok(committed) -} - -/// Record that the view now reflects `source_version`, including the view -/// Refuse to act on a view that is not `expected`'s incarnation, judged from -/// the latest stored manifest. Not a commit condition; see -/// `RefreshMaterializedViewBuilder::expect_incarnation`. -pub(super) async fn ensure_incarnation( - view_ds: &Dataset, - expected: Option<&str>, - what: &str, -) -> Result<()> { - let Some(expected) = expected else { - return Ok(()); - }; - let mut latest = view_ds.clone(); - latest.checkout_latest().await?; - match latest.schema().metadata.get(INCARNATION_META_KEY) { - Some(actual) if actual == expected => Ok(()), - Some(_) => Err(Error::Runtime { - message: format!( - "materialized view '{what}' is not the incarnation this refresh was \ - requested for: it was dropped and recreated" - ), - }), - None => Err(Error::Runtime { - message: format!( - "materialized view '{what}' carries no incarnation token: its schema \ - metadata was replaced since the token was captured" - ), - }), - } -} - -/// version this very commit produces. The version is predicted and then -/// verified; on a mismatch another commit raced in between, and the stamp -/// ABORTS rather than certify that commit as the refresh's own generation. -/// The view is left visibly unstamped, so the next refresh rebuilds. -pub(super) async fn stamp_watermark( - view_native: &NativeTable, - mut dataset: Dataset, - source_version: u64, - source_ts: u128, - definition: Option<&MaterializedViewDefinition>, - expected_incarnation: Option<&str>, -) -> Result { - ensure_incarnation(&dataset, expected_incarnation, dataset.uri()).await?; - let predicted = dataset.version().version + 1; - // A view with no token (declared before tokens existed, or its metadata - // replaced wholesale) starts a new incarnation here. - let incarnation = dataset - .schema() - .metadata - .get(INCARNATION_META_KEY) - .cloned() - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let mut metadata = vec![(INCARNATION_META_KEY.to_string(), Some(incarnation))]; - if let Some(definition) = definition { - metadata.push(( - DEFINITION_META_KEY.to_string(), - Some(definition_to_metadata(definition)?), - )); - } - metadata.extend([ - ( - SOURCE_VERSION_META_KEY.to_string(), - Some(source_version.to_string()), - ), - ( - SOURCE_VERSION_TS_META_KEY.to_string(), - Some(source_ts.to_string()), - ), - ( - REFRESHED_AT_MS_META_KEY.to_string(), - Some(now_ms().to_string()), - ), - ( - VIEW_VERSION_META_KEY.to_string(), - Some(predicted.to_string()), - ), - ]); - dataset.update_schema_metadata(metadata).await?; - let actual = dataset.version().version; - if actual != predicted { - return Err(Error::Runtime { - message: format!( - "a concurrent commit raced this refresh (view version {actual}, \ - expected {predicted}); the refresh is unrecorded and the next \ - one will rebuild" - ), - }); - } - view_native.dataset.update(dataset); - Ok(predicted) -} - -fn now_ms() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() -} - -/// Evaluate the definition over `source`, restricted to `fragments` when -/// given, as batches in the view's schema. The filter is pushed into the -/// scan; projections are `(name, expression)` pairs, never spliced into SQL; -/// [`SOURCE_ROW_ID_COLUMN`] is filled by the scan's row id. -/// Which source rows a compute pass reads. -#[derive(Default)] -struct RowScope { - /// Read only these fragments. - fragments: Option>, - /// Read only rows created after this version. - created_after: Option, - /// Read only rows changed in place after the first version and no later - /// than the second. - updated_between: Option<(u64, u64)>, - /// Stop after this many rows. - limit: Option, -} - -/// Whether every commit on the view after `recorded` is a fill of its -/// computed columns: a column rewrite or data replacement touching only -/// those fields and neither adding nor removing rows, or the freshness -/// stamp a fill leaves on them. A version whose transaction cannot be read -/// is not proven, so it counts as drift. -async fn only_computed_rewrites_since(view_ds: &Dataset, recorded: u64) -> Result { - // A fill may write any field under a computed column, so the whole - // subtree counts, not only the root. - let physical = ArrowSchema::from(view_ds.schema()); - fn subtree(field: &lance_core::datatypes::Field, ids: &mut Vec) { - ids.push(field.id as u32); - for child in &field.children { - subtree(child, ids); - } - } - let mut computed_fields = Vec::new(); - for column in computed_columns(&physical) { - if let Some(field) = view_ds.schema().field(&column.name) { - subtree(field, &mut computed_fields); - } - } - if computed_fields.is_empty() { - return Ok(false); - } - for version in recorded + 1..=view_ds.version().version { - let Some(transaction) = view_ds.read_transaction_by_version(version).await? else { - return Ok(false); - }; - let fill = match &transaction.operation { - Operation::Update { - removed_fragment_ids, - new_fragments, - fields_modified, - update_mode: Some(UpdateMode::RewriteColumns), - .. - } => { - removed_fragment_ids.is_empty() - && new_fragments.is_empty() - && !fields_modified.is_empty() - && fields_modified - .iter() - .all(|field| computed_fields.contains(field)) - } - // What `refresh_column` commits for a SQL declaration. - Operation::DataReplacement { replacements } => { - !replacements.is_empty() - && replacements.iter().all(|group| { - !group.1.fields.is_empty() - && group - .1 - .fields - .iter() - .all(|field| computed_fields.contains(&(*field as u32))) - }) - } - // The stamp `refresh_column` writes after its fill (see - // `table::freshness`): field metadata on computed columns, no data. - Operation::UpdateConfig { - config_updates: None, - table_metadata_updates: None, - schema_metadata_updates: None, - field_metadata_updates, - } => { - !field_metadata_updates.is_empty() - && field_metadata_updates - .keys() - .all(|field| computed_fields.contains(&(*field as u32))) - } - _ => false, - }; - if !fill { - return Ok(false); - } - } - Ok(true) + Ok(native.dataset.get().await?.as_ref().clone()) } async fn compute_stream( @@ -1332,58 +326,23 @@ async fn compute_stream( definition: &MaterializedViewDefinition, inputs: &[String], unnest: Option<&super::ViewUnnest>, - scope: RowScope, schema: SchemaRef, rows_written: Arc, ) -> Result { if definition.is_grouped() { return super::grouped::stream(source, definition, inputs, schema, rows_written).await; } - let RowScope { - fragments, - created_after, - updated_between, - limit, - } = scope; let mut scanner = source.scan(); - if let Some(fragments) = fragments { - scanner.with_fragments(fragments); + if let Some(filter) = definition.filter.as_deref().filter(|_| unnest.is_none()) { + scanner.filter(filter)?; } - scanner.with_row_id(); - // Narrowing keeps the definition's filter, so a row updated out of the - // view simply does not come back. Changed rows are named by the predicate - // `DatasetDelta::get_updated_rows` uses, not its streamed ids: an id list - // grows with the delta, this does not. - let updated_filter = updated_between.map(|(from, to)| { - format!( - "{ROW_CREATED_AT_VERSION} <= {from} \ - AND {ROW_LAST_UPDATED_AT_VERSION} > {from} \ - AND {ROW_LAST_UPDATED_AT_VERSION} <= {to}" - ) - }); - let created_filter = - created_after.map(|version| format!("{ROW_CREATED_AT_VERSION} > {version}")); - let clauses: Vec = definition - .filter - .clone() - .filter(|_| unnest.is_none()) - .map(|f| format!("({f})")) - .into_iter() - .chain(updated_filter) - .chain(created_filter) - .collect(); - if !clauses.is_empty() { - scanner.filter(&clauses.join(" AND "))?; - } - // An expanded view cannot project or filter in the scan: both read the - // unnested element, which exists only after the per-batch expansion. let expanded = match unnest { Some(unnest) => Some(UnnestPlan::new(source, definition, inputs, unnest)?), None => { let transforms: Vec<(&str, &str)> = definition .projections .iter() - .map(|p| (p.output.as_str(), p.expression.as_str())) + .map(|projection| (projection.output.as_str(), projection.expression.as_str())) .collect(); scanner.project_with_transform(&transforms)?; None @@ -1392,54 +351,43 @@ async fn compute_stream( if let Some(expanded) = &expanded { scanner.project(&expanded.raw_inputs)?; } - // A scan reads a limit of zero as no limit at all, so a view capped at - // nothing is answered without one. - if limit == Some(0) { + if definition.limit == Some(0) { return Ok(Box::pin(RecordBatchStreamAdapter::new( - schema.clone(), + schema, futures::stream::empty(), ))); } - if let Some(limit) = limit { + if let Some(limit) = definition.limit { let limit = i64::try_from(limit).map_err(|_| Error::InvalidInput { message: format!("view limit {limit} exceeds the maximum of {}", i64::MAX), })?; scanner.limit(Some(limit), None)?; } - - let out_schema = schema.clone(); + let output_schema = schema.clone(); let mapped = scanner.try_into_stream().await?.map(move |batch| { - let batch = batch.map_err(|e| DataFusionError::External(Box::new(e)))?; + let batch = batch.map_err(|error| DataFusionError::External(Box::new(error)))?; let batch = match &expanded { - None => batch, Some(expanded) => expanded.apply(&batch)?, + None => batch, }; - let batch = to_view_batch(&batch, &out_schema)?; + let batch = to_view_batch(&batch, &output_schema)?; rows_written.fetch_add(batch.num_rows() as u64, Ordering::Relaxed); Ok(batch) }); Ok(Box::pin(RecordBatchStreamAdapter::new(schema, mapped))) } -/// `batch` in the view's physical schema: the row id becomes the -/// provenance column and computed columns are written NULL for their owner -/// to fill. pub(super) fn to_view_batch( batch: &RecordBatch, - out_schema: &SchemaRef, + output_schema: &SchemaRef, ) -> datafusion::common::Result { - let mut columns = Vec::with_capacity(out_schema.fields().len()); - for field in out_schema.fields() { + let mut columns = Vec::with_capacity(output_schema.fields().len()); + for field in output_schema.fields() { if computed_column_from_field(field).is_some() { columns.push(new_null_array(field.data_type(), batch.num_rows())); continue; } - let name = if field.name() == SOURCE_ROW_ID_COLUMN { - ROW_ID - } else { - field.name() - }; - let column = batch.column_by_name(name).ok_or_else(|| { + let column = batch.column_by_name(field.name()).ok_or_else(|| { DataFusionError::Internal(format!( "view column '{}' is not produced by the view's definition", field.name() @@ -1447,13 +395,9 @@ pub(super) fn to_view_batch( })?; columns.push(column.clone()); } - Ok(RecordBatch::try_new(out_schema.clone(), columns)?) + Ok(RecordBatch::try_new(output_schema.clone(), columns)?) } -/// The post-scan half of an unnested view's refresh: the scan reads -/// `raw_inputs` plus the row id, and each batch is unnested on the list -/// column, filtered, then projected by expressions typed against -/// `read_schema`, where the element sits under the alias. struct UnnestPlan { column: String, raw_inputs: Vec, @@ -1469,7 +413,6 @@ impl UnnestPlan { inputs: &[String], unnest: &super::ViewUnnest, ) -> Result { - // Whole root columns: a nested input is projected by the expression. let mut raw_inputs: Vec = inputs .iter() .map(|input| super::root(input).to_string()) @@ -1478,9 +421,7 @@ impl UnnestPlan { raw_inputs.sort(); raw_inputs.dedup(); let flattened = super::flattened_schema(&ArrowSchema::from(source.schema()), unnest)?; - // Physical expressions index columns by position, so the schema is - // exactly the scan's output: `raw_inputs` in order, then the row id. - let mut read_fields = Vec::with_capacity(raw_inputs.len() + 1); + let mut read_fields = Vec::with_capacity(raw_inputs.len()); for name in &raw_inputs { let name = if *name == unnest.column { &unnest.alias @@ -1494,25 +435,27 @@ impl UnnestPlan { })?; read_fields.push(field.clone()); } - read_fields.push(ArrowField::new(ROW_ID, DataType::UInt64, false)); let read_schema = Arc::new(ArrowSchema::new(read_fields)); let planner = Planner::new(read_schema.clone()); let physical = |what: &str, sql: &str| -> Result> { - let err = |e: lance::Error| Error::Runtime { - message: format!("{what}: {e}"), + let error = |error: lance::Error| Error::Runtime { + message: format!("{what}: {error}"), }; - let parsed = planner.parse_expr(sql).map_err(err)?; - let optimized = planner.optimize_expr(parsed).map_err(err)?; - planner.create_physical_expr(&optimized).map_err(err) + let parsed = planner.parse_expr(sql).map_err(error)?; + let optimized = planner.optimize_expr(parsed).map_err(error)?; + planner.create_physical_expr(&optimized).map_err(error) }; - let mut projections = Vec::with_capacity(definition.projections.len()); - for projection in &definition.projections { - let expr = physical( - &format!("view column '{}'", projection.output), - &projection.expression, - )?; - projections.push((projection.output.clone(), expr)); - } + let projections = definition + .projections + .iter() + .map(|projection| { + physical( + &format!("view column '{}'", projection.output), + &projection.expression, + ) + .map(|expression| (projection.output.clone(), expression)) + }) + .collect::>()?; let filter = definition .filter .as_deref() @@ -1529,8 +472,7 @@ impl UnnestPlan { fn apply(&self, batch: &RecordBatch) -> datafusion::common::Result { let unnested = unnest_batch(batch, &self.column) - .map_err(|e| DataFusionError::External(Box::new(e)))?; - // Same columns, renamed: the list column is now the element under the alias. + .map_err(|error| DataFusionError::External(Box::new(error)))?; let unnested = RecordBatch::try_new(self.read_schema.clone(), unnested.columns().to_vec())?; let unnested = match &self.filter { None => unnested, @@ -1544,25 +486,20 @@ impl UnnestPlan { arrow_select::filter::filter_record_batch(&unnested, keep)? } }; - let mut columns = Vec::with_capacity(self.projections.len() + 1); - for (output, expr) in &self.projections { - let value = expr.evaluate(&unnested)?.into_array(unnested.num_rows())?; - columns.push((output.clone(), value)); - } - let row_id = unnested - .column_by_name(ROW_ID) - .expect("scan carries the row id") - .clone(); - columns.push((ROW_ID.to_string(), row_id)); + let columns = self + .projections + .iter() + .map(|(output, expression)| { + expression + .evaluate(&unnested)? + .into_array(unnested.num_rows()) + .map(|value| (output.clone(), value)) + }) + .collect::>>()?; Ok(RecordBatch::try_from_iter(columns)?) } } -/// Expand `list_column` one row per element, repeating every other column -/// for each element; an empty or null list contributes no rows. The list -/// column is replaced by its element type, so a projection reads the -/// element's fields as `alias.field` after this. This is the row-cardinality -/// step of an `expanded_select` view, applied per batch on the scan stream. fn unnest_batch(batch: &RecordBatch, list_column: &str) -> Result { use arrow_array::{Array, ListArray, UInt32Array}; use arrow_select::take::take; @@ -1583,30 +520,24 @@ fn unnest_batch(batch: &RecordBatch, list_column: &str) -> Result { batch.column(list_index).data_type() ), })?; - - // One take index per element, naming the source row it came from. A null - // list has no elements; its offsets are equal, so it repeats nothing. let offsets = list.value_offsets(); let mut repeat = Vec::with_capacity(list.values().len()); for row in 0..list.len() { if list.is_valid(row) { - let count = (offsets[row + 1] - offsets[row]) as usize; - repeat.extend(std::iter::repeat_n(row as u32, count)); + repeat.extend(std::iter::repeat_n( + row as u32, + (offsets[row + 1] - offsets[row]) as usize, + )); } } let repeat = UInt32Array::from(repeat); - // The flattened elements, in the same order as `repeat`: only the ranges - // valid rows cover, so a null row's stale range (if any) is skipped. - let elements = { - let mut ranges = Vec::new(); - for row in 0..list.len() { - if list.is_valid(row) { - ranges.extend((offsets[row] as u32)..(offsets[row + 1] as u32)); - } + let mut ranges = Vec::new(); + for row in 0..list.len() { + if list.is_valid(row) { + ranges.extend((offsets[row] as u32)..(offsets[row + 1] as u32)); } - take(list.values().as_ref(), &UInt32Array::from(ranges), None)? - }; - + } + let elements = take(list.values().as_ref(), &UInt32Array::from(ranges), None)?; let mut fields = Vec::with_capacity(batch.num_columns()); let mut columns = Vec::with_capacity(batch.num_columns()); for (index, field) in batch.schema().fields().iter().enumerate() { @@ -1636,3168 +567,38 @@ fn unnest_batch(batch: &RecordBatch, list_column: &str) -> Result { Ok(RecordBatch::try_new(schema, columns)?) } -/// Commit the view's removals and additions as one change, on the exact -/// generation the refresh planned from. Lance rejects an overlapping -/// provenance key, but an unrelated write to the view is not a key conflict, -/// so the generation is checked here too. -async fn publish( - view_ds: &Dataset, - eviction: Option<(Vec, Vec)>, - new_fragments: Vec, - keys: Option, - expected_incarnation: Option<&str>, -) -> Result { - let planned = view_ds.version().version; - #[cfg(test)] - tests::hold_before_publish(view_ds.uri()).await; - #[cfg(test)] - tests::hold_until_peers_planned(); - ensure_incarnation(view_ds, expected_incarnation, view_ds.uri()).await?; - let (updated_fragments, removed_fragment_ids) = eviction.unwrap_or_default(); - let committed = CommitBuilder::new(WriteDestination::Dataset(Arc::new(view_ds.clone()))) - .execute(Transaction::new( - planned, - Operation::Update { - removed_fragment_ids, - updated_fragments, - new_fragments, - fields_modified: Vec::new(), - compacted_sstables: Vec::new(), - fields_for_preserving_frag_bitmap: Vec::new(), - update_mode: None, - inserted_rows_filter: keys, - updated_fragment_offsets: None, - }, - None, - )) - .await?; - if committed.version().version != planned + 1 { - return Err(Error::Runtime { - message: format!( - "a concurrent commit raced this refresh (view version {}); - the refresh is unrecorded and the next one will rebuild", - committed.version().version - ), - }); - } - Ok(committed) -} - -/// A delta batch's row id column, borrowed: a delta batch is as large as one -/// fragment's deletions, so copying it out would be the unbounded step the -/// chunking exists to avoid. -fn row_ids_of(batch: &RecordBatch) -> Result<&UInt64Array> { - let column = batch.column_by_name(ROW_ID).ok_or_else(|| Error::Runtime { - message: format!("'{ROW_ID}' is missing from a delta batch"), - })?; - column - .as_primitive_opt::() - .ok_or_else(|| Error::Runtime { - message: "row ids are not UInt64".into(), - }) -} - -/// A provenance id no source row can hold, carried in every refresh's -/// inserted-rows filter: any two refreshes of one view overlap on it, so -/// the loser of a race conflicts at commit whatever rows each planned. -const REFRESH_TOKEN_ID: u64 = u64::MAX; - -/// A builder with no collected keys, for publishes that only remove rows. -fn empty_keys(view_ds: &Dataset) -> Result>> { - Ok(Arc::new(StdMutex::new(KeyExistenceFilterBuilder::new( - vec![source_row_id_field_id(view_ds)?], - )))) -} - -/// An inserted-rows filter holding the refresh token plus whatever the -/// tee collected. -fn refresh_filter(keys: &Arc>) -> Result { - let mut keys = keys.lock().map_err(|_| Error::Runtime { - message: "the provenance key filter was poisoned mid-refresh".into(), - })?; - keys.insert(KeyValue::UInt64(REFRESH_TOKEN_ID)) - .map_err(|e| Error::Runtime { - message: format!("failed to mark the refresh's filter: {e}"), - })?; - Ok(keys.build()) -} - -/// Reconciled ids past this fall back to the streamed rebuild, whose memory -/// does not grow with the delta. -fn eviction_rebuild_cap() -> usize { - #[cfg(test)] - if let Some(cap) = tests::eviction_cap_override() { - return cap; - } - 4 * 1024 * 1024 -} - -/// Provenance ids per staged eviction: the delete predicate carries one -/// literal per id, so a whole delta at once is unbounded. Each chunk costs a -/// pass over the view's provenance column, which is why it is not smaller. -const EVICTION_CHUNK: usize = 64 * 1024; - -/// Accumulates the view's removals from bounded chunks of provenance ids into -/// the one set of fragment changes the refresh publishes. -struct Eviction { - /// The view as the chunks staged so far leave it. A chunk's delete has to - /// see the deletion vectors the earlier ones wrote, or it stages a - /// fragment that drops them. - snapshot: Dataset, - chunk: usize, - updated: HashMap, - removed: Vec, - pending: Vec, - staged: bool, - /// Largest the buffer ever got, which is the bound under test. - #[cfg(test)] - peak: usize, -} - -impl Eviction { - fn new(view_ds: &Dataset, chunk: usize) -> Self { - Self { - snapshot: view_ds.clone(), - chunk, - updated: HashMap::new(), - removed: Vec::new(), - pending: Vec::with_capacity(chunk), - staged: false, - #[cfg(test)] - peak: 0, - } - } - - /// Fill a chunk at a time. Taking the batch whole and splitting it would - /// hold a fragment's worth of ids and recopy the tail per chunk. - async fn push(&mut self, ids: &UInt64Array) -> Result<()> { - for id in ids.values() { - self.pending.push(*id); - #[cfg(test)] - { - self.peak = self.peak.max(self.pending.len()); - } - if self.pending.len() == self.chunk { - self.flush().await?; - } - } - Ok(()) - } - - /// Stage what is buffered, keeping the buffer's allocation. - async fn flush(&mut self) -> Result<()> { - let mut chunk = std::mem::take(&mut self.pending); - let staged = self.stage(&chunk).await; - chunk.clear(); - self.pending = chunk; - staged - } - - /// The staged fragment changes, or `None` where nothing was evicted. - async fn finish(mut self) -> Result, Vec)>> { - if !self.pending.is_empty() { - self.flush().await?; - } - if !self.staged { - return Ok(None); - } - let mut updated: Vec = self.updated.into_values().collect(); - updated.sort_unstable_by_key(|f| f.id); - Ok(Some((updated, self.removed))) - } - - async fn stage(&mut self, ids: &[u64]) -> Result<()> { - let (updated, removed) = stage_eviction(&self.snapshot, ids).await?; - self.snapshot = advance(&self.snapshot, &updated); - for fragment in updated { - self.updated.insert(fragment.id, fragment); - } - self.removed.extend(removed); - self.staged = true; - Ok(()) - } -} - -/// The view as staged removals leave it, without committing them. Fragments -/// are replaced in place so the fragment bitmap stays in step; an emptied one -/// is left alone, since the delta names each provenance id only once. -fn advance(view_ds: &Dataset, updated: &[Fragment]) -> Dataset { - if updated.is_empty() { - return view_ds.clone(); - } - let by_id: HashMap = updated.iter().map(|f| (f.id, f)).collect(); - let fragments = view_ds - .manifest - .fragments - .iter() - .map(|f| by_id.get(&f.id).map_or_else(|| f.clone(), |u| (*u).clone())) - .collect(); - let mut manifest = view_ds.manifest.as_ref().clone(); - manifest.fragments = Arc::new(fragments); - let mut snapshot = view_ds.clone(); - snapshot.manifest = Arc::new(manifest); - snapshot -} - -/// The fragment changes that remove the view's rows for `ids`, staged rather -/// than committed so they can ride in the refresh's single data commit. -async fn stage_eviction(view_ds: &Dataset, ids: &[u64]) -> Result<(Vec, Vec)> { - // An expression rather than SQL text: the id list is a value here, not a - // predicate string that grows with the delta and has to be parsed. - let predicate = col(SOURCE_ROW_ID_COLUMN).in_list( - ids.iter() - .map(|id| lit(ScalarValue::UInt64(Some(*id)))) - .collect(), - false, - ); - let staged = DeleteBuilder::from_expr(Arc::new(view_ds.clone()), predicate) - .execute_uncommitted() - .await?; - let Operation::Delete { - updated_fragments, - deleted_fragment_ids, - .. - } = staged.transaction.operation - else { - return Err(Error::Runtime { - message: "expected a delete when staging the view's evictions".into(), - }); - }; - Ok((updated_fragments, deleted_fragment_ids)) -} - -fn source_row_id_field_id(view_ds: &Dataset) -> Result { - view_ds - .schema() - .field(SOURCE_ROW_ID_COLUMN) - .map(|f| f.id) - .ok_or_else(|| Error::Runtime { - message: format!("the view has no '{SOURCE_ROW_ID_COLUMN}' column"), - }) -} - -/// Tee the provenance ids of everything written into `keys`. -fn collect_source_row_ids( - stream: SendableRecordBatchStream, - keys: Arc>, - written: Arc, -) -> SendableRecordBatchStream { - let schema = stream.schema(); - let mapped = stream.map(move |batch| { - let batch = batch?; - let column = batch - .column_by_name(SOURCE_ROW_ID_COLUMN) - .ok_or_else(|| { - DataFusionError::Internal(format!( - "'{SOURCE_ROW_ID_COLUMN}' is missing from the rows being written" - )) - })? - .as_primitive_opt::() - .ok_or_else(|| { - DataFusionError::Internal(format!("'{SOURCE_ROW_ID_COLUMN}' is not a uint64")) - })?; - let mut keys = keys - .lock() - .map_err(|_| DataFusionError::Internal("provenance key filter poisoned".into()))?; - for id in column.values() { - keys.insert(KeyValue::UInt64(*id)) - .map_err(|e| DataFusionError::Internal(e.to_string()))?; - } - written.fetch_add(batch.num_rows() as u64, Ordering::Relaxed); - Ok(batch) - }); - Box::pin(RecordBatchStreamAdapter::new(schema, mapped)) -} - #[cfg(test)] -pub(crate) mod tests { - - /// Park a refresh between planning and publication so a test can move - /// the view underneath it. Inert unless [`DRIFT_TARGET`] names this view. - pub(in crate::materialized_view) async fn hold_before_publish(uri: &str) { - { - let mut target = DRIFT_TARGET.lock().unwrap(); - if target.as_deref() != Some(uri) { - return; - } - // Take it: memory:// uris are relative and repeat across tests, so - // leaving it armed would park an unrelated refresh forever. - *target = None; - } - DRIFT_PLANNED.notify_one(); - DRIFT_RELEASED.notified().await; - } - - /// The rendezvous below is one global pair, so the cases that use it run - /// one at a time rather than trading each other's signals. - pub(super) static EVICTION_CAP: StdMutex> = StdMutex::new(None); - pub(super) fn eviction_cap_override() -> Option { - *EVICTION_CAP.lock().unwrap() - } - - pub(in crate::materialized_view) static DRIFT_LOCK: tokio::sync::Mutex<()> = - tokio::sync::Mutex::const_new(()); - pub(in crate::materialized_view) static DRIFT_TARGET: StdMutex> = - StdMutex::new(None); - pub(in crate::materialized_view) static DRIFT_PLANNED: tokio::sync::Notify = - tokio::sync::Notify::const_new(); - pub(in crate::materialized_view) static DRIFT_RELEASED: tokio::sync::Notify = - tokio::sync::Notify::const_new(); - - /// Block until every participant in a cross-process race has planned and - /// staged its write, so the commits they then attempt genuinely contend - /// rather than depending on the scheduler to overlap them. Inert unless - /// `MV_RACE_SYNC` names a directory shared by the participants. - pub(super) fn hold_until_peers_planned() { - let (Ok(dir), Ok(tag), Ok(peers)) = ( - std::env::var("MV_RACE_SYNC"), - std::env::var("MV_RACE_TAG"), - std::env::var("MV_RACE_PEERS"), - ) else { - return; - }; - let dir = std::path::PathBuf::from(dir); - let peers: usize = peers.parse().unwrap(); - std::fs::write(dir.join(format!("planned-{tag}")), b"1").unwrap(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); - while planned_count(&dir) < peers { - assert!( - std::time::Instant::now() < deadline, - "peers never reached the commit boundary" - ); - std::thread::sleep(std::time::Duration::from_millis(2)); - } - } - - fn planned_count(dir: &std::path::Path) -> usize { - std::fs::read_dir(dir) - .map(|entries| { - entries - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().starts_with("planned-")) - .count() - }) - .unwrap_or(0) - } - use arrow_array::{Int32Array, record_batch}; - use futures::TryStreamExt; - use lance::dataset::NewColumnTransform; - use lance_file::version::LanceFileVersion; +mod tests { + use arrow_array::record_batch; + use datafusion::physical_plan::stream::RecordBatchStreamAdapter; use super::*; use crate::connect; - use crate::connection::Connection; - use crate::index::Index; - use crate::index::scalar::BTreeIndexBuilder; - use crate::materialized_view::MaterializedView; - use crate::query::{ExecutableQuery, QueryBase, Select}; - use crate::table::{CompactionOptions, OptimizeAction}; - - async fn db_with_source(values: Vec) -> (Connection, Table) { - let conn = connect("memory://").execute().await.unwrap(); - let batch = record_batch!(("x", Int32, values)).unwrap(); - let table = conn - .create_table("src", batch) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - (conn, table) - } - - async fn doubled_view(conn: &Connection) -> MaterializedView { - conn.create_materialized_view("doubled", "src") - .with_no_data(true) - .select([("x", "x"), ("twice", "x * 2")]) - .execute() - .await - .unwrap() - } - - /// A refreshed doubled view over a source holding `values`. - async fn refreshed_doubled(values: Vec) -> (Connection, Table, MaterializedView) { - let (conn, source) = db_with_source(values).await; - let view = doubled_view(&conn).await; - view.refresh().execute().await.unwrap(); - (conn, source, view) - } - - /// The per-batch expansion behind an `expanded_select` view: every - /// element of the list column becomes a row, the other columns repeat - /// for each, and an empty or null list contributes no rows at all -- - /// which is exactly "zero rows out" for a table-valued function. - /// Four documents with a `list>` column named - /// `c`: doc 1 has two chunks, doc 2 none, doc 3 a null list, doc 4 one. - fn chunked_batch() -> RecordBatch { - use arrow_array::builder::{Int32Builder, ListBuilder, StringBuilder, StructBuilder}; - use arrow_array::{ArrayRef, Int64Array}; - use arrow_schema::{DataType, Field, Fields}; - - let element_fields = Fields::from(vec![ - Field::new("chunk", DataType::Utf8, true), - Field::new("ordinal", DataType::Int32, true), - ]); - let mut list = ListBuilder::new(StructBuilder::new( - element_fields, - vec![ - Box::new(StringBuilder::new()), - Box::new(Int32Builder::new()), - ], - )); - for chunks in [Some(vec!["a", "b"]), Some(vec![]), None, Some(vec!["c"])] { - match chunks { - Some(chunks) => { - for (i, c) in chunks.iter().enumerate() { - let s = list.values(); - s.field_builder::(0).unwrap().append_value(c); - s.field_builder::(1) - .unwrap() - .append_value(i as i32); - s.append(true); - } - list.append(true); - } - None => list.append(false), - } - } - let meta = arrow_array::StructArray::from(vec![( - Arc::new(Field::new("title", DataType::Utf8, true)), - Arc::new(arrow_array::StringArray::from(vec!["t1", "t2", "t3", "t4"])) as ArrayRef, - )]); - RecordBatch::try_from_iter(vec![ - ( - "id", - Arc::new(Int64Array::from(vec![1, 2, 3, 4])) as ArrayRef, - ), - ("meta", Arc::new(meta) as ArrayRef), - ("c", Arc::new(list.finish()) as ArrayRef), - ]) - .unwrap() - } - - #[test] - fn unnest_repeats_siblings_per_element_and_drops_empty_lists() { - use arrow_array::{StringArray, StructArray}; - use arrow_schema::{DataType, Field, Fields}; - - let batch = chunked_batch(); - let element_fields = Fields::from(vec![ - Field::new("chunk", DataType::Utf8, true), - Field::new("ordinal", DataType::Int32, true), - ]); - let out = unnest_batch(&batch, "c").unwrap(); - assert_eq!(out.num_rows(), 3, "{out:?}"); - let ids: Vec = out["id"] - .as_primitive::() - .values() - .to_vec(); - assert_eq!(ids, [1, 1, 4]); - let element = out["c"].as_any().downcast_ref::().unwrap(); - let chunks: Vec<&str> = element - .column(0) - .as_any() - .downcast_ref::() - .unwrap() - .iter() - .flatten() - .collect(); - assert_eq!(chunks, ["a", "b", "c"]); - let ordinals: Vec = element - .column(1) - .as_primitive::() - .values() - .to_vec(); - assert_eq!(ordinals, [0, 1, 0]); - // the element column is now the struct itself, not a list of it - assert_eq!( - out.schema().field_with_name("c").unwrap().data_type(), - &DataType::Struct(element_fields) - ); - } - - /// A Function in FROM position is refreshed from its staging table: the - /// query on the view stays the one the user wrote, the staging binding - /// says which table and list column refresh reads, and rows come out one - /// per element as with UNNEST. Without a staging, a local database - /// refuses the query rather than guess. - #[tokio::test] - async fn a_function_in_from_position_refreshes_from_its_staging() { - use arrow_array::StringArray; - - let conn = connect("memory://").execute().await.unwrap(); - let staging = conn - .create_table("docs__chunk", chunked_batch()) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - let query = - "SELECT id AS doc, e.chunk AS text, e.ordinal FROM docs, chunk(meta.title, 2) AS e"; - let definition = MaterializedViewDefinition::from_sql(query).unwrap(); - - let err = crate::materialized_view::prepare_definition(&staging, definition.clone()) - .await - .unwrap_err(); - assert!( - matches!(&err, Error::InvalidInput { message } if message.contains("reads 'docs'")), - "{err:?}" - ); - - let view = crate::materialized_view::prepare_staged_definition(&staging, definition, "c") - .await - .unwrap() - .create("chunks") - .await - .unwrap(); - assert_eq!(view.definition().to_sql(), query); - let metadata = view.table().schema().await.unwrap().metadata().clone(); - assert_eq!( - crate::materialized_view::read_staging(&metadata).unwrap(), - Some(crate::materialized_view::StagingBinding { - table: "docs__chunk".into(), - namespace: Vec::new(), - column: "c".into(), - }) - ); - - view.refresh().execute().await.unwrap(); - assert_eq!(read(view.table(), "ordinal").await, [0, 0, 1]); - let batches = view - .table() - .query() - .select(Select::columns(&["text"])) - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - let out = arrow_select::concat::concat_batches(&batches[0].schema(), &batches).unwrap(); - let texts: Vec<&str> = out["text"] - .as_any() - .downcast_ref::() - .unwrap() - .iter() - .flatten() - .collect(); - assert_eq!(texts, ["a", "b", "c"]); - - // The reopened view reads the same logical query and refreshes - // incrementally from the staging. - staging.add(chunked_batch()).execute().await.unwrap(); - let reopened = conn.open_materialized_view("chunks").await.unwrap(); - assert_eq!(reopened.definition().to_sql(), query); - let result = reopened.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(read(reopened.table(), "ordinal").await, [0, 0, 0, 0, 1, 1]); - } - - /// An expanded view materializes one row per list element, with the - /// projections reading the element through the alias and the other - /// source columns repeated alongside; sources with no elements yield - /// no rows. The lineage is the list column, so a change to it is what - /// drives incremental refresh. - #[tokio::test] - async fn an_expanded_view_materializes_one_row_per_element() { - use arrow_array::{Int64Array, StringArray}; - - let conn = connect("memory://").execute().await.unwrap(); - let source = conn - .create_table("docs", chunked_batch()) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - let mut view = crate::materialized_view::prepare_definition( - &source, - MaterializedViewDefinition::from_sql( - "SELECT id AS doc, meta.title AS title, e.ordinal + 1 AS nth \ - FROM docs, UNNEST(c) AS e WHERE e.ordinal < 5", - ) - .unwrap(), - ) - .await - .unwrap(); - // A computed column's input read through the alias is an element - // field; the recorded source input is the list column. - let text = view.input_column("e.chunk").unwrap(); - assert!(view.definition.lateral.is_some()); - let view = view.create("chunks").await.unwrap(); - view.refresh().execute().await.unwrap(); - - let batches = view - .table() - .query() - .select(Select::columns(&["doc", &text, "title"])) - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - let out = arrow_select::concat::concat_batches(&batches[0].schema(), &batches).unwrap(); - let docs: Vec = out["doc"] - .as_any() - .downcast_ref::() - .unwrap() - .values() - .to_vec(); - let strings = |column: &str| -> Vec { - out[column] - .as_any() - .downcast_ref::() - .unwrap() - .iter() - .flatten() - .map(str::to_string) - .collect() - }; - assert_eq!(docs, [1, 1, 4]); - assert_eq!(strings(&text), ["a", "b", "c"]); - assert_eq!(strings("title"), ["t1", "t1", "t4"]); - assert_eq!(read(view.table(), "nth").await, [1, 1, 2]); - - // Appended documents expand incrementally; the existing rows stay. - source.add(chunked_batch()).execute().await.unwrap(); - view.refresh().execute().await.unwrap(); - assert_eq!(read(view.table(), "nth").await, [1, 1, 1, 1, 2, 2]); - } - - async fn read(table: &Table, column: &str) -> Vec { - let batches = table - .query() - .select(Select::columns(&[column])) - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - let mut values: Vec = batches - .iter() - .flat_map(|batch| { - batch[column] - .as_any() - .downcast_ref::() - .unwrap() - .iter() - .flatten() - .collect::>() - }) - .collect(); - values.sort(); - values - } - - async fn append(table: &Table, values: Vec) { - let batch = record_batch!(("x", Int32, values)).unwrap(); - table.add(batch).execute().await.unwrap(); - } #[tokio::test] - async fn test_first_refresh_materializes_the_view() { - let (conn, _) = db_with_source(vec![1, 2, 3]).await; - let view = doubled_view(&conn).await; - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(result.rows_written, 3); - assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6]); - - // The watermark survives on the stored schema, not just the handle. - let reopened = conn.open_materialized_view("doubled").await.unwrap(); - let again = reopened.refresh().execute().await.unwrap(); - assert_eq!(again.mode, RefreshMode::NoOp); - assert_eq!(again.rows_written, 0); - } - - #[tokio::test] - async fn test_filter_selects_the_source_rows() { - let (conn, _) = db_with_source(vec![1, 20, 3, 40]).await; - let view = conn - .create_materialized_view("big", "src") - .with_no_data(true) - .select([("x", "x")]) - .only_if("x > 10") + async fn raced_first_full_refresh_lands_one_result() { + let directory = tempfile::tempdir().unwrap(); + let connection = connect(directory.path().to_str().unwrap()) .execute() .await .unwrap(); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.rows_written, 2); - assert_eq!(read(view.table(), "x").await, vec![20, 40]); - } - - #[tokio::test] - async fn test_mixed_case_filter_is_canonicalized_for_lineage_and_refresh() { - let conn = connect("memory://").execute().await.unwrap(); - let batch = record_batch!( - ("id", Int32, [1, 2, 3]), - ("PartyAbbrev", Utf8, ["D", "R", "D"]) - ) - .unwrap(); - conn.create_table("src", batch) - .write_options(crate::materialized_view::tests::stable_row_ids()) + let source_batch = record_batch!(("x", Int32, [1, 2, 3])).unwrap(); + connection + .create_table("src", source_batch.clone()) .execute() .await .unwrap(); - conn.create_materialized_view("democrats", "src") - .with_no_data(true) - .select([("id", "id")]) - .only_if(r#""PartyAbbrev" = 'D'"#) - .execute() - .await - .unwrap(); - - // Reopen from schema metadata so these assertions cover the stored - // predicate and lineage, not only the declaration-time handle. - let view = conn.open_materialized_view("democrats").await.unwrap(); - assert_eq!( - view.definition().filter.as_deref(), - Some("`PartyAbbrev` = 'D'") - ); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.rows_written, 2); - assert_eq!(read(view.table(), "id").await, vec![1, 3]); - } - - /// A legacy layout whose query means what the canonical one means is - /// rewritten in the current layout on the next refresh, without a - /// rebuild: the rows it certified are the rows the query produces. - #[tokio::test] - async fn a_legacy_layout_with_the_same_meaning_is_rewritten_without_a_rebuild() { - let (conn, source, view) = refreshed_doubled(vec![1]).await; - let legacy = serde_json::json!({ - "kind": "select", - "source_table": "src", - "projections": [ - {"output": "x", "expression": "`x`"}, - {"output": "twice", "expression": "x*2"}, - ], - "inputs": ["x"], - }) - .to_string(); - let native = view.table().as_native().unwrap(); - let mut dataset = native.dataset.get().await.unwrap().as_ref().clone(); - let predicted = dataset.version().version + 1; - dataset - .update_schema_metadata([ - (DEFINITION_META_KEY.to_string(), Some(legacy)), - ( - VIEW_VERSION_META_KEY.to_string(), - Some(predicted.to_string()), - ), - ]) - .await - .unwrap(); - native.dataset.update(dataset); - - append(&source, vec![2]).await; - let reopened = conn.open_materialized_view("doubled").await.unwrap(); - let result = reopened.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(read(reopened.table(), "twice").await, vec![2, 4]); - let stored: serde_json::Value = serde_json::from_str( - &reopened.table().schema().await.unwrap().metadata()[DEFINITION_META_KEY], - ) - .unwrap(); - assert_eq!( - stored, - serde_json::json!({ - "kind": "query", - "format": 1, - "query": "SELECT x, x * 2 AS twice FROM src", - }) - ); - } - - #[tokio::test] - async fn test_legacy_raw_filter_rebuilds_and_persists_canonical_definition() { - let conn = connect("memory://").execute().await.unwrap(); - let batch = record_batch!( - ("id", Int32, [1, 2, 3]), - ("PartyAbbrev", Utf8, ["D", "R", "D"]) - ) - .unwrap(); - conn.create_table("legacy_src", batch) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - let view = conn - .create_materialized_view("legacy_view", "legacy_src") - .with_no_data(true) - .select([("id", "id")]) - .only_if(r#""PartyAbbrev" = 'X'"#) - .execute() - .await - .unwrap(); - assert_eq!(view.refresh().execute().await.unwrap().rows_written, 0); - - // Model a definition and up-to-date watermark written before filter - // canonicalization was applied to materialized views. - let legacy = serde_json::json!({ - "kind": "select", - "source_table": "legacy_src", - "projections": [{"output": "id", "expression": "id"}], - "filter": r#""PartyAbbrev" = 'D'"#, - "inputs": ["id"], - }) - .to_string(); - let native = view.table().as_native().unwrap(); - let mut dataset = native.dataset.get().await.unwrap().as_ref().clone(); - let predicted = dataset.version().version + 1; - dataset - .update_schema_metadata([ - (DEFINITION_META_KEY.to_string(), Some(legacy)), - ( - VIEW_VERSION_META_KEY.to_string(), - Some(predicted.to_string()), - ), - ]) - .await - .unwrap(); - native.dataset.update(dataset); - - let reopened = conn.open_materialized_view("legacy_view").await.unwrap(); - let result = reopened.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(result.rows_written, 2); - assert_eq!(read(reopened.table(), "id").await, vec![1, 3]); - - // A fresh handle proves the migration was stored alongside the new - // watermark and therefore happens only once. - let migrated = conn.open_materialized_view("legacy_view").await.unwrap(); - assert_eq!( - migrated.definition().filter.as_deref(), - Some("`PartyAbbrev` = 'D'") - ); - assert_eq!( - migrated.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - assert_eq!(read(migrated.table(), "id").await, vec![1, 3]); - } - - #[tokio::test] - async fn test_append_refreshes_incrementally() { - let (_conn, source, view) = refreshed_doubled(vec![1, 2]).await; - - append(&source, vec![5]).await; - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 1); - assert_eq!(read(view.table(), "twice").await, vec![2, 4, 10]); - } - - #[tokio::test] - async fn test_incremental_applies_the_filter() { - let (conn, source) = db_with_source(vec![1, 20]).await; - let view = conn - .create_materialized_view("big", "src") - .with_no_data(true) - .select([("x", "x")]) - .only_if("x > 10") - .execute() - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - - append(&source, vec![3, 30]).await; - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 1); - assert_eq!(read(view.table(), "x").await, vec![20, 30]); - } - - /// The watermark has to advance even when no appended row matches, or the - /// same fragments would be rescanned by every later refresh. - #[tokio::test] - async fn test_incremental_with_nothing_matching_advances_the_watermark() { - let (conn, source) = db_with_source(vec![20]).await; - let view = conn - .create_materialized_view("big", "src") - .with_no_data(true) - .select([("x", "x")]) - .only_if("x > 10") - .execute() - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - - append(&source, vec![1, 2]).await; - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 0); - - let again = view.refresh().execute().await.unwrap(); - assert_eq!(again.mode, RefreshMode::NoOp); - } - - /// Unlike a computed column, a view reflects source mutation: an update - /// rebuilds rather than going stale. - #[tokio::test] - async fn test_update_replaces_the_rows_it_changed() { - let (_conn, source, view) = refreshed_doubled(vec![1, 2, 3]).await; - - // An update changes rows in place, so the view replaces exactly - // those rows and leaves the rest of what it holds alone. - source - .update() - .column("x", "20") - .only_if("x = 2") - .execute() - .await - .unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 1, "only the changed row is recomputed"); - assert_eq!(read(view.table(), "twice").await, vec![2, 6, 40]); - } - - /// Legacy storage cannot serve the row-version columns update discovery - /// scans; appends stay incremental, updates rebuild rather than fail. - #[tokio::test] - async fn test_a_legacy_storage_source_is_reconciled() { - let conn = connect("memory://").execute().await.unwrap(); - let batch = record_batch!(("x", Int32, [1, 2, 3])).unwrap(); - let source = conn - .create_table("legacy_src", batch) - .write_options(crate::table::WriteOptions { - lance_write_params: Some(lance::dataset::WriteParams { - enable_stable_row_ids: true, - data_storage_version: Some(LanceFileVersion::Legacy), - ..Default::default() - }), - }) - .execute() - .await - .unwrap(); - let view = conn - .create_materialized_view("legacy_doubled", "legacy_src") - .with_no_data(true) - .select([("x", "x"), ("twice", "x * 2")]) - .execute() - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - - source - .add(record_batch!(("x", Int32, [4])).unwrap()) - .execute() - .await - .unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6, 8]); - - source - .update() - .column("x", "20") - .only_if("x = 2") - .execute() - .await - .unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(read(view.table(), "twice").await, vec![2, 6, 8, 40]); - } - - #[tokio::test] - async fn test_delete_evicts_the_view_rows_it_removed() { - let (_conn, source, view) = refreshed_doubled(vec![1, 2, 3]).await; - - // A delete removes source rows without changing the ones that - // remain, so the view evicts exactly those rows and keeps the rest - // rather than recomputing every row it already held. - source.delete("x = 2").await.unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(read(view.table(), "twice").await, vec![2, 6]); - - // A delete and an append in one span: both are applied. - source.delete("x = 1").await.unwrap(); - source - .add(record_batch!(("x", Int32, vec![4])).unwrap()) - .execute() - .await - .unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(read(view.table(), "twice").await, vec![6, 8]); - } - - /// merge_insert commits an `Update`, and its by-source arm removes rows - /// rather than changing them, so the classifier must treat that - /// transaction form as a source of deletions. - #[tokio::test] - async fn test_merge_insert_by_source_delete_evicts_the_view_rows() { - let (_conn, source, view) = refreshed_doubled(vec![1, 2, 3]).await; - - let batch = record_batch!(("x", Int32, vec![1, 3])).unwrap(); - let reader = arrow_array::RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema()); - let mut merge = source.merge_insert(&["x"]); - merge.when_not_matched_by_source_delete(None); - merge.execute(Box::new(reader)).await.unwrap(); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(read(view.table(), "twice").await, vec![2, 6]); - } - - /// A refresh may only certify the generation it planned from. A write - /// that lands between planning and publication is drift the refresh did - /// not account for, so it aborts rather than stamp it as materialized. - #[tokio::test(flavor = "multi_thread")] - async fn test_refresh_aborts_rather_than_certify_view_drift() { - let _serial = DRIFT_LOCK.lock().await; - let (conn, source) = db_with_source(vec![1, 2, 3]).await; - let view = conn - .create_materialized_view("drifting_view", "src") - .with_no_data(true) - .select([("x", "x"), ("twice", "x * 2")]) - .execute() - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - let certified = source_version_of(view.table()).await; - - // The source loses a row, so the next refresh plans an eviction. - source.delete("x = 2").await.unwrap(); - - let uri = view - .table() - .as_native() - .unwrap() - .dataset - .get() - .await - .unwrap() - .uri() - .to_string(); - *DRIFT_TARGET.lock().unwrap() = Some(uri); - let refreshing = tokio::spawn(async move { view.refresh().execute().await }); - - // Move the view once the refresh has planned against it. - tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified()) - .await - .expect("the refresh never reached the publication boundary"); - let drifted = conn.open_table("drifting_view").execute().await.unwrap(); - drifted.delete("twice = 6").await.unwrap(); - DRIFT_RELEASED.notify_one(); - - // Publishing removals and additions as one change makes the drift a - // conflict lance itself rejects; a pure append, which touches no - // existing fragment, still relies on the generation check. - let err = refreshing.await.unwrap().unwrap_err(); - let message = err.to_string(); - assert!( - message.contains("raced this refresh") || message.contains("preempted by concurrent"), - "got {err:?}" - ); - // The watermark still names the generation that was actually proven. - assert_eq!(source_version_of(&drifted).await, certified); - } - - async fn source_version_of(table: &Table) -> Option { - table - .schema() - .await - .unwrap() - .metadata() - .get(SOURCE_VERSION_META_KEY) - .cloned() - } - - /// A transaction file carries placeholder ids for the fragments it - /// creates. Into an empty source those collide with the ids the commit - /// assigns, so treating them as already-materialized drops the very - /// first rows while the watermark still advances past them. - #[tokio::test] - async fn test_merge_into_an_empty_source_is_materialized() { - let (_conn, source, view) = refreshed_doubled(vec![]).await; - assert_eq!(read(view.table(), "twice").await, Vec::::new()); - - let batch = record_batch!(("x", Int32, vec![1, 2])).unwrap(); - let reader = arrow_array::RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema()); - let mut merge = source.merge_insert(&["x"]); - merge - .when_matched_update_all(None) - .when_not_matched_insert_all(); - merge.execute(Box::new(reader)).await.unwrap(); - - view.refresh().execute().await.unwrap(); - assert_eq!(read(view.table(), "twice").await, vec![2, 4]); - // A second refresh must not double them either. - view.refresh().execute().await.unwrap(); - assert_eq!(read(view.table(), "twice").await, vec![2, 4]); - } - - /// A refresh publishes what it removes and what it adds as one change, - /// so an update never exposes the view without the rows it is replacing. - #[tokio::test(flavor = "multi_thread")] - async fn test_an_update_is_never_visible_as_a_gap() { - let _serial = DRIFT_LOCK.lock().await; - let (conn, source) = db_with_source(vec![1, 2, 3]).await; - let view = conn - .create_materialized_view("atomic_view", "src") - .with_no_data(true) - .select([("x", "x"), ("twice", "x * 2")]) - .execute() - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - - source - .update() - .column("x", "x + 10") - .execute() - .await - .unwrap(); - - let uri = view - .table() - .as_native() - .unwrap() - .dataset - .get() - .await - .unwrap() - .uri() - .to_string(); - *DRIFT_TARGET.lock().unwrap() = Some(uri); - let refreshing = tokio::spawn(async move { view.refresh().execute().await }); - - // Read the view while the refresh is staged but not yet published. - tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified()) - .await - .expect("the refresh never reached the publication boundary"); - let midway = conn.open_table("atomic_view").execute().await.unwrap(); - assert_eq!( - read(&midway, "twice").await, - vec![2, 4, 6], - "the pre-refresh rows must still be there in full" - ); - DRIFT_RELEASED.notify_one(); - - refreshing.await.unwrap().unwrap(); - let after = conn.open_table("atomic_view").execute().await.unwrap(); - assert_eq!(read(&after, "twice").await, vec![22, 24, 26]); - } - - async fn provenance_by_x(table: &Table) -> HashMap { - let batches = table - .query() - .select(Select::columns(&["x", SOURCE_ROW_ID_COLUMN])) - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - batches - .iter() - .flat_map(|batch| { - let xs = batch["x"].as_any().downcast_ref::().unwrap(); - let ids = batch[SOURCE_ROW_ID_COLUMN].as_primitive::(); - (0..batch.num_rows()) - .map(|i| (xs.value(i), ids.value(i))) - .collect::>() - }) - .collect() - } - - /// One delta batch is as large as a fragment's deletions, so it arrives - /// well past a chunk: it has to be drained a chunk at a time without ever - /// holding the batch, and the passes' deletion vectors have to accumulate - /// rather than replace each other. - #[tokio::test] - async fn test_a_chunked_eviction_removes_every_row_it_names() { - let (_conn, _, view) = refreshed_doubled(vec![1, 2, 3, 4, 5, 6]).await; - - let native = view.table().as_native().unwrap(); - let view_ds = native.dataset.get().await.unwrap().as_ref().clone(); - let provenance = provenance_by_x(view.table()).await; - - let mut eviction = Eviction::new(&view_ds, 2); - let batch = UInt64Array::from_iter_values([1, 2, 3, 4].map(|x| provenance[&x])); - eviction.push(&batch).await.unwrap(); - assert_eq!( - eviction.peak, 2, - "a batch past the chunk was buffered whole" - ); - let staged = eviction.finish().await.unwrap(); - assert!(staged.is_some(), "four ids over a chunk of two stage twice"); - publish(&view_ds, staged, Vec::new(), None, None) - .await - .unwrap(); - native.dataset.reload().await.unwrap(); - - assert_eq!(read(view.table(), "x").await, vec![5, 6]); - } - - /// Two first rebuilds materialize the same source rows; the loser must - /// key-conflict at commit and land nothing, or the view doubles. - #[tokio::test] - async fn test_a_raced_first_rebuild_lands_nothing() { - let _guard = DRIFT_LOCK.lock().await; - let (conn, _) = db_with_source(vec![1, 2, 3]).await; - let view = conn - .create_materialized_view("raced_rebuild", "src") - .with_no_data(true) - .select([("x", "x"), ("twice", "x * 2")]) - .execute() - .await - .unwrap(); - let native = view.table().as_native().unwrap(); - let view_ds = native.dataset.get().await.unwrap().as_ref().clone(); - let uri = view_ds.uri().to_string(); - *DRIFT_TARGET.lock().unwrap() = Some(uri); - let racing = tokio::spawn(async move { view.refresh().execute().await }); - tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified()) - .await - .expect("the rebuild never reached the publication boundary"); - - // The other process's first rebuild, reduced to its commit. Its - // source rows are DISJOINT from ours -- the sentinel alone must make - // the two rebuilds conflict. - let batch = record_batch!( - ("x", Int32, [8, 9]), - ("twice", Int32, [16, 18]), - ("__source_row_id", UInt64, [7u64, 8]) - ) - .unwrap(); - let schema = Arc::new(ArrowSchema::from(view_ds.schema())); - let batch = RecordBatch::try_new( - schema.clone(), - schema - .fields() - .iter() - .map(|f| batch.column_by_name(f.name()).unwrap().clone()) - .collect(), - ) - .unwrap(); - let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new( - schema.clone(), - futures::stream::iter([Ok(batch)]), - )); - let keys = Arc::new(StdMutex::new(KeyExistenceFilterBuilder::new(vec![ - source_row_id_field_id(&view_ds).unwrap(), - ]))); - let stream = collect_source_row_ids(stream, keys.clone(), Arc::new(AtomicU64::new(0))); - let write_txn = InsertBuilder::new(WriteDestination::Dataset(Arc::new(view_ds.clone()))) - .with_params(&WriteParams { - mode: WriteMode::Append, - ..Default::default() - }) - .execute_uncommitted_stream(stream) - .await - .unwrap(); - let Operation::Append { fragments } = write_txn.operation else { - panic!("expected an append"); - }; - let filter = { - let mut keys = keys.lock().unwrap(); - keys.insert(KeyValue::UInt64(super::REFRESH_TOKEN_ID)) - .unwrap(); - keys.build() - }; - CommitBuilder::new(WriteDestination::Dataset(Arc::new(view_ds.clone()))) - .execute(Transaction::new( - view_ds.version().version, - Operation::Update { - removed_fragment_ids: Vec::new(), - updated_fragments: Vec::new(), - new_fragments: fragments, - fields_modified: Vec::new(), - compacted_sstables: Vec::new(), - fields_for_preserving_frag_bitmap: Vec::new(), - update_mode: None, - inserted_rows_filter: Some(filter), - updated_fragment_offsets: None, - }, - None, - )) - .await - .unwrap(); - DRIFT_RELEASED.notify_one(); - - let err = racing.await.unwrap().unwrap_err(); - // The loser lands nothing: only the winner's rows remain. - let raced = conn.open_table("raced_rebuild").execute().await.unwrap(); - assert_eq!( - read(&raced, "twice").await, - vec![16, 18], - "the losing rebuild must not union with the winner ({err})" - ); - } - - /// An incremental refresh racing any other refresh must land nothing, - /// even when their planned rows are disjoint: the shared token makes the - /// commits conflict. - #[tokio::test] - async fn test_a_raced_incremental_lands_nothing() { - let _guard = DRIFT_LOCK.lock().await; - let (conn, source) = db_with_source(vec![1, 2, 3]).await; - let view = conn - .create_materialized_view("raced_incremental", "src") - .with_no_data(true) - .select([("x", "x"), ("twice", "x * 2")]) - .execute() - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - append(&source, vec![4]).await; - - let native = view.table().as_native().unwrap(); - native.dataset.reload().await.unwrap(); - let view_ds = native.dataset.get().await.unwrap().as_ref().clone(); - *DRIFT_TARGET.lock().unwrap() = Some(view_ds.uri().to_string()); - let racing = tokio::spawn(async move { view.refresh().execute().await }); - tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified()) - .await - .expect("the refresh never reached the publication boundary"); - - // The concurrent refresh, reduced to its commit: disjoint rows, the - // shared token alone must collide. - let batch = record_batch!( - ("x", Int32, [9]), - ("twice", Int32, [18]), - ("__source_row_id", UInt64, [8u64]) - ) - .unwrap(); - let schema = Arc::new(ArrowSchema::from(view_ds.schema())); - let batch = RecordBatch::try_new( - schema.clone(), - schema - .fields() - .iter() - .map(|f| batch.column_by_name(f.name()).unwrap().clone()) - .collect(), - ) - .unwrap(); - let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new( - schema.clone(), - futures::stream::iter([Ok(batch)]), - )); - let keys = empty_keys(&view_ds).unwrap(); - let stream = collect_source_row_ids(stream, keys.clone(), Arc::new(AtomicU64::new(0))); - let write_txn = InsertBuilder::new(WriteDestination::Dataset(Arc::new(view_ds.clone()))) - .with_params(&WriteParams { - mode: WriteMode::Append, - ..Default::default() - }) - .execute_uncommitted_stream(stream) - .await - .unwrap(); - let Operation::Append { fragments } = write_txn.operation else { - panic!("expected an append"); - }; - let filter = refresh_filter(&keys).unwrap(); - CommitBuilder::new(WriteDestination::Dataset(Arc::new(view_ds.clone()))) - .execute(Transaction::new( - view_ds.version().version, - Operation::Update { - removed_fragment_ids: Vec::new(), - updated_fragments: Vec::new(), - new_fragments: fragments, - fields_modified: Vec::new(), - compacted_sstables: Vec::new(), - fields_for_preserving_frag_bitmap: Vec::new(), - update_mode: None, - inserted_rows_filter: Some(filter), - updated_fragment_offsets: None, - }, - None, - )) - .await - .unwrap(); - DRIFT_RELEASED.notify_one(); - - let err = racing.await.unwrap().unwrap_err(); - let raced = conn - .open_table("raced_incremental") - .execute() - .await - .unwrap(); - assert_eq!( - read(&raced, "twice").await, - vec![2, 4, 6, 18], - "the losing increment must not land its rows ({err})" - ); - } - - /// Past the eviction cap the refresh falls back to the streamed rebuild, - /// and the result is identical either way. - #[tokio::test] - async fn test_oversized_delta_falls_back_to_rebuild() { - let (conn, source) = db_with_source((1..=20).collect()).await; - let view = doubled_view(&conn).await; - view.refresh().execute().await.unwrap(); - - source.delete("x <= 10").await.unwrap(); - *tests::EVICTION_CAP.lock().unwrap() = Some(4); - let result = view.refresh().execute().await; - *tests::EVICTION_CAP.lock().unwrap() = None; - let result = result.unwrap(); - assert_eq!( - result.mode, - RefreshMode::Rebuild, - "ten evictions, cap of four" - ); - assert_eq!(read(view.table(), "x").await, (11..=20).collect::>()); - - // Under the cap the same shape stays incremental. - source.delete("x = 11").await.unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(read(view.table(), "x").await, (12..=20).collect::>()); - } - - /// A cap of zero is a view that holds nothing, not a view without a cap. - #[tokio::test] - async fn test_zero_limit_holds_no_rows() { - let (conn, source) = db_with_source(vec![1, 2, 3]).await; - let view = conn - .create_materialized_view("empty", "src") - .with_no_data(true) - .select([("x", "x")]) - .limit(0) - .execute() - .await - .unwrap(); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.rows_written, 0); - assert_eq!(view.table().count_rows(None).await.unwrap(), 0); - - // Still nothing after the source grows, and the watermark advances. - append(&source, vec![4]).await; - view.refresh().execute().await.unwrap(); - assert_eq!(view.table().count_rows(None).await.unwrap(), 0); - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - } - - /// An update rewrites a whole fragment. When it lands on one appended - /// since the watermark, that fragment also holds rows the update never - /// touched -- rows the recompute does not cover and the append set no - /// longer reaches. - #[tokio::test] - async fn test_update_touching_a_new_fragment_keeps_its_untouched_rows() { - let (_conn, source, view) = refreshed_doubled(vec![1, 2]).await; - - // One fragment, appended after the watermark, holding both a row the - // update will change and a row it will not. - append(&source, vec![3, 40]).await; - source - .update() - .column("x", "99") - .only_if("x = 3") - .execute() - .await - .unwrap(); - - view.refresh().execute().await.unwrap(); - assert_eq!( - read(view.table(), "twice").await, - vec![2, 4, 80, 198], - "a row appended into the updated fragment went missing" - ); - } - - async fn compact(source: &Table) { - source - .optimize(OptimizeAction::Compact { - options: CompactionOptions::default(), - remap_options: None, - }) - .await - .unwrap(); - } - - /// A compaction rearranges rows without changing them, so it costs the - /// view nothing: the watermark advances and no row is recomputed. - #[tokio::test] - async fn test_compaction_alone_refreshes_incrementally() { - let (_conn, source, view) = refreshed_doubled(vec![1, 2]).await; - append(&source, vec![3]).await; - view.refresh().execute().await.unwrap(); - - compact(&source).await; - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 0); - assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6]); - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - } - - /// Rows appended after a compaction are separable through the transaction - /// log: only the appended fragments are computed. - #[tokio::test] - async fn test_append_after_compaction_stays_incremental() { - let (_conn, source, view) = refreshed_doubled(vec![1]).await; - append(&source, vec![2]).await; - view.refresh().execute().await.unwrap(); - - compact(&source).await; - append(&source, vec![3]).await; - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 1); - assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6]); - } - - /// An append swallowed by a later compaction cannot be told apart from - /// the rows the view already holds, so the refresh rebuilds -- once -- - /// rather than duplicate or drop. - #[tokio::test] - async fn test_append_folded_into_compaction_rebuilds() { - let (_conn, source, view) = refreshed_doubled(vec![1]).await; - - append(&source, vec![2]).await; - compact(&source).await; - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(read(view.table(), "twice").await, vec![2, 4]); - } - - /// The create-time gate holds across a drop-and-recreate of the source - /// under the same name. - #[tokio::test] - async fn test_refresh_refuses_a_recreated_source_without_stable_row_ids() { - let (conn, _, view) = refreshed_doubled(vec![1]).await; - - conn.drop_table("src", &[]).await.unwrap(); - let batch = record_batch!(("x", Int32, [9])).unwrap(); - conn.create_table("src", batch).execute().await.unwrap(); - - let err = view.refresh().execute().await.unwrap_err(); - assert!( - matches!(err, Error::InvalidInput { message } if message.contains("stable row ids")) - ); - } - - /// A change to a column the view does not read is not a reason to - /// rebuild: the exact signature check is scoped to the view's inputs. - #[tokio::test] - async fn test_unrelated_column_change_does_not_rebuild() { - let (_conn, source, view) = refreshed_doubled(vec![1, 2]).await; - - source - .add_columns() - .transform(NewColumnTransform::AllNulls(Arc::new(ArrowSchema::new( - vec![arrow_schema::Field::new( - "unrelated", - arrow_schema::DataType::Int32, - true, - )], - )))) - .execute() - .await - .unwrap(); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 0); - assert_eq!(read(view.table(), "twice").await, vec![2, 4]); - } - - #[tokio::test] - async fn test_full_forces_a_rebuild() { - let (_conn, source, view) = refreshed_doubled(vec![1]).await; - - append(&source, vec![2]).await; - let result = view.refresh().full(true).execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(result.rows_written, 2); - assert_eq!(read(view.table(), "twice").await, vec![2, 4]); - } - - /// A cap and incremental reconciliation do not compose: rows skipped at - /// the cap fall behind the watermark, so room freed later cannot be - /// refilled from any delta. A capped view rebuilds instead, which its - /// own cap keeps cheap. - #[tokio::test] - async fn test_limited_view_rebuilds_rather_than_reconcile() { - let (conn, source) = db_with_source(vec![1, 2]).await; - let view = conn - .create_materialized_view("capped", "src") - .with_no_data(true) - .select([("x", "x")]) - .limit(2) - .execute() - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - assert_eq!(read(view.table(), "x").await, vec![1, 2]); - - append(&source, vec![3, 4]).await; - source - .update() - .column("x", "11") - .only_if("x = 1") - .execute() - .await - .unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - let held = read(view.table(), "x").await; - assert_eq!(held.len(), 2, "the cap holds: {held:?}"); - let selectable = read(&source, "x").await; - assert!( - held.iter().all(|x| selectable.contains(x)), - "{held:?} is not a subset of {selectable:?}" - ); - } - - #[tokio::test] - async fn test_limit_caps_the_view() { - let (conn, source) = db_with_source(vec![1, 2, 3]).await; - let view = conn - .create_materialized_view("capped", "src") - .with_no_data(true) - .select([("x", "x")]) - .limit(4) - .execute() - .await - .unwrap(); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.rows_written, 3); - - // The cap counts already-held rows, so only one appended row lands. - append(&source, vec![4, 5, 6]).await; - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 1); - assert_eq!(view.table().count_rows(None).await.unwrap(), 4); - - // At the cap, later appends only move the watermark. - append(&source, vec![7]).await; - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.rows_written, 0); - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - } - - /// The whole point of the fragment-swap commit: a rebuild must never - /// leave the view without its index definitions. - #[tokio::test] - async fn test_rebuild_retains_indexes() { - let (_conn, source, view) = refreshed_doubled(vec![1, 2, 3]).await; - - view.table() - .create_index(&["twice"], Index::BTree(BTreeIndexBuilder::default())) - .execute() - .await - .unwrap(); - assert_eq!(view.table().list_indices().await.unwrap().len(), 1); - - source - .update() - .column("x", "x + 10") - .execute() - .await - .unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(view.table().list_indices().await.unwrap().len(), 1); - assert_eq!(read(view.table(), "twice").await, vec![22, 24, 26]); - - // The swapped-in rows are reachable through an indexed query. - let batches = view - .table() - .query() - .only_if("twice = 24") - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - assert_eq!(batches.iter().map(|b| b.num_rows()).sum::(), 1); - } - - #[tokio::test] - async fn test_rebuild_of_an_empty_result_is_an_empty_view() { - let (conn, _) = db_with_source(vec![1, 2]).await; - let view = conn - .create_materialized_view("none", "src") - .with_no_data(true) - .select([("x", "x")]) - .only_if("x > 100") - .execute() - .await - .unwrap(); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(result.rows_written, 0); - assert_eq!(view.table().count_rows(None).await.unwrap(), 0); - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - } - - /// Provenance: every view row records the source row that produced it. - #[tokio::test] - async fn test_source_row_ids_are_recorded() { - let (_conn, _, view) = refreshed_doubled(vec![1, 2, 3]).await; - - let batches = view - .table() - .query() - .select(Select::columns(&[SOURCE_ROW_ID_COLUMN])) - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - let total: usize = batches.iter().map(|b| b.num_rows()).sum(); - assert_eq!(total, 3); - for batch in &batches { - assert_eq!(batch[SOURCE_ROW_ID_COLUMN].null_count(), 0); - } - } - - #[tokio::test] - async fn test_dropping_a_source_input_fails_the_refresh() { - let (conn, source) = db_with_source(vec![1]).await; - let view = conn - .create_materialized_view("v", "src") - .with_no_data(true) - .select([("twice", "x * 2")]) - .execute() - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - - append(&source, vec![2]).await; - source - .add_columns() - .transform(NewColumnTransform::AllNulls(Arc::new(ArrowSchema::new( - vec![arrow_schema::Field::new( - "y", - arrow_schema::DataType::Int32, - true, - )], - )))) - .execute() - .await - .unwrap(); - source.drop_columns(&["x"]).await.unwrap(); - - let err = view.refresh().execute().await.unwrap_err(); - assert!( - matches!(&err, Error::Schema { message } - if message.contains("dropped or renamed") && message.contains("No field named x")), - "{err:?}" - ); - } - - /// A pinned refresh materializes the source as of `version`; catching up - /// to the appends beyond it stays incremental. - #[tokio::test] - async fn test_pinned_refresh_and_catch_up() { - let (conn, source) = db_with_source(vec![1]).await; - let view = doubled_view(&conn).await; - let pinned = source.version().await.unwrap(); - - append(&source, vec![2]).await; - let result = view - .refresh() - .source_version(pinned) - .execute() - .await - .unwrap(); - assert_eq!(result.source_version, pinned); - assert_eq!(read(view.table(), "twice").await, vec![2]); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(read(view.table(), "twice").await, vec![2, 4]); - } - - /// Views chain: a view's stable row ids and provenance column make it a - /// source like any other, and the default projection takes its declared - /// columns without copying its provenance. - #[tokio::test] - async fn test_a_view_can_source_another_view() { - let (conn, source) = db_with_source(vec![1, 2, 30]).await; - let first = doubled_view(&conn).await; - first.refresh().execute().await.unwrap(); - - let second = conn - .create_materialized_view("second", "doubled") - .with_no_data(true) - .only_if("twice > 10") - .execute() - .await - .unwrap(); - assert!( - second - .definition() - .projections - .iter() - .all(|p| p.output != SOURCE_ROW_ID_COLUMN) - ); - let result = second.refresh().execute().await.unwrap(); - assert_eq!(result.rows_written, 1); - assert_eq!(read(second.table(), "twice").await, vec![60]); - - append(&source, vec![50]).await; - first.refresh().execute().await.unwrap(); - let result = second.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(read(second.table(), "twice").await, vec![60, 100]); - } - - /// The watermark speaks only for the state a refresh left behind: a - /// direct write to the view is drift, and the next refresh rebuilds - /// rather than preserving it as current. - #[tokio::test] - async fn test_direct_view_mutation_forces_a_rebuild() { - let (_conn, _, view) = refreshed_doubled(vec![1, 2]).await; - - view.table().delete("x = 1").await.unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(read(view.table(), "twice").await, vec![2, 4]); - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - } - - /// A dropped and recreated source reuses version numbers but never their - /// timestamps; the watermark must not vouch for the replacement's rows. - #[tokio::test] - async fn test_source_recreation_forces_a_rebuild() { - let (conn, _, view) = refreshed_doubled(vec![1]).await; - - conn.drop_table("src", &[]).await.unwrap(); - let batch = record_batch!(("x", Int32, [7])).unwrap(); - conn.create_table("src", batch) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(read(view.table(), "twice").await, vec![14]); - } - - /// A refresh bound to an incarnation refuses a view dropped and recreated - /// since, even under the same name and definition; the recreated view's - /// own token is accepted, and the token survives a refresh's stamp. - #[tokio::test] - async fn test_refresh_refuses_a_recreated_view_incarnation() { - let (conn, _, view) = refreshed_doubled(vec![1]).await; - let token = view.incarnation().unwrap().to_string(); - view.refresh() - .expect_incarnation(&token) - .execute() - .await - .unwrap(); - let reopened = conn.open_materialized_view("doubled").await.unwrap(); - assert_eq!(reopened.incarnation(), Some(token.as_str())); - - conn.drop_table("doubled", &[]).await.unwrap(); - let recreated = doubled_view(&conn).await; - assert_ne!(recreated.incarnation(), Some(token.as_str())); - - let err = recreated - .refresh() - .expect_incarnation(&token) - .execute() - .await - .unwrap_err(); - assert!(err.to_string().contains("dropped and recreated"), "{err}"); - assert_eq!(read(recreated.table(), "twice").await, Vec::::new()); - - recreated - .refresh() - .expect_incarnation(recreated.incarnation().unwrap()) - .execute() - .await - .unwrap(); - assert_eq!(read(recreated.table(), "twice").await, vec![2]); - } - - /// A cloned declaration creates two physical tables; each gets its own - /// token. - #[tokio::test] - async fn test_cloned_declaration_mints_a_fresh_incarnation_per_create() { - let (conn, source) = db_with_source(vec![1]).await; - let prepared = crate::materialized_view::prepare_declaration( - &source, - Some(&[("x".into(), "x".into()), ("twice".into(), "x * 2".into())]), - None, - None, - ) - .await - .unwrap(); - let replacement = prepared.clone(); - let first = prepared.create("cloned").await.unwrap(); - let first_token = first.incarnation().unwrap().to_string(); - - conn.drop_table("cloned", &[]).await.unwrap(); - let second = replacement.create("cloned").await.unwrap(); - assert_ne!(second.incarnation(), Some(first_token.as_str())); - } - - /// A recreation that lands after planning but before publication is - /// caught by the pre-commit read: the stale refresh fails and the - /// replacement stays empty under its own token. - #[tokio::test(flavor = "multi_thread")] - async fn test_bound_refresh_cannot_publish_into_a_raced_recreation() { - let _serial = DRIFT_LOCK.lock().await; - let (conn, _) = db_with_source(vec![1]).await; - let view = doubled_view(&conn).await; - let token = view.incarnation().unwrap().to_string(); - let uri = view - .table() - .as_native() - .unwrap() - .dataset - .get() - .await - .unwrap() - .uri() - .to_string(); - - *DRIFT_TARGET.lock().unwrap() = Some(uri); - let refreshing = - tokio::spawn(async move { view.refresh().expect_incarnation(token).execute().await }); - tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified()) - .await - .expect("refresh never reached publication"); - - conn.drop_table("doubled", &[]).await.unwrap(); - let replacement = doubled_view(&conn).await; - let replacement_token = replacement.incarnation().unwrap().to_string(); - DRIFT_RELEASED.notify_one(); - - let result = refreshing.await.unwrap(); - assert!(result.is_err(), "the stale refresh unexpectedly succeeded"); - let reopened = conn.open_materialized_view("doubled").await.unwrap(); - assert_eq!(reopened.incarnation(), Some(replacement_token.as_str())); - assert_eq!(read(reopened.table(), "twice").await, Vec::::new()); - } - - /// Replacing the schema metadata wholesale drops the token. A refresh - /// bound to the old token is refused for that reason, not as a - /// recreation; an unbound refresh mints the view a fresh one. - #[tokio::test] - async fn test_a_view_whose_metadata_was_replaced_starts_a_new_incarnation() { - let (conn, _, view) = refreshed_doubled(vec![1]).await; - let token = view.incarnation().unwrap().to_string(); - let mut metadata = HashMap::new(); - metadata.insert( - crate::materialized_view::DEFINITION_META_KEY.to_string(), - crate::materialized_view::definition_to_metadata(view.definition()).unwrap(), - ); - view.table() - .as_native() - .unwrap() - .replace_schema_metadata(metadata) - .await - .unwrap(); - assert_eq!( - conn.open_materialized_view("doubled") - .await - .unwrap() - .incarnation(), - None - ); - - let err = view - .refresh() - .expect_incarnation(&token) - .execute() - .await - .unwrap_err(); - assert!(err.to_string().contains("no incarnation token"), "{err}"); - - view.refresh().execute().await.unwrap(); - let reopened = conn.open_materialized_view("doubled").await.unwrap(); - assert!(reopened.incarnation().is_some()); - assert_ne!(reopened.incarnation(), Some(token.as_str())); - } - - /// In-process refreshes of one view serialize: the loser of the race - /// observes the winner's watermark instead of appending the same rows. - #[tokio::test(flavor = "multi_thread")] - async fn test_concurrent_refreshes_do_not_duplicate() { - let (_conn, source, view) = refreshed_doubled(vec![1]).await; - - append(&source, vec![2, 3]).await; - let (a, b) = tokio::join!(view.refresh().execute(), view.refresh().execute()); - let (a, b) = (a.unwrap(), b.unwrap()); - assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6]); - let modes = [a.mode, b.mode]; - assert!(modes.contains(&RefreshMode::Incremental)); - assert!(modes.contains(&RefreshMode::NoOp)); - } - - /// A second handle's lazy cache must not defeat the lock: after another - /// handle's refresh commits, the stale handle plans from the reloaded - /// state and no-ops instead of appending the same fragments again. - #[tokio::test] - async fn test_a_second_handle_does_not_double_append() { - let (conn, source, view) = refreshed_doubled(vec![1, 2, 3]).await; - let stale = conn.open_materialized_view("doubled").await.unwrap(); - - append(&source, vec![4]).await; - view.refresh().execute().await.unwrap(); - - let result = stale.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::NoOp); - assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6, 8]); - } - - /// A commit racing between a refresh's data commit and its stamp must - /// not be certified as the refresh's generation: the stamp aborts, and - /// the next refresh rebuilds from the drifted state. - #[tokio::test] - async fn test_stamp_aborts_on_a_racing_commit() { - let (_conn, _, view) = refreshed_doubled(vec![1, 2]).await; - - let view_native = view.table().as_native().unwrap(); - let stale = view_native.dataset.get().await.unwrap().as_ref().clone(); - view.table().delete("x = 1").await.unwrap(); - - let err = stamp_watermark(view_native, stale, 99, 99, None, None).await; - assert!(err.is_err()); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(read(view.table(), "twice").await, vec![2, 4]); - } - - /// What refresh executes and what it stamps must be one generation: a - /// replaced definition wins over whatever a stale handle cached. - #[tokio::test] - async fn test_refresh_uses_the_latest_persisted_definition() { - let (_conn, _, view) = refreshed_doubled(vec![1, 2]).await; - - let replacement = crate::materialized_view::MaterializedViewDefinition { - vector_source: None, - source_table: "src".into(), - source_namespace: Vec::new(), - projections: vec![ - crate::materialized_view::ViewProjection { - output: "x".into(), - expression: "x".into(), - }, - crate::materialized_view::ViewProjection { - output: "twice".into(), - expression: "x * 3".into(), - }, - ], - filter: None, - group_by: Vec::new(), - limit: None, - lateral: None, - }; - let mut metadata = HashMap::new(); - metadata.insert( - crate::materialized_view::DEFINITION_META_KEY.to_string(), - crate::materialized_view::definition_to_metadata(&replacement).unwrap(), - ); - view.table() - .as_native() - .unwrap() - .replace_schema_metadata(metadata) - .await - .unwrap(); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(read(view.table(), "twice").await, vec![3, 6]); - } - - /// A persisted definition that does not produce this view's schema must - /// not refresh at all, let alone be certified. - #[tokio::test] - async fn test_definition_view_schema_mismatch_is_refused() { - let (_conn, _, view) = refreshed_doubled(vec![1, 2]).await; - - let narrower = crate::materialized_view::MaterializedViewDefinition { - vector_source: None, - source_table: "src".into(), - source_namespace: Vec::new(), - projections: vec![crate::materialized_view::ViewProjection { - output: "x".into(), - expression: "x".into(), - }], - filter: None, - group_by: Vec::new(), - limit: None, - lateral: None, - }; - let mut metadata = HashMap::new(); - metadata.insert( - crate::materialized_view::DEFINITION_META_KEY.to_string(), - crate::materialized_view::definition_to_metadata(&narrower).unwrap(), - ); - view.table() - .as_native() - .unwrap() - .replace_schema_metadata(metadata) - .await - .unwrap(); - - let err = view.refresh().execute().await.unwrap_err(); - assert!(matches!(err, Error::Schema { message } if message.contains("does not produce")),); - } - - /// An overlay replaces cell values without changing any file path; the - /// signature must see it, scoped to the columns the view reads like - /// data files are. - #[test] - fn test_fragment_signature_sees_overlays() { - use lance_file::version::ConcreteFileVersion; - use lance_table::format::DataFile; - use lance_table::format::overlay::{DataOverlayFile, OverlayCoverage}; - - let base = Fragment::new(7); - let mut file = DataFile::new_unstarted("f0.lance", ConcreteFileVersion::V2_1); - file.fields = vec![0, 1].into(); - let mut with_file = base.clone(); - with_file.files.push(file.clone()); - - let overlay = |field: i32| { - let mut data_file = DataFile::new_unstarted("o0.lance", ConcreteFileVersion::V2_1); - data_file.fields = vec![field].into(); - DataOverlayFile { - data_file, - coverage: OverlayCoverage::PerField(Vec::new()), - committed_version: 9, - } - }; - let relevant: HashSet = [0].into_iter().collect(); - - let mut overlaid_relevant = with_file.clone(); - overlaid_relevant.overlays.push(overlay(0)); - assert_ne!( - fragment_signature(&with_file, &relevant), - fragment_signature(&overlaid_relevant, &relevant), - ); - - let mut overlaid_unrelated = with_file.clone(); - overlaid_unrelated.overlays.push(overlay(5)); - assert_eq!( - fragment_signature(&with_file, &relevant), - fragment_signature(&overlaid_unrelated, &relevant), - ); - } - - /// An output whose name needs quoting flows through as a projection - /// alias, never spliced into SQL text. - #[tokio::test] - async fn test_output_names_needing_quotes() { - let (conn, _) = db_with_source(vec![1, 2]).await; - let view = conn - .create_materialized_view("v", "src") - .with_no_data(true) - .select([("double value", "x * 2")]) - .execute() - .await - .unwrap(); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.rows_written, 2); - assert_eq!(read(view.table(), "double value").await, vec![2, 4]); - } - - /// MemWAL tiers are visible to reads but not to the refresh scan, so LSM - /// state disqualifies every participant: the source at create, either - /// side at refresh, and the view can never accept a spec. Retained - /// un-compacted rows (the catch-up flag outlives unset) count as state. - #[tokio::test] - async fn lsm_state_disqualifies_source_and_view() { - use crate::table::LsmWriteSpec; - use arrow_array::RecordBatchIterator; - - let tmp_dir = tempfile::tempdir().unwrap(); - let conn = connect(tmp_dir.path().to_str().unwrap()) - .execute() - .await - .unwrap(); - // Hand-rolled: the LSM primary key must be non-nullable, which - // record_batch! cannot express. - let schema = Arc::new(ArrowSchema::new(vec![ - arrow_schema::Field::new("id", arrow_schema::DataType::Int64, false), - arrow_schema::Field::new("x", arrow_schema::DataType::Int32, false), - ])); - let batch = RecordBatch::try_new( - schema.clone(), - vec![ - Arc::new(arrow_array::Int64Array::from(vec![1, 2])) as _, - Arc::new(Int32Array::from(vec![1, 2])) as _, - ], - ) - .unwrap(); - let table = conn - .create_table("src", batch.clone()) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - table.set_unenforced_primary_key(["id"]).await.unwrap(); - table - .set_lsm_write_spec(LsmWriteSpec::unsharded()) - .await - .unwrap(); - - // An active-LSM source is refused at create. - let err = conn - .create_materialized_view("v", "src") - .with_no_data(true) - .execute() - .await - .unwrap_err(); - assert!(err.to_string().contains("un-compacted"), "{err}"); - - // Unset with nothing written clears the state: the view creates, - // and a spec can never be installed over it. - table.unset_lsm_write_spec().await.unwrap(); - let view = conn - .create_materialized_view("v", "src") + let view = connection + .create_materialized_view("copy", "src") .with_no_data(true) .execute() .await .unwrap(); - let err = view - .table() - .set_lsm_write_spec(LsmWriteSpec::unsharded()) - .await - .unwrap_err(); - assert!(err.to_string().contains("materialized view"), "{err}"); - - // A source that acquires a spec after create fails refresh. - table - .set_lsm_write_spec(LsmWriteSpec::unsharded()) - .await - .unwrap(); - let err = view.refresh().execute().await.unwrap_err(); - assert!(err.to_string().contains("source table 'src'"), "{err}"); - - // Retained rows outlive unset: write through the WAL, unset, and - // refresh still refuses. - let mut merge = table.merge_insert(&["id"]); - merge - .when_matched_update_all(None) - .when_not_matched_insert_all() - .use_lsm(true); - merge - .execute(Box::new(RecordBatchIterator::new( - vec![Ok(batch.clone())], - batch.schema(), - ))) - .await - .unwrap(); - table.unset_lsm_write_spec().await.unwrap(); - let err = view.refresh().execute().await.unwrap_err(); - assert!(err.to_string().contains("source table 'src'"), "{err}"); - } - - /// A view with a computed column, declared over `people` and refreshed. - async fn refreshed_computed_view(conn: &Connection) -> MaterializedView { - use crate::materialized_view::tests::{computed_field, people, test_binding}; - let source = people(conn).await; - let view = crate::materialized_view::prepare_declaration( - &source, - Some(&[ - ("id".to_string(), "id".to_string()), - ("name".to_string(), "name".to_string()), - ]), - None, - None, - ) - .await - .unwrap() - .with_computed_columns( - vec![(2, computed_field("emb", "fb_1", "name"))], - &[test_binding("fb_1", "name", "emb")], - ) - .unwrap() - .create("v") - .await - .unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - view - } - - async fn unfilled(view: &MaterializedView) -> usize { - view.table() - .count_rows(Some("emb IS NULL".to_string())) - .await - .unwrap() - } - - async fn append_people(conn: &Connection, ids: Vec, names: Vec<&str>) { - let batch = record_batch!(("id", Int32, ids), ("name", Utf8, names)).unwrap(); - conn.open_table("people") - .execute() - .await - .unwrap() - .add(batch) - .execute() - .await - .unwrap(); - } - - /// Commit the fill job's shape on the view: a column rewrite of - /// `fields`, touching no rows. The data is left as it is; what matters - /// here is how the next refresh classifies the commit. - async fn commit_column_rewrite(view: &MaterializedView, fields: &[&str]) { - let native = view.table().as_native().unwrap(); - native.dataset.reload().await.unwrap(); - let dataset = native.dataset.get().await.unwrap().as_ref().clone(); - let fields_modified = fields - .iter() - .map(|name| dataset.schema().field(name).unwrap().id as u32) - .collect(); - let updated_fragments = dataset - .get_fragments() - .iter() - .map(|fragment| fragment.metadata().clone()) - .collect(); - let operation = Operation::Update { - removed_fragment_ids: Vec::new(), - updated_fragments, - new_fragments: Vec::new(), - fields_modified, - compacted_sstables: Vec::new(), - fields_for_preserving_frag_bitmap: Vec::new(), - update_mode: Some(UpdateMode::RewriteColumns), - inserted_rows_filter: None, - updated_fragment_offsets: None, - }; - let read_version = dataset.version().version; - CommitBuilder::new(WriteDestination::Dataset(Arc::new(dataset))) - .execute(Transaction::new(read_version, operation, None)) - .await - .unwrap(); - } - - /// Refresh never computes a computed column: every row it writes, on a - /// rebuild, an append and a rewrite, carries NULL there, and the - /// declaration survives all three. - #[tokio::test] - async fn test_computed_columns_are_written_null_and_kept() { - let conn = connect("memory://").execute().await.unwrap(); - let view = refreshed_computed_view(&conn).await; - assert_eq!(unfilled(&view).await, 3); - - append_people(&conn, vec![4, 5], vec!["d", "e"]).await; - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(unfilled(&view).await, 5); - - conn.open_table("people") - .execute() - .await - .unwrap() - .update() - .column("name", "'z'") - .only_if("id = 1") - .execute() - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - assert_eq!(unfilled(&view).await, 5); - assert_eq!(read(view.table(), "id").await, vec![1, 2, 3, 4, 5]); - let schema = view.table().schema().await.unwrap(); - assert!( - crate::table::computed_columns::function_bindings(&schema) - .unwrap() - .iter() - .any(|b| b.binding_id() == "fb_1"), - "the binding envelope was lost" - ); - assert!( - computed_column_from_field(schema.field_with_name("emb").unwrap()).is_some(), - "the declaration was lost" - ); - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - } - - /// Field metadata on `field` only, the commit shape of the freshness - /// stamp `refresh_column` leaves after its fill. - async fn commit_field_metadata(view: &MaterializedView, field: &str, key: &str) { - let native = view.table().as_native().unwrap(); - native.dataset.reload().await.unwrap(); - let mut dataset = native.dataset.get().await.unwrap().as_ref().clone(); - dataset - .update_field_metadata() - .update(field, [(key.to_string(), "{}".to_string())]) - .unwrap() - .await - .unwrap(); - } - - /// The stamp is metadata on the computed column and rewrites nothing - /// refresh certifies, so it is not drift; the same commit shape on a - /// projected column is, like any other write to it. - #[tokio::test] - async fn test_a_freshness_stamp_is_not_drift() { - let conn = connect("memory://").execute().await.unwrap(); - let view = refreshed_computed_view(&conn).await; - - commit_field_metadata( - &view, - "emb", - crate::table::computed_columns::SOURCE_SIGNATURE_META_KEY, - ) - .await; - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - - commit_field_metadata(&view, "id", "probe").await; - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::Rebuild - ); - } - - /// The fill job's commit rewrites only computed columns. It is the one - /// commit on a view that is not drift: the next refresh carries on from - /// its watermark instead of rebuilding, which would null what the fill - /// just wrote. - #[tokio::test] - async fn test_a_computed_column_fill_is_not_drift() { - let conn = connect("memory://").execute().await.unwrap(); - let view = refreshed_computed_view(&conn).await; - - commit_column_rewrite(&view, &["emb"]).await; - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - - commit_column_rewrite(&view, &["emb"]).await; - append_people(&conn, vec![4], vec!["d"]).await; - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Incremental); - assert_eq!(result.rows_written, 1); - assert_eq!(read(view.table(), "id").await, vec![1, 2, 3, 4]); - } - - /// A column rewrite that reaches a projected column is drift like any - /// other write: refresh certifies those columns and must recompute them. - #[tokio::test] - async fn test_a_rewrite_of_a_projected_column_is_drift() { - let conn = connect("memory://").execute().await.unwrap(); - let view = refreshed_computed_view(&conn).await; - - commit_column_rewrite(&view, &["emb", "name"]).await; - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::Rebuild - ); - } - - /// The declaration contract is checked before any refresh mutation: a - /// missing binding envelope and a column that lost its declaration both - /// fail closed. - #[tokio::test] - async fn test_a_broken_declaration_is_refused_before_refresh() { - let conn = connect("memory://").execute().await.unwrap(); - let view = refreshed_computed_view(&conn).await; - let native = view.table().as_native().unwrap(); - let mut dataset = native.dataset.get().await.unwrap().as_ref().clone(); - dataset - .update_schema_metadata(vec![( - crate::table::computed_columns::FUNCTION_BINDINGS_META_KEY.to_string(), - None, - )]) - .await - .unwrap(); - let err = view.refresh().execute().await.unwrap_err().to_string(); - assert!(err.contains("references missing binding 'fb_1'"), "{err}"); - - let conn = connect("memory://").execute().await.unwrap(); - let view = refreshed_computed_view(&conn).await; - let native = view.table().as_native().unwrap(); - let mut dataset = native.dataset.get().await.unwrap().as_ref().clone(); - dataset - .replace_field_metadata(vec![( - dataset.schema().field("emb").unwrap().id as u32, - HashMap::new(), - )]) - .await - .unwrap(); - let err = view.refresh().execute().await.unwrap_err().to_string(); - assert!(err.contains("does not match binding 'fb_1'"), "{err}"); - } - - /// An input the view does not project is materialized on every refresh - /// path, before the provenance column, with the source's values. - #[tokio::test] - async fn test_internal_inputs_are_materialized_and_refreshed() { - use crate::materialized_view::tests::{computed_field, strict_people, test_binding}; - let conn = connect("memory://").execute().await.unwrap(); - let source = strict_people(&conn).await; - let mut prepared = crate::materialized_view::prepare_declaration( - &source, - Some(&[("id".to_string(), "id".to_string())]), - None, - None, - ) - .await - .unwrap(); - let input = prepared.input_column("name").unwrap(); - let view = prepared - .with_computed_columns( - vec![(1, computed_field("emb", "fb_1", &input))], - &[test_binding("fb_1", &input, "emb")], - ) - .unwrap() - .create("v") - .await - .unwrap(); - let names: Vec = view + let result_batch = + RecordBatch::try_new(schema.clone(), source_batch.columns().to_vec()).unwrap(); + let planned = view .table() - .schema() - .await - .unwrap() - .fields() - .iter() - .map(|f| f.name().clone()) - .collect(); - assert_eq!(names, ["id", "emb", "__input_name", SOURCE_ROW_ID_COLUMN]); - - let unfilled_inputs = || async { - view.table() - .count_rows(Some("__input_name IS NULL".to_string())) - .await - .unwrap() - }; - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::Rebuild - ); - assert_eq!(view.table().count_rows(None).await.unwrap(), 3); - assert_eq!(unfilled_inputs().await, 0); - - let more = arrow_array::RecordBatch::try_new( - source.schema().await.unwrap(), - vec![ - Arc::new(Int32Array::from(vec![4])), - Arc::new(arrow_array::StringArray::from(vec!["d"])), - ], - ) - .unwrap(); - source.add(more).execute().await.unwrap(); - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::Incremental - ); - assert_eq!(unfilled_inputs().await, 0); - assert_eq!( - view.table() - .count_rows(Some("__input_name = 'd'".to_string())) - .await - .unwrap(), - 1 - ); - - source - .update() - .column("name", "'z'") - .only_if("id = 1") - .execute() - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - assert_eq!( - view.table() - .count_rows(Some("__input_name = 'z'".to_string())) - .await - .unwrap(), - 1 - ); - assert_eq!( - unfilled(&view).await, - 4, - "rewritten and new rows are unfilled" - ); - } - - /// A SQL declaration is filled by `refresh_column` on the view, which - /// commits a data replacement and then its freshness stamp; the next - /// refresh continues from its watermark and keeps what the fill wrote, - /// and only rows the view added since come back unfilled. - #[tokio::test] - async fn test_a_sql_fill_is_not_drift() { - use crate::materialized_view::tests::{people, sql_field}; - let conn = connect("memory://").execute().await.unwrap(); - let source = people(&conn).await; - let view = crate::materialized_view::prepare_declaration( - &source, - Some(&[("id".to_string(), "id".to_string())]), - None, - None, - ) - .await - .unwrap() - .with_computed_columns( - vec![( - 1, - sql_field("next", arrow_schema::DataType::Int32, "id + 1", r#"["id"]"#), - )], - &[], - ) - .unwrap() - .create("v") - .await - .unwrap(); - let filled = || async { - view.table() - .count_rows(Some("next = id + 1".to_string())) - .await - .unwrap() - }; - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::Rebuild - ); - assert_eq!( - view.table() - .refresh_column("next") - .await - .unwrap() - .rows_filled, - 3 - ); - assert_eq!(filled().await, 3); - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - assert_eq!(filled().await, 3); - - append_people(&conn, vec![4], vec!["d"]).await; - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::Incremental - ); - assert_eq!(filled().await, 3); - assert_eq!( - view.table() - .refresh_column("next") - .await - .unwrap() - .rows_filled, - 1 - ); - assert_eq!(filled().await, 4); - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - } - - /// A fill of a nested computed column writes its child fields; that is - /// still a fill, not drift. - #[tokio::test] - async fn test_a_nested_sql_fill_is_not_drift() { - use crate::materialized_view::tests::{people, sql_field}; - let conn = connect("memory://").execute().await.unwrap(); - let source = people(&conn).await; - let payload = sql_field( - "payload", - arrow_schema::DataType::Struct( - vec![arrow_schema::Field::new( - "value", - arrow_schema::DataType::Utf8, - true, - )] - .into(), - ), - "named_struct('value', name)", - r#"["name"]"#, - ); - let view = crate::materialized_view::prepare_declaration( - &source, - Some(&[("name".to_string(), "name".to_string())]), - None, - None, - ) - .await - .unwrap() - .with_computed_columns(vec![(1, payload)], &[]) - .unwrap() - .create("v") - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - assert_eq!( - view.table() - .refresh_column("payload") - .await - .unwrap() - .rows_filled, - 3 - ); - assert_eq!( - view.refresh().execute().await.unwrap().mode, - RefreshMode::NoOp - ); - assert_eq!( - view.table() - .count_rows(Some("payload.value = name".to_string())) - .await - .unwrap(), - 3 - ); - } - - async fn grouped_source(conn: &Connection) -> Table { - conn.create_table( - "src", - record_batch!(("k", Int32, [1, 1, 2, 3]), ("x", Int32, [10, 11, 20, 30])).unwrap(), - ) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap() - } - - pub(in crate::materialized_view) async fn declare( - source: &Table, - name: &str, - sql: &str, - ) -> Result { - crate::materialized_view::prepare_definition( - source, - MaterializedViewDefinition::from_sql(sql)?, - ) - .await? - .create(name) - .await - } - - /// Rows of `table` as `columns` rendered to text, sorted. - pub(in crate::materialized_view) async fn rows(table: &Table, columns: &[&str]) -> Vec { - let batches = table - .query() - .select(Select::columns(columns)) - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - let mut rows = Vec::new(); - for batch in &batches { - let formatters: Vec<_> = batch - .columns() - .iter() - .map(|c| { - arrow_cast::display::ArrayFormatter::try_new(c.as_ref(), &Default::default()) - .unwrap() - }) - .collect(); - for row in 0..batch.num_rows() { - let cells: Vec = formatters - .iter() - .map(|f| f.value(row).to_string()) - .collect(); - rows.push(cells.join(" ")); - } - } - rows.sort(); - rows - } - - #[tokio::test] - async fn a_grouped_view_holds_one_row_per_group() { - let conn = connect("memory://").execute().await.unwrap(); - let source = grouped_source(&conn).await; - let view = declare( - &source, - "groups", - "SELECT k, array_agg(x) AS xs, sum(x) AS total FROM src WHERE x < 30 GROUP BY k", - ) - .await - .unwrap(); - assert_eq!( - view.definition().to_sql(), - "SELECT k, array_agg(x) AS xs, sum(x) AS total FROM src WHERE x < 30 GROUP BY k" - ); - - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(result.rows_written, 2); - assert_eq!(rows(view.table(), &["k", "total"]).await, ["1 21", "2 20"]); - let unchanged = view.refresh().execute().await.unwrap(); - assert_eq!(unchanged.mode, RefreshMode::NoOp); - - // Any source change recomputes every group. - source - .add(record_batch!(("k", Int32, [2]), ("x", Int32, [5])).unwrap()) - .execute() - .await - .unwrap(); - let result = view.refresh().execute().await.unwrap(); - assert_eq!(result.mode, RefreshMode::Rebuild); - assert_eq!(rows(view.table(), &["k", "total"]).await, ["1 21", "2 25"]); - - // A view over the groups expands them again. - let expanded = declare( - view.table(), - "elements", - "SELECT k, e AS x FROM groups, UNNEST(xs) AS e", - ) - .await - .unwrap(); - expanded.refresh().execute().await.unwrap(); - assert_eq!( - rows(expanded.table(), &["k", "x"]).await, - ["1 10", "1 11", "2 20", "2 5"] - ); - } - - /// Provenance is one source row per group, so the view's row-id keys - /// stay unique. - #[tokio::test] - async fn a_grouped_view_records_one_source_row_per_group() { - let conn = connect("memory://").execute().await.unwrap(); - let source = grouped_source(&conn).await; - let view = declare( - &source, - "groups", - "SELECT k, count(*) AS n FROM src GROUP BY k", - ) - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - let ids = rows(view.table(), &[SOURCE_ROW_ID_COLUMN]).await; - assert_eq!(ids.len(), 3); - assert_eq!(ids.iter().collect::>().len(), 3, "{ids:?}"); - } - - #[tokio::test] - async fn a_grouped_view_is_planned_at_declaration() { - let conn = connect("memory://").execute().await.unwrap(); - let source = grouped_source(&conn).await; - for sql in [ - // x is neither a key nor aggregated - "SELECT k, x FROM src GROUP BY k", - "SELECT k, random() AS r FROM src GROUP BY k", - "SELECT k, count(*) AS n FROM src GROUP BY missing", - "SELECT k, count(*) AS __source_row_id FROM src GROUP BY k", - "SELECT k, count(*) AS k FROM src GROUP BY k", - ] { - assert!(declare(&source, "v", sql).await.is_err(), "{sql}"); - } - let mut grouped = crate::materialized_view::prepare_definition( - &source, - MaterializedViewDefinition::from_sql("SELECT k, count(*) AS n FROM src GROUP BY k") - .unwrap(), - ) - .await - .unwrap(); - assert!(grouped.input_column("x").is_err()); - } - - /// Twenty nonzero vectors in two clusters far apart, ids 0-9 and 10-19. - /// Nonzero so every vector has a cosine direction. - pub(in crate::materialized_view) async fn clustered_source(conn: &Connection) -> Table { - use arrow_array::types::Float32Type; - use arrow_array::{FixedSizeListArray, Int32Array}; - - let vectors = FixedSizeListArray::from_iter_primitive::( - (0..20).map(|i| { - let base = if i < 10 { 0.0 } else { 100.0 }; - Some(vec![Some(base + 1.0 + i as f32 * 0.1), Some(base)]) - }), - 2, - ); - let batch = RecordBatch::try_from_iter(vec![ - ( - "id", - Arc::new(Int32Array::from_iter_values(0..20)) as arrow_array::ArrayRef, - ), - ("vec", Arc::new(vectors) as arrow_array::ArrayRef), - ]) - .unwrap(); - conn.create_table("images", batch) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap() - } - - #[tokio::test] - async fn ivf_partition_groups_by_the_index_on_the_column() { - use crate::index::vector::IvfFlatIndexBuilder; - - const BUCKETS: &str = "SELECT ivf_partition(vec) AS bucket, count(*) AS n, \ - min(id) AS lo, max(id) AS hi FROM images GROUP BY ivf_partition(vec)"; - let conn = connect("memory://").execute().await.unwrap(); - let source = clustered_source(&conn).await; - let unindexed = declare(&source, "buckets", BUCKETS).await.unwrap_err(); - assert!( - unindexed.to_string().contains("IVF vector index"), - "{unindexed}" - ); - - source - .create_index( - &["vec"], - Index::IvfFlat(IvfFlatIndexBuilder::default().num_partitions(2)), - ) - .execute() - .await - .unwrap(); - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - view.refresh().execute().await.unwrap(); - assert_eq!( - rows(view.table(), &["n", "lo", "hi"]).await, - ["10 0 9", "10 10 19"] - ); - let buckets = rows(view.table(), &["bucket"]).await; - assert_eq!(buckets, ["0", "1"]); - } - - #[tokio::test] - async fn ivf_partition_takes_a_column() { - let conn = connect("memory://").execute().await.unwrap(); - let source = clustered_source(&conn).await; - for sql in [ - "SELECT ivf_partition(id) AS b, count(*) AS n FROM images GROUP BY ivf_partition(id)", - "SELECT ivf_partition(vec, vec) AS b, count(*) AS n FROM images \ - GROUP BY ivf_partition(vec, vec)", - ] { - assert!(declare(&source, "v", sql).await.is_err(), "{sql}"); - } - } - - /// A cosine index assigns by L2 over unit vectors, as lance's own index - /// path does; forwarding cosine to the assigner panics. - #[tokio::test] - async fn ivf_partition_supports_cosine_indices() { - use crate::index::vector::IvfFlatIndexBuilder; - - const BUCKETS: &str = "SELECT ivf_partition(vec) AS bucket, count(*) AS n, \ - min(id) AS lo, max(id) AS hi FROM images GROUP BY ivf_partition(vec)"; - let conn = connect("memory://").execute().await.unwrap(); - let source = clustered_source(&conn).await; - source - .create_index( - &["vec"], - Index::IvfFlat( - IvfFlatIndexBuilder::default() - .num_partitions(2) - .distance_type(crate::DistanceType::Cosine), - ), - ) - .execute() - .await - .unwrap(); - let view = declare(&source, "cosine_buckets", BUCKETS).await.unwrap(); - view.refresh().execute().await.unwrap(); - assert_eq!( - rows(view.table(), &["n", "lo", "hi"]).await, - ["10 0 9", "10 10 19"] - ); - } - - /// Segments of one index trained separately carry different centroids; - /// grouping by one of them would bucket the other segments' rows wrongly. - #[tokio::test] - async fn ivf_partition_refuses_segments_with_different_models() { - use arrow_array::types::Float32Type; - use arrow_array::{ArrayRef, FixedSizeListArray, Int32Array}; - use lance::index::DatasetIndexExt; - use lance::index::vector::VectorIndexParams; - use lance_index::IndexType; - - let conn = connect("memory://").execute().await.unwrap(); - let source = clustered_source(&conn).await; - // A second fragment far from the first, so each segment trains its own centroids. - let far = FixedSizeListArray::from_iter_primitive::( - (0..20).map(|i| Some(vec![Some(-500.0 - i as f32), Some(-500.0)])), - 2, - ); - source - .add( - RecordBatch::try_from_iter(vec![ - ( - "id", - Arc::new(Int32Array::from_iter_values(20..40)) as ArrayRef, - ), - ("vec", Arc::new(far) as ArrayRef), - ]) - .unwrap(), - ) - .execute() - .await - .unwrap(); - let dataset = source.as_native().unwrap().dataset.get().await.unwrap(); - let mut dataset = dataset.as_ref().clone(); - let params = VectorIndexParams::ivf_flat(2, lance_linalg::distance::DistanceType::L2); - let mut segments = Vec::new(); - for fragment in dataset.get_fragments() { - let segment = dataset - .create_index_builder(&["vec"], IndexType::Vector, ¶ms) - .name("shared".to_string()) - .fragments(vec![fragment.id() as u32]) - .execute_uncommitted() - .await - .unwrap(); - segments.push(segment); - } - dataset - .commit_existing_index_segments("shared", "vec", segments) - .await - .unwrap(); - assert_eq!(dataset.load_indices().await.unwrap().len(), 2); - - let source = conn.open_table("images").execute().await.unwrap(); - let error = declare( - &source, - "buckets", - "SELECT ivf_partition(vec) AS b, count(*) AS n FROM images GROUP BY ivf_partition(vec)", - ) - .await - .unwrap_err(); - assert!(error.to_string().contains("trained separately"), "{error}"); - } - - /// A vector the assigner cannot place has no bucket: it is grouped under - /// NULL, never under partition 0. - #[tokio::test] - async fn ivf_partition_leaves_an_unassignable_vector_unbucketed() { - use crate::index::vector::IvfFlatIndexBuilder; - use arrow_array::types::Float32Type; - use arrow_array::{ArrayRef, FixedSizeListArray, Int32Array}; - - let conn = connect("memory://").execute().await.unwrap(); - let source = clustered_source(&conn).await; - source - .create_index( - &["vec"], - Index::IvfFlat( - IvfFlatIndexBuilder::default() - .num_partitions(2) - .distance_type(crate::DistanceType::Cosine), - ), - ) - .execute() - .await - .unwrap(); - // The zero vector has no direction, so cosine cannot place it. - let zero = FixedSizeListArray::from_iter_primitive::( - [Some(vec![Some(0.0), Some(0.0)])], - 2, - ); - source - .add( - RecordBatch::try_from_iter(vec![ - ("id", Arc::new(Int32Array::from(vec![20])) as ArrayRef), - ("vec", Arc::new(zero) as ArrayRef), - ]) - .unwrap(), - ) - .execute() - .await - .unwrap(); - let view = declare( - &source, - "buckets", - "SELECT ivf_partition(vec) AS bucket, count(*) AS n, min(id) AS lo, max(id) AS hi \ - FROM images GROUP BY ivf_partition(vec)", - ) - .await - .unwrap(); - view.refresh().execute().await.unwrap(); - // ArrayFormatter renders NULL as the empty string; partition numbers - // are the index's own and not asserted. - let groups = rows(view.table(), &["bucket", "n", "lo", "hi"]).await; - let (unbucketed, bucketed): (Vec<_>, Vec<_>) = - groups.iter().partition(|row| row.starts_with(' ')); - assert_eq!(unbucketed, [" 1 20 20"], "{groups:?}"); - let mut clusters: Vec<&str> = bucketed - .iter() - .map(|row| row.split_once(' ').unwrap().1) - .collect(); - clusters.sort(); - assert_eq!(clusters, ["10 0 9", "10 10 19"], "{groups:?}"); - } - - /// Byte vectors group by a Hamming index, the phash case. The centroids - /// are given, as lance's own tests do: two-means over a handful of - /// hashes can converge with one partition empty. - #[tokio::test] - async fn ivf_partition_groups_byte_vectors_by_hamming() { - use arrow_array::types::UInt8Type; - use arrow_array::{ArrayRef, FixedSizeListArray, Int32Array}; - use lance::index::DatasetIndexExt; - use lance::index::vector::VectorIndexParams; - use lance_index::IndexType; - use lance_index::vector::ivf::IvfBuildParams; - - let hashes = FixedSizeListArray::from_iter_primitive::( - (0..20u8).map(|i| { - let base = if i < 10 { 0x00 } else { 0xff }; - Some(vec![ - Some(base ^ (i % 4)), - Some(base), - Some(base), - Some(base), - ]) - }), - 4, - ); - let batch = RecordBatch::try_from_iter(vec![ - ( - "id", - Arc::new(Int32Array::from_iter_values(0..20)) as ArrayRef, - ), - ("phash", Arc::new(hashes) as ArrayRef), - ]) - .unwrap(); - let conn = connect("memory://").execute().await.unwrap(); - let source = conn - .create_table("images", batch) - .write_options(crate::materialized_view::tests::stable_row_ids()) - .execute() - .await - .unwrap(); - let centroids = FixedSizeListArray::from_iter_primitive::( - [Some(vec![Some(0); 4]), Some(vec![Some(0xff); 4])], - 4, - ); - let params = VectorIndexParams::with_ivf_flat_params( - lance_linalg::distance::DistanceType::Hamming, - IvfBuildParams::try_with_centroids(2, Arc::new(centroids)).unwrap(), - ); - let mut dataset = source .as_native() .unwrap() .dataset @@ -4806,19 +607,63 @@ pub(crate) mod tests { .unwrap() .as_ref() .clone(); - dataset - .create_index(&["phash"], IndexType::Vector, None, ¶ms, true) + assert!(planned.get_fragments().is_empty()); + + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let loser_batch = result_batch.clone(); + let blocked = futures::stream::once(async move { + entered_tx.send(()).unwrap(); + release_rx.await.unwrap(); + Ok::<_, DataFusionError>(loser_batch) + }); + let stream = Box::pin(RecordBatchStreamAdapter::new(schema.clone(), blocked)); + let loser_table = view.table().clone(); + let loser = + tokio::spawn( + async move { replace_materialized_view_fragments(&loser_table, stream).await }, + ); + entered_rx.await.unwrap(); + + let winner_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new( + schema, + futures::stream::iter([Ok(result_batch)]), + )); + let planned = Arc::new(planned); + let staged = InsertBuilder::new(WriteDestination::Dataset(planned.clone())) + .with_params(&WriteParams { + mode: WriteMode::Append, + ..Default::default() + }) + .execute_uncommitted_stream(winner_stream) .await .unwrap(); - let source = conn.open_table("images").execute().await.unwrap(); + let Operation::Append { fragments } = staged.operation else { + panic!("expected append staging"); + }; + let filter = refresh_conflict_filter(&planned).unwrap(); + CommitBuilder::new(WriteDestination::Dataset(planned.clone())) + .execute(Transaction::new( + planned.version().version, + Operation::Update { + removed_fragment_ids: Vec::new(), + updated_fragments: Vec::new(), + new_fragments: fragments, + fields_modified: Vec::new(), + compacted_sstables: Vec::new(), + fields_for_preserving_frag_bitmap: Vec::new(), + update_mode: None, + inserted_rows_filter: Some(filter), + updated_fragment_offsets: None, + }, + None, + )) + .await + .unwrap(); + release_tx.send(()).unwrap(); - const BUCKETS: &str = "SELECT ivf_partition(phash) AS bucket, count(*) AS n, \ - min(id) AS lo, max(id) AS hi FROM images GROUP BY ivf_partition(phash)"; - let view = declare(&source, "buckets", BUCKETS).await.unwrap(); - view.refresh().execute().await.unwrap(); - assert_eq!( - rows(view.table(), &["bucket", "n", "lo", "hi"]).await, - ["0 10 0 9", "1 10 10 19"] - ); + loser.await.unwrap().unwrap_err(); + let current = connection.open_table("copy").execute().await.unwrap(); + assert_eq!(current.count_rows(None).await.unwrap(), 3); } } diff --git a/rust/lancedb/src/materialized_view/vector_dedup.rs b/rust/lancedb/src/materialized_view/vector_dedup.rs deleted file mode 100644 index dc2759433..000000000 --- a/rust/lancedb/src/materialized_view/vector_dedup.rs +++ /dev/null @@ -1,636 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The LanceDB Authors - -//! Two dependent waves in the ordinary refresh fleet: index partition selection, -//! then source fragment materialization. Only the latter publishes data files. - -use std::collections::BTreeMap; -use std::io::Cursor; -use std::sync::Arc; - -use arrow_array::{Array, RecordBatch, UInt64Array}; -use arrow_schema::{DataType, Field, Schema, SchemaRef}; -use datafusion::prelude::{SessionConfig, SessionContext, col}; -use datafusion_common::DataFusionError; -use datafusion_execution::{ - SendableRecordBatchStream, TaskContext, runtime_env::RuntimeEnvBuilder, -}; -use datafusion_physical_plan::{ExecutionPlan, stream::RecordBatchStreamAdapter}; -use futures::TryStreamExt; -use lance::Dataset; -use lance::dataset::rowids::{get_row_id_index, load_row_id_sequence}; -use lance::dataset::scanner::{RowAddrMask, RowAddrTreeMap}; -use lance::dataset::transaction::Operation; -use lance::dataset::{InsertBuilder, WriteDestination, WriteMode, WriteParams}; -use lance_core::datatypes::BlobHandling; -use lance_core::utils::address::RowAddress; -use lance_datafusion::exec::{OneShotExec, SessionContextExt}; -use lance_table::format::Fragment; -use roaring::{RoaringBitmap, RoaringTreemap}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use uuid::Uuid; - -use super::duplicate_pairs::{self, DuplicatePairsRefreshPlan, VectorSourceKind}; -use super::{GroupedRefreshPlan, RefreshMaterializedViewResult}; -use crate::table::Table; -use crate::table::datafusion::udtf::duplicate_pairs::{DuplicatePairsExec, plan_duplicate_pairs}; -use crate::{Error, Result}; - -const POLICY_VERSION: u32 = 1; -const SORT_MEMORY_BYTES: usize = 256 * 1024 * 1024; -const MATERIALIZE_BATCH_ROWS: usize = 1024; -const MASK_FILE: &str = "dedup.bitmap"; -const MASK_MAGIC: &[u8] = b"LANCEDB_DEDUP_1"; - -fn invalid(message: impl Into) -> Error { - Error::InvalidInput { - message: message.into(), - } -} -fn io_error(error: std::io::Error) -> Error { - invalid(format!("invalid dedup bitmap: {error}")) -} - -/// Immutable snapshot, partition tasks and source fragments for both waves. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct VectorDedupPlan { - pub snapshot: GroupedRefreshPlan, - native: DuplicatePairsRefreshPlan, - source_fragments: Vec, - policy_version: u32, -} -impl VectorDedupPlan { - pub(super) fn dependencies(&self) -> u32 { - self.native.snapshot.units - } - fn identity(&self) -> Result { - Ok(format!( - "{:x}", - Sha256::digest(serde_json::to_vec(self).map_err(|e| invalid(e.to_string()))?) - )) - } -} - -/// Small receipt for a durable selection mask. Row IDs and pairs are not stored -/// in the host's task registry. UUID directories use ordinary index-file GC. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct MaskFile { - source_fragment: u32, - object_id: Uuid, - bytes: usize, - digest: String, -} - -/// A selection task produces masks; a materialization task produces data files. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WrittenDedupUnit { - unit: u32, - plan_id: String, - masks: Vec, - pub(super) fragments: Vec, - pub(super) rows: u64, -} - -pub(super) async fn plan_refresh(view: &Table, pinned: Option) -> Result { - let native = duplicate_pairs::plan_refresh(view, pinned).await?; - let (_, _, source) = duplicate_pairs::open_planned(view, &native).await?; - let source_fragments = source - .get_fragments() - .iter() - .map(|f| f.id() as u32) - .collect::>(); - let mut snapshot = native.snapshot.clone(); - snapshot.units = snapshot - .units - .checked_add( - u32::try_from(source_fragments.len()) - .map_err(|_| invalid("too many source fragments"))?, - ) - .ok_or_else(|| invalid("too many dedup refresh units"))?; - Ok(VectorDedupPlan { - snapshot, - native, - source_fragments, - policy_version: POLICY_VERSION, - }) -} - -async fn open( - view: &Table, - plan: &VectorDedupPlan, -) -> Result<(Dataset, super::MaterializedViewDefinition, Arc)> { - let (view_ds, definition, source) = duplicate_pairs::open_planned(view, &plan.native).await?; - let mut snapshot = plan.native.snapshot.clone(); - snapshot.units = snapshot - .units - .checked_add( - u32::try_from(plan.source_fragments.len()) - .map_err(|_| invalid("too many dedup fragments"))?, - ) - .ok_or_else(|| invalid("too many dedup units"))?; - if plan.policy_version != POLICY_VERSION - || snapshot != plan.snapshot - || definition.vector_source.as_ref().map(|s| s.kind) != Some(VectorSourceKind::Dedup) - || source - .get_fragments() - .iter() - .map(|f| f.id() as u32) - .collect::>() - != plan.source_fragments - { - return Err(invalid("dedup task belongs to another snapshot or policy")); - } - Ok((view_ds, definition, source)) -} - -fn edge_schema() -> SchemaRef { - Arc::new(Schema::new(vec![ - Field::new("a", DataType::UInt64, false), - Field::new("b", DataType::UInt64, false), - ])) -} - -/// Sorting canonical edges makes the first unremoved endpoint the smallest -/// unassigned representative. The sort spills; only vertex masks stay in RAM. -pub async fn select_representatives( - stream: SendableRecordBatchStream, - memory: usize, -) -> Result<(RoaringTreemap, RoaringTreemap)> { - let schema = edge_schema(); - let batch_schema = schema.clone(); - let canonical = stream - .map_ok(move |batch| { - let a = batch - .column(0) - .as_any() - .downcast_ref::() - .expect("native row id"); - let b = batch - .column(1) - .as_any() - .downcast_ref::() - .expect("native row id"); - RecordBatch::try_new( - batch_schema.clone(), - vec![ - Arc::new(UInt64Array::from_iter_values( - a.values().iter().zip(b.values()).map(|(&a, &b)| a.min(b)), - )), - Arc::new(UInt64Array::from_iter_values( - a.values().iter().zip(b.values()).map(|(&a, &b)| a.max(b)), - )), - ], - ) - .map_err(DataFusionError::from) - }) - .and_then(futures::future::ready); - let canonical = Box::pin(RecordBatchStreamAdapter::new(schema, canonical)); - let mut config = SessionConfig::default().with_batch_size(MATERIALIZE_BATCH_ROWS); - // Leave enough room to merge spilled runs while the final in-memory run - // is still resident. Small batches also bound each merge input buffer. - config.options_mut().execution.sort_spill_reservation_bytes = memory / 2; - let ctx = SessionContext::new_with_config_rt( - config, - RuntimeEnvBuilder::new() - .with_memory_limit(memory, 1.0) - .build_arc()?, - ); - let mut sorted = ctx - .read_one_shot(canonical)? - .sort_by(vec![col("a"), col("b")])? - .execute_stream() - .await?; - let mut removed = RoaringTreemap::new(); - let mut representatives = RoaringTreemap::new(); - while let Some(batch) = sorted.try_next().await? { - let a = batch - .column(0) - .as_any() - .downcast_ref::() - .expect("sorted id"); - let b = batch - .column(1) - .as_any() - .downcast_ref::() - .expect("sorted id"); - for (&a, &b) in a.values().iter().zip(b.values()) { - if a != b && !removed.contains(a) && !removed.contains(b) { - representatives.insert(a); - removed.insert(b); - } - } - } - Ok((removed, representatives)) -} - -#[derive(Default)] -struct Masks { - removed: RoaringBitmap, - representatives: RoaringBitmap, -} - -async fn persist_masks( - view: &Dataset, - source: &Dataset, - removed: RoaringTreemap, - representatives: RoaringTreemap, -) -> Result> { - let row_index = get_row_id_index(source).await?; - let mut masks: BTreeMap = BTreeMap::new(); - for (ids, is_removed) in [(removed, true), (representatives, false)] { - for id in ids { - let address = match &row_index { - Some(index) => index - .get(id)? - .ok_or_else(|| invalid("pair row id missing from pinned source"))?, - None => RowAddress::new_from_u64(id), - }; - let mask = masks.entry(address.fragment_id()).or_default(); - if is_removed { - mask.removed.insert(address.row_offset()); - } else { - mask.representatives.insert(address.row_offset()); - } - } - } - let store = view.object_store(None).await?; - let mut files = Vec::new(); - for (source_fragment, mask) in masks { - if source.get_fragment(source_fragment as usize).is_none() { - return Err(invalid("pair row address is outside the pinned snapshot")); - } - let mut bytes = MASK_MAGIC.to_vec(); - mask.removed.serialize_into(&mut bytes).map_err(io_error)?; - mask.representatives - .serialize_into(&mut bytes) - .map_err(io_error)?; - let object_id = Uuid::new_v4(); - store - .put( - &view - .indices_dir() - .join(object_id.to_string()) - .join(MASK_FILE), - &bytes, - ) - .await?; - files.push(MaskFile { - source_fragment, - object_id, - bytes: bytes.len(), - digest: format!("{:x}", Sha256::digest(&bytes)), - }); - } - Ok(files) -} - -fn validate_receipts<'a>( - plan: &VectorDedupPlan, - receipts: &'a [WrittenDedupUnit], - count: u32, -) -> Result> { - let identity = plan.identity()?; - let mut ordered = vec![None; count as usize]; - for receipt in receipts { - if receipt.plan_id != identity { - return Err(invalid("dedup receipt belongs to another plan")); - } - let slot = ordered - .get_mut(receipt.unit as usize) - .ok_or_else(|| invalid("unexpected dedup receipt"))?; - if slot.is_some() { - return Err(invalid("duplicate dedup receipt")); - } - if receipt.unit < plan.dependencies() { - if !receipt.fragments.is_empty() - || receipt.rows != 0 - || receipt - .masks - .iter() - .any(|m| !plan.source_fragments.contains(&m.source_fragment)) - { - return Err(invalid("invalid selection receipt")); - } - } else if !receipt.masks.is_empty() { - return Err(invalid("invalid materialization receipt")); - } - *slot = Some(receipt); - } - ordered - .into_iter() - .collect::>>() - .ok_or_else(|| invalid("missing dedup dependency receipt")) -} - -async fn load_masks( - view: &Dataset, - source_fragment: u32, - dependencies: &[&WrittenDedupUnit], -) -> Result { - let store = view.object_store(None).await?; - let mut masks = Masks::default(); - for file in dependencies - .iter() - .flat_map(|u| &u.masks) - .filter(|m| m.source_fragment == source_fragment) - { - let reader = store - .open( - &view - .indices_dir() - .join(file.object_id.to_string()) - .join(MASK_FILE), - ) - .await?; - if reader.size().await? != file.bytes { - return Err(invalid("dedup mask length mismatch")); - } - let bytes = reader.get_range(0..file.bytes).await?; - if !bytes.starts_with(MASK_MAGIC) || format!("{:x}", Sha256::digest(&bytes)) != file.digest - { - return Err(invalid("dedup mask checksum mismatch")); - } - let mut cursor = Cursor::new(&bytes[MASK_MAGIC.len()..]); - masks.removed |= RoaringBitmap::deserialize_from(&mut cursor).map_err(io_error)?; - masks.representatives |= RoaringBitmap::deserialize_from(&mut cursor).map_err(io_error)?; - if cursor.position() as usize != bytes.len() - MASK_MAGIC.len() { - return Err(invalid("dedup mask has trailing data")); - } - } - // Native index scopes normally have disjoint rows. Fail closed if an - // overlapping scope would delete a representative used by another scope. - if !masks.removed.is_disjoint(&masks.representatives) { - return Err(invalid( - "overlapping index scopes disagree on retained representatives", - )); - } - Ok(masks.removed) -} - -pub(super) async fn write_unit( - view: &Table, - unit: u32, - plan: &VectorDedupPlan, - dependencies: &[WrittenDedupUnit], -) -> Result { - let (view_ds, definition, source) = open(view, plan).await?; - let mut written = WrittenDedupUnit { - unit, - plan_id: plan.identity()?, - masks: Vec::new(), - fragments: Vec::new(), - rows: 0, - }; - if unit < plan.dependencies() { - let config = definition - .vector_source - .as_ref() - .expect("dedup source") - .config()?; - let exec = DuplicatePairsExec::try_new( - source.clone(), - config, - vec![plan.native.tasks[unit as usize].clone()], - )?; - let stream = exec.execute(0, Arc::new(TaskContext::default()))?; - let (removed, representatives) = select_representatives(stream, SORT_MEMORY_BYTES).await?; - written.masks = persist_masks(&view_ds, &source, removed, representatives).await?; - return Ok(written); - } - let source_fragment = *plan - .source_fragments - .get((unit - plan.dependencies()) as usize) - .ok_or_else(|| invalid("dedup unit is outside the plan"))?; - let dependencies = validate_receipts(plan, dependencies, plan.dependencies())?; - let removed = load_masks(&view_ds, source_fragment, &dependencies).await?; - let fragment = source - .get_fragment(source_fragment as usize) - .ok_or_else(|| invalid("source fragment missing"))?; - // The scanner prefilter uses snapshot row IDs. Our persisted masks use - // physical offsets so each writer only loads its own fragment's artifacts. - let blocked = if source.manifest.uses_stable_row_ids() { - let sequence = load_row_id_sequence(&source, fragment.metadata()).await?; - if removed - .max() - .is_some_and(|offset| offset as u64 >= sequence.len()) - { - return Err(invalid("dedup mask offset is outside the source fragment")); - } - RowAddrTreeMap::from_iter(sequence.select(removed.iter().map(|offset| offset as usize))) - } else { - RowAddrTreeMap::from_iter( - removed - .iter() - .map(|offset| u64::from(RowAddress::new_from_parts(source_fragment, offset))), - ) - }; - let mut scanner = source.scan(); - scanner - .with_fragments(vec![fragment.metadata().clone()]) - .with_row_addr_prefilter(RowAddrMask::from_block(blocked)) - .blob_handling(BlobHandling::AllBinary) - .batch_size(MATERIALIZE_BATCH_ROWS) - .strict_batch_size(true); - let stream = scanner.try_into_stream().await?; - let schema = lance_io::stream::RecordBatchStream::schema(&stream); - let rows = Arc::new(std::sync::atomic::AtomicU64::new(0)); - let count = rows.clone(); - let stream = stream - .map_err(|e| DataFusionError::External(Box::new(e))) - .inspect_ok(move |batch| { - count.fetch_add( - batch.num_rows() as u64, - std::sync::atomic::Ordering::Relaxed, - ); - }); - let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(schema, stream)); - // Materialized binary payloads must become the destination's logical blob - // structs before the Lance writer encodes them. Reuse the normal add path's - // coercions, including nulls and nested fields. - let coerced = crate::table::datafusion::cast::cast_to_table_schema( - Arc::new(OneShotExec::new(stream)), - &Schema::from(view_ds.schema()), - )?; - let stream = coerced.execute(0, Arc::new(TaskContext::default()))?; - let txn = InsertBuilder::new(WriteDestination::Dataset(Arc::new(view_ds))) - .with_params(&WriteParams { - mode: WriteMode::Append, - ..Default::default() - }) - .execute_uncommitted_stream(stream) - .await?; - let Operation::Append { fragments } = txn.operation else { - return Err(invalid( - "dedup materialization did not stage append fragments", - )); - }; - written.fragments = fragments; - written.rows = rows.load(std::sync::atomic::Ordering::Relaxed); - Ok(written) -} - -pub(super) async fn commit( - view: &Table, - plan: &VectorDedupPlan, - units: Vec, - expected: Option<&str>, -) -> Result { - let (_, definition, source) = open(view, plan).await?; - let config = definition - .vector_source - .as_ref() - .expect("dedup source") - .config()?; - if plan_duplicate_pairs(source.clone(), &config).await? != plan.native.tasks { - return Err(invalid("dedup plan does not cover the pinned index")); - } - validate_receipts(plan, &units, plan.snapshot.units)?; - let rows = units.iter().map(|u| u.rows).sum(); - let fragments = units.into_iter().flat_map(|u| u.fragments).collect(); - super::grouped_units::commit_fragments( - view, - &plan.snapshot, - &definition, - source.manifest.timestamp_nanos, - fragments, - rows, - expected, - ) - .await -} - -#[cfg(test)] -mod tests { - use super::*; - - fn edges(values: &[(u64, u64)], batch_size: usize) -> SendableRecordBatchStream { - let schema = edge_schema(); - let batches = values - .chunks(batch_size) - .map(|edges| { - Ok(RecordBatch::try_new( - schema.clone(), - vec![ - Arc::new(UInt64Array::from_iter_values(edges.iter().map(|e| e.0))), - Arc::new(UInt64Array::from_iter_values(edges.iter().map(|e| e.1))), - ], - ) - .unwrap()) - }) - .collect::>(); - Box::pin(RecordBatchStreamAdapter::new( - schema, - futures::stream::iter(batches), - )) - } - - #[tokio::test] - async fn direct_representatives_handle_chains_orientation_and_uint64() { - let high = u64::MAX - 2; - let pairs = [ - (2, 1), - (1, 0), - (high + 2, high + 1), - (high, high + 1), - (1, 0), - ]; - let (removed, representatives) = - select_representatives(edges(&pairs, 1), SORT_MEMORY_BYTES) - .await - .unwrap(); - assert_eq!(removed.iter().collect::>(), [1, high + 1]); - assert_eq!(representatives.iter().collect::>(), [0, high]); - } - - #[tokio::test] - async fn masks_reject_overlapping_representatives_and_corrupt_artifacts() { - let dir = tempfile::tempdir().unwrap(); - let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::UInt64, false)])); - let batch = RecordBatch::try_new( - schema.clone(), - vec![Arc::new(UInt64Array::from(vec![0, 1, 2]))], - ) - .unwrap(); - let ds = Dataset::write( - arrow_array::RecordBatchIterator::new(vec![Ok(batch)], schema), - dir.path().join("source.lance").to_str().unwrap(), - None, - ) - .await - .unwrap(); - let first = persist_masks( - &ds, - &ds, - [1].into_iter().collect(), - [0].into_iter().collect(), - ) - .await - .unwrap(); - let second = persist_masks( - &ds, - &ds, - [2].into_iter().collect(), - [1].into_iter().collect(), - ) - .await - .unwrap(); - let receipt = |masks| WrittenDedupUnit { - unit: 0, - plan_id: "test".into(), - masks, - fragments: vec![], - rows: 0, - }; - let first = receipt(first); - let second = receipt(second); - assert_eq!( - load_masks(&ds, 0, &[&first]).await.unwrap(), - [1].into_iter().collect() - ); - assert!( - load_masks(&ds, 0, &[&first, &second]) - .await - .unwrap_err() - .to_string() - .contains("overlapping") - ); - let file = &first.masks[0]; - ds.object_store(None) - .await - .unwrap() - .put( - &ds.indices_dir() - .join(file.object_id.to_string()) - .join(MASK_FILE), - &vec![0; file.bytes], - ) - .await - .unwrap(); - assert!( - load_masks(&ds, 0, &[&first]) - .await - .unwrap_err() - .to_string() - .contains("checksum") - ); - } - - #[tokio::test] - async fn external_sort_handles_more_edges_than_its_budget() { - let pairs = (0..262144) - .rev() - .map(|i| ((i + 1) as u64, i as u64)) - .collect::>(); - let (removed, reps) = select_representatives(edges(&pairs, 4096), 2 * 1024 * 1024) - .await - .unwrap(); - assert_eq!(removed.len(), 131072); - assert_eq!(reps.len(), 131072); - assert!(removed.iter().all(|id| id % 2 == 1)); - assert!(reps.iter().all(|id| id % 2 == 0)); - } -} diff --git a/rust/lancedb/src/query.rs b/rust/lancedb/src/query.rs index fffb703fb..6b20fb123 100644 --- a/rust/lancedb/src/query.rs +++ b/rust/lancedb/src/query.rs @@ -11,7 +11,7 @@ use arrow_array::{ Array, Float16Array, Float32Array, Float64Array, RecordBatch, UInt64Array, cast::AsArray, make_array, - types::{Int64Type, UInt64Type}, + types::{Float32Type, Int64Type, UInt64Type}, }; use arrow_schema::{DataType, SchemaRef}; use datafusion_common::{DataFusionError, Result as DataFusionResult}; @@ -1024,6 +1024,8 @@ impl Query { /// /// By default, there is no embedding model, and the input should be /// vector/slice of floats. + /// NaN and infinite components are rejected with [`Error::InvalidInput`] + /// after conversion to Float32, for both local and remote tables. /// /// If there is only one vector column (a column whose data type is a /// fixed size list of floats) then the column does not need to be specified. @@ -1050,8 +1052,7 @@ impl Query { /// * `vector` - The vector that will be used for search. pub fn nearest_to(self, vector: impl IntoQueryVector) -> Result { let mut vector_query = self.into_vector(); - let query_vector = vector.to_query_vector(&DataType::Float32, "default")?; - vector_query.request.query_vector.push(query_vector); + vector_query.request.add_query_vector(vector)?; if vector_query.request.base.limit.is_none() { vector_query.request.base.limit = Some(DEFAULT_TOP_K); @@ -1154,6 +1155,23 @@ impl Default for VectorQueryRequest { } impl VectorQueryRequest { + fn add_query_vector(&mut self, vector: impl IntoQueryVector) -> Result<()> { + let vector = vector.to_query_vector(&DataType::Float32, "default")?; + let array = + vector + .as_primitive_opt::() + .ok_or_else(|| Error::InvalidInput { + message: "VectorQuery vector must be of type Float32".into(), + })?; + if array.values().iter().any(|value| !value.is_finite()) { + return Err(Error::InvalidInput { + message: "query vector must contain only finite values".into(), + }); + } + self.query_vector.push(vector); + Ok(()) + } + pub fn from_plain_query(query: QueryRequest) -> Self { Self { base: query, @@ -1222,9 +1240,11 @@ impl VectorQuery { /// The output data will contain an additional column `query_index` which /// will contain the index of the query vector that was used to generate the /// result. + /// + /// NaN and infinite components are rejected with [`Error::InvalidInput`] + /// after conversion to Float32, for both local and remote tables. pub fn add_query_vector(mut self, vector: impl IntoQueryVector) -> Result { - let query_vector = vector.to_query_vector(&DataType::Float32, "default")?; - self.request.query_vector.push(query_vector); + self.request.add_query_vector(vector)?; Ok(self) } @@ -2336,6 +2356,69 @@ mod tests { use crate::{Table, connect, database::CreateTableMode, index::Index}; + #[tokio::test] + async fn test_query_vector_non_finite() { + let conn = connect("memory://non_finite_vectors") + .execute() + .await + .unwrap(); + let table = conn + .create_table("my_table", make_test_batches()) + .execute() + .await + .unwrap(); + let assert_invalid = |result: Result| { + let err = result.unwrap_err(); + assert!(matches!(err, Error::InvalidInput { .. })); + assert!(err.to_string().contains("only finite values")); + }; + + // Float64 values can be finite before conversion but overflow Float32. + for value in [ + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + f64::MAX, + f64::MIN, + ] { + assert_invalid(table.query().nearest_to(&[0.1, value as f32])); + assert_invalid(table.query().nearest_to(vec![0.1, value])); + assert_invalid( + table + .query() + .nearest_to(&[0.1, 0.2]) + .unwrap() + .add_query_vector(vec![0.1, value]), + ); + + let arrays: [Arc; 3] = [ + Arc::new(Float16Array::from(vec![ + f16::from_f32(0.1), + f16::from_f64(value), + ])), + Arc::new(Float32Array::from(vec![0.1, value as f32])), + Arc::new(Float64Array::from(vec![0.1, value])), + ]; + for array in arrays { + assert_invalid(table.query().nearest_to(array.clone())); + assert_invalid( + table + .query() + .nearest_to(&[0.1, 0.2]) + .unwrap() + .add_query_vector(array), + ); + } + } + + table + .query() + .nearest_to(&[f32::MIN, f32::MAX]) + .unwrap() + .add_query_vector(&[0.0, -0.0]) + .unwrap(); + } + #[tokio::test] async fn test_setters_getters() { let batches = make_test_batches(); diff --git a/rust/lancedb/src/remote/catalog.rs b/rust/lancedb/src/remote/catalog.rs index 1c0129889..72b77a87e 100644 --- a/rust/lancedb/src/remote/catalog.rs +++ b/rust/lancedb/src/remote/catalog.rs @@ -416,6 +416,7 @@ mod tests { RemoteCatalog::try_new(&endpoint, options).unwrap(), )); let page = catalog + .catalog() .list_databases(ListDatabasesRequest::default().limit(1).page_token("a/b")) .await .unwrap(); @@ -443,6 +444,7 @@ mod tests { .unwrap(); assert!( catalog + .catalog() .list_databases(ListDatabasesRequest::default()) .await .unwrap() @@ -538,6 +540,128 @@ mod tests { ); } + #[tokio::test] + async fn database_names_follow_pages_lazily() { + use futures::TryStreamExt; + + let (endpoint, task) = server(vec![ + ( + 200, + json!({"namespaces": ["first", "second"], "page_token": "a/b"}), + ), + (200, json!({"namespaces": [], "page_token": "next"})), + (200, json!({"namespaces": ["last"], "page_token": ""})), + ]) + .await; + let catalog = CatalogConnection::new(Arc::new( + RemoteCatalog::try_new(endpoint, RemoteCatalogOptions::default()).unwrap(), + )); + let mut names = catalog.list_databases(None, None); + drop(catalog); + assert_eq!(names.num_page_results(), 0); + assert_eq!(names.page_token(), None); + assert_eq!(names.try_next().await.unwrap().as_deref(), Some("first")); + assert_eq!(names.num_page_results(), 1); + assert_eq!(names.page_token(), Some("a/b")); + assert_eq!(names.try_next().await.unwrap().as_deref(), Some("second")); + assert_eq!(names.num_page_results(), 0); + assert_eq!(names.page_token(), Some("a/b")); + assert!(!task.is_finished()); + assert_eq!(names.try_next().await.unwrap().as_deref(), Some("last")); + assert_eq!(names.try_next().await.unwrap(), None); + assert_eq!(names.try_next().await.unwrap(), None); + let requests = task.await.unwrap(); + assert_eq!(requests[0].line, "GET /v1/namespace/$/list HTTP/1.1"); + assert_eq!( + requests[1].line, + "GET /v1/namespace/$/list?page_token=a%2Fb HTTP/1.1" + ); + assert_eq!( + requests[2].line, + "GET /v1/namespace/$/list?page_token=next HTTP/1.1" + ); + } + + #[tokio::test] + async fn database_names_stop_after_error() { + use futures::StreamExt; + + let (endpoint, task) = server(vec![ + (200, json!({"namespaces": ["first"], "page_token": "next"})), + (401, json!({"error": "unauthorized"})), + ]) + .await; + let catalog = CatalogConnection::new(Arc::new( + RemoteCatalog::try_new(endpoint, RemoteCatalogOptions::default()).unwrap(), + )); + let mut names = catalog.list_databases(None, None); + assert_eq!(names.next().await.unwrap().unwrap(), "first"); + assert!(names.next().await.unwrap().is_err()); + assert_eq!(names.num_page_results(), 0); + assert_eq!(names.page_token(), Some("next")); + assert!(names.next().await.is_none()); + task.await.unwrap(); + } + + #[tokio::test] + async fn database_names_can_resume_with_page_limit() { + use futures::TryStreamExt; + let (endpoint, task) = server(vec![ + ( + 200, + json!({"namespaces": ["first", "second"], "page_token": "next/token"}), + ), + (200, json!({"namespaces": ["third", "fourth"]})), + ]) + .await; + let catalog = CatalogConnection::new(Arc::new( + RemoteCatalog::try_new(endpoint, RemoteCatalogOptions::default()).unwrap(), + )); + let mut names = catalog.list_databases(Some("start/token".into()), Some(2)); + assert_eq!(names.page_token(), Some("start/token")); + assert_eq!(names.num_page_results(), 0); + assert_eq!(names.try_next().await.unwrap().as_deref(), Some("first")); + assert_eq!(names.page_token(), Some("next/token")); + assert_eq!(names.num_page_results(), 1); + assert_eq!(names.try_next().await.unwrap().as_deref(), Some("second")); + assert_eq!(names.num_page_results(), 0); + let token = names.page_token().map(str::to_owned); + drop(names); + let mut resumed = catalog.list_databases(token, Some(2)); + assert_eq!(resumed.try_next().await.unwrap().as_deref(), Some("third")); + assert_eq!(resumed.page_token(), None); + assert_eq!(resumed.num_page_results(), 1); + assert_eq!(resumed.try_next().await.unwrap().as_deref(), Some("fourth")); + assert_eq!(resumed.num_page_results(), 0); + assert_eq!(resumed.try_next().await.unwrap(), None); + let requests = task.await.unwrap(); + assert_eq!( + requests[0].line, + "GET /v1/namespace/$/list?limit=2&page_token=start%2Ftoken HTTP/1.1" + ); + assert_eq!( + requests[1].line, + "GET /v1/namespace/$/list?limit=2&page_token=next%2Ftoken HTTP/1.1" + ); + } + + #[tokio::test] + async fn database_names_reject_invalid_page_limit() { + use futures::TryStreamExt; + let catalog = CatalogConnection::new(Arc::new( + RemoteCatalog::try_new("http://127.0.0.1:1", RemoteCatalogOptions::default()).unwrap(), + )); + for limit in [0, u32::MAX] { + let mut names = catalog.list_databases(None, Some(limit)); + assert!(matches!( + names.try_next().await, + Err(Error::InvalidInput { .. }) + )); + assert_eq!(names.num_page_results(), 0); + assert_eq!(names.try_next().await.unwrap(), None); + } + } + #[tokio::test] async fn catalog_validates_before_sending_requests() { for endpoint in [ diff --git a/rust/lancedb/src/remote/db.rs b/rust/lancedb/src/remote/db.rs index 27606cdfe..c6462a834 100644 --- a/rust/lancedb/src/remote/db.rs +++ b/rust/lancedb/src/remote/db.rs @@ -20,8 +20,8 @@ use lance_namespace::models::{ use crate::Error; use crate::database::{ - CloneTableRequest, CreateTableMode, CreateTableRequest, Database, DatabaseOptions, JobInfo, - OpenTableRequest, ReadConsistency, TableNamesRequest, + CloneTableRequest, CreateTableMode, CreateTableRequest, Database, DatabaseOptions, + ExecuteQueryRequest, JobInfo, OpenTableRequest, ReadConsistency, TableNamesRequest, }; use crate::error::Result; use crate::function::{ @@ -29,6 +29,7 @@ use crate::function::{ PythonRuntimeSpec, }; use crate::job::Job; +use crate::listing::{ListingOptions, ListingPage}; use crate::materialized_view::CreateMaterializedViewRequest; use crate::remote::job::{PauseJobResponse, RemoteJob, ResumeJobResponse, job_state_to_client}; use crate::remote::util::stream_as_body; @@ -47,6 +48,10 @@ use super::table::RemoteTable; use super::util::parse_server_version; use super::{ARROW_STREAM_CONTENT_TYPE, extract_job_id}; +fn quote_sql_identifier(identifier: &str) -> String { + format!("\"{}\"", identifier.replace('"', "\"\"")) +} + // Request structure for the remote clone table API #[derive(serde::Serialize)] struct RemoteCloneTableRequest { @@ -839,10 +844,6 @@ struct RemoteListViewsResponse { page_token: Option, } -/// Bound on `list_jobs` page walking; a warning is logged when the listing -/// is truncated at this many pages. -const MAX_LIST_JOBS_PAGES: usize = 100; - #[async_trait] impl Database for RemoteDatabase { fn uri(&self) -> &str { @@ -860,42 +861,22 @@ impl Database for RemoteDatabase { &self, request: CreateMaterializedViewRequest, ) -> Result { - let identifier = build_table_identifier(&request.name, &request.namespace_path)?; - let req = self - .client - .post(&format!("/v1/materialized_view/{identifier}/create")) - .json(&serde_json::json!({ - "query": request.query, - "with_no_data": request.with_no_data, - })); - let (request_id, response) = self.client.send(req).await?; - let response = self.client.check_response(&request_id, response).await?; - let status = response.status(); - let body = response.text().await.err_to_http(request_id.clone())?; - let job_id = extract_job_id(&body); - if request.with_no_data { - return Ok(match job_id { - Some(job_id) => Job::new(Box::new(RemoteJob::new(self.client.clone(), job_id))), - None => Job::new_done(), + return Err(Error::NotSupported { + message: "remote materialized views are always populated by their SQL job" + .to_string(), }); } - if status != StatusCode::ACCEPTED { - return Err(Error::Http { - source: "materialized-view creation with data must return 202 Accepted".into(), - request_id, - status_code: Some(status), - }); - } - let job_id = job_id.ok_or_else(|| Error::Http { - source: "materialized-view creation response did not contain a valid job_id".into(), - request_id, - status_code: Some(status), + let client = self.sql_client.clone().ok_or_else(|| Error::NotSupported { + message: "SQL is unavailable for this remote database client".to_string(), })?; - Ok(Job::new(Box::new(RemoteJob::new( - self.client.clone(), - job_id, - )))) + let namespace = request.namespace_path; + let statement = format!( + "CREATE MATERIALIZED VIEW {} AS {}", + quote_sql_identifier(&request.name), + request.query + ); + Ok(client.submit_as_job(statement, namespace, || async { Ok(()) })) } async fn drop_materialized_view_async( @@ -935,45 +916,28 @@ impl Database for RemoteDatabase { Ok(job) } - async fn list_materialized_views(&self, namespace_path: &[String]) -> Result> { - #[derive(serde::Deserialize)] - struct ListMaterializedViewsResponse { - #[serde(default)] - views: Vec, - #[serde(default)] - page_token: Option, - } - + async fn list_materialized_views( + &self, + namespace_path: &[String], + options: ListingOptions, + ) -> Result> { let namespace_id = build_namespace_identifier(namespace_path)?; let path = format!("/v1/namespace/{namespace_id}/materialized_view/list"); - let mut views = Vec::new(); - let mut page_token: Option = None; - let mut seen_page_tokens = HashSet::new(); - loop { - let mut req = self.client.get(&path); - if let Some(token) = &page_token { - req = req.query(&[("page_token", token)]); - } - let (request_id, response) = self.client.send(req).await?; - let response = self.client.check_response(&request_id, response).await?; - let status = response.status(); - let response: ListMaterializedViewsResponse = - response.json().await.err_to_http(request_id.clone())?; - views.extend(response.views); - let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty()) - else { - break; - }; - if !seen_page_tokens.insert(next_page_token.clone()) { - return Err(Error::Http { - source: "Materialized-view listing response repeated a page_token".into(), - request_id, - status_code: Some(status), - }); - } - page_token = Some(next_page_token); + let mut req = self.client.get(&path); + if let Some(token) = options.page_token { + req = req.query(&[("page_token", token)]); } - Ok(views) + if let Some(limit) = options.page_limit { + req = req.query(&[("limit", limit)]); + } + let (request_id, response) = self.client.send(req).await?; + let response = self.client.check_response(&request_id, response).await?; + let response: RemoteListViewsResponse = response.json().await.err_to_http(request_id)?; + let items = response.views; + Ok(ListingPage { + items, + page_token: response.page_token, + }) } async fn create_function_async( @@ -1024,45 +988,34 @@ impl Database for RemoteDatabase { response.json().await.err_to_http(request_id) } - async fn list_functions(&self, namespace_path: &[String]) -> Result> { + async fn list_functions( + &self, + namespace_path: &[String], + options: ListingOptions, + ) -> Result> { let namespace_id = build_namespace_identifier(namespace_path)?; let path = format!("/v1/namespace/{namespace_id}/function/list"); - let mut functions = Vec::new(); - let mut page_token: Option = None; - let mut seen_page_tokens = HashSet::new(); - loop { - let mut req = self - .client - .get(&path) - .query(&[("include_definition", true)]); - if let Some(token) = &page_token { - req = req.query(&[("page_token", token)]); - } - let (request_id, response) = self.client.send(req).await?; - let response = self.client.check_response(&request_id, response).await?; - let status = response.status(); - let response: RemoteListFunctionsResponse = - response.json().await.err_to_http(request_id.clone())?; - functions.extend( - response - .functions - .into_iter() - .map(|listed| listed.definition), - ); - let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty()) - else { - break; - }; - if !seen_page_tokens.insert(next_page_token.clone()) { - return Err(Error::Http { - source: "Function listing response repeated a page_token".into(), - request_id, - status_code: Some(status), - }); - } - page_token = Some(next_page_token); + let mut req = self.client.get(&path); + if let Some(token) = options.page_token { + req = req.query(&[("page_token", token)]); } - Ok(functions) + if let Some(limit) = options.page_limit { + req = req.query(&[("limit", limit)]); + } + req = req.query(&[("include_definition", true)]); + let (request_id, response) = self.client.send(req).await?; + let response = self.client.check_response(&request_id, response).await?; + let response: RemoteListFunctionsResponse = + response.json().await.err_to_http(request_id)?; + let items = response + .functions + .into_iter() + .map(|listed| listed.definition) + .collect(); + Ok(ListingPage { + items, + page_token: response.page_token, + }) } async fn drop_function( @@ -1145,37 +1098,32 @@ impl Database for RemoteDatabase { .await } - async fn list_secrets(&self, namespace_path: &[String]) -> Result> { + async fn list_secrets( + &self, + namespace_path: &[String], + options: ListingOptions, + ) -> Result> { let namespace_id = build_namespace_identifier(namespace_path)?; let path = format!("/v1/namespace/{namespace_id}/secret/list"); - let mut names = Vec::new(); - let mut page_token: Option = None; - let mut seen_page_tokens = HashSet::new(); - loop { - let mut req = self.client.get(&path); - if let Some(token) = &page_token { - req = req.query(&[("page_token", token)]); - } - let (request_id, response) = self.client.send(req).await?; - let response = self.client.check_response(&request_id, response).await?; - let status = response.status(); - let response: RemoteListSecretsResponse = - response.json().await.err_to_http(request_id.clone())?; - names.extend(response.secrets.into_iter().map(|secret| secret.name)); - let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty()) - else { - break; - }; - if !seen_page_tokens.insert(next_page_token.clone()) { - return Err(Error::Http { - source: "Secret listing response repeated a page_token".into(), - request_id, - status_code: Some(status), - }); - } - page_token = Some(next_page_token); + let mut req = self.client.get(&path); + if let Some(token) = options.page_token { + req = req.query(&[("page_token", token)]); } - Ok(names) + if let Some(limit) = options.page_limit { + req = req.query(&[("limit", limit)]); + } + let (request_id, response) = self.client.send(req).await?; + let response = self.client.check_response(&request_id, response).await?; + let response: RemoteListSecretsResponse = response.json().await.err_to_http(request_id)?; + let items = response + .secrets + .into_iter() + .map(|secret| secret.name) + .collect(); + Ok(ListingPage { + items, + page_token: response.page_token, + }) } async fn drop_secret(&self, name: &str, namespace_path: &[String]) -> Result<()> { @@ -1264,37 +1212,28 @@ impl Database for RemoteDatabase { } } - async fn list_views(&self, namespace_path: &[String]) -> Result> { + async fn list_views( + &self, + namespace_path: &[String], + options: ListingOptions, + ) -> Result> { let namespace_id = build_namespace_identifier(namespace_path)?; let path = format!("/v1/namespace/{namespace_id}/view/list"); - let mut views = Vec::new(); - let mut page_token: Option = None; - let mut seen_page_tokens = HashSet::new(); - loop { - let mut req = self.client.get(&path); - if let Some(token) = &page_token { - req = req.query(&[("page_token", token)]); - } - let (request_id, response) = self.client.send(req).await?; - let response = self.client.check_response(&request_id, response).await?; - let status = response.status(); - let response: RemoteListViewsResponse = - response.json().await.err_to_http(request_id.clone())?; - views.extend(response.views); - let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty()) - else { - break; - }; - if !seen_page_tokens.insert(next_page_token.clone()) { - return Err(Error::Http { - source: "View listing response repeated a page_token".into(), - request_id, - status_code: Some(status), - }); - } - page_token = Some(next_page_token); + let mut req = self.client.get(&path); + if let Some(token) = options.page_token { + req = req.query(&[("page_token", token)]); } - Ok(views) + if let Some(limit) = options.page_limit { + req = req.query(&[("limit", limit)]); + } + let (request_id, response) = self.client.send(req).await?; + let response = self.client.check_response(&request_id, response).await?; + let response: RemoteListViewsResponse = response.json().await.err_to_http(request_id)?; + let items = response.views; + Ok(ListingPage { + items, + page_token: response.page_token, + }) } async fn open_job(&self, job_id: &str) -> Result { @@ -1311,47 +1250,33 @@ impl Database for RemoteDatabase { } } - async fn list_jobs(&self) -> Result> { - let mut out = Vec::new(); - let mut page_token: Option = None; - let mut seen_page_tokens = HashSet::new(); - for page in 0..MAX_LIST_JOBS_PAGES { - let mut body = serde_json::json!({}); - if let Some(token) = &page_token { - body["page_token"] = serde_json::Value::String(token.clone()); - } - let req = self.client.post("/v1/jobs/list").json(&body); - let (request_id, rsp) = self.client.send(req).await?; - let rsp = self.client.check_response(&request_id, rsp).await?; - let status = rsp.status(); - let body: RemoteListJobsResponse = rsp.json().await.err_to_http(request_id.clone())?; - out.extend(body.jobs.into_iter().map(|row| JobInfo { + async fn list_jobs(&self, options: ListingOptions) -> Result> { + let mut body = serde_json::json!({}); + if let Some(token) = options.page_token { + body["page_token"] = token.into(); + } + if let Some(limit) = options.page_limit { + body["limit"] = limit.into(); + } + let req = self.client.post("/v1/jobs/list").json(&body); + let (request_id, rsp) = self.client.send(req).await?; + let rsp = self.client.check_response(&request_id, rsp).await?; + let response: RemoteListJobsResponse = rsp.json().await.err_to_http(request_id)?; + let items = response + .jobs + .into_iter() + .map(|row| JobInfo { job_id: row.job_id, table: row.table, job_type: row.job_type, state: job_state_to_client(&row.state), created_at_millis: row.created_at_millis, - })); - let Some(next_page_token) = body.page_token.filter(|token| !token.is_empty()) else { - break; - }; - if !seen_page_tokens.insert(next_page_token.clone()) { - return Err(Error::Http { - source: "Job listing response repeated a page_token".into(), - request_id, - status_code: Some(status), - }); - } - page_token = Some(next_page_token); - if page + 1 == MAX_LIST_JOBS_PAGES { - log::warn!( - "list_jobs truncated after {} pages ({} jobs)", - MAX_LIST_JOBS_PAGES, - out.len() - ); - } - } - Ok(out) + }) + .collect(); + Ok(ListingPage { + items, + page_token: response.page_token, + }) } async fn cancel_job(&self, job_id: &str) -> Result { @@ -1404,18 +1329,14 @@ impl Database for RemoteDatabase { }) } - async fn execute_query_async( - &self, - query: &str, - default_namespace_path: &[String], - ) -> Result { + async fn execute_query_async(&self, request: ExecuteQueryRequest) -> Result { let client = self .sql_client .as_ref() .ok_or_else(|| Error::NotSupported { message: "SQL is unavailable for this remote database client".to_string(), })?; - client.submit(query, default_namespace_path).await + client.execute(request).await } async fn describe_query(&self, query_id: uuid::Uuid) -> Result { @@ -1429,25 +1350,49 @@ impl Database for RemoteDatabase { } async fn table_names(&self, request: TableNamesRequest) -> Result> { + // A zero limit is an empty listing, even on servers that reject limit=0. + if request.limit == Some(0) { + return Ok(Vec::new()); + } let (tables, version) = if request.namespace_path.is_empty() { // The flat route resumes after a table name and orders by name, which is exactly - // what `start_after` means, so the server does the paging. - let mut req = self.client.get("/v1/table/"); - if let Some(limit) = request.limit { - req = req.query(&[("limit", limit)]); + // what `start_after` means. Subsequent cursors come from the server and may be + // opaque. Follow them until the requested limit or the end of the listing. + let mut tables = Vec::new(); + let mut version = None; + let mut page_token = request.start_after.clone(); + let mut seen_tokens = HashSet::new(); + if let Some(ref token) = page_token { + seen_tokens.insert(token.clone()); } - if let Some(ref start_after) = request.start_after { - req = req.query(&[("page_token", start_after)]); + loop { + let mut req = self.client.get("/v1/table/"); + if let Some(limit) = request.limit { + req = req.query(&[("limit", limit as usize - tables.len())]); + } + if let Some(ref token) = page_token { + req = req.query(&[("page_token", token)]); + } + let (request_id, rsp) = self.client.send_with_retry(req, None, true).await?; + let rsp = self.client.check_response(&request_id, rsp).await?; + if version.is_none() { + version = Some(parse_server_version(&request_id, &rsp)?); + } + let response: ListTablesResponse = rsp.json().await.err_to_http(request_id)?; + tables.extend(response.tables); + if let Some(limit) = request.limit + && tables.len() >= limit as usize + { + tables.truncate(limit as usize); + break; + } + // Empty or repeated tokens must not restart the listing or loop forever. + match response.page_token.filter(|token| !token.is_empty()) { + Some(token) if seen_tokens.insert(token.clone()) => page_token = Some(token), + _ => break, + } } - let (request_id, rsp) = self.client.send_with_retry(req, None, true).await?; - let rsp = self.client.check_response(&request_id, rsp).await?; - let version = parse_server_version(&request_id, &rsp)?; - let tables = rsp - .json::() - .await - .err_to_http(request_id)? - .tables; - (tables, version) + (tables, version.unwrap_or_default()) } else { self.table_names_in_namespace(&request).await? }; @@ -1491,6 +1436,7 @@ impl Database for RemoteDatabase { } async fn create_table(&self, mut request: CreateTableRequest) -> Result> { + let data_schema = request.data.schema(); let body = stream_as_body(request.data.scan_as_stream())?; let identifier = build_table_identifier(&request.name, &request.namespace_path)?; @@ -1521,7 +1467,17 @@ impl Database for RemoteDatabase { managed_versioning: None, }; let req = (callback)(req); - self.open_table(req).await + let table = self.open_table(req).await?; + let table_schema = table.schema().await?; + + if table_schema.as_ref() != data_schema.as_ref() { + return Err(Error::Schema { + message: "Provided schema does not match existing table schema" + .to_string(), + }); + } + + Ok(table) } // This should not happen, as we explicitly set the mode to overwrite and the server @@ -1547,12 +1503,13 @@ impl Database for RemoteDatabase { let version = parse_server_version(&request_id, &rsp)?; let table_identifier = build_table_identifier(&request.name, &request.namespace_path)?; let cache_key = build_cache_key(&request.name, &request.namespace_path); - let table = Arc::new(RemoteTable::new( + let table = Arc::new(RemoteTable::new_with_sql_client( self.client.clone(), request.name.clone(), request.namespace_path.clone(), table_identifier, version.clone(), + self.sql_client.clone(), )); self.table_cache.insert(cache_key, version).await; @@ -1589,12 +1546,13 @@ impl Database for RemoteDatabase { let version = parse_server_version(&request_id, &rsp)?; let cache_key = build_cache_key(&request.target_table_name, &request.target_namespace_path); - let table = Arc::new(RemoteTable::new( + let table = Arc::new(RemoteTable::new_with_sql_client( self.client.clone(), request.target_table_name.clone(), request.target_namespace_path.clone(), table_identifier, version.clone(), + self.sql_client.clone(), )); self.table_cache.insert(cache_key, version).await; @@ -1607,12 +1565,13 @@ impl Database for RemoteDatabase { // Every open gets its own checkout, schema cache, and freshness state. if let Some(version) = self.table_cache.get(&cache_key).await { - Ok(Arc::new(RemoteTable::new( + Ok(Arc::new(RemoteTable::new_with_sql_client( self.client.clone(), request.name, request.namespace_path, identifier, version, + self.sql_client.clone(), ))) } else { // Describe the table to confirm it exists before moving on. @@ -1625,12 +1584,13 @@ impl Database for RemoteDatabase { let rsp = self.client.check_response(&request_id, rsp).await?; let version = parse_server_version(&request_id, &rsp)?; let describe_body = rsp.text().await.ok(); - let table = Arc::new(RemoteTable::new( + let table = Arc::new(RemoteTable::new_with_sql_client( self.client.clone(), request.name.clone(), request.namespace_path.clone(), identifier, version.clone(), + self.sql_client.clone(), )); // This describe already carries the schema, so hand it to the table // instead of making the first schema read fetch it again. A version or @@ -1906,6 +1866,8 @@ impl From for RemoteOptions { #[cfg(test)] mod tests { use super::{NamespaceHeaderProviderContext, build_cache_key}; + use crate::listing::{Listing, ListingOptions}; + use futures::TryStreamExt; use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; @@ -1913,6 +1875,7 @@ mod tests { use arrow_array::{Int32Array, RecordBatch}; use arrow_schema::{DataType, Field, Schema}; use lance_namespace_impls::{DynamicContextProvider, OperationInfo}; + use rstest::rstest; use crate::connection::ConnectBuilder; use crate::database::Database; @@ -1957,29 +1920,11 @@ mod tests { } #[tokio::test] - async fn test_create_materialized_view_uses_item_route_and_job() { - let db = super::RemoteDatabase::new_mock(|request| { - assert_eq!(request.method(), "POST"); - assert_eq!( - request.url().path(), - "/v1/materialized_view/analytics$adults/create" - ); - let body = request - .body() - .and_then(reqwest::Body::as_bytes) - .and_then(|bytes| serde_json::from_slice::(bytes).ok()) - .unwrap(); - assert_eq!( - body["query"], - "SELECT age AS \"age\" FROM \"raw\".\"people\" WHERE age >= 18 LIMIT 10" - ); - assert_eq!(body["with_no_data"], false); - http::Response::builder() - .status(202) - .body(serde_json::json!({"job_id": "j1-mv-create"}).to_string()) - .unwrap() + async fn test_create_materialized_view_requires_sql_client() { + let db = super::RemoteDatabase::new_mock(|request| -> http::Response { + panic!("unexpected REST request: {}", request.url().path()) }); - let job = db + let error = db .create_materialized_view_async(CreateMaterializedViewRequest { name: "adults".into(), namespace_path: vec!["analytics".into()], @@ -1988,8 +1933,8 @@ mod tests { with_no_data: false, }) .await - .unwrap(); - assert_eq!(job.id(), Some("j1-mv-create")); + .unwrap_err(); + assert!(error.to_string().contains("SQL is unavailable")); } #[tokio::test] @@ -2141,9 +2086,19 @@ mod tests { } }); assert_eq!( - db.list_materialized_views(&["analytics".into()]) - .await - .unwrap(), + Listing::new(Default::default(), { + let db = Arc::new(db); + move |options| { + let db = db.clone(); + async move { + db.list_materialized_views(&["analytics".into()], options) + .await + } + } + }) + .try_collect::>() + .await + .unwrap(), ["adults"] ); } @@ -2205,6 +2160,133 @@ mod tests { assert_eq!(names, vec!["table1", "table2"]); } + #[rstest::rstest] + #[case(None, None)] + #[case(Some("t009"), None)] + #[case(None, Some(200))] + #[case(Some("t009"), Some(15))] + #[tokio::test] + async fn test_table_names_follows_server_pages( + #[case] start_after: Option<&str>, + #[case] limit: Option, + ) { + let conn = Connection::new_with_handler(|request| { + assert_eq!(request.method(), &reqwest::Method::GET); + assert_eq!(request.url().path(), "/v1/table/"); + let query: HashMap<_, _> = request.url().query_pairs().collect(); + let start = query + .get("page_token") + .map(|token| token[1..4].parse::().unwrap() + 1) + .unwrap_or(0); + let limit = query + .get("limit") + .map(|limit| limit.parse::().unwrap()) + .unwrap_or(10); + // A server may return a short page even when more tables remain. + let end = (start + limit.min(10)).min(130); + let tables: Vec<_> = (start..end).map(|i| format!("t{i:03}")).collect(); + let page_token = (end < 130).then(|| format!("t{:03}.lance/", end - 1)); + http::Response::builder() + .status(200) + .body(serde_json::json!({"tables": tables, "page_token": page_token}).to_string()) + .unwrap() + }); + let mut op = conn.table_names(); + if let Some(start_after) = start_after { + op = op.start_after(start_after); + } + if let Some(limit) = limit { + op = op.limit(limit); + } + let start = if start_after.is_some() { 10 } else { 0 }; + let end = limit.map(|limit| (start + limit).min(130)).unwrap_or(130); + let expected: Vec<_> = (start..end).map(|i| format!("t{i:03}")).collect(); + assert_eq!(op.execute().await.unwrap(), expected); + } + + #[rstest::rstest] + #[case(vec![])] + #[case(vec!["ns".to_string()])] + #[tokio::test] + async fn test_table_listing_zero_limit_never_sends_a_request(#[case] namespace: Vec) { + let conn = Connection::new_with_handler(|_| -> http::Response { + panic!("a zero limit must not be sent to the server") + }); + assert!( + conn.table_names() + .namespace(namespace.clone()) + .limit(0) + .execute() + .await + .unwrap() + .is_empty() + ); + let page = conn + .list_tables(lance_namespace::models::ListTablesRequest { + id: Some(namespace), + limit: Some(0), + ..Default::default() + }) + .await + .unwrap(); + assert!(page.tables.is_empty()); + assert!(page.page_token.is_none()); + } + + #[rstest::rstest] + #[case(vec![], "/v1/namespace/$/table/list")] + #[case(vec!["ns".to_string()], "/v1/namespace/ns/table/list")] + #[tokio::test] + async fn test_list_tables_default_page_size( + #[case] namespace: Vec, + #[case] path: &'static str, + ) { + let conn = Connection::new_with_handler(move |request| { + assert_eq!(request.url().path(), path); + let query: HashMap<_, _> = request.url().query_pairs().collect(); + assert_eq!(query.get("limit").map(|limit| limit.as_ref()), Some("100")); + let start = match query.get("page_token") { + None => 0, + Some(token) => { + assert_eq!(token, "opaque-token"); + 100 + } + }; + let end = (start + 100).min(130); + let tables: Vec<_> = (start..end).map(|i| format!("t{i:03}")).collect(); + http::Response::builder() + .status(200) + .body( + serde_json::json!({ + "tables": tables, + "page_token": (end < 130).then_some("opaque-token") + }) + .to_string(), + ) + .unwrap() + }); + let first = conn + .list_tables(lance_namespace::models::ListTablesRequest { + id: Some(namespace.clone()), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(first.tables.len(), 100); + assert_eq!(first.page_token.as_deref(), Some("opaque-token")); + let second = conn + .list_tables(lance_namespace::models::ListTablesRequest { + id: Some(namespace), + page_token: first.page_token, + ..Default::default() + }) + .await + .unwrap(); + let expected: Vec<_> = (100..130).map(|i| format!("t{i:03}")).collect(); + assert_eq!(second.tables, expected); + assert!(second.page_token.is_none()); + } + #[tokio::test] async fn test_table_names_in_a_namespace_never_invents_a_page_token() { // The namespace route's token belongs to the store, so `table_names` cannot build one @@ -2250,8 +2332,11 @@ mod tests { assert_eq!(names, vec!["widgets"]); } + #[rstest::rstest] + #[case(vec![])] + #[case(vec!["ns".to_string()])] #[tokio::test] - async fn test_table_names_in_a_namespace_stops_on_a_repeated_token() { + async fn test_table_names_stops_on_a_repeated_token(#[case] namespace: Vec) { // A server that handed back the token it was given would never finish the walk. let conn = Connection::new_with_handler(|_request| { http::Response::builder() @@ -2262,7 +2347,7 @@ mod tests { let names = conn .table_names() - .namespace(vec!["ns".to_string()]) + .namespace(namespace) .execute() .await .unwrap(); @@ -2271,8 +2356,11 @@ mod tests { assert_eq!(names, vec!["a", "a"]); } + #[rstest::rstest] + #[case(vec![])] + #[case(vec!["ns".to_string()])] #[tokio::test] - async fn test_table_names_in_a_namespace_stops_on_an_empty_token() { + async fn test_table_names_stops_on_an_empty_token(#[case] namespace: Vec) { // An empty token ends the listing. Sending it back would ask a server that reads it // as "start from the beginning" for the first page a second time, and every name on // that page would be collected twice. @@ -2292,7 +2380,7 @@ mod tests { let names = conn .table_names() - .namespace(vec!["ns".to_string()]) + .namespace(namespace) .execute() .await .unwrap(); @@ -2538,6 +2626,75 @@ mod tests { ); } + #[rstest] + #[case::matching(Field::new("a", DataType::Int32, false), true)] + #[case::different_name(Field::new("x", DataType::Int32, false), false)] + #[case::different_type(Field::new("a", DataType::Int64, false), false)] + #[case::different_nullability(Field::new("a", DataType::Int32, true), false)] + #[tokio::test] + async fn test_create_table_exist_ok_validates_schema( + #[case] existing_field: Field, + #[case] matches: bool, + #[values(false, true)] empty: bool, + #[values(false, true)] cached: bool, + ) { + let existing_schema = Schema::new(vec![existing_field]); + let description = serde_json::json!({ + "version": 1, + "schema": lance::arrow::json::JsonSchema::try_from(&existing_schema).unwrap(), + }) + .to_string(); + let mut db = super::RemoteDatabase::new_mock(move |request| { + assert_eq!(request.method(), &reqwest::Method::POST); + match request.url().path() { + "/v1/table/table1/create/" => { + assert_eq!(request.url().query(), Some("mode=exist_ok")); + http::Response::builder() + .status(400) + .body("Table table1 already exists".to_string()) + .unwrap() + } + "/v1/table/table1/describe/" => http::Response::builder() + .status(200) + .body(description.clone()) + .unwrap(), + path => panic!("unexpected path: {path}"), + } + }); + db.table_cache = moka::future::Cache::new(10); + let conn = Connection::new( + Arc::new(db), + Arc::new(crate::embeddings::MemoryRegistry::new()), + ); + if cached { + conn.open_table("table1").execute().await.unwrap(); + } + + let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])); + let builder = if empty { + conn.create_empty_table("table1", schema.clone()) + } else { + let data = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![1, 2, 3]))], + ) + .unwrap(); + conn.create_table("table1", data) + }; + let result = builder + .mode(CreateTableMode::exist_ok(|b| b)) + .execute() + .await; + if matches { + let table = result.unwrap(); + assert_eq!(table.name(), "table1"); + assert_eq!(table.schema().await.unwrap(), schema); + } else { + assert!(matches!(result, Err(Error::Schema { message }) + if message == "Provided schema does not match existing table schema")); + } + } + #[tokio::test] async fn test_create_table_modes() { let test_cases = [ @@ -2577,7 +2734,14 @@ mod tests { .status(400) .body("Table table1 already exists") .unwrap(), - "/v1/table/table1/describe/" => http::Response::builder().status(200).body("").unwrap(), + "/v1/table/table1/describe/" => http::Response::builder() + .status(200) + .body( + r#"{"version": 1, "schema": {"fields": [ + {"name": "a", "type": {"type": "int32"}, "nullable": false} + ]}}"#, + ) + .unwrap(), _ => { panic!("unexpected path: {:?}", request.url().path()); } @@ -3476,6 +3640,110 @@ mod tests { } } + #[tokio::test] + async fn test_resource_listings_are_lazy_and_forward_pagination() { + async fn check(mut items: Listing, count: Arc) { + assert_eq!(count.load(Ordering::SeqCst), 0); + assert_eq!(items.num_page_results(), 0); + assert_eq!(items.page_token(), Some("start/token")); + assert!(items.try_next().await.unwrap().is_some()); + assert_eq!(items.num_page_results(), 1); + assert_eq!(items.page_token(), Some("empty")); + assert!(items.try_next().await.unwrap().is_some()); + assert_eq!(count.load(Ordering::SeqCst), 1); + assert!(items.try_next().await.unwrap().is_some()); + assert_eq!(count.load(Ordering::SeqCst), 3); + assert_eq!(items.num_page_results(), 1); + assert_eq!(items.page_token(), None); + assert!(items.try_next().await.unwrap().is_some()); + assert!(items.try_next().await.unwrap().is_none()); + assert!(items.try_next().await.unwrap().is_none()); + } + for resource in ["secret", "view", "function", "materialized_view", "jobs"] { + let count = Arc::new(AtomicUsize::new(0)); + let conn = Connection::new_with_handler({ + let count = count.clone(); + move |request| { + let page = count.fetch_add(1, Ordering::SeqCst); + assert!(page < 3); + let token = ["start/token", "empty", "last"][page]; + if resource == "jobs" { + assert_eq!(request.url().path(), "/v1/jobs/list"); + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()) + .unwrap(); + assert_eq!(body, serde_json::json!({"page_token": token, "limit": 2})); + } else { + let ns = if resource == "materialized_view" { + "$" + } else { + "team" + }; + assert_eq!( + request.url().path(), + format!("/v1/namespace/{ns}/{resource}/list") + ); + let query = request.url().query_pairs().collect::>(); + assert_eq!(query.get("page_token").unwrap(), token); + assert_eq!(query.get("limit").unwrap(), "2"); + if resource == "function" { + assert_eq!(query.get("include_definition").unwrap(), "true"); + } + } + let (key, item) = match resource { + "secret" => ("secrets", serde_json::json!({"name": "a"})), + "jobs" => ( + "jobs", + serde_json::json!({"job_id": "a", "table": "t", "job_type": "create_index", "state": "in_progress", "created_at_millis": 1}), + ), + "function" => ( + "functions", + serde_json::json!({"name": "embed", "version": "1", "definition": serde_json::from_str::(include_str!("../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json")).unwrap()}), + ), + _ => ("views", serde_json::json!("a")), + }; + let items = if page == 1 { + vec![] + } else { + vec![item.clone(), item] + }; + http::Response::builder().status(200).body(serde_json::json!({key: items, "page_token": (["empty", "last", ""][page])}).to_string()).unwrap() + } + }); + let options = ListingOptions::default() + .page_token("start/token") + .page_limit(2); + let namespace = vec!["team".to_owned()]; + match resource { + "secret" => check(conn.list_secrets(&namespace, options), count).await, + "view" => check(conn.list_views(&namespace, options), count).await, + "function" => check(conn.list_functions(&namespace, options), count).await, + "materialized_view" => check(conn.list_materialized_views(options), count).await, + _ => check(conn.list_jobs(options), count).await, + } + } + } + + #[tokio::test] + async fn test_list_jobs_does_not_truncate_after_100_pages() { + let conn = Connection::new_with_handler(|request| { + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); + let page: u32 = body["page_token"].as_str().unwrap_or("0").parse().unwrap(); + http::Response::builder().status(200).body(serde_json::json!({ + "jobs": [{"job_id": page.to_string(), "table": "t", "job_type": "create_index", "state": "in_progress", "created_at_millis": 1}], + "page_token": (page < 110).then(|| (page + 1).to_string()), + }).to_string()).unwrap() + }); + let jobs = conn + .list_jobs(Default::default()) + .try_collect::>() + .await + .unwrap(); + assert_eq!(jobs.len(), 111); + assert_eq!(jobs.last().unwrap().job_id, "110"); + } + #[tokio::test] async fn test_list_jobs_paginates() { let page = Arc::new(AtomicUsize::new(0)); @@ -3505,7 +3773,11 @@ mod tests { } } }); - let jobs = conn.list_jobs().await.unwrap(); + let jobs = conn + .list_jobs(Default::default()) + .try_collect::>() + .await + .unwrap(); assert_eq!(jobs.len(), 3); assert_eq!(jobs[0].job_id, "job-1"); assert_eq!(jobs[0].table, "t1"); @@ -3534,17 +3806,14 @@ mod tests { .unwrap() }); - let error = conn.list_jobs().await.unwrap_err(); + let mut items = conn.list_jobs(Default::default()); + let error = (&mut items).try_collect::>().await.unwrap_err(); assert!( - matches!( - &error, - Error::Http { - status_code: Some(http::StatusCode::OK), - .. - } - ), + matches!(&error, Error::Runtime { message } if message.contains("repeated a page_token")), "got {error:?}" ); + assert!(items.page_token().is_some()); + assert!(items.try_next().await.unwrap().is_none()); assert_eq!(requests.load(Ordering::SeqCst), 2); } @@ -3812,7 +4081,8 @@ mod tests { ); let by_segment = conn - .list_secrets(&[component.to_string()]) + .list_secrets(&[component.to_string()], Default::default()) + .try_collect::>() .await .expect_err("a dot-only namespace segment must be refused"); assert!( @@ -4011,7 +4281,10 @@ mod tests { http::Response::builder().status(200).body(body).unwrap() }); assert_eq!( - conn.list_secrets(&[]).await.unwrap(), + conn.list_secrets(&[], Default::default()) + .try_collect::>() + .await + .unwrap(), vec!["openai-prod".to_string(), "hf-prod".to_string()] ); } @@ -4026,7 +4299,11 @@ mod tests { .body(r#"{"secrets":[{"name":"openai-prod"}],"page_token":"same"}"#) .unwrap() }); - let error = conn.list_secrets(&[]).await.unwrap_err(); + let error = conn + .list_secrets(&[], Default::default()) + .try_collect::>() + .await + .unwrap_err(); assert!( error.to_string().contains("repeated a page_token"), "{error}" @@ -4089,9 +4366,13 @@ mod tests { .body(r#"{"secrets":[]}"#) .unwrap() }); - conn.list_secrets(&["prod".to_string(), "vision".to_string()]) - .await - .unwrap(); + conn.list_secrets( + &["prod".to_string(), "vision".to_string()], + Default::default(), + ) + .try_collect::>() + .await + .unwrap(); } /// A view description carries the schema in the namespace spec's JSON @@ -4256,7 +4537,10 @@ mod tests { http::Response::builder().status(200).body(body).unwrap() }); assert_eq!( - conn.list_views(&["analytics".into()]).await.unwrap(), + conn.list_views(&["analytics".into()], Default::default()) + .try_collect::>() + .await + .unwrap(), vec!["adults".to_string()] ); } @@ -4271,7 +4555,11 @@ mod tests { .body(r#"{"views":["adults"],"page_token":"same"}"#) .unwrap() }); - let error = conn.list_views(&[]).await.unwrap_err(); + let error = conn + .list_views(&[], Default::default()) + .try_collect::>() + .await + .unwrap_err(); assert!( error.to_string().contains("repeated a page_token"), "{error}" @@ -4393,7 +4681,11 @@ mod tests { } } }); - let functions = conn.list_functions(&[]).await.unwrap(); + let functions = conn + .list_functions(&[], Default::default()) + .try_collect::>() + .await + .unwrap(); assert_eq!(functions.len(), 1); assert_eq!(functions[0].name(), "embed"); assert_eq!(functions[0].version(), "1"); @@ -4416,7 +4708,11 @@ mod tests { .unwrap() }); - let functions = conn.list_functions(&[]).await.unwrap(); + let functions = conn + .list_functions(&[], Default::default()) + .try_collect::>() + .await + .unwrap(); assert!(functions.is_empty()); assert_eq!(requests.load(Ordering::SeqCst), 1); } @@ -4457,17 +4753,14 @@ mod tests { .unwrap() }); - let error = conn.list_functions(&[]).await.unwrap_err(); + let mut items = conn.list_functions(&[], Default::default()); + let error = (&mut items).try_collect::>().await.unwrap_err(); assert!( - matches!( - &error, - Error::Http { - status_code: Some(http::StatusCode::OK), - .. - } - ), + matches!(&error, Error::Runtime { message } if message.contains("repeated a page_token")), "got {error:?}" ); + assert!(items.page_token().is_some()); + assert!(items.try_next().await.unwrap().is_none()); assert_eq!(requests.load(Ordering::SeqCst), 3); } @@ -4545,7 +4838,13 @@ mod tests { .unwrap(); assert_eq!(job.id(), Some("job-function-1")); conn.get_function("embed", "1", &namespace).await.unwrap(); - assert!(conn.list_functions(&namespace).await.unwrap().is_empty()); + assert!( + conn.list_functions(&namespace, Default::default()) + .try_collect::>() + .await + .unwrap() + .is_empty() + ); assert!(conn.drop_function("embed", "1", &namespace).await.unwrap()); } @@ -4558,7 +4857,12 @@ mod tests { }); let namespace = ["analytics".to_string(), String::new()]; assert!(conn.get_function("embed", "1", &namespace).await.is_err()); - assert!(conn.list_functions(&namespace).await.is_err()); + assert!( + conn.list_functions(&namespace, Default::default()) + .try_collect::>() + .await + .is_err() + ); assert!(conn.drop_function("embed", "1", &namespace).await.is_err()); } diff --git a/rust/lancedb/src/remote/sql.rs b/rust/lancedb/src/remote/sql.rs index 489158922..a58a9afad 100644 --- a/rust/lancedb/src/remote/sql.rs +++ b/rust/lancedb/src/remote/sql.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::fs; +use std::future::{Future, pending}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use std::time::{Duration, Instant}; @@ -20,16 +21,23 @@ use arrow_schema::{Schema, SchemaRef}; use futures::TryStreamExt; use http::header::{HeaderMap, HeaderName, HeaderValue}; use prost::Message; -use tokio::sync::{Mutex, Notify, OnceCell, mpsc}; +use serde::{Serialize, de::DeserializeOwned}; +use tokio::sync::{Mutex, Notify, OnceCell, mpsc, watch}; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; use uuid::Uuid; use crate::arrow::{SendableRecordBatchStream, SimpleRecordBatchStream}; +use crate::database::ExecuteQueryRequest; use crate::error::{Error, Result}; +use crate::job::Job; use crate::remote::client::{ClientConfig, TlsConfig}; use crate::remote::retry::ResolvedRetryConfig; use crate::sql::{Query, QueryDescription, QueryHandle, QueryStatus}; +mod exchange; + +use exchange::{ExchangeQuery, ExchangeQueryHandle}; + const DEFAULT_SQL_PORT: u16 = 10025; const DEFAULT_SQL_TLS_PORT: u16 = 10026; const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(120); @@ -182,6 +190,23 @@ impl SqlClient { } } + pub(super) async fn execute(&self, request: ExecuteQueryRequest) -> Result { + match request.parameters { + Some(parameters) => { + self.submit_with_parameters( + &request.query, + parameters, + &request.default_namespace_path, + ) + .await + } + None => { + self.submit(&request.query, &request.default_namespace_path) + .await + } + } + } + pub(super) async fn submit( &self, query: &str, @@ -203,12 +228,78 @@ impl SqlClient { default_namespace_path.to_vec(), poll_info, )?); - self.queries.insert(query_id, query.clone()); + self.queries + .insert(query_id, RegisteredQuery::Polled(query.clone())); Ok(Query::new(Arc::new(RemoteQueryHandle::new(query)))) }) .await } + pub(super) fn submit_as_job( + &self, + statement: String, + namespace: Vec, + finish: F, + ) -> Job + where + T: Clone + Serialize + DeserializeOwned + Send + Sync + 'static, + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let client = self.clone(); + let (query_tx, query_rx) = watch::channel(None::>); + let task = tokio::spawn(async move { + let query = Arc::new(client.submit(&statement, &namespace).await?); + query_tx.send_replace(Some(query.clone())); + let mut reader = query.reader().await?; + while reader.try_next().await?.is_some() {} + finish().await + }); + Job::spawned_with_cancellation(task, move || { + let mut query_rx = query_rx.clone(); + async move { + loop { + let query = query_rx.borrow().clone(); + if let Some(query) = query { + return query.cancel().await; + } + if query_rx.changed().await.is_err() { + pending::<()>().await; + } + } + } + }) + } + + /// Values cannot ride on a poll, so a statement that has them is sent on + /// its own exchange instead. + pub(super) async fn submit_with_parameters( + &self, + query: &str, + parameters: RecordBatch, + default_namespace_path: &[String], + ) -> Result { + let timeout = self.inner.overall_timeout()?; + with_overall_timeout(timeout, "SQL query submission", async { + validate_namespace_path(default_namespace_path)?; + let query_id = Uuid::now_v7(); + let query = Arc::new( + ExchangeQuery::open( + query_id, + self.inner.clone(), + query, + parameters, + default_namespace_path, + ) + .await?, + ); + self.queries + .insert(query_id, RegisteredQuery::Exchanged(query.clone())); + Ok(Query::new(Arc::new(ExchangeQueryHandle::new(query)))) + }) + .await + } + pub(super) async fn describe(&self, query_id: Uuid) -> Result { let query = self .queries @@ -491,8 +582,40 @@ impl SqlClientInner { } } +/// A submitted query as the connection remembers it. +#[derive(Clone)] +enum RegisteredQuery { + Polled(Arc), + Exchanged(Arc), +} + +impl RegisteredQuery { + fn touch(&self) { + match self { + Self::Polled(query) => query.touch(), + Self::Exchanged(query) => query.touch(), + } + } + + /// Whether the registry may forget this query. It is abandoned when the + /// registry holds the only reference to it. + fn registry_expired(&self) -> bool { + match self { + Self::Polled(query) => query.registry_expired(Arc::strong_count(query) == 1), + Self::Exchanged(query) => query.registry_expired(Arc::strong_count(query) == 1), + } + } + + async fn describe(&self) -> Result { + match self { + Self::Polled(query) => query.describe().await, + Self::Exchanged(query) => query.describe(), + } + } +} + struct QueryRegistry { - queries: StdMutex>>, + queries: StdMutex>, } impl QueryRegistry { @@ -502,12 +625,12 @@ impl QueryRegistry { } } - fn insert(&self, id: Uuid, query: Arc) { + fn insert(&self, id: Uuid, query: RegisteredQuery) { self.remove_expired(); self.queries.lock().unwrap().insert(id, query); } - fn get(&self, id: Uuid) -> Option> { + fn get(&self, id: Uuid) -> Option { self.remove_expired(); let query = self.queries.lock().unwrap().get(&id).cloned(); if let Some(query) = &query { @@ -520,7 +643,7 @@ impl QueryRegistry { self.queries .lock() .unwrap() - .retain(|_, query| !query.registry_expired(Arc::strong_count(query) == 1)); + .retain(|_, query| !query.registry_expired()); } } diff --git a/rust/lancedb/src/remote/sql/exchange.rs b/rust/lancedb/src/remote/sql/exchange.rs new file mode 100644 index 000000000..523aaf5d7 --- /dev/null +++ b/rust/lancedb/src/remote/sql/exchange.rs @@ -0,0 +1,435 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! SQL statements sent with parameters. +//! +//! A statement without parameters is polled: `PollFlightInfo` starts it +//! detached from the connection and `DoGet` reads its endpoints. Neither call +//! carries Arrow data, so a statement's values would have to be spelled into +//! its text. A statement with parameters is sent on one `DoExchange` instead: +//! the command and the parameter schema, then one row, and the result rows +//! come back on the same call. The call is the statement's lifetime, so +//! cancelling it -- or dropping the handle before reading -- stops the +//! statement on the server. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::time::Instant; + +use arrow_array::RecordBatch; +use arrow_flight::FlightDescriptor; +use arrow_flight::encode::FlightDataEncoderBuilder; +use arrow_flight::sql::{CommandStatementQuery, ProstMessageExt}; +use arrow_schema::SchemaRef; +use prost::Message; +use tokio::sync::{Notify, mpsc}; +use uuid::Uuid; + +use super::{ + ABANDONED_QUERY_RETENTION, DEFAULT_READ_TIMEOUT, ResultEndpointStream, ResultStartGuard, + SqlClientInner, TERMINAL_QUERY_RETENTION, resolve_timeout, sql_error, with_overall_timeout, +}; +use crate::arrow::{SendableRecordBatchStream, SimpleRecordBatchStream}; +use crate::error::{Error, Result}; +use crate::sql::{QueryDescription, QueryHandle, QueryStatus}; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Lifecycle { + Running, + /// Every row has been received. + Finished, + Cancelled, + Failed(String), +} + +/// A failed statement, keeping the message `describe` reports it with -- not +/// `Runtime error: …` again when `describe` wraps it in a runtime error. +fn failure(error: &Error) -> Lifecycle { + Lifecycle::Failed(match error { + Error::Runtime { message } => message.clone(), + other => other.to_string(), + }) +} + +/// A statement running on its own `DoExchange` call. +pub(super) struct ExchangeQuery { + id: Uuid, + client: Arc, + /// The call's rows, until a reader takes them or a cancellation drops them. + response: StdMutex>, + lifecycle: StdMutex, + cancelled: Notify, + terminal_at: OnceLock, + last_accessed: StdMutex, +} + +struct PreparedExchange { + schema: SchemaRef, + first: Option, + rest: Option, +} + +/// The call's rows while a reader waits for their first batch. +/// +/// A reader whose future is dropped mid-wait -- its caller gave up, or its +/// task was cancelled -- puts them back, so the next reader finds the query as +/// this one did. A query cancelled meanwhile has its rows dropped instead, +/// which ends the call. +struct Borrowed<'a> { + query: &'a ExchangeQuery, + rows: Option, +} + +impl Borrowed<'_> { + fn get(&mut self) -> &mut ResultEndpointStream { + self.rows.as_mut().expect("rows are borrowed until kept") + } + + fn keep(mut self) -> ResultEndpointStream { + self.rows.take().expect("rows are borrowed until kept") + } +} + +impl Drop for Borrowed<'_> { + fn drop(&mut self) { + let Some(rows) = self.rows.take() else { + return; + }; + // Checked under the response lock: `cancel` settles first and takes + // the response after, so rows put back here are never missed by it. + let mut response = self.query.response.lock().unwrap(); + if *self.query.lifecycle.lock().unwrap() == Lifecycle::Running { + *response = Some(rows); + } + } +} + +impl ExchangeQuery { + /// Send the statement and its parameters, returning once the server has + /// planned it: a statement it refuses is refused here, as a polled one is + /// on its first poll. + pub(super) async fn open( + id: Uuid, + client: Arc, + query: &str, + parameters: RecordBatch, + default_namespace_path: &[String], + ) -> Result { + if parameters.num_rows() != 1 { + return Err(Error::InvalidInput { + message: format!( + "SQL query parameters must be exactly one row, got {}", + parameters.num_rows() + ), + }); + } + let request_id = Uuid::new_v4().to_string(); + let read_timeout = resolve_timeout( + client.client_config.timeout_config.read_timeout, + "LANCE_CLIENT_READ_TIMEOUT", + Some(DEFAULT_READ_TIMEOUT), + )? + .unwrap(); + let descriptor = FlightDescriptor::new_cmd( + CommandStatementQuery { + query: query.to_string(), + transaction_id: None, + } + .as_any() + .encode_to_vec(), + ); + // The descriptor rides on the schema message, and the row follows it. + let request = FlightDataEncoderBuilder::new() + .with_flight_descriptor(Some(descriptor)) + .with_schema(parameters.schema()) + .build(futures::stream::iter([Ok(parameters)])); + let mut flight = client + .client_with_headers(default_namespace_path, &request_id) + .await?; + let stream = tokio::time::timeout(read_timeout, flight.do_exchange(request)) + .await + .map_err(|_| sql_error(&request_id, "SQL query submission timed out"))? + .map_err(|err| sql_error(&request_id, err))?; + Ok(Self { + id, + client, + response: StdMutex::new(Some(ResultEndpointStream { + stream, + request_id, + read_timeout, + })), + lifecycle: StdMutex::new(Lifecycle::Running), + cancelled: Notify::new(), + terminal_at: OnceLock::new(), + last_accessed: StdMutex::new(Instant::now()), + }) + } + + pub(super) fn touch(&self) { + *self.last_accessed.lock().unwrap() = Instant::now(); + } + + pub(super) fn registry_expired(&self, abandoned: bool) -> bool { + if let Some(finished) = self.terminal_at.get() { + return finished.elapsed() >= TERMINAL_QUERY_RETENTION; + } + abandoned && self.last_accessed.lock().unwrap().elapsed() >= ABANDONED_QUERY_RETENTION + } + + pub(super) fn describe(&self) -> Result { + self.touch(); + let status = match &*self.lifecycle.lock().unwrap() { + Lifecycle::Running => QueryStatus::Running, + Lifecycle::Finished => QueryStatus::Finished, + Lifecycle::Cancelled => QueryStatus::Cancelled, + Lifecycle::Failed(message) => { + return Err(Error::Runtime { + message: message.clone(), + }); + } + }; + Ok(QueryDescription { + id: self.id, + status, + progress: (status == QueryStatus::Finished).then_some(1.0), + expires_at: None, + }) + } + + /// Stop the statement. Idempotent, and a no-op once it has ended. + pub(super) fn cancel(&self) { + self.touch(); + let settled = self.settle(Lifecycle::Cancelled); + // Unread rows are dropped here, which cancels the call; a query that + // already ended has none. A reader's stream is dropped by its task + // when it sees the cancellation. + self.response.lock().unwrap().take(); + if settled { + self.cancelled.notify_waiters(); + } + } + + /// Move a running statement to `state`. `true` if this call did. + fn settle(&self, state: Lifecycle) -> bool { + let mut lifecycle = self.lifecycle.lock().unwrap(); + if *lifecycle != Lifecycle::Running { + return false; + } + *lifecycle = state; + drop(lifecycle); + let _ = self.terminal_at.set(Instant::now()); + true + } + + fn is_cancelled(&self) -> bool { + *self.lifecycle.lock().unwrap() == Lifecycle::Cancelled + } + + fn cancelled_error(&self) -> Error { + Error::JobCancelled { + job_id: Some(self.id.to_string()), + } + } + + async fn wait_for_cancellation(&self) { + loop { + let cancelled = self.cancelled.notified(); + if self.is_cancelled() { + return; + } + cancelled.await; + } + } + + /// Take the rows and wait for the first batch, which carries the schema. + async fn prepare(&self) -> Result { + let Some(rows) = self.response.lock().unwrap().take() else { + // Taken by an earlier attempt that failed, or dropped by a cancel. + return Err(match &*self.lifecycle.lock().unwrap() { + Lifecycle::Failed(message) => Error::Runtime { + message: message.clone(), + }, + _ => self.cancelled_error(), + }); + }; + let mut rows = Borrowed { + query: self, + rows: Some(rows), + }; + let first = tokio::select! { + biased; + _ = self.wait_for_cancellation() => return Err(self.cancelled_error()), + result = rows.get().next_batch() => result, + }; + // Read or failed, the rows are this attempt's now: a failed read is + // not worth putting back. + let rows = rows.keep(); + let first = first?; + let schema = first + .as_ref() + .map(RecordBatch::schema) + .or_else(|| rows.stream.schema().cloned()) + .ok_or_else(|| Error::Runtime { + message: "SQL result stream did not include a schema".to_string(), + })?; + if first.is_none() { + self.settle(Lifecycle::Finished); + } + Ok(PreparedExchange { + schema, + rest: first.is_some().then_some(rows), + first, + }) + } + + async fn run( + self: Arc, + mut prepared: PreparedExchange, + sender: mpsc::Sender>, + ) -> Result<()> { + if let Some(batch) = prepared.first.take() + && !self.send(&sender, batch).await? + { + return Ok(()); + } + let Some(mut rows) = prepared.rest.take() else { + return Ok(()); + }; + loop { + let batch = tokio::select! { + biased; + // The reader was dropped: the rows go with this task, which + // ends the call. + _ = sender.closed() => { + self.settle(Lifecycle::Cancelled); + return Ok(()); + } + _ = self.wait_for_cancellation() => return Err(self.cancelled_error()), + result = rows.next_batch() => result, + }; + match batch { + Ok(Some(batch)) => { + if !self.send(&sender, batch).await? { + return Ok(()); + } + } + Ok(None) => { + self.settle(Lifecycle::Finished); + return Ok(()); + } + Err(error) => { + self.settle(failure(&error)); + return Err(error); + } + } + } + } + + async fn send( + &self, + sender: &mpsc::Sender>, + batch: RecordBatch, + ) -> Result { + tokio::select! { + biased; + _ = self.wait_for_cancellation() => Err(self.cancelled_error()), + result = sender.send(Ok(batch)) => { + if result.is_err() { + self.settle(Lifecycle::Cancelled); + } + Ok(result.is_ok()) + } + } + } +} + +pub(super) struct ExchangeQueryHandle { + query: Arc, + result_started: AtomicBool, +} + +impl ExchangeQueryHandle { + pub(super) fn new(query: Arc) -> Self { + Self { + query, + result_started: AtomicBool::new(false), + } + } +} + +impl Drop for ExchangeQueryHandle { + // Rows nobody took are rows nobody will read. Keeping the call open would + // hold the statement running on the server, stalled on flow control, + // until the registry forgot it. + fn drop(&mut self) { + if self.query.response.lock().unwrap().is_some() { + self.query.cancel(); + } + } +} + +#[async_trait::async_trait] +impl QueryHandle for ExchangeQueryHandle { + fn id(&self) -> Uuid { + self.query.touch(); + self.query.id + } + + async fn describe(&self) -> Result { + self.query.describe() + } + + async fn reader(&self) -> Result { + let timeout = self.query.client.overall_timeout()?; + if self + .result_started + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return Err(Error::Runtime { + message: "SQL query results can only be consumed once".to_string(), + }); + } + let result_start = ResultStartGuard::new(&self.result_started); + self.query.touch(); + let started = Instant::now(); + let prepared = + match with_overall_timeout(timeout, "SQL query result", self.query.prepare()).await { + Ok(prepared) => prepared, + Err(error) => { + // A failed read or the timeout ends the query, and with it + // the call, including rows a timed-out wait put back. + self.query.settle(failure(&error)); + self.query.response.lock().unwrap().take(); + return Err(error); + } + }; + let remaining_timeout = timeout.map(|timeout| timeout.saturating_sub(started.elapsed())); + let schema = prepared.schema.clone(); + let (sender, receiver) = mpsc::channel(2); + let error_sender = sender.clone(); + let query = self.query.clone(); + tokio::spawn(async move { + let result = with_overall_timeout( + remaining_timeout, + "SQL query result", + query.clone().run(prepared, sender), + ) + .await; + if let Err(error) = result { + query.settle(failure(&error)); + let _ = error_sender.send(Err(error)).await; + } + }); + let stream = futures::stream::unfold(receiver, |mut receiver| async move { + receiver.recv().await.map(|item| (item, receiver)) + }); + result_start.commit(); + Ok(Box::pin(SimpleRecordBatchStream::new(stream, schema))) + } + + async fn cancel(&self) -> Result<()> { + self.query.cancel(); + Ok(()) + } +} diff --git a/rust/lancedb/src/remote/sql_test.rs b/rust/lancedb/src/remote/sql_test.rs index d20ba495f..79ca339db 100644 --- a/rust/lancedb/src/remote/sql_test.rs +++ b/rust/lancedb/src/remote/sql_test.rs @@ -4,7 +4,8 @@ use std::sync::atomic::AtomicUsize; use arrow_array::builder::StringDictionaryBuilder; -use arrow_array::{Array, Int64Array, StringArray, types::Int32Type}; +use arrow_array::types::{Float32Type, Int32Type}; +use arrow_array::{Array, ArrayRef, FixedSizeListArray, Float32Array, Int64Array, StringArray}; use arrow_flight::encode::FlightDataEncoderBuilder; use arrow_flight::flight_service_server::{FlightService, FlightServiceServer}; use arrow_flight::sql::{Any, CommandStatementQuery}; @@ -15,13 +16,17 @@ use arrow_flight::{ use arrow_schema::{DataType, Field, Schema}; use futures::stream::BoxStream; use futures::{StreamExt, TryStreamExt}; +use tonic::metadata::MetadataMap; use tonic::{Request, Response, Status, Streaming}; use super::*; use crate::database::Database; use crate::remote::RemoteCatalogOptions; use crate::remote::client::HeaderProvider; +use crate::remote::client::test_utils::client_with_handler; use crate::remote::db::RemoteDatabase; +use crate::remote::table::RemoteTable; +use crate::table::BaseTable; #[derive(Debug, Default)] struct DelayedHeaderProvider { @@ -60,6 +65,9 @@ struct CapturedHeaders { database_prefix: String, } +/// Every exchange's statement and the parameter row it arrived with. +type ReceivedExchanges = Arc)>>>; + #[derive(Clone)] struct TestSqlService { query_count: Arc, @@ -73,11 +81,47 @@ struct TestSqlService { first_continuation_count: Arc, transient_poll_failures: Arc, headers: Arc>>, + gate_refresh_release: Arc, + exchanges: ReceivedExchanges, + /// How many exchange responses have been dropped, finished or not. + exchange_drops: Arc, result: RecordBatch, large_result: RecordBatch, dictionary_result: RecordBatch, } +/// Counts a response stream being dropped, which is how a server sees a call +/// end -- by the client cancelling it or by the rows running out. +struct DropSignal(Arc); + +impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +impl TestSqlService { + fn capture_headers(&self, metadata: &MetadataMap) { + let header = |name| { + metadata + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap() + .to_string() + }; + self.headers.lock().unwrap().push(CapturedHeaders { + database: header("database"), + namespace_path: header("namespace-path"), + request_id: header("x-request-id"), + api_key: header("x-api-key"), + database_prefix: metadata + .get("x-lancedb-database-prefix") + .map(|value| value.to_str().unwrap().to_string()) + .unwrap_or_default(), + }); + } +} + impl Default for TestSqlService { fn default() -> Self { let schema = Arc::new(Schema::new(vec![Field::new( @@ -121,6 +165,9 @@ impl Default for TestSqlService { first_continuation_count: Arc::new(AtomicUsize::new(0)), transient_poll_failures: Arc::new(AtomicUsize::new(0)), headers: Arc::new(std::sync::Mutex::new(Vec::new())), + gate_refresh_release: Arc::new(Notify::new()), + exchanges: Arc::new(std::sync::Mutex::new(Vec::new())), + exchange_drops: Arc::new(AtomicUsize::new(0)), result, large_result, dictionary_result, @@ -163,24 +210,7 @@ impl FlightService for TestSqlService { &self, request: Request, ) -> std::result::Result, Status> { - let metadata = request.metadata(); - let header = |name| { - metadata - .get(name) - .and_then(|value| value.to_str().ok()) - .unwrap() - .to_string() - }; - self.headers.lock().unwrap().push(CapturedHeaders { - database: header("database"), - namespace_path: header("namespace-path"), - request_id: header("x-request-id"), - api_key: header("x-api-key"), - database_prefix: metadata - .get("x-lancedb-database-prefix") - .map(|value| value.to_str().unwrap().to_string()) - .unwrap_or_default(), - }); + self.capture_headers(request.metadata()); let command = Any::decode(request.get_ref().cmd.as_ref()) .ok() @@ -279,6 +309,7 @@ impl FlightService for TestSqlService { ) -> std::result::Result::DoGetStream>, Status> { self.do_get_count.fetch_add(1, Ordering::SeqCst); let ticket = request.get_ref().ticket.as_ref(); + let gate_refresh = ticket == b"REFRESH MATERIALIZED VIEW \"cancel_view\""; let empty = ticket == b"SELECT empty"; let slow = ticket == b"SELECT slow get"; let large = ticket == b"SELECT large message"; @@ -290,7 +321,11 @@ impl FlightService for TestSqlService { self.result.clone() }; let schema = result.schema(); + let gate_refresh_release = self.gate_refresh_release.clone(); let input = futures::stream::once(async move { + if gate_refresh { + gate_refresh_release.notified().await; + } if slow { tokio::time::sleep(Duration::from_millis(250)).await; } @@ -384,11 +419,336 @@ impl FlightService for TestSqlService { Err(Status::unimplemented("list_actions")) } + /// Answers with the parameter row it was sent, so a test can compare what + /// came back with what went out. async fn do_exchange( &self, - _request: Request>, + request: Request>, ) -> std::result::Result, Status> { - Err(Status::unimplemented("do_exchange")) + self.capture_headers(request.metadata()); + let mut messages = request.into_inner(); + let first = messages + .message() + .await? + .ok_or_else(|| Status::invalid_argument("empty exchange"))?; + let query = first + .flight_descriptor + .as_ref() + .and_then(|descriptor| Any::decode(descriptor.cmd.as_ref()).ok()) + .and_then(|any| any.unpack::().ok().flatten()) + .map(|command| command.query) + .ok_or_else(|| Status::invalid_argument("exchange without a statement"))?; + let data = futures::stream::once(async move { Ok(first) }) + .chain(messages) + .map_err(|status| FlightError::Tonic(Box::new(status))); + let parameters = FlightRecordBatchStream::new_from_flight_data(data) + .try_next() + .await + .map_err(|error| Status::invalid_argument(error.to_string()))?; + self.exchanges + .lock() + .unwrap() + .push((query.clone(), parameters.clone())); + if query == "SELECT exchange refused" { + return Err(Status::invalid_argument( + "parameter 1 is not referenced by the statement", + )); + } + if query == "SELECT exchange stall" { + return Ok(Response::new(futures::stream::pending().boxed())); + } + let rows = + parameters.ok_or_else(|| Status::invalid_argument("exchange without parameters"))?; + let schema = rows.schema(); + let rest: BoxStream<'static, arrow_flight::error::Result> = + if query == "SELECT exchange slow" { + futures::stream::pending().boxed() + } else { + futures::stream::empty().boxed() + }; + let delay = if query == "SELECT exchange delayed" { + Duration::from_millis(300) + } else { + Duration::ZERO + }; + let dropped = DropSignal(self.exchange_drops.clone()); + let stream = FlightDataEncoderBuilder::new() + .with_schema(schema) + .build( + futures::stream::once(async move { + tokio::time::sleep(delay).await; + Ok(rows) + }) + .chain(rest), + ) + .map_err(Status::from) + .map(move |message| { + let _dropped = &dropped; + message + }); + Ok(Response::new(Box::pin(stream))) + } +} + +fn parameter_row() -> RecordBatch { + let vector = FixedSizeListArray::from_iter_primitive::( + [Some([0.1_f32, 1e-45, -0.0].map(Some))], + 3, + ); + RecordBatch::try_from_iter([ + ("vector", Arc::new(vector) as ArrayRef), + ("k", Arc::new(Int64Array::from(vec![10])) as ArrayRef), + ]) + .unwrap() +} + +fn parameterized(query: &str, parameters: RecordBatch) -> ExecuteQueryRequest { + ExecuteQueryRequest { + parameters: Some(parameters), + ..ExecuteQueryRequest::new(query) + } +} + +async fn start_sql_service( + service: TestSqlService, + client_config: ClientConfig, +) -> (SqlClient, tokio::sync::oneshot::Sender<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let incoming = futures::stream::try_unfold(listener, |listener| async { + let (socket, _) = listener.accept().await?; + Ok::<_, std::io::Error>(Some((socket, listener))) + }); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + tokio::spawn( + tonic::transport::Server::builder() + .add_service(FlightServiceServer::new(service)) + .serve_with_incoming_shutdown(incoming, async { + let _ = shutdown_rx.await; + }), + ); + let client = SqlClient::new( + "analytics".to_string(), + None, + "test-key".to_string(), + None, + Some(format!("grpc://{address}")), + client_config, + ); + (client, shutdown_tx) +} + +async fn wait_for(count: &AtomicUsize, expected: usize, what: &str) { + tokio::time::timeout(Duration::from_secs(5), async { + while count.load(Ordering::SeqCst) < expected { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("{what}")); +} + +/// The statement and its row go out on one exchange -- no poll, no ticket -- +/// and the row arrives as exactly the bytes that were sent. +#[tokio::test] +async fn parameters_travel_on_one_exchange() { + let service = TestSqlService::default(); + let exchanges = service.exchanges.clone(); + let query_count = service.query_count.clone(); + let do_get_count = service.do_get_count.clone(); + let headers = service.headers.clone(); + let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await; + + let parameters = parameter_row(); + let query = client + .execute(ExecuteQueryRequest { + default_namespace_path: vec!["events".to_string(), "raw".to_string()], + ..parameterized("SELECT echo", parameters.clone()) + }) + .await + .unwrap(); + assert_eq!(query.describe().await.unwrap().status, QueryStatus::Running); + let rows = collect_result(&query).await.unwrap(); + assert_eq!(rows, vec![parameters.clone()]); + let echoed = rows[0] + .column(0) + .as_any() + .downcast_ref::() + .unwrap() + .values() + .as_any() + .downcast_ref::() + .unwrap() + .values() + .iter() + .map(|value| value.to_bits()) + .collect::>(); + assert_eq!( + echoed, + [0.1_f32, 1e-45, -0.0].map(f32::to_bits), + "every float keeps its exact bits, the subnormal and the sign of zero included" + ); + + let finished = query.describe().await.unwrap(); + assert_eq!(finished.status, QueryStatus::Finished); + assert_eq!(finished.progress, Some(1.0)); + assert_eq!( + client.describe(query.id()).await.unwrap().status, + QueryStatus::Finished + ); + assert!(query.reader().await.is_err()); + + assert_eq!( + *exchanges.lock().unwrap(), + vec![("SELECT echo".to_string(), Some(parameters))] + ); + assert_eq!(query_count.load(Ordering::SeqCst), 0); + assert_eq!(do_get_count.load(Ordering::SeqCst), 0); + let headers = headers.lock().unwrap(); + assert_eq!(headers.len(), 1); + assert_eq!(headers[0].database, "analytics"); + assert_eq!(headers[0].namespace_path, "events$raw"); + assert_eq!(headers[0].api_key, "test-key"); +} + +/// The server plans before it answers, so a refusal is the submission's. +#[tokio::test] +async fn a_refused_statement_fails_its_submission() { + let (client, _shutdown) = + start_sql_service(TestSqlService::default(), ClientConfig::default()).await; + let refused = client + .execute(parameterized("SELECT exchange refused", parameter_row())) + .await + .unwrap_err(); + assert!( + refused + .to_string() + .contains("not referenced by the statement"), + "{refused}" + ); + + let two_rows = + RecordBatch::try_from_iter([("k", Arc::new(Int64Array::from(vec![1, 2])) as ArrayRef)]) + .unwrap(); + assert!(matches!( + client.execute(parameterized("SELECT echo", two_rows)).await, + Err(Error::InvalidInput { .. }) + )); +} + +/// The call is the statement's lifetime: cancelling a read, or dropping a +/// handle nobody read, ends the call and so the work behind it. +#[tokio::test] +async fn cancelling_or_abandoning_an_exchange_ends_the_call() { + let service = TestSqlService::default(); + let drops = service.exchange_drops.clone(); + let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await; + + let reading = client + .execute(parameterized("SELECT exchange slow", parameter_row())) + .await + .unwrap(); + let mut rows = reading.reader().await.unwrap(); + assert_eq!(rows.try_next().await.unwrap().unwrap(), parameter_row()); + reading.cancel().await.unwrap(); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(5), rows.try_next()) + .await + .expect("cancellation must wake the reader"), + Err(Error::JobCancelled { .. }) + )); + assert_eq!( + reading.describe().await.unwrap().status, + QueryStatus::Cancelled + ); + wait_for(&drops, 1, "cancelling must end the call on the server").await; + reading.cancel().await.unwrap(); + + let unread = client + .execute(parameterized("SELECT exchange slow", parameter_row())) + .await + .unwrap(); + let id = unread.id(); + drop(unread); + wait_for(&drops, 2, "dropping an unread handle must end the call").await; + assert_eq!( + client.describe(id).await.unwrap().status, + QueryStatus::Cancelled + ); + + let dropped_reader = client + .execute(parameterized("SELECT exchange slow", parameter_row())) + .await + .unwrap(); + let rows = dropped_reader.reader().await.unwrap(); + drop(rows); + wait_for(&drops, 3, "dropping a reader must end the call").await; +} + +/// A reader abandoned before the first batch -- its task cancelled -- must +/// leave the rows for the next reader, as a polled query does, rather than +/// take the call down with it. +#[tokio::test] +async fn an_abandoned_reader_leaves_the_rows_for_the_next_one() { + let service = TestSqlService::default(); + let drops = service.exchange_drops.clone(); + let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await; + + let delayed = Arc::new( + client + .execute(parameterized("SELECT exchange delayed", parameter_row())) + .await + .unwrap(), + ); + let abandoned = { + let delayed = delayed.clone(); + tokio::spawn(async move { delayed.reader().await.map(|_| ()) }) + }; + tokio::time::sleep(Duration::from_millis(50)).await; + abandoned.abort(); + assert!(abandoned.await.unwrap_err().is_cancelled()); + assert_eq!( + delayed.describe().await.unwrap().status, + QueryStatus::Running + ); + assert_eq!(drops.load(Ordering::SeqCst), 0, "the call must survive"); + + assert_eq!( + collect_result(&delayed).await.unwrap(), + vec![parameter_row()] + ); + assert_eq!( + delayed.describe().await.unwrap().status, + QueryStatus::Finished + ); +} + +/// A reader that gives up before the first batch has already taken the rows, +/// so the query must say it failed rather than report `Running` for a call +/// that is gone. +#[tokio::test] +async fn a_reader_that_times_out_reports_the_failure() { + let mut client_config = ClientConfig::default(); + client_config.timeout_config.timeout = Some(Duration::from_millis(200)); + let (client, _shutdown) = start_sql_service(TestSqlService::default(), client_config).await; + + let stalled = client + .execute(parameterized("SELECT exchange stall", parameter_row())) + .await + .unwrap(); + assert_overall_timeout(stalled.reader().await, "result"); + let Err(retried) = stalled.reader().await else { + panic!("the rows are gone, so a second reader cannot succeed"); + }; + for error in [ + stalled.describe().await.unwrap_err(), + client.describe(stalled.id()).await.unwrap_err(), + retried, + ] { + assert!( + matches!(&error, Error::Runtime { message } if message == "SQL query result timed out"), + "{error}" + ); } } @@ -420,7 +780,10 @@ async fn catalog_connections_use_explicit_sql_endpoint_and_database_scope() { let database = RemoteDatabase::for_catalog("https://catalog.example", Some(name), &options).unwrap(); let query = database - .execute_query_async("SELECT 42", &[]) + .execute_query_async(ExecuteQueryRequest { + default_namespace_path: vec![], + ..ExecuteQueryRequest::new("SELECT 42") + }) .await .unwrap(); assert_eq!( @@ -446,6 +809,147 @@ async fn catalog_connections_use_explicit_sql_endpoint_and_database_scope() { server.await.unwrap().unwrap(); } +#[tokio::test] +async fn remote_refresh_twice_keeps_latest_selector() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let incoming = futures::stream::try_unfold(listener, |listener| async { + let (socket, _) = listener.accept().await?; + Ok::<_, std::io::Error>(Some((socket, listener))) + }); + let service = TestSqlService::default(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn( + tonic::transport::Server::builder() + .add_service(FlightServiceServer::new(service)) + .serve_with_incoming_shutdown(incoming, async { + let _ = shutdown_rx.await; + }), + ); + let sql_client = SqlClient::new( + "analytics".into(), + None, + "test-key".into(), + None, + Some(format!("grpc://{address}")), + ClientConfig::default(), + ); + let selectors = Arc::new(std::sync::Mutex::new(Vec::new())); + let captured = selectors.clone(); + let client = client_with_handler(move |request| { + let body = request + .body() + .and_then(reqwest::Body::as_bytes) + .and_then(|bytes| serde_json::from_slice::(bytes).ok()) + .unwrap(); + captured.lock().unwrap().push(body["version"].clone()); + let text = match request.url().path() { + "/v1/table/ns$view/describe/" => r#"{"version": 42, "schema": {"fields": []}}"#, + "/v1/table/ns$view/count_rows/" => "2", + other => panic!("unexpected REST request: {other}"), + }; + http::Response::builder() + .status(200) + .body(text.to_string()) + .unwrap() + }); + let table = RemoteTable::new_with_sql_client( + client, + "view".into(), + vec!["ns".into()], + "ns$view".into(), + Default::default(), + Some(sql_client), + ); + + let first = table + .refresh_materialized_view_async(None) + .await + .unwrap() + .wait() + .await + .unwrap(); + let second = table + .refresh_materialized_view_async(None) + .await + .unwrap() + .wait() + .await + .unwrap(); + + assert_eq!(first.version, 42); + assert_eq!(second.version, 42); + assert!( + selectors + .lock() + .unwrap() + .iter() + .all(serde_json::Value::is_null) + ); + shutdown_tx.send(()).unwrap(); + server.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn cancelling_remote_refresh_cancels_sql_query() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let incoming = futures::stream::try_unfold(listener, |listener| async { + let (socket, _) = listener.accept().await?; + Ok::<_, std::io::Error>(Some((socket, listener))) + }); + let service = TestSqlService::default(); + let cancel_count = service.cancel_count.clone(); + let do_get_count = service.do_get_count.clone(); + let gate_refresh_release = service.gate_refresh_release.clone(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn( + tonic::transport::Server::builder() + .add_service(FlightServiceServer::new(service)) + .serve_with_incoming_shutdown(incoming, async { + let _ = shutdown_rx.await; + }), + ); + let sql_client = SqlClient::new( + "analytics".into(), + None, + "test-key".into(), + None, + Some(format!("grpc://{address}")), + ClientConfig::default(), + ); + let client = client_with_handler(|request| -> http::Response { + panic!( + "cancelled refresh must not read result metadata: {}", + request.url().path() + ); + }); + let table = RemoteTable::new_with_sql_client( + client, + "cancel_view".into(), + vec![], + "cancel_view".into(), + Default::default(), + Some(sql_client), + ); + let job = table.refresh_materialized_view_async(None).await.unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while do_get_count.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + job.cancel().await.unwrap(); + assert!(matches!(job.wait().await, Err(Error::JobCancelled { .. }))); + assert_eq!(cancel_count.load(Ordering::SeqCst), 1); + + gate_refresh_release.notify_one(); + shutdown_tx.send(()).unwrap(); + server.await.unwrap().unwrap(); +} + #[tokio::test] async fn submits_polls_fetches_cancels_and_reuses_client() { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -662,7 +1166,11 @@ async fn submits_polls_fetches_cancels_and_reuses_client() { .submit("SELECT slow", &["public".to_string()]) .await .unwrap(); - let tracked_dropped_reader = client.queries.get(dropped_reader.id()).unwrap(); + let Some(RegisteredQuery::Polled(tracked_dropped_reader)) = + client.queries.get(dropped_reader.id()) + else { + panic!("a submitted statement is registered as a polled query"); + }; let continuation_count_before = first_continuation_count.load(Ordering::SeqCst); let dropped_result_stream = dropped_reader.reader().await.unwrap(); tokio::time::timeout(Duration::from_millis(100), async { @@ -956,8 +1464,11 @@ async fn submits_polls_fetches_cancels_and_reuses_client() { ) .unwrap(), ); - registry.insert(id, query.clone()); - assert!(Arc::ptr_eq(®istry.get(id).unwrap(), &query)); + registry.insert(id, RegisteredQuery::Polled(query.clone())); + let Some(RegisteredQuery::Polled(found)) = registry.get(id) else { + panic!("the registry returns the query it was given"); + }; + assert!(Arc::ptr_eq(&found, &query)); } let expired_id = Uuid::now_v7(); @@ -974,7 +1485,7 @@ async fn submits_polls_fetches_cancels_and_reuses_client() { ) .unwrap(), ); - registry.insert(expired_id, expired_query); + registry.insert(expired_id, RegisteredQuery::Polled(expired_query)); assert!(registry.get(expired_id).is_none()); let stale_id = Uuid::now_v7(); @@ -991,7 +1502,7 @@ async fn submits_polls_fetches_cancels_and_reuses_client() { .unwrap(), ); *stale_query.last_accessed.lock().unwrap() = Instant::now() - ABANDONED_QUERY_RETENTION; - registry.insert(stale_id, stale_query.clone()); + registry.insert(stale_id, RegisteredQuery::Polled(stale_query.clone())); drop(stale_query); assert!(registry.get(stale_id).is_none()); diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index 5756d7788..a17aadc9f 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -8,6 +8,7 @@ use self::insert::{RemoteWriteExec, WriteOp}; use super::client::RequestResultExt; use super::client::{HttpSend, RestfulLanceDbClient, Sender}; use super::db::ServerVersion; +use super::sql::SqlClient; use super::{ARROW_FILE_CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE, extract_job_id}; use crate::blob::BlobFile; use crate::data::scannable::{PeekedScannable, Scannable, estimate_write_partitions}; @@ -18,7 +19,7 @@ use crate::index::scalar::FtsQuery; use crate::index::waiter::wait_for_index; use crate::job::Job; use crate::materialized_view::{ - MaterializedViewDefinition, MaterializedViewInfo, RefreshMaterializedViewResult, ViewProjection, + MaterializedViewDefinition, MaterializedViewInfo, RefreshMaterializedViewResult, }; use crate::query::wal_fusion::PkFusionMemory; // WAL-PK-FUSION: delete. use crate::query::{QueryFilter, QueryRequest, Select, VectorQueryRequest}; @@ -264,7 +265,6 @@ struct FreshnessJob { enum TrackedJobResult { None, RefreshColumn, - MaterializedView, } #[async_trait] @@ -305,11 +305,6 @@ impl crate::job::JobHandle for FreshnessJob { }) }) }), - TrackedJobResult::MaterializedView => result.value().and_then(|value| { - serde_json::from_value::(value.clone()) - .ok() - .map(|result| result.version) - }), } .filter(|version| *version != 0); if let Some(version) = result_version { @@ -347,6 +342,10 @@ fn compute_min_timestamp( } } +fn quote_sql_identifier(identifier: &str) -> String { + format!("\"{}\"", identifier.replace('"', "\"\"")) +} + fn freshness_headers_snapshot( freshness: &Mutex, interval: Option, @@ -496,6 +495,7 @@ pub struct RemoteTable { namespace: Vec, identifier: String, server_version: ServerVersion, + sql_client: Option, version: Arc>>, location: RwLock>, @@ -620,9 +620,9 @@ impl RemoteTable { }); } } - _ => { + Index::IvfHnswPq(_) => { return Err(Error::NotSupported { - message: "Index type not supported".into(), + message: "IVF_HNSW_PQ is not supported for remote tables. Please use IVF_HNSW_SQ instead.".into(), }); } }; @@ -654,12 +654,13 @@ impl RemoteTable { Ok(job_id) } - pub fn new( + pub(super) fn new_with_sql_client( client: RestfulLanceDbClient, name: String, namespace: Vec, identifier: String, server_version: ServerVersion, + sql_client: Option, ) -> Self { Self { client, @@ -667,6 +668,7 @@ impl RemoteTable { namespace, identifier, server_version, + sql_client, version: Arc::new(RwLock::new(None)), location: RwLock::new(None), schema_cache: BackgroundCache::new(SCHEMA_CACHE_TTL, SCHEMA_CACHE_REFRESH_WINDOW), @@ -701,6 +703,7 @@ impl RemoteTable { namespace: self.namespace.clone(), identifier: self.identifier.clone(), server_version: self.server_version.clone(), + sql_client: self.sql_client.clone(), version: Arc::new(RwLock::new(None)), location: RwLock::new(None), schema_cache: BackgroundCache::new(SCHEMA_CACHE_TTL, SCHEMA_CACHE_REFRESH_WINDOW), @@ -1082,7 +1085,7 @@ impl RemoteTable { if let Some(full_text_search) = ¶ms.full_text_search { if full_text_search.wand_factor.is_some() { return Err(Error::NotSupported { - message: "Wand factor is not yet supported in LanceDB Cloud".into(), + message: "Wand factor is not supported for remote tables.".into(), }); } @@ -1177,11 +1180,14 @@ impl RemoteTable { .values() .iter() .map(|v| { - serde_json::Value::Number( - serde_json::Number::from_f64(*v as f64).unwrap(), - ) + serde_json::Number::from_f64(*v as f64) + .map(serde_json::Value::Number) + .ok_or_else(|| Error::InvalidInput { + message: "query vector must contain only finite values" + .into(), + }) }) - .collect(), + .collect::>>()?, )) } _ => Err(Error::InvalidInput { @@ -1619,6 +1625,7 @@ mod test_utils { namespace: vec![], identifier: name, server_version: version.map(ServerVersion).unwrap_or_default(), + sql_client: None, version: Arc::new(RwLock::new(None)), location: RwLock::new(None), schema_cache: BackgroundCache::new(SCHEMA_CACHE_TTL, SCHEMA_CACHE_REFRESH_WINDOW), @@ -1644,6 +1651,7 @@ mod test_utils { namespace: vec![], identifier: name, server_version: ServerVersion::default(), + sql_client: None, version: Arc::new(RwLock::new(None)), location: RwLock::new(None), schema_cache: BackgroundCache::new(SCHEMA_CACHE_TTL, SCHEMA_CACHE_REFRESH_WINDOW), @@ -1678,6 +1686,7 @@ mod test_utils { namespace: vec![], identifier: name, server_version: version.map(ServerVersion).unwrap_or_default(), + sql_client: None, version: Arc::new(RwLock::new(None)), location: RwLock::new(None), schema_cache: BackgroundCache::new(SCHEMA_CACHE_TTL, SCHEMA_CACHE_REFRESH_WINDOW), @@ -2106,29 +2115,9 @@ impl BaseTable for RemoteTable { &self.identifier } async fn materialized_view_info(&self) -> Result { - #[derive(Deserialize)] - struct Projection { - output_column: String, - expression: String, - } - #[derive(Deserialize)] struct DescribeMaterializedViewResponse { - source_table: String, - #[serde(default)] - source_namespace: Vec, - #[serde(default)] - projections: Vec, - #[serde(default)] - filter: Option, - #[serde(default)] - limit: Option, - /// The defining query, once the server describes a view by it; - /// takes precedence over the structured fields. - #[serde(default)] - query: Option, - #[serde(default)] - incarnation: Option, + query: String, } let request = self.client.post(&format!( @@ -2139,79 +2128,64 @@ impl BaseTable for RemoteTable { let response = self.check_table_response(&request_id, response).await?; let response: DescribeMaterializedViewResponse = response.json().await.err_to_http(request_id)?; - let definition = match response.query { - Some(query) => MaterializedViewDefinition::from_sql(&query)?, - None => MaterializedViewDefinition { - vector_source: None, - source_table: response.source_table, - source_namespace: response.source_namespace, - lateral: None, - projections: response - .projections - .into_iter() - .map(|projection| ViewProjection { - output: projection.output_column, - expression: projection.expression, - }) - .collect(), - filter: response.filter, - group_by: Vec::new(), - limit: response.limit, - }, - }; + let parsed_definition = MaterializedViewDefinition::from_sql(&response.query).ok(); Ok(MaterializedViewInfo { - definition, - incarnation: response.incarnation, + definition_sql: response.query, + parsed_definition, }) } async fn refresh_materialized_view_async( &self, - full: bool, source_version: Option, - expected_incarnation: Option<&str>, ) -> Result> { self.check_mutable().await?; - let mut body = serde_json::json!({ "full": full }); - if let Some(source_version) = source_version { - body["source_version"] = source_version.into(); - } - if let Some(expected_incarnation) = expected_incarnation { - body["expected_incarnation"] = expected_incarnation.into(); - } - let request = self - .client - .post(&format!( - "/v1/materialized_view/{}/refresh", - self.identifier - )) - .json(&body); - let freshness_request = self.snapshot_freshness_headers(); - let (request_id, response) = self - .send_with_freshness(request, true, freshness_request) - .await?; - let response = self.check_table_response(&request_id, response).await?; - let status = response.status(); - let body = response.text().await.err_to_http(request_id.clone())?; - if status != StatusCode::ACCEPTED { - return Err(Error::Http { - source: "materialized-view refresh must return 202 Accepted".into(), - request_id, - status_code: Some(status), + if source_version.is_some() { + return Err(Error::NotSupported { + message: "source-version pinning is available only for local materialized views" + .to_string(), }); } - let job_id = extract_job_id(&body).ok_or_else(|| Error::Http { - source: "materialized-view refresh response did not contain a valid job_id".into(), - request_id, - status_code: Some(status), + let sql_client = self.sql_client.clone().ok_or_else(|| Error::NotSupported { + message: "SQL is unavailable for this remote table client".to_string(), })?; - Ok(Job::new_typed(Box::new(FreshnessJob { - inner: RemoteJob::new(self.client.clone(), job_id), - freshness: self.freshness.clone(), - version: self.version.clone(), - tracked_result: TrackedJobResult::MaterializedView, - freshness_request, - }))) + let statement = format!( + "REFRESH MATERIALIZED VIEW {}", + quote_sql_identifier(&self.name) + ); + let namespace = self.namespace.clone(); + let client = self.client.clone(); + let name = self.name.clone(); + let identifier = self.identifier.clone(); + let server_version = self.server_version.clone(); + let freshness = self.freshness.clone(); + let freshness_request = self.snapshot_freshness_headers(); + let job_sql_client = sql_client.clone(); + Ok( + sql_client.submit_as_job(statement, namespace.clone(), move || async move { + let table = Self::new_with_sql_client( + client, + name, + namespace, + identifier, + server_version, + Some(job_sql_client), + ); + table.checkout_latest().await?; + let version = table.version().await?; + let rows_written = + u64::try_from(table.count_rows(None).await?).map_err(|_| Error::Runtime { + message: "materialized-view row count exceeds u64".to_string(), + })?; + freshness_request.observe_version(&freshness, version); + Ok(RefreshMaterializedViewResult { + mode: crate::materialized_view::RefreshMode::Rebuild, + rows_written, + source_version: 0, + version, + }) + }), + ) } async fn query_snapshot(&self) -> Result> { let description = self.describe().await?; @@ -2301,11 +2275,14 @@ impl BaseTable for RemoteTable { Ok(Some(Arc::new(snapshot))) } async fn restore(&self) -> Result<()> { + let read_snapshot = self.snapshot_read_state().await; + let version = read_snapshot.version.ok_or_else(|| Error::InvalidInput { + message: "you must run checkout before running restore".to_string(), + })?; let mut request = self .client .post(&format!("/v1/table/{}/restore/", self.identifier)); - let read_snapshot = self.snapshot_read_state().await; - let mut body = serde_json::json!({ "version": read_snapshot.version }); + let mut body = serde_json::json!({ "version": version }); self.apply_branch_body(&mut body); request = request.json(&body); @@ -2848,6 +2825,16 @@ impl BaseTable for RemoteTable { self.fetch_blobs_impl(column, row_ids).await } + async fn fetch_blob_ranges( + &self, + _column: &str, + _requests: &[crate::blob::BlobRangeRequest], + ) -> Result { + Err(Error::NotSupported { + message: "fetch_blob_ranges is not supported for remote tables.".into(), + }) + } + async fn fetch_blob_files( &self, column: &str, @@ -3162,7 +3149,7 @@ impl BaseTable for RemoteTable { async fn set_unenforced_primary_key(&self, _columns: &[&str]) -> Result<()> { Err(Error::NotSupported { - message: "set_unenforced_primary_key is not supported on LanceDB cloud.".into(), + message: "set_unenforced_primary_key is not supported for remote tables.".into(), }) } @@ -3281,8 +3268,9 @@ impl BaseTable for RemoteTable { #[derive(Deserialize)] struct LsmWriteSpecBody { sharding: Sharding, + /// `null` selects every index the table has; `[]` selects none. #[serde(default)] - maintained_indexes: Vec, + maintained_indexes: Option>, #[serde(default)] writer_config_defaults: std::collections::HashMap, } @@ -3353,7 +3341,7 @@ impl BaseTable for RemoteTable { async fn optimize(&self, _action: OptimizeAction) -> Result { self.check_mutable().await?; Err(Error::NotSupported { - message: "optimize is not supported on LanceDB cloud.".into(), + message: "optimize is not supported for remote tables.".into(), }) } async fn add_columns( @@ -3523,7 +3511,12 @@ impl BaseTable for RemoteTable { let (request_id, response) = self .send_with_freshness(request, true, freshness_request) .await?; - let response = self.check_table_response(&request_id, response).await?; + // A Function declaration can return 404 for the Function rather than the table. + let response = self + .client + .check_response(&request_id, response) + .await + .inspect_err(|error| self.handle_error_invalidation(error))?; let body = response.text().await.err_to_http(request_id.clone())?; if body.trim().is_empty() { @@ -3563,7 +3556,12 @@ impl BaseTable for RemoteTable { .post(&format!("/v1/table/{}/backfill_column", self.identifier)) .json(&body); let (request_id, response) = self.send(request, true).await?; - let response = self.check_table_response(&request_id, response).await?; + // Preserve dependency errors: a deleted bound Function also returns 404. + let response = self + .client + .check_response(&request_id, response) + .await + .inspect_err(|error| self.handle_error_invalidation(error))?; let body = response.text().await.err_to_http(request_id.clone())?; #[derive(serde::Deserialize)] @@ -3943,22 +3941,14 @@ impl TryFrom for MergeInsertRequest { let when_matched_update_all_filt = match value.when_matched_update_all_filt { Some(MergeFilter::Sql(sql)) => Some(sql), - Some(MergeFilter::Expr(_)) => { - return Err(Error::NotSupported { - message: "DataFusion expressions are not supported on remote tables".into(), - }); - } + Some(MergeFilter::Expr(expr)) => Some(expr_to_sql_string(&expr)?), None => None, }; let when_not_matched_by_source_delete_filt = match value.when_not_matched_by_source_delete_filt { Some(MergeFilter::Sql(sql)) => Some(sql), - Some(MergeFilter::Expr(_)) => { - return Err(Error::NotSupported { - message: "DataFusion expressions are not supported on remote tables".into(), - }); - } + Some(MergeFilter::Expr(expr)) => Some(expr_to_sql_string(&expr)?), None => None, }; @@ -4057,7 +4047,7 @@ mod tests { )) }; - // All endpoints should translate 404 to TableNotFound. + // These table operations should translate 404 to TableNotFound. let results: Vec>> = vec![ Box::pin(table.version().map_ok(|_| ())), Box::pin(table.schema().map_ok(|_| ())), @@ -4638,10 +4628,18 @@ mod tests { } #[rstest] - #[case(true)] - #[case(false)] + #[case::old_server("", 0, 0)] + #[case::rows_updated(r#"{"rows_updated": 5, "version": 43}"#, 5, 43)] + #[case::updated_rows(r#"{"updated_rows": 5, "version": 43}"#, 5, 43)] + #[case::zero_updated_rows(r#"{"updated_rows": 0, "version": 43}"#, 0, 43)] + #[case::missing_row_count(r#"{"version": 43}"#, 0, 43)] #[tokio::test] - async fn test_update(#[case] old_server: bool) { + async fn test_update( + #[case] response_body: &'static str, + #[case] expected_rows_updated: u64, + #[case] expected_version: u64, + #[values(true, false)] filtered: bool, + ) { let table = Table::new_with_handler("my_table", move |request| { if request.url().path() == "/v1/table/my_table/update/" { assert_eq!(request.method(), "POST"); @@ -4666,32 +4664,29 @@ mod tests { assert_eq!(col_name, "b"); assert_eq!(expression, "b - 1"); - let only_if = value.get("predicate").unwrap().as_str().unwrap(); - assert_eq!(only_if, "`B` > 10"); + assert_eq!( + value.get("predicate").unwrap(), + &serde_json::json!(if filtered { Some("`B` > 10") } else { None }) + ); } - if old_server { - http::Response::builder().status(200).body("").unwrap() - } else { - http::Response::builder() - .status(200) - .body(r#"{"rows_updated": 5, "version": 43}"#) - .unwrap() - } + http::Response::builder() + .status(200) + .body(response_body) + .unwrap() } else { panic!("Unexpected request path: {}", request.url().path()); } }); - let update = table - .update() - .column("a", "a + 1") - .column("b", "b - 1") - .only_if(r#""B" > 10"#); + let mut update = table.update().column("a", "a + 1").column("b", "b - 1"); + if filtered { + update = update.only_if(r#""B" > 10"#); + } let result = table.base_table().update(update).await.unwrap(); - assert_eq!(result.version, if old_server { 0 } else { 43 }); - assert_eq!(result.rows_updated, if old_server { 0 } else { 5 }); + assert_eq!(result.version, expected_version); + assert_eq!(result.rows_updated, expected_rows_updated); } #[tokio::test] @@ -4826,6 +4821,69 @@ mod tests { } } + #[rstest] + #[case::sql(false, false)] + #[case::expr_update(true, false)] + #[case::expr_delete(false, true)] + #[case::expr_both(true, true)] + #[tokio::test] + async fn test_merge_insert_filter_expressions( + #[case] update_expr: bool, + #[case] delete_expr: bool, + ) { + use datafusion_expr::{col, lit}; + + let batch = record_batch!(("id", Int32, [0, 1]), ("v", Int32, [100, 110])).unwrap(); + let data: Box = Box::new(RecordBatchIterator::new( + [Ok(batch.clone())], + batch.schema(), + )); + + let table = Table::new_with_handler("my_table", move |request| { + assert_eq!(request.method(), "POST"); + assert_eq!(request.url().path(), "/v1/table/my_table/merge_insert/"); + let params = request.url().query_pairs().collect::>(); + assert_eq!(params["on"], "id"); + assert_eq!(params["when_matched_update_all"], "true"); + assert_eq!(params["when_not_matched_insert_all"], "false"); + assert_eq!(params["when_not_matched_by_source_delete"], "true"); + assert_eq!( + params["when_matched_update_all_filt"], + if update_expr { + "(`target`.v < `source`.v)" + } else { + "target.v < source.v" + } + ); + assert_eq!( + params["when_not_matched_by_source_delete_filt"], + if delete_expr { "(id > 3)" } else { "id > 3" } + ); + http::Response::builder() + .status(200) + .body(r#"{"version": 2, "num_updated_rows": 2, "num_inserted_rows": 0, "num_deleted_rows": 2}"#) + .unwrap() + }); + + let mut merge = table.merge_insert(&["id"]); + if update_expr { + merge.when_matched_update_all_expr(col("target.v").lt(col("source.v"))); + } else { + merge.when_matched_update_all(Some("target.v < source.v".into())); + } + if delete_expr { + merge.when_not_matched_by_source_delete_expr(col("id").gt(lit(3))); + } else { + merge.when_not_matched_by_source_delete(Some("id > 3".into())); + } + + let result = merge.execute(data).await.unwrap(); + assert_eq!(result.version, 2); + assert_eq!(result.num_updated_rows, 2); + assert_eq!(result.num_inserted_rows, 0); + assert_eq!(result.num_deleted_rows, 2); + } + #[rstest] #[case::no_batches_insert(false, false)] #[case::empty_batch_insert(true, false)] @@ -5700,6 +5758,72 @@ mod tests { } } + #[tokio::test] + async fn test_remote_unsupported_operations() { + let table = Table::new_with_handler("my_table", |_| -> http::Response { + panic!("unsupported operations must not send requests to the server"); + }); + let mut fts = FullTextSearchQuery::new("test".into()); + fts.wand_factor = Some(1.0); + let results = [ + ( + "set_unenforced_primary_key", + table.set_unenforced_primary_key(["id"]).await, + ), + ( + "optimize", + table.optimize(OptimizeAction::All).await.map(|_| ()), + ), + ( + "fetch_blob_ranges", + table.fetch_blob_ranges("image", []).await.map(|_| ()), + ), + ( + "Wand factor", + table + .query() + .full_text_search(fts) + .execute() + .await + .map(|_| ()), + ), + ]; + for (operation, result) in results { + match result.unwrap_err() { + Error::NotSupported { message } => assert_eq!( + message, + format!("{operation} is not supported for remote tables.") + ), + error => panic!("expected not-supported error, got {error}"), + } + } + } + + #[tokio::test] + async fn test_remote_unsupported_hnsw_pq() { + let table = Table::new_with_handler("my_table", |request| { + assert_eq!(request.url().path(), "/v1/table/my_table/describe/"); + let schema = Schema::new(vec![Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2), + false, + )]); + http::Response::builder() + .status(200) + .body(describe_response(&schema)) + .unwrap() + }); + let error = table + .create_index(&["vector"], Index::IvfHnswPq(Default::default())) + .execute() + .await + .unwrap_err(); + assert_not_supported_error( + error, + "IVF_HNSW_PQ is not supported for remote tables. Please use IVF_HNSW_SQ instead.", + ); + } + #[tokio::test] async fn test_fetch_blobs_rejects_missing_column() { let batch = RecordBatch::try_new( @@ -5813,12 +5937,12 @@ mod tests { assert_not_supported_error( table.fetch_blobs("image", &[1]).await.unwrap_err(), - "fetch_blobs", + "fetch_blobs is not supported by this LanceDB server.", ); assert_not_supported_error( table.fetch_blob_files("image", &[1]).await.unwrap_err(), - "requires LanceDB Cloud server 0.5.0 or newer", + "requires LanceDB server 0.5.0 or newer", ); } @@ -5902,6 +6026,47 @@ mod tests { assert_eq!(blobs.value(0), b"alpha"); } + #[rstest] + #[case(DEFAULT_SERVER_VERSION.clone())] + #[case(semver::Version::new(0, 2, 0))] + #[tokio::test] + async fn test_query_vector_non_finite(#[case] version: semver::Version) { + let table = + Table::new_with_handler_version("my_table", version, |_| -> http::Response { + panic!("non-finite vectors must be rejected before sending a request") + }); + + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + // Requests built without the query builder must also return an + // error, including a non-finite vector later in a batch. + for batched in [false, true] { + let mut request = table + .query() + .nearest_to(&[0.1, 0.2]) + .unwrap() + .into_request(); + if !batched { + request.query_vector.clear(); + } + request + .query_vector + .push(Arc::new(arrow_array::Float32Array::from(vec![0.1, value]))); + let result = table + .base_table() + .query( + &AnyQuery::VectorQuery(request), + QueryExecutionOptions::default(), + ) + .await; + let Err(err) = result else { + panic!("non-finite query vector unexpectedly succeeded") + }; + assert!(matches!(err, Error::InvalidInput { .. })); + assert!(err.to_string().contains("only finite values")); + } + } + } + #[tokio::test] async fn test_query_vector_default_values() { let expected_data = RecordBatch::try_new( @@ -8176,6 +8341,43 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn test_restore_requires_checkout() { + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let table = Table::new_with_handler("my_table", move |request| { + request_count_clone.fetch_add(1, Ordering::SeqCst); + assert_eq!(request_body_json(&request)["version"], 42); + let body = match request.url().path() { + "/v1/table/my_table/describe/" => r#"{"version":42,"schema":{"fields":[]}}"#, + "/v1/table/my_table/restore/" => r#"{"version":43}"#, + path => panic!("unexpected request path: {path}"), + }; + http::Response::builder().status(200).body(body).unwrap() + }); + + let err = table.restore().await.unwrap_err(); + assert!(matches!(err, Error::InvalidInput { message } + if message == "you must run checkout before running restore")); + assert_eq!(request_count.load(Ordering::SeqCst), 0); + + table.checkout(42).await.unwrap(); + table.checkout_latest().await.unwrap(); + let err = table.restore().await.unwrap_err(); + assert!(matches!(err, Error::InvalidInput { message } + if message == "you must run checkout before running restore")); + assert_eq!(request_count.load(Ordering::SeqCst), 1); + + table.checkout(42).await.unwrap(); + table.restore().await.unwrap(); + assert_eq!(request_count.load(Ordering::SeqCst), 3); + + let err = table.restore().await.unwrap_err(); + assert!(matches!(err, Error::InvalidInput { message } + if message == "you must run checkout before running restore")); + assert_eq!(request_count.load(Ordering::SeqCst), 3); + } + #[tokio::test] async fn test_fails_if_checkout_version_doesnt_exist() { let table = Table::new_with_handler("my_table", |request| { @@ -8793,6 +8995,73 @@ mod tests { ); } + #[rstest] + #[case(false, "Function is unavailable")] + #[case(true, "Function name was not found")] + #[tokio::test] + async fn test_function_column_operations_preserve_dependency_not_found( + #[case] declare_column: bool, + #[case] message: &'static str, + ) { + let schema_requests = Arc::new(AtomicUsize::new(0)); + let requests = schema_requests.clone(); + let table = Table::new_with_handler("my_table", move |request| { + match request.url().path() { + "/v1/table/my_table/describe/" => { + requests.fetch_add(1, Ordering::SeqCst); + http::Response::builder() + .status(200) + .body( + r#"{"version":1,"schema":{"fields":[{"name":"description","nullable":true,"type":{"type":"string"}}]}}"#.to_string(), + ) + .unwrap() + } + "/v1/table/my_table/backfill_column" | "/v1/table/my_table/add_columns/" => { + http::Response::builder() + .status(404) + .body(json!({"code": 4, "error": message}).to_string()) + .unwrap() + } + path => panic!("unexpected request: {path}"), + } + }); + table.schema().await.unwrap(); + assert_eq!(schema_requests.load(Ordering::SeqCst), 1); + + let error = if declare_column { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_fixed_size_declaration_request.json" + )) + .unwrap(); + let application = crate::function::FunctionApplication::from_json( + &fixture["function"]["application"].to_string(), + ) + .unwrap(); + table + .add_columns() + .function_as("embedding", application) + .execute() + .await + .unwrap_err() + } else { + table.refresh_column_async("embedding").await.unwrap_err() + }; + let Error::Http { + source, + status_code, + request_id, + } = error + else { + panic!("dependency 404 was misclassified: {error:?}"); + }; + assert_eq!(status_code, Some(StatusCode::NOT_FOUND)); + assert!(source.to_string().contains(message)); + assert!(!request_id.is_empty()); + + table.schema().await.unwrap(); + assert_eq!(schema_requests.load(Ordering::SeqCst), 2); + } + /// The error listing is table-addressed with optional job and column /// filters, mirroring the server's SQL surface, and the two non-record /// signals come back as their own fields rather than as rows. @@ -9536,6 +9805,43 @@ mod tests { } } + /// Every selection reads back as the server reported it: every index + /// (`null`), none (`[]`), and a named list. + #[rstest::rstest] + #[case::every_index(serde_json::Value::Null, None)] + #[case::no_index(serde_json::json!([]), Some(vec![]))] + #[case::named(serde_json::json!(["id_idx"]), Some(vec!["id_idx".to_string()]))] + #[tokio::test] + async fn test_get_lsm_write_spec_round_trips_the_selection( + #[case] reported: serde_json::Value, + #[case] expected: Option>, + ) { + let table = Table::new_with_handler("my_table", move |_| { + let response = serde_json::json!({ + "lsm_write_spec": { + "sharding": { "mode": "unsharded" }, + "maintained_indexes": reported, + "writer_config_defaults": {}, + } + }); + http::Response::builder() + .status(200) + .body(response.to_string()) + .unwrap() + }); + + let spec = table + .get_lsm_write_spec() + .await + .unwrap() + .expect("a spec should be reported"); + assert_eq!( + spec.maintained_indexes().map(<[String]>::to_vec), + expected, + "the selection the server reported must survive the read" + ); + } + #[tokio::test] async fn test_get_lsm_write_spec_absent() { let table = Table::new_with_handler("my_table", |request| { @@ -10901,22 +11207,24 @@ mod tests { } }); + table.checkout(1).await.unwrap(); + // First schema call let schema1 = table.schema().await.unwrap(); - assert_eq!(call_count.load(Ordering::SeqCst), 1); + assert_eq!(call_count.load(Ordering::SeqCst), 2); // Second schema call uses cache let schema2 = table.schema().await.unwrap(); assert_eq!(Arc::as_ptr(&schema2), Arc::as_ptr(&schema1)); - assert_eq!(call_count.load(Ordering::SeqCst), 1); + assert_eq!(call_count.load(Ordering::SeqCst), 2); // Restore operation - let _ = table.restore().await; + table.restore().await.unwrap(); // Schema call after restore should re-fetch (cache invalidated) let schema3 = table.schema().await.unwrap(); assert_ne!(Arc::as_ptr(&schema3), Arc::as_ptr(&schema1)); - assert_eq!(call_count.load(Ordering::SeqCst), 2); + assert_eq!(call_count.load(Ordering::SeqCst), 3); } /// Test that centralized error handling invalidates cache on query errors @@ -12899,54 +13207,16 @@ mod tests { } #[tokio::test] - async fn test_materialized_view_describe_and_refresh() { + async fn test_materialized_view_describe_and_refresh_requires_sql() { + const QUERY: &str = + "SELECT s.x, d.label FROM analytics.source s JOIN analytics.dim d ON s.id = d.id"; let table = Table::new_with_handler("my_table", |request| match request.url().path() { "/v1/materialized_view/my_table/describe" => http::Response::builder() .status(200) .body( json!({ "name": "my_table", - "source_table": "source", - "source_namespace": ["analytics"], - "projections": [{ - "output_column": "double_x", - "expression": "x * 2" - }], - "filter": "x > 0", - "limit": 10, - "inputs": ["x"], - "incarnation": "inc-1" - }) - .to_string(), - ) - .unwrap(), - "/v1/materialized_view/my_table/refresh" => { - assert_eq!(request.method(), "POST"); - assert_eq!( - request_body_json(&request), - json!({ - "full": true, - "source_version": 7, - "expected_incarnation": "inc-1" - }) - ); - http::Response::builder() - .status(202) - .body(json!({"job_id": "j1-mv-refresh"}).to_string()) - .unwrap() - } - "/v1/jobs/describe" => http::Response::builder() - .status(200) - .body( - json!({ - "job_id": "j1-mv-refresh", - "job_state": "DONE", - "result": { - "mode": "rebuild", - "rows_written": 2, - "source_version": 7, - "version": 9 - } + "query": QUERY }) .to_string(), ) @@ -12954,21 +13224,10 @@ mod tests { path => panic!("unexpected request: {path}"), }); let view = crate::MaterializedView::from_table(table).await.unwrap(); - assert_eq!(view.definition().source_table, "source"); - assert_eq!(view.definition().source_namespace, ["analytics"]); - assert_eq!(view.incarnation(), Some("inc-1")); - - let result = view - .refresh() - .full(true) - .source_version(7) - .expect_incarnation("inc-1") - .execute() - .await - .unwrap(); - assert_eq!(result.mode, crate::RefreshMode::Rebuild); - assert_eq!(result.rows_written, 2); - assert_eq!(result.version, 9); + assert_eq!(view.definition_sql(), QUERY); + assert!(view.definition().is_err()); + let error = view.refresh().execute().await.unwrap_err(); + assert!(error.to_string().contains("SQL is unavailable")); } #[tokio::test] @@ -13786,8 +14045,17 @@ mod tests { .status(200) .body("{}".to_string()) .unwrap(), + "/v1/table/my_table/describe/" => { + assert_eq!(request_body_json(&request)["branch"], "exp"); + assert_eq!(request_body_json(&request)["version"], 1); + http::Response::builder() + .status(200) + .body(r#"{"version":1,"schema":{"fields":[]}}"#.to_string()) + .unwrap() + } "/v1/table/my_table/restore/" => { assert_eq!(request_body_json(&request)["branch"], "exp"); + assert_eq!(request_body_json(&request)["version"], 1); http::Response::builder() .status(200) .body(r#"{"version":1}"#.to_string()) @@ -13799,6 +14067,7 @@ mod tests { .create_branch("exp", Ref::Version(None, None)) .await .unwrap(); + branch.checkout(1).await.unwrap(); branch.restore().await.unwrap(); } @@ -14254,4 +14523,18 @@ mod tests { "no retry should have been attempted" ); } + + #[tokio::test] + async fn test_stats_num_deleted_rows_is_none_when_absent() { + // The stats endpoint does not report num_deleted_rows, so the response + // must still parse and value set to None. + let body = r#"{"total_bytes":1,"num_rows":3,"num_indices":0,"fragment_stats":{"num_fragments":1,"num_small_fragments":0,"lengths":{"min":3,"max":3,"mean":3,"p25":3,"p50":3,"p75":3,"p99":3}}}"#; + let table = Table::new_with_handler("my_table", move |_| { + http::Response::builder() + .status(200) + .body(body.to_string()) + .unwrap() + }); + assert_eq!(table.stats().await.unwrap().num_deleted_rows, None); + } } diff --git a/rust/lancedb/src/remote/table/blobs.rs b/rust/lancedb/src/remote/table/blobs.rs index ac37bf7ae..b8506adb3 100644 --- a/rust/lancedb/src/remote/table/blobs.rs +++ b/rust/lancedb/src/remote/table/blobs.rs @@ -393,7 +393,7 @@ impl RemoteTable { } if !self.server_version.support_blobs() { return Err(Error::NotSupported { - message: "fetch_blobs is not supported on this LanceDB Cloud server".into(), + message: "fetch_blobs is not supported by this LanceDB server.".into(), }); } let mut read_snapshot = self.snapshot_read_state().await; @@ -716,7 +716,7 @@ impl RemoteTable { Ok(()) } else { Err(Error::NotSupported { - message: "fetch_blob_files requires LanceDB Cloud server 0.5.0 or newer".into(), + message: "fetch_blob_files requires LanceDB server 0.5.0 or newer.".into(), }) } } diff --git a/rust/lancedb/src/sql.rs b/rust/lancedb/src/sql.rs index 7c040c51b..c2ef13ba5 100644 --- a/rust/lancedb/src/sql.rs +++ b/rust/lancedb/src/sql.rs @@ -17,6 +17,10 @@ pub enum QueryStatus { /// The server is still executing the query. Running, /// The server has made the complete result available. + /// + /// A query submitted with parameters streams its rows on the call that + /// runs it, so it reports `Finished` only once its reader has received + /// the last batch. Read it rather than waiting for this status. Finished, /// The server accepted cancellation but has not confirmed it yet. Cancelling, @@ -60,7 +64,9 @@ pub(crate) trait QueryHandle: Send + Sync { /// A handle to a submitted SQL query. /// /// The handle can be inspected, opened as an Arrow reader, or cancelled. -/// Dropping it does not cancel the server-side query. +/// Dropping it does not cancel the server-side query, unless the query was +/// submitted with parameters: those run on the call that returns their rows, +/// so dropping the handle before reading, or dropping the reader, stops them. /// Identifier lookup is scoped to the connection that submitted the query and /// is not a durable resume mechanism. pub struct Query { @@ -88,6 +94,11 @@ impl Query { } /// Get a point-in-time description of the query. + /// + /// For a query submitted with parameters the status follows its result + /// stream: it stays [`QueryStatus::Running`] until the reader has received + /// every row, so open [`Self::reader`] directly instead of polling for + /// [`QueryStatus::Finished`] first. pub async fn describe(&self) -> Result { self.handle.describe().await } diff --git a/rust/lancedb/src/table.rs b/rust/lancedb/src/table.rs index ea6e512fd..b296d135d 100644 --- a/rust/lancedb/src/table.rs +++ b/rust/lancedb/src/table.rs @@ -563,7 +563,7 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync { fn id(&self) -> &str; /// Get the arrow [Schema] of the table. async fn schema(&self) -> Result; - /// Read this table's materialized-view definition and incarnation. + /// Read this table's materialized-view definition. #[doc(hidden)] async fn materialized_view_info( &self, @@ -578,9 +578,7 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync { #[doc(hidden)] async fn refresh_materialized_view_async( &self, - _full: bool, _source_version: Option, - _expected_incarnation: Option<&str>, ) -> Result> { Err(Error::NotSupported { message: "remote materialized-view refresh is not supported on this table type".into(), @@ -2036,12 +2034,16 @@ impl Table { /// Read the [`LsmWriteSpec`] currently installed on this table. /// - /// Returns `Ok(None)` when the MemWAL LSM write path is not enabled (no - /// spec has been set, or it was removed with [`Table::unset_lsm_write_spec`]). - /// The returned spec mirrors what was passed to - /// [`Table::set_lsm_write_spec`], except that - /// [`LsmWriteSpec::maintained_indexes`] always reports the concrete list - /// resolved when the spec was set — `None` never round-trips. + /// `Ok(None)` means the LSM write path is not enabled at all — no spec has + /// been set, or one was removed with [`Table::unset_lsm_write_spec`]. That + /// is a different answer from a spec whose + /// [`LsmWriteSpec::maintained_indexes`] is `None`, which is an installed + /// spec selecting indexes automatically. + /// + /// The spec read back is the one that was installed, selection included: + /// `None` maintains every supported index the table has now or gains + /// later, `[]` maintains none, and a non-empty list maintains exactly + /// those. All three round-trip. /// /// # Example /// @@ -3118,6 +3120,16 @@ impl NativeTable { }) } + /// Refuse `operation` when this table is a materialized view. + pub(crate) async fn ensure_not_a_view(&self, operation: &str) -> Result<()> { + let dataset = self.dataset.get().await?; + crate::materialized_view::ensure_not_a_view( + &self.name, + &dataset.schema().metadata, + operation, + ) + } + /// Merge new data into this table. pub async fn merge( &mut self, @@ -3126,6 +3138,7 @@ impl NativeTable { right_on: &str, ) -> Result<()> { self.dataset.ensure_mutable()?; + self.ensure_not_a_view("merge into").await?; let mut dataset = (*self.dataset.get().await?).clone(); dataset.merge(batches, left_on, right_on).await?; self.dataset.update(dataset); @@ -3428,6 +3441,7 @@ impl BaseTable for NativeTable { self.dataset.ensure_mutable()?; let ds_wrapper = self.dataset.clone(); let ds = self.dataset.get().await?; + crate::materialized_view::ensure_not_a_view(&self.name, &ds.schema().metadata, "add to")?; let table_schema = Schema::from(&ds.schema().clone()); computed_columns::ensure_supported_function_metadata(&table_schema)?; @@ -3602,6 +3616,7 @@ impl BaseTable for NativeTable { new_data: Box, ) -> Result { let source_schema = arrow_array::RecordBatchReader::schema(&new_data); + self.ensure_not_a_view("merge insert into").await?; computed_columns::ensure_not_written( &Schema::from(self.dataset.get().await?.schema()), source_schema.fields().iter().map(|f| f.name().as_str()), @@ -3954,6 +3969,7 @@ impl BaseTable for NativeTable { let num_rows = self.count_rows(None).await?; let num_indices = self.list_indices().await?.len(); let ds = self.dataset.get().await?; + let num_deleted_rows = Some(ds.count_deleted_rows().await?); // Sizes come from the manifest. Summing per-field `bytes_on_disk` instead // would open every data file to read its column metadata, which costs one // IO per fragment and reports 0 for legacy v1 storage. @@ -4018,6 +4034,7 @@ impl BaseTable for NativeTable { let stats = TableStatistics { total_bytes, num_rows, + num_deleted_rows, num_indices, fragment_stats: frag_stats, }; @@ -4030,6 +4047,11 @@ impl BaseTable for NativeTable { write_params: WriteParams, ) -> Result> { let ds = self.dataset.get().await?; + crate::materialized_view::ensure_not_a_view( + &self.name, + &ds.schema().metadata, + "insert into", + )?; let dataset = Arc::new((*ds).clone()); Ok(Arc::new(datafusion::insert::InsertExec::new( self.dataset.clone(), @@ -4054,6 +4076,20 @@ pub struct TableStatistics { /// The number of rows in the table pub num_rows: usize, + /// The number of rows marked as deleted across all fragments of the table + /// + /// Deleted rows are only marked in deletion files and still occupy space on + /// disk until the table is compacted, so a large value here is a good + /// indication that [`Table::optimize`] should be called. + /// + /// These rows are not included in [`Self::num_rows`]. Fragments in which + /// every row was deleted are dropped outright rather than kept with a + /// deletion file, so their rows are not counted here either. + /// + /// `None` means the backend did not report a deletion count. + #[serde(default)] + pub num_deleted_rows: Option, + /// The number of indices in the table pub num_indices: usize, @@ -5882,6 +5918,7 @@ mod tests { res, TableStatistics { num_rows: 250, + num_deleted_rows: Some(0), num_indices: 0, total_bytes: 8969, fragment_stats: FragmentStatistics { @@ -5905,6 +5942,7 @@ mod tests { res, TableStatistics { num_rows: 0, + num_deleted_rows: Some(0), num_indices: 0, total_bytes: 0, fragment_stats: FragmentStatistics { @@ -6122,4 +6160,60 @@ mod tests { stats.fragment_stats.num_fragments ); } + + #[tokio::test] + pub async fn test_stats_num_deleted_rows() { + let tmp_dir = tempdir().unwrap(); + let uri = tmp_dir.path().to_str().unwrap(); + + let conn = ConnectBuilder::new(uri).execute().await.unwrap(); + + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + let batch = |ids: Vec| { + RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(ids))]).unwrap() + }; + + let table = conn + .create_table("test_stats_deletions", batch(vec![0, 1])) + .execute() + .await + .unwrap(); + // A second fragment, holding only rows that the delete below removes. + table.add(batch(vec![1])).execute().await.unwrap(); + table.add(batch(vec![1, 2, 3])).execute().await.unwrap(); + + let res = table.stats().await.unwrap(); + assert_eq!(res.num_deleted_rows, Some(0)); + assert_eq!(res.fragment_stats.num_fragments, 3); + + table.delete("id = 1").await.unwrap(); + + let res = table.stats().await.unwrap(); + // 6 rows are inserted over 3 fragments + // 3 rows with id = 1 are deleted (one in each fragment), and that empties the 2nd + // fragment, which is dropped outright rather than kept with a deletion + // file, so only the row marked in the surviving fragment are counted. + assert_eq!(res.num_deleted_rows, Some(2)); + assert_eq!(res.num_rows, 3); + assert_eq!(res.fragment_stats.num_fragments, 2); + // The marked row is are physically there, so it counts towards the + // 3rd fragment length even though it is excluded from num_rows. + assert_eq!(res.fragment_stats.lengths.max, 3); + + // Compaction materializes the deletions. + table + .optimize(OptimizeAction::Compact { + options: CompactionOptions { + target_rows_per_fragment: 1000, + ..Default::default() + }, + remap_options: None, + }) + .await + .unwrap(); + + let res = table.stats().await.unwrap(); + assert_eq!(res.num_deleted_rows, Some(0)); + assert_eq!(res.num_rows, 3); + } } diff --git a/rust/lancedb/src/table/computed_columns.rs b/rust/lancedb/src/table/computed_columns.rs index d92599294..88c47992d 100644 --- a/rust/lancedb/src/table/computed_columns.rs +++ b/rust/lancedb/src/table/computed_columns.rs @@ -388,7 +388,7 @@ pub(crate) fn ensure_supported_function_metadata(schema: &ArrowSchema) -> Result } } } - Ok(()) + ensure_computed_inputs_acyclic(schema, &bindings) } /// Refuse `operation` outright on a table with a Function binding. For @@ -785,6 +785,157 @@ fn resolve_field_path<'a>(schema: &'a ArrowSchema, path: &str) -> Result Result { + if root + .metadata() + .get(COMPUTED_COLUMN_META_KEY) + .map(String::as_str) + != Some("true") + { + return Ok(false); + } + match computed_column_from_field(root).map(|column| column.kind) { + Some(ComputedColumnKind::Sql { .. }) => Ok(true), + Some(ComputedColumnKind::Function { output_ordinal, .. }) + if output_ordinal != FUNCTION_ASSIGNMENT_OUTPUT_ORDINAL => + { + Ok(true) + } + Some(ComputedColumnKind::Function { .. }) => Err(invalid_function(format!( + "Function input '{path}' is an internal Function assignment column" + ))), + Some(ComputedColumnKind::Unrecognized { kind }) => Err(Error::NotSupported { + message: format!( + "Function input '{path}' is computed by '{kind}', which this version of \ + lancedb cannot fill" + ), + }), + None => Err(invalid_function(format!( + "Function input '{path}' carries an incomplete computed-column declaration" + ))), + } +} + +/// Nullability of the parameter a Function input binds: the source field's, +/// except that a computed source never passes a null (see +/// [`function_input_is_computed`]). +fn function_input_nullability(resolved: &ResolvedFieldPath<'_>, path: &str) -> Result { + Ok(!function_input_is_computed(resolved.root, path)? && resolved.leaf.is_nullable()) +} + +/// The metadata a Function parameter carries from the column it binds: the +/// column's semantic Arrow metadata, such as a Blob v2 extension, without its +/// computed-column declaration or refresh stamps. A binding's input schema is +/// immutable, and a refresh of a computed input rewrites those stamps, so +/// they cannot be part of it. A plain column carries none of them, so its +/// parameter keeps every key. +pub fn function_input_metadata(field: &ArrowField) -> HashMap { + field + .metadata() + .iter() + .filter(|(key, _)| !is_declaration_key(key)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +/// The input paths of `binding` that read a computed column. A Function +/// refresh computes a row only where every one of them holds a value; see +/// [`function_input_is_computed`]. +pub fn computed_function_inputs( + schema: &ArrowSchema, + binding: &FunctionBinding, +) -> Result> { + let mut computed = Vec::new(); + for input in binding.inputs() { + let resolved = resolve_field_path(schema, &input.field_path)?; + if function_input_is_computed(resolved.root, &input.field_path)? { + computed.push(input.field_path.clone()); + } + } + Ok(computed) +} + +/// Refuse a schema whose computed columns read each other in a cycle: no +/// refresh order could fill any column on it. A declaration can only read +/// columns that already exist, so only a schema supplied whole can hold one. +fn ensure_computed_inputs_acyclic( + schema: &ArrowSchema, + bindings: &[FunctionBinding], +) -> Result<()> { + let columns = computed_columns(schema); + let declared = columns + .iter() + .map(|column| column.name.as_str()) + .collect::>(); + let bindings_by_id = bindings + .iter() + .map(|binding| (binding.binding_id(), binding)) + .collect::>(); + let mut reads: HashMap<&str, BTreeSet> = HashMap::new(); + for column in &columns { + let paths = match &column.kind { + ComputedColumnKind::Function { binding_id, .. } => bindings_by_id + .get(binding_id.as_str()) + .map(|binding| { + binding + .inputs() + .iter() + .map(|input| input.field_path.clone()) + .collect::>() + }) + .unwrap_or_default(), + _ => column.inputs.clone(), + }; + let roots = paths + .iter() + .map(|path| root(path)) + .filter(|root| declared.contains(root.as_str())) + .collect(); + reads.insert(column.name.as_str(), roots); + } + + #[derive(Clone, Copy, PartialEq)] + enum Visit { + Open, + Done, + } + fn visit<'a>( + column: &'a str, + reads: &'a HashMap<&str, BTreeSet>, + state: &mut HashMap<&'a str, Visit>, + ) -> Result<()> { + match state.get(column) { + Some(Visit::Done) => return Ok(()), + Some(Visit::Open) => { + return Err(Error::InvalidInput { + message: format!("computed column '{column}' depends on itself"), + }); + } + None => {} + } + state.insert(column, Visit::Open); + for input in reads.get(column).into_iter().flatten() { + visit(input.as_str(), reads, state)?; + } + state.insert(column, Visit::Done); + Ok(()) + } + let mut state = HashMap::new(); + for column in &columns { + visit(column.name.as_str(), &reads, &mut state)?; + } + Ok(()) +} + fn canonical_input_arrow_type(field: &JsonArrowField) -> Result { let is_blob_v2 = field .metadata @@ -1004,21 +1155,21 @@ fn ensure_binding_matches_schema(schema: &ArrowSchema, binding: &FunctionBinding let mut input_fields = Vec::with_capacity(binding.inputs().len()); for input in binding.inputs() { let resolved = resolve_field_path(schema, &input.field_path)?; + let computed = function_input_is_computed(resolved.root, &input.field_path)?; let field = resolved.leaf; - if field - .metadata() - .get(COMPUTED_COLUMN_META_KEY) - .map(String::as_str) - == Some("true") - { + if computed && input.nullable { return Err(invalid_function(format!( - "Function input '{}' is computed", - input.field_path + "Function input '{}' is computed, so parameter '{}' in binding '{}' must be \ + non-nullable: a refresh passes it only rows where the input holds a value", + input.field_path, + input.parameter, + binding.binding_id() ))); } // A non-null source is within a nullable parameter's domain. The // reverse can pass nulls to a Function that does not accept them. - if field.is_nullable() && !input.nullable { + // A computed source never passes one: see `function_input_nullability`. + if !computed && field.is_nullable() && !input.nullable { return Err(invalid_function(format!( "Function input column '{}' is nullable, but parameter '{}' in binding '{}' is non-nullable", input.field_path, @@ -1031,7 +1182,7 @@ fn ensure_binding_matches_schema(schema: &ArrowSchema, binding: &FunctionBinding field.data_type().clone(), input.nullable, ) - .with_metadata(field.metadata().clone()); + .with_metadata(function_input_metadata(field)); let json = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ parameter_field.clone(), ])) @@ -1257,24 +1408,11 @@ pub(crate) fn plan_function_application( )) })?; let resolved = resolve_field_path(schema, path)?; - if resolved - .root - .metadata() - .get(COMPUTED_COLUMN_META_KEY) - .map(String::as_str) - == Some("true") - { - return Err(invalid_function(format!( - "Function input '{path}' is computed; computed-on-computed bindings are not supported" - ))); - } + let nullable = function_input_nullability(&resolved, path)?; let field = resolved.leaf; - let parameter_field = ArrowField::new( - input.parameter.clone(), - field.data_type().clone(), - field.is_nullable(), - ) - .with_metadata(field.metadata().clone()); + let parameter_field = + ArrowField::new(input.parameter.clone(), field.data_type().clone(), nullable) + .with_metadata(function_input_metadata(field)); let input_schema = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ parameter_field.clone(), ])) @@ -1284,7 +1422,7 @@ pub(crate) fn plan_function_application( parameter: input.parameter.clone(), field_path: path.to_string(), arrow_type: canonical_input_arrow_type(&json_field)?, - nullable: field.is_nullable(), + nullable, }); input_fields.push(parameter_field); } @@ -1549,9 +1687,9 @@ pub(crate) fn ensure_batch_writes_no_computed_values( /// Validate every computed-column declaration `schema` carries against the /// schema itself: every field with declaration metadata is a complete /// declaration, a SQL declaration re-plans to the field it declares, a -/// Function declaration satisfies the binding contract, and no declaration -/// reads another computed column. What passes here is what `refresh_column` -/// can execute. +/// Function declaration satisfies the binding contract, and no SQL +/// declaration reads another computed column. What passes here is what +/// `refresh_column` can execute. pub(crate) fn ensure_declarations_are_planned(schema: &ArrowSchema) -> Result<()> { let invalid = |message: String| Error::InvalidInput { message }; // A field with any declaration key is a declaration; a partial one is @@ -1610,28 +1748,8 @@ pub(crate) fn ensure_declarations_are_planned(schema: &ArrowSchema) -> Result<() ))); } } - ComputedColumnKind::Function { binding_id, .. } => { - // The binding validator resolves each input's leaf; the - // no-computed-input rule is about the root it hangs from. - let bindings = function_bindings(schema)?; - let Some(binding) = bindings.iter().find(|b| b.binding_id() == binding_id) else { - continue; // reported by the binding validator below - }; - // Roots come from the canonical path parser: a quoted - // top-level name may itself contain a dot. - if let Some(input) = binding - .inputs() - .iter() - .filter_map(|input| resolve_field_path(schema, &input.field_path).ok()) - .map(|resolved| resolved.root.name().as_str()) - .find(|r| declared.contains(*r)) - { - return Err(invalid(format!( - "computed column '{}' reads computed column '{input}'", - column.name - ))); - } - } + // The binding validator below checks what a Function reads. + ComputedColumnKind::Function { .. } => {} ComputedColumnKind::Unrecognized { kind } => { return Err(Error::NotSupported { message: format!( @@ -3614,12 +3732,7 @@ mod tests { "title", DataType::Struct(vec![ArrowField::new("value", DataType::Utf8, true)].into()), true, - ) - .with_metadata(HashMap::from([ - (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), - (KIND_META_KEY.to_string(), SQL_KIND.to_string()), - (EXPRESSION_META_KEY.to_string(), "title".to_string()), - ])); + ); let mut fields = vec![title, ArrowField::new("body", DataType::Utf8, true)]; fields.extend(binding.outputs().iter().map(|output| { let data_type = match output.arrow_type.as_str() { @@ -3697,11 +3810,11 @@ mod tests { }"#, ) .unwrap(); - let err = plan_function_application(&reopened, &dependent_application, Some("dependent")) - .unwrap_err(); - assert!( - matches!(&err, Error::InvalidInput { message } if message.contains("computed-on-computed")) - ); + let dependent = + plan_function_application(&reopened, &dependent_application, Some("dependent")) + .unwrap(); + assert_eq!(dependent.input_bindings[0].field_path, "search_text"); + assert!(!dependent.input_bindings[0].nullable); let plan = plan_function_application( &reopened, &named_struct_application( @@ -4235,60 +4348,315 @@ mod tests { } #[test] - fn test_function_inputs_use_paths_and_cannot_be_computed() { - let mut schema = function_input_schema(); - let plan = - plan_function_application(&schema, &named_struct_application("{}"), None).unwrap(); + fn test_function_inputs_use_paths() { + let plan = plan_function_application( + &function_input_schema(), + &named_struct_application("{}"), + None, + ) + .unwrap(); assert_eq!(plan.input_bindings[0].field_path, "title"); assert_eq!(plan.input_bindings[1].field_path, "body"); + assert!(plan.input_bindings.iter().all(|input| input.nullable)); + } - let title = schema - .field(0) - .as_ref() - .clone() - .with_metadata(HashMap::from([ - (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), - (KIND_META_KEY.to_string(), SQL_KIND.to_string()), - (EXPRESSION_META_KEY.to_string(), "title".to_string()), - ])); - schema = ArrowSchema::new(vec![title, schema.field(1).as_ref().clone()]); - let err = - plan_function_application(&schema, &named_struct_application("{}"), None).unwrap_err(); + /// Declare `plan` on `schema` the way the table service persists it: the + /// binding in schema metadata, one nullable field per output. + fn declare_plan( + schema: &ArrowSchema, + plan: &FunctionDeclarationPlan, + binding_id: &str, + ) -> ArrowSchema { + let mut raw = serde_json::to_value(binding_from_plan(plan)).unwrap(); + raw["binding_id"] = Value::String(binding_id.to_string()); + let binding: FunctionBinding = serde_json::from_value(raw).unwrap(); + let inputs = binding + .inputs() + .iter() + .map(|input| input.field_path.clone()) + .collect::>(); + let mut fields = schema + .fields() + .iter() + .map(|field| field.as_ref().clone()) + .collect::>(); + for (output, field) in binding.outputs().iter().zip(&plan.output_schema.fields) { + let field = lance_namespace::schema::convert_json_arrow_field(field).unwrap(); + fields.push(field.with_metadata(function_computed_column_metadata( + binding_id, + output.output_ordinal, + &inputs, + ))); + } + let mut bindings = function_bindings(schema).unwrap(); + bindings.push(binding); + ArrowSchema::new_with_metadata( + fields, + HashMap::from([( + FUNCTION_BINDINGS_META_KEY.to_string(), + function_bindings_metadata(&bindings).unwrap(), + )]), + ) + } + + fn sql_computed_field(name: &str, data_type: DataType, expression: &str) -> ArrowField { + ArrowField::new(name, data_type, true) + .with_metadata(computed_column_metadata(expression, &["title".to_string()])) + } + + #[test] + fn test_function_reads_a_function_output() { + let upstream = plan_function_application( + &function_input_schema(), + &named_struct_application("{}"), + None, + ) + .unwrap(); + let schema = declare_plan(&function_input_schema(), &upstream, "fb_upstream"); + + let downstream = plan_function_application( + &schema, + &single_input_application("normalized_text"), + Some("inspected"), + ) + .unwrap(); + // The refresh skips a row whose computed input is still null, so the + // Function only ever sees a value. + assert!(!downstream.input_bindings[0].nullable); + assert!(!downstream.input_schema.fields[0].nullable); + + let schema = declare_plan(&schema, &downstream, "fb_downstream"); + ensure_supported_function_metadata(&schema).unwrap(); + ensure_declarations_are_planned(&schema).unwrap(); + let bindings = function_bindings(&schema).unwrap(); assert!( - matches!(&err, Error::InvalidInput { message } if message.contains("computed-on-computed")) + computed_function_inputs(&schema, &bindings[0]) + .unwrap() + .is_empty() + ); + assert_eq!( + computed_function_inputs(&schema, &bindings[1]).unwrap(), + ["normalized_text"] ); - let nested_title = ArrowField::new( + // An upstream output a binding reads cannot be dropped out from under it. + let unbinding = + plan_function_unbinding(&schema, &["normalized_text", "token_count"]).unwrap(); + assert!( + unbinding + .ensure_retained_unaffected("drop", ["normalized_text"]) + .is_err() + ); + let unbinding = + plan_function_unbinding(&schema, &["normalized_text", "token_count", "inspected"]) + .unwrap(); + assert!(unbinding.retained.is_empty()); + } + + #[test] + fn test_function_reads_a_sql_computed_column_and_nested_path() { + let title = ArrowField::new( "title", DataType::Struct(vec![ArrowField::new("value", DataType::Utf8, true)].into()), true, - ) - .with_metadata(HashMap::from([ - (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), - (KIND_META_KEY.to_string(), SQL_KIND.to_string()), - ( - EXPRESSION_META_KEY.to_string(), - "struct('value')".to_string(), + ); + let schema = ArrowSchema::new(vec![ + title, + sql_computed_field("upper_title", DataType::Utf8, "upper(title.value)"), + sql_computed_field( + "wrapped", + DataType::Struct(vec![ArrowField::new("value", DataType::Utf8, true)].into()), + "named_struct('value', title.value)", ), - ])); - let nested_schema = ArrowSchema::new(vec![nested_title, schema.field(1).as_ref().clone()]); - let nested_application = FunctionApplication::from_json( - r#"{ - "function":{"name":"text_features","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, - "inputs":[ - {"parameter":"title","kind":"column","value":{"path":"title.value"}}, - {"parameter":"body","kind":"column","value":{"path":"body"}} - ], - "output":{"kind":"named_struct","fields":[ - {"name":"normalized_text","arrow_type":"utf8","nullable":false}, - {"name":"token_count","arrow_type":"int64","nullable":false} - ]} - }"#, + ]); + for path in ["upper_title", "wrapped.value"] { + let plan = + plan_function_application(&schema, &single_input_application(path), Some("out")) + .unwrap(); + assert_eq!(plan.input_bindings[0].field_path, path); + assert!(!plan.input_bindings[0].nullable, "{path}"); + } + } + + /// A refresh of a computed input rewrites its freshness stamps; the + /// downstream binding's immutable input schema must not record them, or + /// the first upstream refresh strands the table. + #[tokio::test] + async fn test_computed_input_survives_upstream_refresh() { + use arrow_array::{Int32Array, Int64Array, RecordBatch}; + + let base = ArrowSchema::new(vec![ArrowField::new("x", DataType::Int32, true)]); + let mut fields = base.fields().to_vec(); + fields.extend( + plan(Arc::new(base), &[("doubled".into(), "x * 2".into())]) + .unwrap() + .into_iter() + .map(Arc::new), + ); + let schema = ArrowSchema::new(fields); + let downstream = plan_function_application( + &schema, + &single_input_application("doubled"), + Some("inspected"), ) .unwrap(); - let err = plan_function_application(&nested_schema, &nested_application, None).unwrap_err(); assert!( - matches!(&err, Error::InvalidInput { message } if message.contains("computed-on-computed")) + downstream.input_schema.fields[0] + .metadata + .as_ref() + .is_none_or(|metadata| !metadata.keys().any(|key| is_declaration_key(key))) + ); + let schema = Arc::new(declare_plan(&schema, &downstream, "fb_downstream")); + ensure_declarations_are_planned(&schema).unwrap(); + let batch = RecordBatch::try_new( + schema, + vec![ + Arc::new(Int32Array::from(vec![1])), + Arc::new(Int32Array::from(vec![None])), + Arc::new(Int64Array::from(vec![None])), + ], + ) + .unwrap(); + let conn = connect("memory://").execute().await.unwrap(); + let table = conn + .create_table("upstream_refresh", batch) + .execute() + .await + .unwrap(); + assert_eq!( + table.refresh_column("doubled").await.unwrap().rows_filled, + 1 + ); + + ensure_supported_function_metadata(&table.schema().await.unwrap()).unwrap(); + table + .add(record_batch!(("x", Int32, [2])).unwrap()) + .execute() + .await + .unwrap(); + table.drop_columns(&["inspected"]).await.unwrap(); + } + + #[test] + fn test_a_computed_input_must_bind_a_non_nullable_parameter() { + let upstream = plan_function_application( + &function_input_schema(), + &named_struct_application("{}"), + None, + ) + .unwrap(); + let schema = declare_plan(&function_input_schema(), &upstream, "fb_upstream"); + let mut downstream = plan_function_application( + &schema, + &single_input_application("normalized_text"), + Some("inspected"), + ) + .unwrap(); + downstream.input_bindings[0].nullable = true; + downstream.input_schema.fields[0].nullable = true; + let schema = declare_plan(&schema, &downstream, "fb_downstream"); + let err = ensure_supported_function_metadata(&schema).unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("non-nullable")), + "{err:?}" + ); + } + + #[test] + fn test_function_cannot_read_an_unreadable_computed_column() { + // A schema carrying either of these fails validation before any input + // is resolved, so the rule is exercised on the field itself. + let unknown = + ArrowField::new("future", DataType::Utf8, true).with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), "from_the_future".to_string()), + (INPUTS_META_KEY.to_string(), "[]".to_string()), + ])); + let err = function_input_is_computed(&unknown, "future").unwrap_err(); + assert!(matches!(err, Error::NotSupported { .. }), "{err:?}"); + let incomplete = ArrowField::new("partial", DataType::Utf8, true).with_metadata( + HashMap::from([(COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string())]), + ); + let err = function_input_is_computed(&incomplete, "partial").unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("incomplete")), + "{err:?}" + ); + + let mut raw_binding: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_function_binding.json" + )) + .unwrap(); + raw_binding["outputs"][0]["nullable"] = Value::Bool(true); + raw_binding["outputs"][1]["nullable"] = Value::Bool(true); + raw_binding["assignment"] = serde_json::json!({ + "output_name": "__function_assignment_fb_01K3TEXT", + "output_field_id": -1, + }); + raw_binding["output_schema"]["fields"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "name": "__function_assignment_fb_01K3TEXT", + "nullable": true, + "type": {"type": "bool"}, + })); + let binding: FunctionBinding = serde_json::from_value(raw_binding).unwrap(); + let schema = ArrowSchema::new_with_metadata( + valid_function_binding_schema(true, true, &binding) + .fields() + .to_vec(), + HashMap::from([( + FUNCTION_BINDINGS_META_KEY.to_string(), + function_bindings_metadata(std::slice::from_ref(&binding)).unwrap(), + )]), + ); + let err = plan_function_application( + &schema, + &single_input_application("__function_assignment_fb_01K3TEXT"), + Some("out"), + ) + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("assignment")), + "{err:?}" + ); + } + + #[test] + fn test_computed_columns_cannot_read_each_other_in_a_cycle() { + let upstream = plan_function_application( + &function_input_schema(), + &named_struct_application("{}"), + None, + ) + .unwrap(); + let schema = declare_plan(&function_input_schema(), &upstream, "fb_upstream"); + let downstream = plan_function_application( + &schema, + &single_input_application("normalized_text"), + Some("inspected"), + ) + .unwrap(); + let schema = declare_plan(&schema, &downstream, "fb_downstream"); + + // Rewire the upstream binding to read the downstream output: a graph + // only a whole-schema write could carry. Field metadata is irrelevant; + // a Function column's inputs are its binding's. + let mut bindings = function_bindings(&schema) + .unwrap() + .into_iter() + .map(|binding| serde_json::to_value(binding).unwrap()) + .collect::>(); + bindings[0]["inputs"][0]["field_path"] = Value::String("inspected".to_string()); + let bindings = bindings + .into_iter() + .map(|binding| serde_json::from_value(binding).unwrap()) + .collect::>(); + let err = ensure_computed_inputs_acyclic(&schema, &bindings).unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("depends on itself")), + "{err:?}" ); } } diff --git a/rust/lancedb/src/table/datafusion.rs b/rust/lancedb/src/table/datafusion.rs index 85366910b..ea5222b15 100644 --- a/rust/lancedb/src/table/datafusion.rs +++ b/rust/lancedb/src/table/datafusion.rs @@ -20,7 +20,8 @@ use datafusion_common::{DataFusionError, Result as DataFusionResult, Statistics} use datafusion_execution::{SendableRecordBatchStream, TaskContext}; use datafusion_expr::{Expr, TableProviderFilterPushDown, TableType, dml::InsertOp}; use datafusion_physical_plan::{ - DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties, stream::RecordBatchStreamAdapter, + DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties, execution_plan::CardinalityEffect, + stream::RecordBatchStreamAdapter, }; use futures::{TryFutureExt, TryStreamExt}; use lance::dataset::{WriteMode, WriteParams}; @@ -123,12 +124,11 @@ impl ExecutionPlan for MetadataEraserExec { children: Vec>, ) -> DataFusionResult> { assert_eq!(children.len(), 1); - let new_properties = Self::compute_properties_from_input(&children[0], &self.schema); - Ok(Arc::new(Self { - input: children[0].clone(), - schema: self.schema.clone(), - properties: new_properties, - })) + Ok(Arc::new(Self::new(children[0].clone()))) + } + + fn cardinality_effect(&self) -> CardinalityEffect { + CardinalityEffect::Equal } fn execute( @@ -668,4 +668,59 @@ pub mod tests { assert!(matches!(partition_stats.num_rows, Precision::Exact(10))); } + + #[tokio::test] + async fn test_count_pushdown_through_metadata_eraser() { + let fixture = TestFixture::new().await; + let state = datafusion::execution::SessionStateBuilder::new() + .with_default_features() + .with_physical_optimizer_rule(Arc::new(lance::io::exec::count_pushdown::CountPushdown)) + .build(); + let ctx = SessionContext::new_with_state(state); + ctx.register_table("foo", fixture.adapter.clone()).unwrap(); + + let df = ctx + .sql("SELECT COUNT(*) FROM foo WHERE indexed > 2") + .await + .unwrap(); + let plan = df.clone().create_physical_plan().await.unwrap(); + let plan = datafusion_physical_plan::displayable(plan.as_ref()) + .indent(true) + .to_string(); + assert!(plan.contains("CountFromMask"), "{plan}"); + + let batches = df.collect().await.unwrap(); + assert_eq!( + batches[0] + .column(0) + .as_primitive::() + .value(0), + 7 + ); + } + + #[tokio::test] + async fn test_metadata_eraser_with_new_children_takes_child_schema() { + let fixture = TestFixture::new().await; + let ctx = SessionContext::new(); + let scan = |adapter: Arc, name: &str| { + LogicalPlanBuilder::scan(name, provider_as_source(adapter), None) + .unwrap() + .build() + .unwrap() + }; + let foo = ctx + .state() + .create_physical_plan(&scan(fixture.adapter.clone(), "foo")) + .await + .unwrap(); + let tbl2 = ctx + .state() + .create_physical_plan(&scan(fixture.adapter2.clone(), "tbl2")) + .await + .unwrap(); + + let rewired = foo.with_new_children(vec![tbl2.clone()]).unwrap(); + assert_eq!(rewired.schema(), tbl2.schema()); + } } diff --git a/rust/lancedb/src/table/datafusion/udtf/vector_query.rs b/rust/lancedb/src/table/datafusion/udtf/vector_query.rs index 0d4f23d92..000addbe1 100644 --- a/rust/lancedb/src/table/datafusion/udtf/vector_query.rs +++ b/rust/lancedb/src/table/datafusion/udtf/vector_query.rs @@ -6,11 +6,15 @@ use std::sync::Arc; -use arrow_schema::SchemaRef; +use arrow_array::{RecordBatch, UInt64Array}; +use arrow_schema::{DataType, Field, Schema, SchemaRef}; use async_trait::async_trait; +use datafusion::prelude::{SessionConfig, SessionContext, col}; use datafusion_catalog::{Session, TableProvider}; -use datafusion_common::{Result, plan_err}; -use datafusion_execution::{SendableRecordBatchStream, TaskContext}; +use datafusion_common::{DataFusionError, Result, plan_err}; +use datafusion_execution::{ + SendableRecordBatchStream, TaskContext, runtime_env::RuntimeEnvBuilder, +}; use datafusion_expr::{Expr, TableType}; use datafusion_physical_expr::{EquivalenceProperties, Partitioning}; use datafusion_physical_plan::{ @@ -25,7 +29,9 @@ use futures::{TryStreamExt, stream}; use lance::dataset::scanner::{RowAddrMask, RowAddrTreeMap}; use lance::{Dataset, index::DatasetIndexInternalExt}; use lance_core::datatypes::BlobHandling; +use lance_datafusion::exec::SessionContextExt; use lance_index::metrics::NoOpMetricsCollector; +use roaring::RoaringTreemap; use super::duplicate_pairs::{ DuplicatePairTask, DuplicatePairsConfig, DuplicatePairsExec, DuplicatePairsOutput, @@ -36,6 +42,84 @@ use crate::table::{NativeTableExt, datafusion::BaseTableAdapter}; const REPRESENTATIVE_SORT_MEMORY_BYTES: usize = 256 * 1024 * 1024; const MATERIALIZE_BATCH_ROWS: usize = 1024; +fn edge_schema() -> SchemaRef { + Arc::new(Schema::new(vec![ + Field::new("a", DataType::UInt64, false), + Field::new("b", DataType::UInt64, false), + ])) +} + +/// Sorting canonical edges makes the first unremoved endpoint the smallest +/// unassigned representative. The sort spills; only vertex masks stay in RAM. +async fn select_representatives( + stream: SendableRecordBatchStream, + memory: usize, +) -> crate::Result<(RoaringTreemap, RoaringTreemap)> { + let schema = edge_schema(); + let batch_schema = schema.clone(); + let canonical = stream + .map_ok(move |batch| { + let a = batch + .column(0) + .as_any() + .downcast_ref::() + .expect("native row id"); + let b = batch + .column(1) + .as_any() + .downcast_ref::() + .expect("native row id"); + RecordBatch::try_new( + batch_schema.clone(), + vec![ + Arc::new(UInt64Array::from_iter_values( + a.values().iter().zip(b.values()).map(|(&a, &b)| a.min(b)), + )), + Arc::new(UInt64Array::from_iter_values( + a.values().iter().zip(b.values()).map(|(&a, &b)| a.max(b)), + )), + ], + ) + .map_err(DataFusionError::from) + }) + .and_then(futures::future::ready); + let canonical = Box::pin(RecordBatchStreamAdapter::new(schema, canonical)); + let mut config = SessionConfig::default().with_batch_size(MATERIALIZE_BATCH_ROWS); + config.options_mut().execution.sort_spill_reservation_bytes = memory / 2; + let ctx = SessionContext::new_with_config_rt( + config, + RuntimeEnvBuilder::new() + .with_memory_limit(memory, 1.0) + .build_arc()?, + ); + let mut sorted = ctx + .read_one_shot(canonical)? + .sort_by(vec![col("a"), col("b")])? + .execute_stream() + .await?; + let mut removed = RoaringTreemap::new(); + let mut representatives = RoaringTreemap::new(); + while let Some(batch) = sorted.try_next().await? { + let a = batch + .column(0) + .as_any() + .downcast_ref::() + .expect("sorted id"); + let b = batch + .column(1) + .as_any() + .downcast_ref::() + .expect("sorted id"); + for (&a, &b) in a.values().iter().zip(b.values()) { + if a != b && !removed.contains(a) && !removed.contains(b) { + representatives.insert(a); + removed.insert(b); + } + } + } + Ok((removed, representatives)) +} + /// Select nonempty physical IVF scopes before dispatch, never by filtering the /// resulting pairs. A count spans all segments; partition IDs are segment-local. #[derive(Debug, Clone, Default)] @@ -322,12 +406,9 @@ impl ExecutionPlan for VectorDedupExec { let dataset = self.dataset.clone(); let pairs = CoalescePartitionsExec::new(self.pairs.clone()).execute(0, context)?; let stream = stream::once(async move { - let (removed, _) = crate::materialized_view::vector_dedup::select_representatives( - pairs, - REPRESENTATIVE_SORT_MEMORY_BYTES, - ) - .await - .map_err(|e| datafusion_common::DataFusionError::External(Box::new(e)))?; + let (removed, _) = select_representatives(pairs, REPRESENTATIVE_SORT_MEMORY_BYTES) + .await + .map_err(|e| datafusion_common::DataFusionError::External(Box::new(e)))?; let mut scanner = dataset.scan(); scanner .with_row_addr_prefilter(RowAddrMask::from_block(RowAddrTreeMap::from_iter( diff --git a/rust/lancedb/src/table/delete.rs b/rust/lancedb/src/table/delete.rs index cb1da03ae..b9e598f3b 100644 --- a/rust/lancedb/src/table/delete.rs +++ b/rust/lancedb/src/table/delete.rs @@ -29,6 +29,7 @@ pub(crate) async fn execute_delete( predicate: Predicate<'_>, ) -> Result { table.dataset.ensure_mutable()?; + table.ensure_not_a_view("delete from").await?; match predicate { Predicate::String(s) => { let predicate = crate::expr::canonicalize_sql_predicate(s)?; diff --git a/rust/lancedb/src/table/refresh.rs b/rust/lancedb/src/table/refresh.rs index 0119ba36d..884648f54 100644 --- a/rust/lancedb/src/table/refresh.rs +++ b/rust/lancedb/src/table/refresh.rs @@ -1847,8 +1847,8 @@ mod tests { assert_eq!( layouts, vec![ - (1, Some(BlobKind::Dedicated)), - (2, Some(BlobKind::Packed)), + (1, Some(BlobKind::Managed)), + (2, Some(BlobKind::Managed)), (3, Some(BlobKind::Inline)), (4, None), ] diff --git a/rust/lancedb/src/table/schema_evolution.rs b/rust/lancedb/src/table/schema_evolution.rs index 4990901ae..4f229835b 100644 --- a/rust/lancedb/src/table/schema_evolution.rs +++ b/rust/lancedb/src/table/schema_evolution.rs @@ -104,6 +104,7 @@ pub(crate) async fn execute_add_columns( transforms: NewColumnTransform, read_columns: Option>, ) -> Result { + table.ensure_not_a_view("add columns to").await?; computed_columns::ensure_not_function_bound( table.schema().await?.as_ref(), "schema evolution", @@ -202,6 +203,15 @@ pub(crate) async fn execute_alter_columns( table.dataset.ensure_mutable()?; let mut dataset = (*table.dataset.get().await?).clone(); let schema = std::sync::Arc::new(ArrowSchema::from(dataset.schema())); + ensure_only_computed_columns_of_a_view( + table, + &dataset, + &schema, + "alter columns of", + alterations + .iter() + .map(|alteration| alteration.path.as_str()), + )?; // A Function binding stores its columns' exact fields, nullability // included, so every alteration of one counts, and a rename's target too. computed_columns::ensure_not_function_bound( @@ -241,6 +251,13 @@ pub(crate) async fn execute_drop_columns( table.dataset.ensure_mutable()?; let mut dataset = (*table.dataset.get().await?).clone(); let schema = std::sync::Arc::new(ArrowSchema::from(dataset.schema())); + ensure_only_computed_columns_of_a_view( + table, + &dataset, + &schema, + "drop columns of", + columns.iter().copied(), + )?; let unbinding = computed_columns::plan_function_unbinding(schema.as_ref(), columns)?; let mut names = columns.iter().map(|c| c.to_string()).collect::>(); @@ -266,6 +283,30 @@ pub(crate) async fn execute_drop_columns( Ok(DropColumnsResult { version }) } +/// A view's stored columns are the definition's output, which refresh checks +/// against the view's schema; only its computed columns may be altered or +/// dropped. +fn ensure_only_computed_columns_of_a_view<'a>( + table: &NativeTable, + dataset: &Dataset, + schema: &ArrowSchema, + operation: &str, + paths: impl IntoIterator, +) -> Result<()> { + let metadata = &dataset.schema().metadata; + if !metadata.contains_key(crate::materialized_view::DEFINITION_META_KEY) { + return Ok(()); + } + let computed = computed_columns::computed_columns(schema); + if paths + .into_iter() + .all(|path| computed.iter().any(|column| column.name == path)) + { + return Ok(()); + } + crate::materialized_view::ensure_not_a_view(&table.name, metadata, operation) +} + /// Retire the bindings a drop covers, in the commit before it. /// /// A binding lives in three places -- the schema-metadata envelope, each diff --git a/rust/lancedb/src/table/update.rs b/rust/lancedb/src/table/update.rs index f10594f23..cec787ff8 100644 --- a/rust/lancedb/src/table/update.rs +++ b/rust/lancedb/src/table/update.rs @@ -13,7 +13,7 @@ use crate::Result; /// The result of an update operation #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct UpdateResult { - #[serde(default)] + #[serde(default, alias = "updated_rows")] pub rows_updated: u64, /// The commit version associated with the operation. #[serde(default)] @@ -93,6 +93,7 @@ pub(crate) async fn execute_update( // 1. Snapshot the current dataset let dataset = table.dataset.get().await?; + crate::materialized_view::ensure_not_a_view(&table.name, &dataset.schema().metadata, "update")?; super::computed_columns::ensure_no_function_bindings_for_mutation( &arrow_schema::Schema::from(dataset.schema()), "update", diff --git a/rust/lancedb/tests/duplicate_pairs.rs b/rust/lancedb/tests/duplicate_pairs.rs index f7d7ed3a2..63f1c9f5a 100644 --- a/rust/lancedb/tests/duplicate_pairs.rs +++ b/rust/lancedb/tests/duplicate_pairs.rs @@ -289,127 +289,6 @@ async fn sql_pairs_match_native_snapshot_and_partition_tasks() -> anyhow::Result ); let retry = ctx.sql(&sql).await?.collect().await?; assert_eq!(pairs(&retry), pairs(&actual)); - // The same table function can be maintained through the MV unit - // lifecycle. Staging attempts do not make any rows visible. - use lancedb::materialized_view::{ - MaterializedViewDefinition, commit_partitioned_refresh, plan_partitioned_refresh, - prepare_definition, write_refresh_partition, - }; - let view_name = format!("pairs_{}", threshold.to_bits()); - let view = prepare_definition(&table, MaterializedViewDefinition::from_sql(&sql)?) - .await? - .create(&view_name) - .await?; - assert_eq!(view.table().schema().await?.fields().len(), 3); - let plan = plan_partitioned_refresh(view.table(), Some(version)) - .await? - .unwrap(); - assert_eq!(plan.units() as usize, tasks.len()); - let plan = serde_json::from_slice(&serde_json::to_vec(&plan)?)?; - let first = write_refresh_partition(view.table(), 0, &plan).await?; - assert_eq!(view.table().count_rows(None).await?, 0); - assert!( - commit_partitioned_refresh( - view.table(), - &plan, - vec![first.clone(), first.clone()], - view.incarnation() - ) - .await - .is_err() - ); - // Lost receipt: its files remain unreferenced; the retry writes - // another attempt, of which only one receipt is ever committed. - drop(write_refresh_partition(view.table(), 0, &plan).await?); - let mut units = vec![first]; - for unit in 1..plan.units() { - units.push(write_refresh_partition(view.table(), unit, &plan).await?); - } - assert_eq!(view.table().count_rows(None).await?, 0); - let outcome = - commit_partitioned_refresh(view.table(), &plan, units.clone(), view.incarnation()) - .await?; - assert_eq!(outcome.rows_written as usize, pairs(&expected).len()); - assert!( - commit_partitioned_refresh(view.table(), &plan, units, view.incarnation()) - .await - .is_err() - ); - use lancedb::query::ExecutableQuery; - let written = view - .table() - .query() - .execute() - .await? - .try_collect::>() - .await?; - assert_eq!(pairs(&written), pairs(&expected)); - // Repeating a refresh replaces the view rather than appending. - view.refresh().execute().await?; - assert_eq!( - view.table().count_rows(None).await? as usize, - pairs(&expected).len() - ); - // The complete declarative pipeline keeps direct representatives - // and materializes original rows, including isolated source rows. - use lancedb::materialized_view::write_refresh_partition_with_inputs; - use lancedb::query::QueryBase; - let definition = MaterializedViewDefinition::from_sql(&format!( - "SELECT * FROM vector_dedup('source', {version}, 'vector', {threshold})" - ))?; - let clean = prepare_definition(&table, definition) - .await? - .create(&format!("clean_{}", threshold.to_bits())) - .await?; - let clean_plan = plan_partitioned_refresh(clean.table(), Some(version)) - .await? - .unwrap(); - assert_eq!(clean_plan.dependency_units() as usize, tasks.len()); - assert_eq!(clean_plan.units() as usize, tasks.len() + 2); - let clean_plan: lancedb::materialized_view::PartitionedRefreshPlan = - serde_json::from_slice(&serde_json::to_vec(&clean_plan)?)?; - assert!( - write_refresh_partition(clean.table(), tasks.len() as u32, &clean_plan) - .await - .is_err() - ); - let mut receipts = Vec::new(); - for unit in 0..clean_plan.units() { - let dependencies = &receipts[..receipts.len().min(tasks.len())]; - if unit == 0 || unit == tasks.len() as u32 { - // Both selection and materialization can lose a receipt. - drop( - write_refresh_partition_with_inputs( - clean.table(), - unit, - &clean_plan, - dependencies, - ) - .await?, - ); - } - let receipt = write_refresh_partition_with_inputs( - clean.table(), - unit, - &clean_plan, - dependencies, - ) - .await?; - receipts.push(serde_json::from_slice(&serde_json::to_vec(&receipt)?)?); - } - assert_eq!(clean.table().count_rows(None).await?, 0); - assert!( - commit_partitioned_refresh( - clean.table(), - &clean_plan, - receipts[..receipts.len() - 1].to_vec(), - clean.incarnation() - ) - .await - .is_err() - ); - commit_partitioned_refresh(clean.table(), &clean_plan, receipts, clean.incarnation()) - .await?; let mut edges = pairs(&expected) .into_iter() .map(|(a, b, _)| (a.min(b), a.max(b))) @@ -449,27 +328,6 @@ async fn sql_pairs_match_native_snapshot_and_partition_tasks() -> anyhow::Result } } } - let kept = clean - .table() - .query() - .select(lancedb::query::Select::columns(&["id"])) - .execute() - .await? - .try_collect::>() - .await?; - let mut actual_ids = kept - .iter() - .flat_map(|b| { - b.column(0) - .as_any() - .downcast_ref::() - .unwrap() - .values() - .iter() - .copied() - }) - .collect::>(); - actual_ids.sort_unstable(); expected_ids.sort_unstable(); let mut query_ids = clean_query .iter() @@ -486,14 +344,10 @@ async fn sql_pairs_match_native_snapshot_and_partition_tasks() -> anyhow::Result query_ids.sort_unstable(); assert_eq!(query_ids, expected_ids); - assert_eq!(actual_ids, expected_ids); if threshold == 1.0 { - assert!(actual_ids.contains(&0) && actual_ids.contains(&2)); - assert!(!actual_ids.contains(&1), "A-B-C retains A and C"); + assert!(query_ids.contains(&0) && query_ids.contains(&2)); + assert!(!query_ids.contains(&1), "A-B-C retains A and C"); } - clean.refresh().execute().await?; - assert_eq!(clean.table().count_rows(None).await?, expected_ids.len()); - assert_eq!(table.count_rows(None).await?, n * 2); assert!( DuplicatePairsExec::try_new( ds.clone(), @@ -527,10 +381,8 @@ async fn sql_pairs_match_native_snapshot_and_partition_tasks() -> anyhow::Result } #[tokio::test] -async fn dedup_materializes_blob_payloads_null_vectors_and_snapshot_deletions() -> anyhow::Result<()> -{ +async fn dedup_query_materializes_blob_payloads_and_null_vectors() -> anyhow::Result<()> { use arrow_array::{LargeBinaryArray, StringArray, StructArray, types::Float32Type}; - use lancedb::materialized_view::{MaterializedViewDefinition, prepare_definition}; for stable in [false, true] { let dir = tempfile::tempdir()?; let conn = lancedb::connect(dir.path().to_str().unwrap()) @@ -607,7 +459,6 @@ async fn dedup_materializes_blob_payloads_null_vectors_and_snapshot_deletions() true, ) .await?; - let version = ds.version().version; use lancedb::table::datafusion::udtf::{ duplicate_pairs::DuplicatePairsOutput, vector_query::{PartitionSelection, VectorQueryOptions, VectorQueryTable}, @@ -692,59 +543,6 @@ async fn dedup_materializes_blob_payloads_null_vectors_and_snapshot_deletions() assert_eq!(images.value(i), vec![ids.value(i) as u8; 4096]); } } - let definition = MaterializedViewDefinition::from_sql(&format!( - "SELECT * FROM vector_dedup('source', {version}, 'vector', 1)" - ))?; - let clean = prepare_definition(&table, definition) - .await? - .create("clean") - .await?; - // A later source delete does not change the declared result snapshot. - table.delete("id = 0").await?; - let adapter = BaseTableAdapter::try_new(table.base_table().clone()).await?; - let explicit = adapter.vector_query_snapshot(Some(version)).await?; - let latest = adapter.vector_query_snapshot(None).await?; - assert_eq!(explicit.version().version, version); - assert!(latest.version().version > version); - assert_eq!(explicit.count_rows(None).await?, 5); - assert_eq!(latest.count_rows(None).await?, 4); - clean.refresh().execute().await?; - assert_eq!(clean.table().count_rows(None).await?, 4); - let output = Dataset::open(&clean.table().uri().await?).await?; - let batches = output - .scan() - .blob_handling(lance_core::datatypes::BlobHandling::AllBinary) - .try_into_stream() - .await? - .try_collect::>() - .await?; - let mut ids = Vec::new(); - for batch in batches { - let row_ids = batch - .column_by_name("id") - .unwrap() - .as_any() - .downcast_ref::() - .unwrap(); - let images = batch - .column_by_name("image") - .unwrap() - .as_any() - .downcast_ref::() - .unwrap(); - for i in 0..batch.num_rows() { - let id = row_ids.value(i); - assert_eq!(images.value(i), vec![id as u8; 4096]); - ids.push(id); - } - } - ids.sort_unstable(); - assert_eq!(ids, [0, 2, 3, 4]); - assert_eq!( - table.count_rows(None).await?, - 4, - "refresh must not mutate the source" - ); } Ok(()) }