docsv0.5.1

Serve a corpus over HTTP

Run the FastAPI and Flask examples from the urna repo to answer questions over HTTP with cited chunks, and what to change before serving a real corpus.

The urna repo has two small web apps, one in FastAPI and one in Flask, that open a .urna file once and answer POST /ask with cited chunks. Both need only the urna wheel, not a repo build. This page runs them and lists what to change before you serve your own corpus.

Run the FastAPI example

Get examples/fastapi/main.py from the repo, then from its directory:

pip install fastapi uvicorn "urna[embed]"
uvicorn main:app --port 8000

Ask it a question:

curl -s localhost:8000/ask -H 'content-type: application/json' \
  -d '{"query": "vector search on the edge", "k": 2}'

Run the Flask example

Get examples/flask/app.py, then from its directory:

pip install flask "urna[embed]"
flask --app app run --port 8000

The same curl works against it.

What the examples do

Both apps follow the same steps:

  1. On startup, if the corpus file does not exist, build a demo corpus there: six sentences embedded with the bundled potion table, written with urna.build(..., reproducible=True). The path comes from URNA_FILE, with demo_fastapi.urna or demo_flask.urna as the default. FastAPI does this in its lifespan handler, Flask at import.
  2. Open the file with urna.open, run validate(), and keep the handle for every request.
  3. On POST /ask with a JSON body {"query": ..., "k": ...} (k defaults to 3), embed the query with potion, call db.retrieve(qvec, k), and return the text, score, citation and source of each hit.
  4. On GET /health, return the corpus path and its file_hash.

The /ask response has this shape:

{
  "hits": [
    {"text": "<stored chunk text>", "score": <cosine>, "citation_id": "urna://sha256:<...>/sha256:<...>", "source_uri": "example://fastapi/<n>"},
    ...
  ]
}

The handlers copy four fields out of each hit into a dict. They have to: hit objects are not JSON serializable. The other fields are listed on SearchHit and RetrieveHit.

Serve your own corpus

Point URNA_FILE at your file:

URNA_FILE=/srv/corpora/handbook.urna uvicorn main:app --port 8000

Before you do, change four things.

Check the model

The examples embed every query with potion. They only give meaningful answers for a corpus built with potion, whose manifest says embedding_model = "minishlab/potion-base-8M/v1". Check with urna stats or in Python:

db.inspect()["manifest"]["embedding_model"]

For a corpus built with another model, embed queries with that model instead. See Embedders.

Turn on the model gate

The examples call retrieve without expected_model_hash, so nothing checks that the query model matches the corpus. Add it to the handler, and refuse to start when the hashes differ:

@asynccontextmanager
async def lifespan(app: FastAPI):
    db = urna.open(str(URNA_FILE))
    if db.model_hash != emb.model_hash():
        raise RuntimeError(f"{URNA_FILE} was not built with {emb.embedding_model}")
    app.state.db = db
    yield


@app.post("/ask")
def ask(req: Ask):
    qvec = emb.embed_texts([req.query])[0]
    hits = app.state.db.retrieve(qvec, req.k, expected_model_hash=emb.model_hash())
    ...

urna.open already validates the whole file, so the extra validate() call in the examples is optional. Why the gate matters is on The model gate.

Drop the demo bootstrap

Both apps build the demo corpus at URNA_FILE whenever that path does not exist. With a typo in the path, the server starts and answers from six demo sentences. Remove _bootstrap_corpus and let a missing file fail at urna.open with ValueError: No such file or directory (os error 2).

Validate k

k of 0 or less raises ValueError: invalid k: 0, which both frameworks return as a server error. Reject it in the request model, and cap it: retrieve returns at most one hit per chunk.

Concurrency

The app opens the file once and shares the UrnaFile across requests. That is safe: the file is read-only.

No urna call releases the GIL, so inside one process requests search one at a time, including FastAPI's thread pool for plain def endpoints. To serve requests in parallel, run several worker processes, for example uvicorn main:app --workers 4. Each worker opens its own mapping of the file.

Build the corpus before you start several workers. With the demo bootstrap left in, each FastAPI worker runs the lifespan handler and could try to build the same file at the same time.

retrieve decodes the stored text of every chunk on each call to attach text to the hits. On a large corpus that decode dominates the request. If a route needs only ids, scores and citations, call db.search or db.search_ann instead and fetch text for the few hits you show.

Replace the corpus

UrnaFile has no close(), and urna.build writes straight to its output path with no temporary file. Do not rebuild over the file a running server has open:

  1. Build the new corpus to a new path.
  2. Check it with urna validate and note its file_hash.
  3. Point URNA_FILE at the new path, or move it into place with os.replace, then restart the workers.
  4. Compare GET /health with the file_hash from step 2 to confirm what each server loaded.

A file from someone else passes urna.open when its bytes are consistent. That proves integrity, not who made it. See Security before you serve a corpus you did not build.

To run the server in a container, continue with Run in Docker.

On this page