Choosing models¶
Primer ships no model. It points at endpoints you already run, which means the interesting decisions are yours to make and mostly invisible until something is quietly worse than it should be.
Everything here is measured on a CPU-only deployment: two consumer GPUs committed to chat models, embedding and reranking on a 72-core host. Where a number appears, it came from that machine rather than from a model card.
The short version¶
If you want a working configuration to copy rather than a discussion, use
values-selfhosted.yaml and read the rest
when something surprises you.
| Role | Model | Why |
|---|---|---|
| Embedding | onnx-community/Qwen3-Embedding-0.6B-ONNX |
1024-dim, strong retrieval, and an ONNX build that is 3-4x faster on CPU than the safetensors |
| Chunk tokenizer | Qwen/Qwen3-Embedding-0.6B |
must match the embedding model; the chart derives it |
| Reranker | BAAI/bge-reranker-base |
~0.2s for ten passages on CPU, against ~20s for a 0.6B reranker |
| Chat | whatever your GPUs fit | Primer is not opinionated here |
Embedding¶
Pick the ONNX build, not the original repo¶
Text Embeddings Inference prefers its ONNX Runtime backend and silently falls back to Candle when a repository publishes no ONNX. That fallback is the single largest performance decision in this stack, and nothing reports it beyond one line in the server's startup log.
Measured on the same model, same host, same settings:
| batch | Candle | ONNX |
|---|---|---|
| 1 | 1.1 chunks/sec | 2.9 chunks/sec |
| 8 | 0.9 chunks/sec | 3.8 chunks/sec |
| 32 | 1.6 chunks/sec | 4.2 chunks/sec |
Candle is flat: batching buys nothing, and neither does concurrency. Eight concurrent clients took eight times as long as one, because it serialises completely. If your ingestion feels slow and adding workers does not help, this is why - and more workers cannot help, because the queue is not where the time goes.
Qwen/Qwen3-Embedding-0.6B publishes no ONNX.
onnx-community/Qwen3-Embedding-0.6B-ONNX does, at the same 1024
dimensions, and produced vectors identical to four decimal places in a
side-by-side comparison. Use the second.
Take the fp32 onnx/model.onnx, not the int8 or q4 builds beside it.
Quantisation changes the vectors materially, and its damage to retrieval
quality is the kind nobody notices.
The ONNX build needs the pooling mode stated¶
Qwen's own repository ships 1_Pooling/config.json; the ONNX export does
not. TEI refuses to start without it rather than guessing:
Error: The `--pooling` arg is not set and we could not find a pooling
configuration (`1_Pooling/config.json`) for this model.
That refusal is doing you a favour. Qwen3-Embedding pools the last
token ("pooling_mode_lasttoken": true upstream), and mean-pooling it
returns plausible vectors that retrieve badly. Pass --pooling last-token.
Dimensions must match, and cannot be changed later¶
inference.embeddings.dimensions is not checked at startup. The first
document indexed is what fails, and the message is a pgvector error about
column widths.
Worse, it cannot be fixed by editing the value: a vector column keeps the
width it was created with, and the Haystack integration is built with
recreate_table=False. Changing embedding models means dropping
vectors.chunks and reindexing everything.
Decide this once, before anyone uploads anything.
Chunking¶
PRIMER_CHUNK_TOKENIZER decides whether chunks are bounded by tokens and
merged, or split on document structure alone. The chart derives it from
inference.embeddings.model when that names a Hugging Face repository, so
usually you set nothing.
Set ingestion.chunkTokenizer explicitly when your embedding model is
hosted - text-embedding-3-small names no repository and publishes no
tokenizer - and name the nearest open model instead.
Getting this wrong is not subtle in effect but is very subtle in appearance. Structural chunking emits one chunk per text item and merges none of them, so a form or a statement is stored one line at a time. Measured on a W2-shaped document: 20 chunks averaging 13 characters, against a single 274-character passage with peers merged. On a clean markdown table the two chunkers agree exactly, which is why this survives casual testing.
The symptom is a citation that quotes two words. The parse worker warns at startup when it is in this state, because nothing downstream will.
Fragments survive a correct tokenizer too¶
A tokenizer is necessary and not sufficient. HybridChunker merges
undersized chunks only when they are peers - items sharing a heading -
and a form defeats that completely: Docling recognises its labels as
headings, so every value sits alone under a heading of its own with no peer
to merge with.
Measured on a W2-shaped document with the tokenizer correctly configured:
five chunks averaging 10 characters, one of them the bare string
52,000.00. A passage like that answers nothing, and it drags the rest of
the index down with it - a bare Homeowners is short enough to score
against any question mentioning a person, which is how a fragment becomes
the top hit for a question it cannot answer.
ingestion.minChunkChars (default 40) joins such passages to the ones
beside them, across the heading boundary the chunker will not cross. The
contents are joined to each other and the contextualized texts to each
other, so a citation stays quotable from its source while the headings that
name the values survive into what is embedded. Never across a page: a
passage carries one page number, and slides are the sharp case.
Leave it alone unless you have a reason. It is far below prose on purpose -
the fragments it exists for measure 8 to 15 characters, a real sentence 60
or more - and raised towards sentence length it merges passages that were
fine and takes their section citations with them. At 200 it merged an
ordinary two-section paper into one chunk. 0 switches it off.
Changing any of this means reindexing¶
Everything indexed under the old settings is stale together. Reindex all, at the top of a library, rebuilds every document in one press. The library keeps answering from its current index while that runs, and switches over a document at a time as each finishes; nothing is deleted. Documents already being rebuilt are reported separately rather than restarted, so pressing it twice is safe.
Text inside pictures¶
Docling runs OCR in its PDF pipeline only. Every other format Primer accepts is converted by a pipeline that has no OCR setting at all, so a slide deck of pasted charts, or a report whose figures carry its numbers, converts to its headings and a row of empty images.
Primer reads those pictures itself, with the same RapidOCR engine the PDF
pipeline uses. ingestion.maxPicturesPerDocument bounds it, because each
picture costs a full pass and a deck built entirely of screenshots is the
slowest thing the parse worker does. Past the ceiling the remaining images
are left unread rather than the document left unfinished; 0 turns picture
reading off while leaving OCR on for PDFs.
Pictures smaller than 64 pixels on a side are skipped. Bullets, logos and spacers are most of the images in a real document and there is nothing in them to read.
The usual caveat applies and is worth repeating: recognized text is a transcription rather than the document's own characters, so a citation drawn from it can be subtly wrong in a way a reader cannot see. Chunks carry no marker for this.
Reranking¶
A vector search compares a question and a passage through two embeddings made without knowing about each other. A cross-encoder reads the pair together. It is much better and much more expensive, so Primer searches widely and cheaply and has the reranker read a shortlist.
Not the obvious partner model¶
Qwen3-Reranker-0.6B looks like the natural companion to the Qwen3
embedding model, and on a CPU-only deployment it is the wrong choice:
- Qwen publishes it as
Qwen3ForCausalLM. It scores by comparing the logits of "yes" and "no" rather than through a classification head, and TEI's/rerankneeds a sequence classifier. - The community conversion that does have one
(
tomaarsen/Qwen3-Reranker-0.6B-seq-cls) publishes no ONNX, so it runs on Candle. - Candle at that model's shape measures ~1 sequence/sec. Twenty passages is twenty seconds on every question.
BAAI/bge-reranker-base is 12 layers and 768 hidden rather than 28 and
1024, publishes onnx/model.onnx, and measured 0.17-0.27s for ten
passages - so a shortlist of twenty costs well under a second. Its
514-position limit is not a practical constraint when chunks are bounded at
512 tokens.
With a GPU free, revisit this. Without one, spending twenty seconds to spare the model fourteen extra passages is not a trade worth making.
Rerankers are silent when they fail¶
A failed rerank falls back to the vector ordering rather than failing the search, which is right - a reranker that is down should not lose the answer. It also means a misconfigured reranker looks exactly like a working one. After configuring it, ask a question whose answer sits outside the first few vector hits and check that it moves.
The relevance floor¶
inference.minScore drops passages scoring below it, even when that leaves
nothing. Returning nothing is the point: a vector search always returns its
top k, so a question your library cannot answer comes back with a full set
of passages that are only the closest of a bad lot, and the model is handed
them as evidence.
Measured on one library before this existed, against a corpus of mortgage paperwork:
| question | score |
|---|---|
| How much were the total closing costs? | 0.364 |
| Describe the plot of the film Casablanca. | 0.392 |
There is no default, because the right value belongs to whatever produced the score. Cosine from one embedding model is not comparable to cosine from another, and neither is comparable to a reranker's output.
Measure it. Ask a question your corpus answers well, one it answers
poorly, and one it cannot answer at all, then put the floor between the
last two. On the reference configuration below, an unanswerable question
tops out around 0.0003 and the weakest genuine answer measured 0.143, so
0.02 sits comfortably between them.
Err low. A floor that is too high discards correct answers, and a passage that genuinely answers a question can still score modestly.
Serving flags that matter¶
These are Text Embeddings Inference specifics that cost real debugging time.
--max-client-batch-size must be at least what the client sends. Primer's
document embedder sends 32 per request; a lower ceiling rejects every
ingestion batch with a 422 and the document fails with nothing wrong on
Primer's side.
--max-concurrent-requests is counted in inputs, not requests. A
POST /embed carrying 32 inputs enqueues 32 tasks against it, so any value
below the client batch size answers every ingestion batch with a 429 before
anything is inferred. Set it well above batch size x concurrent clients.
--max-batch-tokens is what warmup allocates against, so it is what an
OOM-at-startup is usually made of. It is not what bounds a queue.
--tokenization-workers defaults to the host's core count. Each worker
holds its own tokenizer, which is nearly free for a BPE vocabulary and
expensive for SentencePiece: bge-reranker-base is XLM-RoBERTa with a
~250k-entry vocabulary, and 64 copies of it was 3.4GB of resident memory. A
handful of workers is plenty for reranking, which runs once per search.
--auto-truncate trims an over-long input rather than failing the whole
batch it arrived in.
Sizing¶
Set memory requests above the pod's idle footprint, not below it. A request is what the scheduler packs a node by, so a request under steady-state usage invites Kubernetes to place a pod somewhere it cannot live. The embedding server here idles at ~4.4GiB; it was requesting 3GiB.
Set limits with room for the warmup batch, not from the parameter count. A 278M-parameter reranker was OOM-killed at a 6GiB ceiling that a 600M-parameter embedder lived inside, because the reranker had been given twice the batch tokens.
A reference configuration¶
deploy/helm/primer/values-selfhosted.yaml is the configuration described
above, ready to copy:
helm install primer oci://ghcr.io/isaiah-harville/charts/primer \
--version <release> \
-f values-selfhosted.yaml \
--set ingress.host=primer.example.com \
--set auth.oidc.issuerUrl=https://auth.example.com/application/o/primer/ \
--set auth.oidc.clientId=primer \
--set auth.oidc.existingSecret=primer-oidc \
--set auth.adminGroup='Primer Admins' \
--set postgresql.existingSecret=primer-postgres \
--set rabbitmq.existingSecret=primer-rabbitmq \
--set sourceStore.existingSecret=primer-s3
It assumes you run the two inference servers yourself. Manifests for both,
matching the flags discussed above, are in
examples/inference/.