diff --git a/.agents/skills/lancedb/SKILL.md b/.agents/skills/lancedb/SKILL.md new file mode 100644 index 000000000..2ed62fc39 --- /dev/null +++ b/.agents/skills/lancedb/SKILL.md @@ -0,0 +1,79 @@ +--- +name: lancedb +description: Use when writing, reviewing, debugging, or documenting LanceDB pipelines in Python or TypeScript, especially code that should work across local LanceDB OSS tables and remote LanceDB Enterprise/Cloud tables. Helps avoid non-portable full-table materialization, choose idiomatic query/search patterns, and apply LanceDB performance defaults for ingestion, indexing, filtering, and diagnostics. +--- + +# Building LanceDB Pipelines + +Use this skill to produce LanceDB pipelines that are portable between local and remote tables (for LanceDB Enterprise/Cloud) and idiomatic for the selected SDK. + +## LanceDB Table Modes + +LanceDB has two common execution modes: + +- **Local table**: embedded, open source, in-process LanceDB. The client opens data from a local path or object storage URI and executes queries in the application process. +- **Remote table**: LanceDB Enterprise/Cloud table opened through a `db://...` URI. The data may be very large, commonly backed by object storage, and queried through a remote service. + +Do NOT assume local-only table helpers exist on remote tables. If the user asks for LanceDB Enterprise, Cloud, `db://...`, production remote access, or a remote table, focus on the remote table path: use `search()` / `query()`, keep reads bounded with `select()` and `limit()`, and avoid table-level full materialization APIs. + +## Workflow + +1. Identify the SDK: Python, TypeScript, or both. +2. Identify the table mode: local/embedded OSS, remote Enterprise/Cloud, or portable across both. If the user says "LanceDB Enterprise", choose the remote table path. +3. Read the matching language branch before writing or changing code: + - Python patterns: `references/python/patterns.md` + - Python API quick reference: `references/python/api_reference.md` + - Python performance guidance: `references/python/performance.md` + - TypeScript patterns: `references/typescript/patterns.md` + - TypeScript API quick reference: `references/typescript/api_reference.md` + - TypeScript performance guidance: `references/typescript/performance.md` +4. Start with `patterns.md` for the selected SDK. Read `api_reference.md` when choosing method names or return collectors. Read `performance.md` when the task involves ingestion, indexing, filtering, query tuning, diagnostics, or large datasets. +5. For Python schemas, favor Pydantic models and validate records before writing. Use PyArrow schemas when Arrow-native, streaming, or highly dynamic data makes them materially better suited. +6. Prefer `search()` or `query()` builders with explicit `select()` and `limit()` for reads. +7. Avoid table-level full materialization in remote or portable code. This is the main local-vs-remote read pitfall. +8. After a successful embedded OSS ingestion, call `table.optimize()`. Do not call it for Enterprise/Cloud; remote maintenance is automatic. +9. For remote Enterprise/Cloud writes, never drop-then-reuse or `mode="overwrite"` the same table name — see "Enterprise: never drop-then-reuse the same table name" below. This is the main local-vs-remote write pitfall. +10. If reviewing an existing file or repo, run `scripts/check_materialization.py` on the relevant paths and inspect each finding before editing. +11. Cross-check unfamiliar or non-trivial API claims against the source tree instead of relying on memory. + +## Core Portability Rule + +Do not write code that assumes a local table API will exist on a remote table. Remote tables can be very large, so whole-table materialization helpers are intentionally unavailable or unsafe. + +This does **not** mean result conversion is forbidden. Bounded query/search result collection is normal: + +- Python: `table.search(...).select([...]).limit(10).to_pandas()` +- TypeScript: `await table.search(...).select([...]).limit(10).toArray()` + +The unsafe pattern is table-level or unbounded collection, plus local-only dataset escape hatches in remote code: + +- Python: `table.to_pandas()`, `table.to_arrow()`, `table.to_polars()`; `table.to_lance()` is local/OSS-only dataset access, not materialization +- TypeScript: `await table.toArrow()`, `await table.query().toArray()` without `limit()` + +## Enterprise: never drop-then-reuse the same table name + +LanceDB Enterprise/Cloud splits a **control plane** (DDL: create/drop/rename) from a **data plane** (query nodes that serve reads). Query nodes cache the resolved dataset for a table name for up to `table_cache_ttl` — **default 300 seconds (5 minutes)**. After you drop or overwrite a table, the control plane updates immediately but the data plane keeps serving the *old* dataset until that cache entry expires. During the window the two planes disagree. + +The failure this causes: you `drop_table("t")` then immediately `create_table("t", ...)` (or `create_table("t", ..., mode="overwrite")`). The DDL returns success, but every query against `t` returns **`500 Internal Server Error`** (the query node resolves the stale/deleted dataset), and a fresh `describe` may still show the *old* schema/version. It looks like your write silently failed; it didn't — the name is cached. + +**`mode="overwrite"` has the same problem** — it is a drop+create of the same name under the hood. + +Rules for portable Enterprise ingestion: + +1. **Never reuse a table name you just dropped/overwrote within the cache TTL.** Do not use `mode="overwrite"` to replace an existing Enterprise table in place. +2. To (re)load data, **write to a fresh table name** (e.g. `_v2`, or a run-stamped suffix). A brand-new name has no cached data-plane entry, so writes and reads work immediately. +3. Before creating, `list_tables()` and **fail loudly if the name already exists** rather than overwriting — prompt for a new name. +4. To land on a specific final name that is currently occupied by an old table: drop the old table, **wait out the TTL (~5 min), then `rename_table(fresh_name, final_name)`**. Renaming onto a name whose old dataset is still cached hits the same race, so the wait is mandatory. `rename_table` is a supported control-plane op. +5. When you hand a table name back to a human, tell them which step still needs the propagation wait (usually: "the old `t` was dropped; run the rename in ~5 minutes"). + +This is Enterprise/Cloud-specific. Local/OSS tables have no separate data plane, so `mode="overwrite"` and immediate same-name reuse are fine there. + +## Script + +Run the scanner when reviewing or modifying an existing codebase: + +```bash +python skills/lancedb/scripts/check_materialization.py path/to/file_or_dir +``` + +The script reports likely unsafe full-table materialization in Python and TypeScript. Treat results as review prompts, not automatic proof of a bug. diff --git a/.agents/skills/lancedb/references/python/api_reference.md b/.agents/skills/lancedb/references/python/api_reference.md new file mode 100644 index 000000000..2c0e34c72 --- /dev/null +++ b/.agents/skills/lancedb/references/python/api_reference.md @@ -0,0 +1,105 @@ +# Python API Reference + +Quick method reference for Python LanceDB code. Cross-check source for non-trivial claims. + +## Connect + +```python +import lancedb + +db = lancedb.connect("./camelot-db") # local/OSS +db = lancedb.connect("db://my-db", api_key=api_key, region=region) # remote +``` + +**Place the local database directory next to the script/entrypoint that opens it** (i.e. resolve the path relative to the script, `Path(__file__).parent / "camelot-db"`), not buried under a shared `data/` folder. The Lance dataset is the database, not a data file — keeping it beside its code makes ownership obvious and paths stable regardless of the working directory the script is launched from. + +**Do not name the directory `lancedb`** (e.g. `./lancedb`, `./data/lancedb`). It collides with the imported `lancedb` package name, which is confusing to read and easy to shadow in scripts. Give it a name derived from the repo or dataset with a clear prefix/suffix — for example `./-db`, `./_lancedb`, or `./vectordb`. + +Async: + +```python +db = await lancedb.connect_async("./camelot-db") +``` + +## Table Reads + +| Task | Preferred API | +| --- | --- | +| Vector search | `table.search(query_vector).limit(k)` | +| Full scan with filters/projection (sync) | `table.search().where(...).select(...).limit(...)` | +| Full scan with filters/projection (async) | `table.query().where(...).select(...).limit(...)` | +| Filter | `.where("col > 10")` | +| Projection | `.select(["id", "text"])` | +| Bound result count | `.limit(20)` | +| Collect bounded result as Python objects (default, no extra deps) | `.to_list()` on query/search result | +| Collect bounded result as Arrow (default, `pyarrow` always available) | `.to_arrow()` on query/search result | +| Collect bounded result as pandas (only if project uses pandas) | `.to_pandas()` on query/search result | +| Collect bounded result as Polars (only if project uses polars) | `.to_polars()` on query/search result | + +## Sync vs Async Scan API + +The plain-scan entry point differs between the sync and async clients. **Verified against `lancedb` 0.34.0** — re-check if the pinned version changes: + +- **Sync** (`lancedb.connect(...)`): the table has **no `.query()` method**. Use `.search()` with no argument for a plain scan; it returns a query builder that supports `.where()`, `.select()`, `.limit()`, and the `.to_list()` / `.to_arrow()` / `.to_pandas()` / `.to_polars()` collectors. + ```python + rows = table.search().where("status = 'ready'").select(["id", "text"]).limit(20).to_list() + ``` +- **Async** (`lancedb.connect_async(...)`): the table has **both** `.query()` and `.search()`. Use `.query()` for a plain scan. + ```python + rows = await async_table.query().where("status = 'ready'").select(["id", "text"]).limit(20).to_list() + ``` + +Do not call `table.query()` on a sync table — it raises `AttributeError`. + +## Local vs Remote Table Methods + +| API | Local table | Remote table | Agent guidance | +| --- | --- | --- | --- | +| `table.search(...)` | Yes | Yes | Preferred read path (sync + async) | +| `table.query()` | Async only | Async only | Sync scan path is `table.search()`; `.query()` is the async scan builder | +| `table.to_pandas()` | Yes | No / unsafe for portability | Avoid in portable code | +| `table.to_arrow()` | Yes | No / unsafe for portability | Avoid in portable code | +| `table.to_polars()` | Yes | No / unsafe for portability | Avoid in portable code | +| `table.to_lance()` | Yes | No | Local/OSS escape hatch only | + +## Indexes + +Use `create_index(...)` for vector indexes and modern index configs. Use scalar indexes for filtered or merge keys. + +Common calls: + +```python +table.create_index("vector") +table.create_scalar_index("status") +table.create_fts_index("text") +``` + +Check source docs before specifying advanced index config names or parameters. + +## Filtering And Recall Knobs + +```python +table.search(query_vector).where("status = 'ready'") # pre-filter by default +table.search(query_vector).where("status = 'ready'", prefilter=False) +table.search(query_vector).limit(10).refine_factor(20) +table.search(query_vector).limit(10).nprobes(50) +``` + +Use post-filtering only when fewer than `limit` results are acceptable. + +## Diagnostics + +```python +print(table.search(query_vector).where("year > 2000").limit(10).analyze_plan()) +print(table.index_stats("vector_idx")) +``` + +Use these before changing indexes or search tuning. + +## Maintenance + +```python +table.optimize() +``` + +Call this after every successful local/OSS ingestion. It handles compaction, cleanup of old versions according to retention, and index optimization. Do not add this for LanceDB Enterprise/Cloud remote tables; Enterprise handles compaction and cleanup automatically from cluster configuration. diff --git a/.agents/skills/lancedb/references/python/patterns.md b/.agents/skills/lancedb/references/python/patterns.md new file mode 100644 index 000000000..4d6be43ef --- /dev/null +++ b/.agents/skills/lancedb/references/python/patterns.md @@ -0,0 +1,173 @@ +# Python Patterns + +Use these patterns when writing Python code with `lancedb`. + +## Before Writing Code + +Choose the output type from what the project actually depends on. **Do not assume `pandas` or `polars` is installed** — they are heavy dependencies that many LanceDB projects do not use. `pyarrow`, by contrast, ships as a LanceDB dependency and is always available, so it is a safe default to lean on. + +Default output (after applying `select()` and `limit()`): + +- **Python objects**: `.to_list()` — a list of dicts, no extra dependencies. Prefer this for scripts, examples, and agent-generated code unless there is a reason to do otherwise. +- **PyArrow**: `.to_arrow()` — a `pyarrow.Table`, when the surrounding code is Arrow-native or you need columnar/zero-copy handoff. + +Only reach for a DataFrame when the project *already* declares that dependency: + +- Pandas projects (pandas in `pyproject.toml`/requirements): `.to_pandas()`. +- Polars projects (polars declared): `.to_polars()`. + +If unsure, check the dependency manifest or the imports in surrounding files. When in doubt, use `.to_list()` or `.to_arrow()`. + +## Schema Design and Validation + +Favor `LanceModel` and Pydantic validation for Python schemas. They keep field +types readable, validate source records before a write, and map directly to a +LanceDB schema. Use `Vector(dimension)` for fixed-size vectors: + +```python +from lancedb.pydantic import LanceModel, Vector + +class Document(LanceModel): + id: int + text: str + vector: Vector(384, nullable=False) + +rows = [Document.model_validate(row) for row in source_rows] +table = db.create_table("documents", schema=Document) +table.add(rows) +``` + +Use PyArrow schemas instead when the pipeline is already Arrow-native, needs +record-batch streaming, or has runtime schema requirements that would make a +Pydantic model harder to understand. Declare Pydantic as a direct project +dependency when application code imports it, even if LanceDB also depends on it. + +## Recommended Patterns + +### Bounded search or query + +Use this for application reads, examples, notebooks, and agent-generated scripts: + +```python +results = ( + table.search(query_vector) + .where("status = 'ready'") + .select(["id", "text"]) + .limit(20) + .to_list() # or .to_arrow(); .to_pandas()/.to_polars() only if the project uses them +) +``` + +Why: `search()` works across local and remote tables and on both the sync and async clients. `select()` avoids fetching unused columns. `limit()` prevents accidental full-table reads. `.to_list()` and `.to_arrow()` avoid assuming pandas/polars is installed (see "Before Writing Code"). + +For a **plain scan** (no query vector), the entry point differs by client: + +```python +# Sync client: no .query() method — use .search() with no argument. +rows = table.search().where("status = 'ready'").select(["id", "text"]).limit(20).to_list() + +# Async client: use .query(). +rows = await async_table.query().where("status = 'ready'").select(["id", "text"]).limit(20).to_list() +``` + +`table.query()` on a sync table raises `AttributeError` (verified on `lancedb` 0.34.0). See the "Sync vs Async Scan API" section in `api_reference.md`. + +### Bounded query result conversion + +It is fine to collect bounded query/search results: + +```python +arrow_table = table.search().select(["id"]).limit(100).to_arrow() # sync plain scan +rows = table.search(query_vector).limit(10).to_list() +df = table.search(query_vector).limit(10).to_pandas() # only if pandas is a project dep +``` + +### Local-only Lance dataset API + +`table.to_lance()` does not itself materialize the full dataset. It returns the underlying `lance.LanceDataset`, making the table accessible through the PyLance dataset API. Use it when the task is explicitly local/OSS and needs Lance dataset methods not exposed by LanceDB: + +```python +# Local/OSS only: RemoteTable does not expose table.to_lance(). +ds = table.to_lance() +for batch in ds.to_batches(columns=["id", "text"], batch_size=10_000): + process(batch) +``` + +### Async Python + +Keep the same shape and bound the result before collecting: + +```python +results = await ( + async_table.query() + .where("status = 'ready'") + .select(["id", "text"]) + .limit(20) + .to_list() # or .to_arrow() +) +``` + +## Anti-Patterns + +**Avoid the following anti-patterns in your code.** + +### Table-level full materialization + +Avoid whole-table collectors in portable or large-table code: + +```python +df = table.to_pandas() +arrow_table = table.to_arrow() +polars_df = table.to_polars() +``` + +Why: local tables expose these whole-table collectors, but remote tables intentionally do not — a remote production table can be far larger than a local development table, so it is easy to accidentally pull the entire table into memory. + +`table.to_lance()` is different: it is not a full materialization call, but it is still local/OSS-only and should not appear in code meant to run against remote Enterprise tables. + +### Unbounded result collection + +Avoid query/search collection without a meaningful limit: + +```python +rows = table.search().to_list() # unbounded plain scan +rows = table.search(query_vector).to_list() # unbounded vector search +``` + +Prefer `select(...).limit(...)` before collecting; for large reads, stream in batches instead. + +### Per-row writes + +Avoid loops that write one row per call: + +```python +for row in rows: + table.add([row]) # one commit + fragment per row +``` + +Each `add()` creates a new version and fragment. Pass the whole batch in a single call, or chunk very large inputs: + +```python +table.add(rows) # single commit +# for very large inputs, add batches of several thousand rows +``` + +After the final successful write to an embedded OSS table, call +`table.optimize()`. Skip this for Enterprise/Cloud tables because their +maintenance is automatic. + +### Drop-then-reuse the same table name (Enterprise/Cloud) + +Avoid dropping or overwriting a remote table and then reusing that name right away: + +```python +db.drop_table("my_table") +table = db.create_table("my_table", data=rows) # reads 500 for ~5 min +table = db.create_table("my_table", data=rows, mode="overwrite") # same problem +``` + +Why: Enterprise/Cloud splits DDL (control plane) from query serving (data plane). The data plane caches the dataset behind a table name for up to `table_cache_ttl` (default 300s / 5 min), so after a drop/overwrite the DDL succeeds but queries against the reused name return `500 Internal Server Error` until the cache expires — and a fresh `describe` may still show the old schema. Instead, write to a **fresh name**, use `list_tables()` and fail if it already exists, then `rename_table(fresh, final)` onto the final name only after the old table's drop has propagated (~5 min). See the "Enterprise: never drop-then-reuse the same table name" section in `SKILL.md`. Local/OSS tables have no separate data plane — overwrite freely there. + +### Guessing performance fixes + +Avoid changing `nprobes`, `refine_factor`, or index types before checking the query plan and index stats. Diagnose first, then tune one knob at a time. diff --git a/.agents/skills/lancedb/references/python/performance.md b/.agents/skills/lancedb/references/python/performance.md new file mode 100644 index 000000000..5fd27440b --- /dev/null +++ b/.agents/skills/lancedb/references/python/performance.md @@ -0,0 +1,131 @@ +# Python Performance Guidance + +Use this when writing Python code that ingests data, queries large tables, builds indexes, or investigates latency. + +## Ingestion + +### Recommended: validate schemas and records with Pydantic + +Favor `LanceModel` for readable Python schema definitions and validate source +records before writing. Use PyArrow directly for Arrow-native or streaming +pipelines where it is the clearer representation. + +```python +from lancedb.pydantic import LanceModel, Vector + +class Document(LanceModel): + id: int + text: str + vector: Vector(384, nullable=False) + +rows = [Document.model_validate(row) for row in source_rows] +table = db.create_table("documents", schema=Document) +table.add(rows) +``` + +### Recommended: bulk ingestion for materialized data + +```python +table.add(arrow_table) +table.add(df) +table.add(pa.dataset("data/", format="parquet")) +``` + +For very large initial loads, create the table empty first, then call `add(...)`. Passing data directly to `create_table(name, data)` can skip the auto-parallel write path. + +### Recommended: iterator ingestion for generated or streamed data + +```python +def batches(): + for raw in source: + vectors = model.encode(raw["text"]) + yield pa.RecordBatch.from_pydict({**raw, "vector": vectors}) + +table.add(batches()) +``` + +Use chunks of several thousand rows or more when practical. Tiny batches and per-row writes create many small fragments. + +### Anti-pattern: per-row `add()` + +```python +for row in rows: + table.add([row]) +``` + +Each call creates a version and fragment. This slows ingestion and later queries. + +## Indexing + +- Build a vector index once brute-force vector search becomes too slow. As a rule of thumb, local brute force is fine below roughly 100K vectors; beyond that, build an index. +- Use `IVF_PQ` as the general-purpose default. Enterprise builds this automatically. +- Use scalar indexes for filtered columns and merge/upsert keys. +- Use `BTREE` for mostly distinct numeric/string/temporal columns, `BITMAP` for booleans and low-cardinality columns, and `LABEL_LIST` for list membership queries. +- Keep full-text defaults unless phrase queries require position data. + +## Querying + +Always be explicit: + +```python +table.search(query_vector).select(["id", "title"]).limit(20) +``` + +- `select()` reduces bytes read and transferred. +- `limit()` prevents accidental full-table materialization. +- Pre-filtering is the default and guarantees returned rows satisfy the predicate. +- Use post-filtering only when fewer than `limit` results are acceptable. + +## Recall Tuning + +Tune one knob at a time: + +- Quantized indexes: raise `refine_factor` to rescore more candidates on full vectors. +- HNSW-backed indexes: raise `ef`; start around `1.5 * k`, increase 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. + +## Maintenance + +After every successful embedded OSS/local ingestion, call `table.optimize()`. +Do not add this to LanceDB Enterprise/Cloud remote table code; remote compaction +and cleanup are handled automatically based on the Enterprise cluster +configuration. + +Why local maintenance is needed: + +- Frequent writes can create many small fragments. Queries then need to scan across more files, which can increase latency. +- Updates, deletes, and appends create new table versions. Old versions are retained for time travel and rollback, which can grow disk usage. +- Indexes may have newly added rows that are not yet fully optimized into the index structure. + +For local/OSS tables, run `optimize()` after the final successful ingestion +write. Also run it after later batches of update/delete operations or on a +regular maintenance schedule: + +```python +table.optimize() +``` + +If the user wants more aggressive local disk cleanup, pass a shorter cleanup retention window: + +```python +from datetime import timedelta + +table.optimize(cleanup_older_than=timedelta(days=1)) +``` + +Do not use very short cleanup windows when the application depends on time travel, rollback, or old versions. + +## Diagnostics + +Before changing code or indexes, inspect: + +```python +print(table.search(query_vector).where("year > 2000").limit(10).analyze_plan()) +print(table.index_stats("vector_idx")) +``` + +Look for high scan bytes, missing indexes, fragmented data, and unindexed rows. + +## Python Multiprocessing + +When using multiprocessing, use `spawn` rather than `fork`. LanceDB is multi-threaded internally, and `fork` plus a multi-threaded process is unsafe. diff --git a/.agents/skills/lancedb/references/typescript/api_reference.md b/.agents/skills/lancedb/references/typescript/api_reference.md new file mode 100644 index 000000000..aec07c9cb --- /dev/null +++ b/.agents/skills/lancedb/references/typescript/api_reference.md @@ -0,0 +1,78 @@ +# TypeScript API Reference + +Quick method reference for TypeScript LanceDB code. Cross-check source for non-trivial claims. + +## Connect + +```typescript +import * as lancedb from "@lancedb/lancedb"; + +const db = await lancedb.connect("./camelot-db"); +``` + +**Place the local database directory next to the script/entrypoint that opens it** (resolve the path relative to the module, e.g. via `import.meta.dirname` / `__dirname`), not buried under a shared `data/` folder. The Lance dataset is the database, not a data file — keeping it beside its code makes ownership obvious and paths stable regardless of the working directory the script is launched from. + +**Do not name the directory `lancedb`** (e.g. `./lancedb`, `./data/lancedb`). It collides with the imported `lancedb` package/namespace, which is confusing to read. Give it a name derived from the repo or dataset with a clear prefix/suffix — for example `./-db`, `./_lancedb`, or `./vectordb`. + +Remote connections use `db://...` plus Enterprise/Cloud credentials and deployment settings. Check current source/docs for exact connection options. + +## Table Reads + +| Task | Preferred API | +| --- | --- | +| Vector search | `table.search(queryVector).limit(k)` | +| Full scan with filters/projection | `table.query().where(...).select(...).limit(...)` | +| Filter | `.where("col > 10")` | +| Projection | `.select(["id", "text"])` | +| Bound result count | `.limit(20)` | +| Collect bounded result as objects | `.toArray()` on query/search result | +| Collect bounded result as Arrow | `.toArrow()` on query/search result | +| Stream result batches | `for await (const batch of table.query()...)` | + +## Local vs Remote Safety + +| API | Agent guidance | +| --- | --- | +| `table.search(...)` | Preferred read path | +| `table.query()` | Preferred scan/filter path | +| `await table.toArrow()` | Avoid in portable or large-table code | +| `await table.query().toArray()` with no `limit()` | Avoid; unbounded collection | +| `await table.query().toArrow()` with no `limit()` | Avoid; unbounded collection | + +## Indexes + +```typescript +await table.createIndex("vector"); +await table.createIndex("status"); +``` + +Use vector indexes for large vector search workloads and scalar indexes for filtered columns or merge/upsert keys. Check source/docs before specifying advanced index options. + +## Filtering And Recall Knobs + +```typescript +await table.search(queryVector).where("status = 'ready'").limit(10).toArray(); +await table.search(queryVector).limit(10).refineFactor(20).toArray(); +await table.search(queryVector).limit(10).nprobes(50).toArray(); +await table.search(queryVector).limit(10).ef(100).toArray(); +await table.search(queryVector).where("status = 'ready'").postfilter().limit(10).toArray(); +``` + +Use `postfilter()` only when fewer than `limit` results are acceptable. + +## Diagnostics + +```typescript +console.log(await table.search(queryVector).where("year > 2000").limit(10).analyzePlan()); +console.log(await table.indexStats("vector_idx")); +``` + +Use these before changing indexes or search tuning. + +## Maintenance + +```typescript +await table.optimize(); +``` + +Call this after every successful local/OSS ingestion. It handles compaction, cleanup of old versions according to retention, and index optimization. Do not add this for LanceDB Enterprise/Cloud remote tables; Enterprise handles compaction and cleanup automatically from cluster configuration. diff --git a/.agents/skills/lancedb/references/typescript/patterns.md b/.agents/skills/lancedb/references/typescript/patterns.md new file mode 100644 index 000000000..1aa380968 --- /dev/null +++ b/.agents/skills/lancedb/references/typescript/patterns.md @@ -0,0 +1,100 @@ +# TypeScript Patterns + +Use these patterns when writing TypeScript code with `@lancedb/lancedb`. + +## Recommended Patterns + +### Bounded query + +Use this for application reads, scripts, and examples: + +```typescript +const rows = await table + .query() + .where("status = 'ready'") + .select(["id", "text"]) + .limit(20) + .toArray(); +``` + +### Bounded vector search + +```typescript +const rows = await table + .search(queryVector) + .select(["id", "text"]) + .limit(20) + .toArray(); +``` + +### Batch streaming for larger reads + +When the task needs many rows, avoid collecting everything at once: + +```typescript +for await (const batch of table + .query() + .where("status = 'ready'") + .select(["id", "text"]) + .limit(10_000)) { + process(batch); +} +``` + +## Anti-Patterns + +**Avoid the following anti-patterns in your code.** + +### Table-level full materialization + +Avoid whole-table collectors in portable or large-table code: + +```typescript +const tableArrow = await table.toArrow(); +``` + +Why: local tables expose these whole-table collectors, but remote tables intentionally do not — a remote production table can be far larger than a local development table, so it is easy to accidentally pull the entire table into memory. + +### Unbounded result collection + +Avoid query/search collection without a meaningful limit: + +```typescript +const rows = await table.query().toArray(); // unbounded plain scan +const rows = await table.search(queryVector).toArray(); // unbounded vector search +``` + +Prefer `select(...).limit(...)` before collecting; for large reads, stream in batches instead. + +### Per-row writes + +Avoid loops that write one row per call: + +```typescript +for (const row of rows) { + await table.add([row]); // one commit + fragment per row +} +``` + +Each `add()` creates a new version and fragment. Pass the whole batch in a single call, or chunk very large inputs: + +```typescript +await table.add(rows); // single commit +// for very large inputs, add in chunks of several thousand rows +``` + +### Drop-then-reuse the same table name (Enterprise/Cloud) + +Avoid dropping or overwriting a remote table and then reusing that name right away: + +```typescript +await db.dropTable("my_table"); +const table = await db.createTable("my_table", rows); // reads 500 for ~5 min +const table = await db.createTable("my_table", rows, { mode: "overwrite" }); // same problem +``` + +Why: Enterprise/Cloud splits DDL (control plane) from query serving (data plane). The data plane caches the dataset behind a table name for up to `table_cache_ttl` (default 300s / 5 min), so after a drop/overwrite the DDL succeeds but queries against the reused name return `500 Internal Server Error` until the cache expires — and a fresh `describe` may still show the old schema. Instead, write to a **fresh name**, use `tableNames()` and fail if it already exists, then `renameTable(fresh, final)` onto the final name only after the old table's drop has propagated (~5 min). See the "Enterprise: never drop-then-reuse the same table name" section in `SKILL.md`. Local/OSS tables have no separate data plane — overwrite freely there. + +### Guessing performance fixes + +Avoid changing `nprobes`, `refineFactor`, `ef`, or index settings before checking `analyzePlan()` and `indexStats(...)`. Diagnose first, then tune one knob at a time. diff --git a/.agents/skills/lancedb/references/typescript/performance.md b/.agents/skills/lancedb/references/typescript/performance.md new file mode 100644 index 000000000..9bf07e9ae --- /dev/null +++ b/.agents/skills/lancedb/references/typescript/performance.md @@ -0,0 +1,78 @@ +# TypeScript Performance Guidance + +Use this when writing TypeScript code that ingests data, queries large tables, builds indexes, or investigates latency. + +## Ingestion + +- Prefer bulk or batched writes. +- Avoid per-row write loops; they create many small commits/fragments. +- For generated data, accumulate reasonable batches before adding. +- For file-backed data, prefer APIs that stream from Arrow/Parquet-style inputs when available. + +## Indexing + +- Build a vector index once brute-force vector search becomes too slow. As a rule of thumb, local brute force is fine below roughly 100K vectors; beyond that, build an index. +- Use the general-purpose vector index defaults unless the task has explicit recall/latency requirements. +- Build scalar indexes for filtered columns and merge/upsert keys. +- Use full-text index phrase options only when phrase queries require them. + +## Querying + +Always be explicit: + +```typescript +await table.search(queryVector).select(["id", "title"]).limit(20).toArray(); +``` + +- `select()` reduces bytes read and transferred. +- `limit()` prevents accidental full-table collection. +- Pre-filtering is the default behavior. Use `postfilter()` only when fewer than `limit` results are acceptable. + +## Recall Tuning + +Tune one knob at a time: + +- Quantized indexes: raise `refineFactor(...)` to rescore more candidates on full vectors. +- HNSW-backed indexes: raise `ef(...)`; start around `1.5 * k`, increase toward `10 * k` if recall is short. +- IVF candidate breadth: `nprobes(...)` is usually auto-tuned; override only when a selective pre-filter leaves too few neighbors. + +## Maintenance + +After every successful embedded OSS/local ingestion, call `table.optimize()`. +Do not add this to LanceDB Enterprise/Cloud remote table code; remote compaction +and cleanup are handled automatically based on the Enterprise cluster +configuration. + +Why local maintenance is needed: + +- Frequent writes can create many small fragments. Queries then need to scan across more files, which can increase latency. +- Updates, deletes, and appends create new table versions. Old versions are retained for time travel and rollback, which can grow disk usage. +- Indexes may have newly added rows that are not yet fully optimized into the index structure. + +For local/OSS tables, run `optimize()` after the final successful ingestion +write. Also run it after later batches of update/delete operations or on a +regular maintenance schedule: + +```typescript +await table.optimize(); +``` + +If the user wants more aggressive local disk cleanup, pass a shorter cleanup retention window: + +```typescript +const olderThan = new Date(Date.now() - 24 * 60 * 60 * 1000); +await table.optimize({ cleanupOlderThan: olderThan }); +``` + +Do not use very short cleanup windows when the application depends on time travel, rollback, or old versions. + +## Diagnostics + +Before changing code or indexes, inspect: + +```typescript +console.log(await table.search(queryVector).where("year > 2000").limit(10).analyzePlan()); +console.log(await table.indexStats("vector_idx")); +``` + +Look for high scan cost, missing indexes, fragmented data, and unindexed rows. diff --git a/.agents/skills/lancedb/scripts/check_materialization.py b/.agents/skills/lancedb/scripts/check_materialization.py new file mode 100644 index 000000000..cbd8abc04 --- /dev/null +++ b/.agents/skills/lancedb/scripts/check_materialization.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Scan Python and TypeScript for likely unsafe LanceDB materialization.""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +PY_FULL_TABLE = re.compile(r"\b\w+\.(to_pandas|to_arrow|to_polars)\s*\(") +TS_TABLE_TO_ARROW = re.compile(r"\b\w+\.toArrow\s*\(") +TS_QUERY_COLLECTOR = re.compile(r"\.query\s*\(\s*\)[\s\S]*?\.to(Array|Arrow)\s*\(") + + +@dataclass +class Finding: + path: Path + line: int + message: str + text: str + + +def iter_files(paths: list[Path]) -> list[Path]: + files: list[Path] = [] + for path in paths: + if path.is_dir(): + files.extend( + p + for p in path.rglob("*") + if p.suffix in {".py", ".ts", ".tsx"} and "node_modules" not in p.parts + ) + elif path.suffix in {".py", ".ts", ".tsx"}: + files.append(path) + return sorted(set(files)) + + +def line_number(text: str, offset: int) -> int: + return text.count("\n", 0, offset) + 1 + + +def scan_python(path: Path, text: str) -> list[Finding]: + findings: list[Finding] = [] + for match in PY_FULL_TABLE.finditer(text): + line_start = text.rfind("\n", 0, match.start()) + 1 + line_end = text.find("\n", match.start()) + if line_end == -1: + line_end = len(text) + line = text[line_start:line_end].strip() + if ".search(" in line or ".query(" in line: + continue + findings.append( + Finding( + path, + line_number(text, match.start()), + f"Review Python `{match.group(1)}()` call; table-level materialization is not portable to remote tables.", + line, + ) + ) + return findings + + +def statement_around(text: str, start: int, end: int) -> str: + before = max(text.rfind(";", 0, start), text.rfind("\n\n", 0, start)) + after_candidates = [pos for pos in (text.find(";", end), text.find("\n\n", end)) if pos != -1] + after = min(after_candidates) if after_candidates else len(text) + return text[before + 1 : after].strip() + + +def scan_typescript(path: Path, text: str) -> list[Finding]: + findings: list[Finding] = [] + for match in TS_TABLE_TO_ARROW.finditer(text): + stmt = statement_around(text, match.start(), match.end()) + if ".query(" in stmt or ".search(" in stmt: + continue + findings.append( + Finding( + path, + line_number(text, match.start()), + "Review TypeScript `table.toArrow()`-style call; table-level materialization is not portable for large/remote tables.", + stmt.splitlines()[0].strip(), + ) + ) + + for match in TS_QUERY_COLLECTOR.finditer(text): + stmt = statement_around(text, match.start(), match.end()) + if ".limit(" in stmt: + continue + findings.append( + Finding( + path, + line_number(text, match.start()), + "Review unbounded TypeScript query collection; add `limit()` or stream batches.", + stmt.splitlines()[0].strip(), + ) + ) + return findings + + +def scan_file(path: Path) -> list[Finding]: + text = path.read_text(encoding="utf-8", errors="replace") + if path.suffix == ".py": + return scan_python(path, text) + if path.suffix in {".ts", ".tsx"}: + return scan_typescript(path, text) + return [] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", type=Path) + parser.add_argument( + "--no-fail", action="store_true", help="Always exit 0 after reporting findings." + ) + args = parser.parse_args() + + findings: list[Finding] = [] + for path in iter_files(args.paths): + findings.extend(scan_file(path)) + + for finding in findings: + print(f"{finding.path}:{finding.line}: {finding.message}") + print(f" {finding.text}") + + if findings: + print( + f"\n{len(findings)} finding(s). Review manually; bounded query result conversion may be OK." + ) + return 0 if args.no_fail or not findings else 1 + + +if __name__ == "__main__": + sys.exit(main())