> ## Documentation Index
> Fetch the complete documentation index at: https://docs.archil.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Vector search with LanceDB

> Store a LanceDB table on an Archil disk, serve vector, filtered, and hybrid search from read-only replicas, and update it in place.

This guide builds a search API over public-domain books with [LanceDB](https://lancedb.com), an embedded vector database
that stores tables as files, so there's no database server to run. An indexer in a [persistent sandbox](/compute/sandboxes/introduction)
writes the table to an Archil disk. Search replicas mount the same disk read-only and answer vector, filtered, and hybrid
(vector plus full-text) queries over HTTPS. When you add or delete documents, the indexer updates the table in place and every
replica serves the new version without a restart. Embeddings come from `all-MiniLM-L6-v2`, a small open model that runs on
CPU, so no model-provider API key is needed.

## Prerequisites

Export your Archil API key and a region that offers sandboxes (`aws-us-east-1`, `aws-us-west-2`, or `aws-eu-west-1`), and
install the SDK for the language you orchestrate from. You'll also need `curl` and `jq`.

```bash theme={null}
export ARCHIL_API_KEY=key-...
export ARCHIL_REGION=aws-us-west-2
npm init -y && npm pkg set type=module && npm install disk tsx   # or: pip install archil
```

<Note>
  The indexer sandbox downloads books from Project Gutenberg and the embedding model from Hugging Face. Sandboxes on the
  Developer plan can reach only a [managed allowlist](/compute/sandboxes/firewall#developer-plan-allowlist) that doesn't
  include those hosts, so this guide needs the Team plan or above.
</Note>

## Write the ingest job

This script runs inside the indexer sandbox. `add` downloads books from Project Gutenberg, splits them into paragraphs, embeds
each one, and writes them to the `books` table. The first `add` creates the table with a vector index and a full-text index;
later ones append rows and call `optimize()`, which folds the new rows into both indexes without rebuilding them. `delete`
removes a book's rows.

```python ingest.py theme={null}
import json, os, re, urllib.request
import lancedb
from fastembed import TextEmbedding
from lancedb.index import FTS, IvfHnswSq

db = lancedb.connect("/mnt/archil/lancedb")
command, *args = json.loads(os.environ["ARGS"])

if command == "add":
    model = TextEmbedding("sentence-transformers/all-MiniLM-L6-v2")
    rows = []
    for book in args:
        text = urllib.request.urlopen(f"https://www.gutenberg.org/cache/epub/{book}/pg{book}.txt").read().decode("utf-8-sig")
        title = re.search(r"Title: (.+)", text).group(1).strip()
        for para in re.split(r"\n\s*\n", text.split("*** START OF")[1].split("*** END OF")[0]):
            para = " ".join(para.split())
            if len(para) >= 200:
                rows.append({"book": title, "text": para})
    for row, vector in zip(rows, model.embed([r["text"] for r in rows], batch_size=64)):
        row["vector"] = vector
    if "books" in db.list_tables().tables:
        table = db.open_table("books")
        table.add(rows)
        table.optimize()
    else:
        table = db.create_table("books", rows)
        table.create_index("vector", config=IvfHnswSq(distance_type="cosine"))
        table.create_index("text", config=FTS())
elif command == "delete":
    table = db.open_table("books")
    table.delete("book = '{}'".format(args[0].replace("'", "''")))

print(f"{table.count_rows()} paragraphs, version {table.version}")
```

## Run the indexer

This script creates the disk and a long-lived indexer sandbox the first time, reuses them after that, and runs `ingest.py`
with whatever arguments you pass. The indexer mounts the disk in [conditional mode](/concepts/sharing-disks), which sends
writes straight to the server and takes no lock, so search replicas can stay attached read-only while it writes.

<CodeGroup>
  ```typescript indexer.ts theme={null}
  import * as archil from "disk";
  import { readFile } from "node:fs/promises";
  const [existing] = await archil.listDisks({ name: "vector-index" });
  const disk = existing ?? (await archil.createDisk({ name: "vector-index" })).disk;
  const indexer = (await archil.listSandboxes()).find((s) => s.name === "vector-indexer") ??
    (await archil.createSandbox({
      name: "vector-indexer", baseImage: "python:3.13", vcpuCount: 4, memSizeMiB: 8192,
      mounts: [{ disk, path: "/mnt/archil", conditional: true }],
    }));
  await indexer.files.uploadFile(await readFile("ingest.py"), "/opt/search/ingest.py");
  const job = await indexer.exec(
    "pip install -q --root-user-action=ignore lancedb==0.40.0 fastembed && python3 /opt/search/ingest.py",
    { env: { ARGS: JSON.stringify(process.argv.slice(2)) }, timeoutSeconds: 900 },
  );
  if (job.exitCode !== 0) throw new Error(job.stderr);
  console.log(job.stdout.trim());
  ```

  ```python indexer.py theme={null}
  import json, sys
  import archil
  existing = archil.list_disks(name="vector-index")
  disk = existing[0] if existing else archil.create_disk(name="vector-index").disk
  indexer = next((s for s in archil.list_sandboxes() if s.name == "vector-indexer"), None) or archil.create_sandbox(
      name="vector-indexer", base_image="python:3.13", vcpu_count=4, mem_size_mib=8192,
      mounts=[archil.SandboxMountSpec(disk=disk, path="/mnt/archil", conditional=True)],
  )
  indexer.files.upload_file("ingest.py", "/opt/search/ingest.py")
  job = indexer.exec(
      "pip install -q --root-user-action=ignore lancedb==0.40.0 fastembed && python3 /opt/search/ingest.py",
      env={"ARGS": json.dumps(sys.argv[1:])},
      timeout_seconds=900,
  )
  if job.exit_code != 0:
      raise RuntimeError(job.stderr)
  print(job.stdout.strip())
  ```
</CodeGroup>

Load five books: Sherlock Holmes, Pride and Prejudice, Frankenstein, Moby Dick, and Alice in Wonderland. The first run takes
about a minute, most of it embedding.

```bash theme={null}
npx tsx indexer.ts add 1661 1342 84 2701 11   # or: python indexer.py add 1661 1342 84 2701 11
```

```
4396 paragraphs, version 3
```

## Write the search server

Each replica runs this server. `GET /search?q=...` runs a vector search; add `book=...` to filter by title, or `mode=hybrid`
to combine vector and full-text ranking. A background thread follows the table as the indexer commits. LanceDB records the
latest version in `latest_version_hint.json`; every second the server re-reads just that file from the server with
[`archil invalidate-cache`](/reference/archil-cli#invalidate-cache), and when it moves, refreshes the mount's cached lookups and
checks out the new version. Requests keep running against the old version until the switch.

```python search_server.py theme={null}
import json, subprocess, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
import lancedb
from fastembed import TextEmbedding

ROOT = "/mnt/archil/lancedb"
HINT = f"{ROOT}/books.lance/_versions/latest_version_hint.json"
model = TextEmbedding("sentence-transformers/all-MiniLM-L6-v2")
table = lancedb.connect(ROOT).open_table("books")

def follow():
    while True:
        time.sleep(1)
        try:
            subprocess.run(["archil", "invalidate-cache", "-q", HINT], check=True, capture_output=True)
            with open(HINT) as f:
                latest = json.load(f)["version"]
            if latest > table.version:
                subprocess.run(["archil", "invalidate-cache", "-q", "/mnt/archil"], check=True, capture_output=True)
                table.checkout_latest()
                print(f"serving version {table.version}", flush=True)
        except Exception as e:
            print(f"still serving version {table.version}: {e}", flush=True)

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):  # GET /search?q=...[&book=...][&mode=hybrid]
        params = {k: v[0] for k, v in parse_qs(urlparse(self.path).query).items()}
        start, version = time.perf_counter(), table.version
        vector = list(model.embed([params.get("q", "")]))[0]
        if params.get("mode") == "hybrid":  # results include _relevance_score
            query, columns = table.search(query_type="hybrid").vector(vector).text(params.get("q", "")), ["book", "text"]
        else:
            query, columns = table.search(vector), ["book", "text", "_distance"]
        if "book" in params:
            query = query.where("book = '{}'".format(params["book"].replace("'", "''")))
        results = query.limit(3).select(columns).to_list()
        took_ms = round((time.perf_counter() - start) * 1000, 1)
        body = json.dumps({"version": version, "took_ms": took_ms, "results": results}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(body)

threading.Thread(target=follow, daemon=True).start()
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
```

## Deploy a search replica

This script creates a sandbox that mounts the disk with `readOnly`, runs the server as a supervised service (restarted on
failure and after reboots), and waits until it answers. The port stays private: the script creates a port token, which callers
send in the `X-Archil-Token` header, and appends the replica's URL and token to `replicas.txt`.

<CodeGroup>
  ```typescript deploy.ts theme={null}
  import * as archil from "disk";
  import { appendFile, readFile } from "node:fs/promises";
  const [disk] = await archil.listDisks({ name: "vector-index" });
  const replica = await archil.createSandbox({
    name: process.argv[2], baseImage: "python:3.13", vcpuCount: 2, memSizeMiB: 4096,
    mounts: [{ disk, path: "/mnt/archil", readOnly: true }],
  });
  await replica.files.uploadFile(await readFile("search_server.py"), "/opt/search/search_server.py");
  const setup = await replica.exec(
    "pip install -q --root-user-action=ignore lancedb==0.40.0 fastembed && " +
      "archil-sandbox services create search --tcp-port 8080 -- python3 /opt/search/search_server.py && " +
      "timeout 120 sh -c 'until curl -s localhost:8080; do sleep 1; done'",
    { timeoutSeconds: 600 },
  );
  if (setup.exitCode !== 0) throw new Error(setup.stderr);
  const { hostname, token } = await replica.createPortToken(8080, { ttl: "24h" });
  await appendFile("replicas.txt", `https://${hostname} ${token}\n`);
  ```

  ```python deploy.py theme={null}
  import sys
  import archil
  disk = archil.list_disks(name="vector-index")[0]
  replica = archil.create_sandbox(
      name=sys.argv[1], base_image="python:3.13", vcpu_count=2, mem_size_mib=4096,
      mounts=[archil.SandboxMountSpec(disk=disk, path="/mnt/archil", read_only=True)],
  )
  replica.files.upload_file("search_server.py", "/opt/search/search_server.py")
  setup = replica.exec(
      "pip install -q --root-user-action=ignore lancedb==0.40.0 fastembed && "
      "archil-sandbox services create search --tcp-port 8080 -- python3 /opt/search/search_server.py && "
      "timeout 120 sh -c 'until curl -s localhost:8080; do sleep 1; done'",
      timeout_seconds=600,
  )
  if setup.exit_code != 0:
      raise RuntimeError(setup.stderr)
  access = replica.create_port_token(8080, ttl="24h")
  with open("replicas.txt", "a") as f:
      f.write(f"https://{access.hostname} {access.token}\n")
  ```
</CodeGroup>

Deploy the first replica and query it from your machine. The second query narrows the search to one book, and the third ranks
by both meaning and the literal words:

```bash theme={null}
npx tsx deploy.ts vector-search-1   # or: python deploy.py vector-search-1
read -r URL TOKEN < replicas.txt
search() { curl -s -G "$URL/search" -H "X-Archil-Token: $TOKEN" --data-urlencode "q=$1" --data-urlencode "$2" | jq -c '.results[] | [.book[:24], .text[:60]]'; }
search "a detective deduces from a stranger's appearance" "mode=vector"
search "a detective deduces from a stranger's appearance" "book=Pride and Prejudice"
search "the white whale" "mode=hybrid"
```

```
["The Adventures of Sherlo","“As a rule,” said Holmes, “the more bizarre a thing is the l"]
["The Adventures of Sherlo","“The case has been an interesting one,” remarked Holmes when"]
["The Adventures of Sherlo","“Had there been women in the house, I should have suspected "]
["Pride and Prejudice","But the attention of every lady was soon caught by a young m"]
["Pride and Prejudice","Mrs. Philips was always glad to see her nieces; and the two "]
["Pride and Prejudice","Elizabeth had settled it that Mr. Darcy would bring his sist"]
["Moby Dick; Or, The Whale","In the distance, a great white mass lazily rose, and rising "]
["Moby Dick; Or, The Whale","“It was the first time in my life that I ever cruised on the"]
["Moby Dick; Or, The Whale","Receiving the top-maul from Starbuck, he advanced towards th"]
```

The vector index is approximate, so your exact results can differ slightly. Without the `X-Archil-Token` header, the same
request gets a `401`.

## Scale out

Read-only mounts take no lock, so any number of replicas can attach to the disk alongside the indexer. Add a second replica,
then send a query to both:

```bash theme={null}
npx tsx deploy.ts vector-search-2   # or: python deploy.py vector-search-2
while read -r URL TOKEN; do
  curl -s -G "$URL/search" -H "X-Archil-Token: $TOKEN" --data-urlencode "q=wolves howling in the mountains at night" \
    | jq -c '[.version, .results[0].book]'
done < replicas.txt
```

```
[3,"Frankenstein; or, the modern prometheus"]
[3,"Frankenstein; or, the modern prometheus"]
```

## Add and delete documents

Dracula isn't in the table yet, so that query has no good answer. Add it while both replicas keep serving. Only the new
paragraphs are embedded and indexed:

```bash theme={null}
npx tsx indexer.ts add 345   # or: python indexer.py add 345
```

```
5457 paragraphs, version 5
```

Run the same loop again, and both replicas answer from the new version:

```
[5,"Dracula"]
[5,"Dracula"]
```

Deleting works the same way. Remove Alice in Wonderland, give the replicas a moment to pick up the new version, then search
each one with a filter on that book:

```bash theme={null}
npx tsx indexer.ts delete "Alice's Adventures in Wonderland"   # or: python indexer.py delete "Alice's Adventures in Wonderland"
sleep 2
while read -r URL TOKEN; do
  curl -s -G "$URL/search" -H "X-Archil-Token: $TOKEN" --data-urlencode "q=a rabbit with a pocket watch" \
    --data-urlencode "book=Alice's Adventures in Wonderland" | jq -c '[.version, (.results | length)]'
done < replicas.txt
```

```
5218 paragraphs, version 6
[6,0]
[6,0]
```

To change rows in place, use `table.update()`, or `table.merge_insert()` for upserts keyed on a column. Each commit is a new
table version, which the replicas pick up the same way.

## Clean up

Stop and delete every sandbox that mounts the disk, then delete the disk.

<CodeGroup>
  ```typescript cleanup.ts theme={null}
  import * as archil from "disk";
  const [disk] = await archil.listDisks({ name: "vector-index" });
  for (const sandbox of await archil.listSandboxes({ disk })) {
    if (sandbox.status !== "stopped") await sandbox.stop();
    await sandbox.delete();
  }
  await disk.delete();
  ```

  ```python cleanup.py theme={null}
  import archil
  disk = archil.list_disks(name="vector-index")[0]
  for sandbox in archil.list_sandboxes(disk=disk):
      if sandbox.status != "stopped":
          sandbox.stop()
      sandbox.delete()
  disk.delete()
  ```
</CodeGroup>

## Next steps

* [LanceDB documentation](https://lancedb.com/docs): index types and tuning for larger tables, reranking, and schema evolution.
* [Sharing disks](/concepts/sharing-disks): how exclusive, conditional, and read-only mounts interact.
* [Persistent sandboxes](/compute/sandboxes/introduction): services, port tokens, and the sandbox lifecycle.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.