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 8000Ask 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 8000The same curl works against it.
What the examples do
Both apps follow the same steps:
- 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 fromURNA_FILE, withdemo_fastapi.urnaordemo_flask.urnaas the default. FastAPI does this in its lifespan handler, Flask at import. - Open the file with
urna.open, runvalidate(), and keep the handle for every request. - On
POST /askwith a JSON body{"query": ..., "k": ...}(kdefaults to 3), embed the query with potion, calldb.retrieve(qvec, k), and return the text, score, citation and source of each hit. - On
GET /health, return the corpus path and itsfile_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 8000Before 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:
- Build the new corpus to a new path.
- Check it with
urna validateand note itsfile_hash. - Point
URNA_FILEat the new path, or move it into place withos.replace, then restart the workers. - Compare
GET /healthwith thefile_hashfrom 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.
Tune media compression
Choose a media profile for an urna image corpus, override single knobs, let the crf gate pick the AV1 rate, or keep JPEGs byte-reversible.
Run in Docker
Build the urna Docker image from the repository's Dockerfile, a static binary in a scratch image, and run the engine verbs on a mounted corpus.