RagMeUp.Client
0.6.0
dotnet add package RagMeUp.Client --version 0.6.0
NuGet\Install-Package RagMeUp.Client -Version 0.6.0
<PackageReference Include="RagMeUp.Client" Version="0.6.0" />
<PackageVersion Include="RagMeUp.Client" Version="0.6.0" />
<PackageReference Include="RagMeUp.Client" />
paket add RagMeUp.Client --version 0.6.0
#r "nuget: RagMeUp.Client, 0.6.0"
#:package RagMeUp.Client@0.6.0
#addin nuget:?package=RagMeUp.Client&version=0.6.0
#tool nuget:?package=RagMeUp.Client&version=0.6.0
RAG engine
Document RAG over Postgres + pgvector. The engine owns ingestion, embedding, and reranking; the app owns filtered retrieval as direct SQL on tables it owns.
service/ Python FastAPI engine (`ragmeup` package): /embed, /documents, /rerank
client/ C# read-path library (RagMeUp.Client): rank, build passages, rerank, read spans
db/ schema.sql — documents + chunks tables, HNSW + GIN indexes
migrations/ — deltas on top of it, applied by the client at startup
Each half carries its own Azure DevOps pipeline (*/azure-pipelines.yml) and
releases independently — the client to nuget.org, the engine as a container image.
See CI/CD.
Database
Needs pgvector >= 0.7.0. A .NET application sets its own schema up at startup, on its own connection:
await using (var ddl = await ownerSource.OpenConnectionAsync())
await RagSchema.Upgrade(ddl, engineHttp);
Passing the engine client is what makes it a compatibility check: it reads the schema version that engine requires and refuses, before touching the database, when the installed package is too old — naming the version to install instead of failing later on a missing column. The engine itself never runs DDL on your tables (why).
Anything not .NET, or a database you would rather build by hand:
psql "$DATABASE_URL" -f db/schema.sql
Same result, and an existing database built this way is adopted in place the first time a client runs. See database setup.
Service
Models live on a volume mounted at /models, not baked into the image, so code
changes rebuild and push only small layers. A one-shot seed populates the volume.
cd service
DATABASE_URL=postgres://… docker compose up --build
Compose runs model-seed first (downloads weights to the models volume), then
starts engine offline against it. On OpenShift, the equivalent is a PVC at
/models seeded by an init Job (or initContainer) running
python -m ragmeup.download_models.
Local dev without containers: pip install -r requirements.txt then
DATABASE_URL=… uvicorn ragmeup.main:app (from service/). Models download from
the hub on first run.
On a GPU dev box, install torch first, from PyTorch's CUDA wheel index
(--index-url https://download.pytorch.org/whl/cu130), matching your driver;
Blackwell cards need cu128 or later. requirements.txt names no index, and on
Windows the default PyPI wheel is CPU-only, so the requirements install leaves a
GPU box quietly running every embed and rerank on the CPU. Verify with
torch.cuda.is_available(), and check the card's compute capability appears in
torch.cuda.get_arch_list(), or the kernels are JIT-compiled from PTX at first
use. This is the same trap the image flavours below spell out, from the other
direction.
Then keep it that way by naming the device instead of leaving it to torch: with
RAG_EMBED_DEVICE and RAG_RERANK_DEVICE set to cuda:0, a venv that has lost
its CUDA wheel fails with No CUDA GPUs are available instead of quietly serving
from the CPU at a fraction of the speed. The run-backend*.ps1 dev scripts pin it
for this reason (-Device cpu when you want the CPU path deliberately).
You should not have to infer any of this, so the engine states it at startup, one line per model:
INFO ragmeup.main: embedder BAAI/bge-m3 on cuda:0
INFO ragmeup.main: reranker BAAI/bge-reranker-v2-m3 on cuda:0
Read that line before trusting a benchmark. Note where a wrong device instead
fails: on the first /embed or /rerank, as a 500, not at startup.
Constructing a backend does not touch the device (FlagEmbedding moves the weights
on the first forward pass), so lifespan builds both models and /healthz
answers 200 on a box whose GPU is gone. A 200 there proves the weights are
resident, which is what it claims; it does not prove they are on the device you
asked for. The startup line is what proves that.
Image flavours
One Dockerfile, two bases, selected by FLAVOR — the shared tail (deps, app,
entrypoint) is written once so the flavours cannot drift:
docker build . # cuda, the default — GPU host (~8 GB)
docker build --build-arg FLAVOR=cpu . # cpu — CI and a GPU-less VPS (~2.4 GB)
The CPU flavour installs torch from PyTorch's CPU wheel index; a plain
pip install torch would pull the CUDA build and ~2.5 GB of nvidia-* wheels
that a CPU host can never use.
Configuring a container
Everything is environment variables — the image has no config file. Only
DATABASE_URL is required; every other knob has a working default, and the
model paths are already set in the image.
docker run -d --name rag-engine \
-e DATABASE_URL=postgres://user:pass@db:5432/ragdb \
-e RAG_TORCH_THREADS=4 \
-v models:/models -e HF_HUB_OFFLINE=1 -e TRANSFORMERS_OFFLINE=1 \
-p 127.0.0.1:8000:8000 \
ghcr.io/jgauffin/ragmeup-engine:latest
| Variable | Default | Effect |
|---|---|---|
DATABASE_URL |
required unless RAG_TARGETS is set |
Postgres+pgvector for the engine's own (privileged) ingestion pool. Also the default ingestion target — the database /documents writes to when a request names none. |
RAG_TARGETS |
unset | JSON {name: dsn} — or {name: {"dsn": …, …}} to give that target its own write-path settings — inline or a path to a file, naming the databases one engine may ingest into. Lets several applications share one loaded set of models — see sharing one engine. |
RAG_EXPOSE_TARGET_NAMES |
off | Lets GET /targets list the names of configured RAG_TARGETS entries. Off by default because a shared engine's roster is a list of every other application on the box; the DATABASE_URL target is always reported, and withheld names are still counted. |
RAG_TARGET_POOL_MIN / RAG_TARGET_POOL_MAX |
1 / 4 |
Connections held per target (†). The ceiling is unchanged from before; the floor is 1 so an idle target costs one connection, not four. |
HF_HUB_OFFLINE, TRANSFORMERS_OFFLINE |
unset | Set to 1 when /models is seeded, so nothing ever reaches the hub at runtime. |
BGE_M3_PATH |
/models/bge-m3 |
Embedder weights. Set in the image; override only to relocate the volume. |
RERANKER_PATH |
/models/bge-reranker-v2-m3 |
Default reranker weights. Likewise. |
RERANKER_CROSS_ENCODER_PATH |
/models/ms-marco-MiniLM-L6-v2 |
Weights for the cross-encoder backend. Set in the image and seeded into the volume, so that backend works under HF_HUB_OFFLINE=1 too. Outside a container it falls back to the hub id. |
RAG_CHUNK_STRATEGY |
window (whitespace windows) |
recursive walks a separator hierarchy (paragraph → sentence → whitespace) but is blind to markdown; structure (alias structure-aware) makes the markdown section the unit, so a heading bounds its chunks instead of merely being a preferred cut point (†) — structure is the one to reach for on markdown corpora. An unrecognised name is rejected rather than silently windowed. Changing it only affects newly ingested documents. |
RAG_CHUNK_SIZE / RAG_CHUNK_OVERLAP |
1200 / 150 |
Window size and overlap, in characters (†). Overlap must be smaller than size, or a window could not advance. |
RAG_EMBED_DEVICE |
torch's choice | e.g. cuda:0, cpu. Not a model selector: the embedding model is fixed by the schema's column dimensions — swapping it is a re-index event. |
RAG_RERANKER_BACKEND |
multilingual bge | cross-encoder selects the ~22M-param English-only model — a different quality/latency point, much cheaper on CPU. berget moves reranking to a hosted API, leaving nothing resident. |
RAG_RERANKER_BY_LANGUAGE |
unset | Per-language override, e.g. en:cross-encoder,sv:berget. |
RAG_BERGET_API_KEY (or BERGET_API_KEY) |
unset | Required by the berget backend. Also RAG_BERGET_MODEL, RAG_BERGET_BASE_URL, RAG_BERGET_TIMEOUT, RAG_BERGET_RETRIES — see reranking strategies. |
RAG_RERANK_DEVICE |
torch's choice | Device for the reranker specifically. |
RAG_RERANK_MAX_LENGTH |
512 |
Tokens read per (query, passage) pair; the tail is silently dropped. Near-linear on latency — the bluntest dial there is. |
RAG_RERANK_QUANTIZE |
off | 1/true enables int8 dynamic quantization: ~2.8x faster on CPU, but ~1.1 GB more resident memory. Speed for memory, not free. |
RAG_TORCH_THREADS |
every logical core | Cap intra-op threads. Set it to the physical core count on a VPS — see below. |
RAG_ENRICHERS |
none (no-op) | structural, contextual or extraction (†). structural embeds each chunk's heading path and needs no LLM at all; the other two call one, so their cost is opt-in. |
ENRICH_LLM_BASE_URL |
OpenAI | Any OpenAI-compatible endpoint (vLLM, Ollama, LM Studio) (†). |
ENRICH_LLM_API_KEY |
not-needed |
Required for real OpenAI; self-hosted endpoints usually ignore it. Inline, or a path to a file holding it (†). |
ENRICH_LLM_MODEL |
gpt-4o-mini |
Model used by the enrichers (†). |
RAG_ENRICH_CONCURRENCY |
4 |
Per-chunk enrichment calls in flight per document. Low on purpose: the binding limit is the provider's rate limit, not the CPU. |
RAG_ENRICH_TIMEOUT / RAG_ENRICH_RETRIES |
30 / 2 |
Seconds per enrichment call, and retries. Explicit rather than the SDK's defaults, because ingest holds one HTTP request open across all of a document's calls. |
RAG_CONTEXT_DOC_LIMIT |
24000 |
Characters of a document usable directly as contextual context (†). Under it the document is the context and no summary call is made; over it the document is summarised once from its heading outline plus opening, so no prompt ever scales with the document. |
RAG_EXTRACTION_PROMPT |
built-in template | Inline template, or a path to one, for the extraction enricher (†). |
RAG_EXTRACTION_FIELDS |
unset | JSON {name: type} map (†). Set, only declared and correctly-typed keys survive — which is what keeps the jsonb @> filter reliable. |
(†) can be set per target. These decide what an ingest writes, so they
belong to a corpus rather than to the process: an engine serving several
applications can chunk and enrich each one's documents differently. Give the
target an object instead of a DSN in RAG_TARGETS — see
sharing one engine.
Everything else here (the embedder, the rerankers, devices, thread counts) is a
property of the loaded process and is necessarily shared.
Two of these are not really optional in production:
- Publish on loopback, not
0.0.0.0. No endpoint is authenticated and/documentswrites with a privileged connection that bypasses your RLS by design. The engine is a trusted internal service — see deployment: trust boundary & scaling. - Set
RAG_TORCH_THREADS. Unset, torch claims every logical core, which over-subscribes SMT pairs (measured: 8 → 16 threads at 0.98x, i.e. slower), and concurrent/rerankcalls then fight for the whole machine.
For a Linux VPS there are two ready-made deployments.
service/deploy/vps/ is the recommended one — Podman
quadlets with unattended, health-gated auto-update, and the engine on an internal
network with no published port at all; see
hosting the engine on a Linux VPS.
service/docker-compose.vps.yml is the compose
equivalent for a host without Podman: it pulls the published image, reserves no
GPU, binds loopback, and reads the knobs above from a .env file.
The container also serves GET /healthz — cheap, dependency-free, and 200
once the models are resident. Both deployments gate their health check on it.
Client
The app builds an NpgsqlDataSource with UseVector() and points
HttpClient.BaseAddress at the engine. Filters live in the app's own SQL,
which produces the candidate chunk-id set passed to Rank.
var q = await rag.Embed(query); // engine /embed
long[] ids = await app.FilteredCandidates(...); // app's WHERE: RLS, geo, joins
var matches = await rag.Rank(conn, q, query, ids, k: 50); // same `query` as embedded
var passages = rag.BuildPassages(matches);
var top = await rag.Rerank(query, passages, topK: 5);
var spans = await rag.ReadSpans(conn, top); // clean original text for /answer
Methods drop the Async suffix — the whole client is async, so it adds nothing.
Tests
Both sides isolate the expensive boundaries (the GPU models, the database) so the logic around them is tested directly, and a thin layer of integration tests exercises the real SQL against Postgres+pgvector.
A throwaway pgvector instance for the integration tests:
docker run -d --name rag-test-pg -e POSTGRES_PASSWORD=test -e POSTGRES_DB=ragtest \
-p 55432:5432 pgvector/pgvector:pg16
Service (service/) — unit tests need no model and no DB (the model boundary
is mocked); the /documents integration test needs the DB above.
cd service
python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt
.venv/Scripts/python -m pytest # unit only (integration auto-skips)
RAG_TEST_DATABASE_URL=postgres://postgres:test@localhost:55432/ragtest \
.venv/Scripts/python -m pytest # + the /documents integration test
Client (client/) — unit tests are pure (no DB/HTTP). The integration tests
use RAG_TEST_DATABASE_URL if set, otherwise spin a pgvector container via
Testcontainers; they auto-skip when neither is available.
cd client
dotnet test tests/RagMeUp.Client.Tests # unit
RAG_TEST_DATABASE_URL=postgres://… \
dotnet test tests/RagMeUp.Client.IntegrationTests # rank/read-path vs real pgvector
Both suites honour the same RAG_TEST_DATABASE_URL, so one local pgvector serves
both. The integration tests reset their tables on start, so point it at a
scratch database, not one with data you care about.
Retrieval quality (client/tests/RagMeUp.Client.EvalTests) — a labelled
corpus in eval/ measured through the real read path. Needs a running
engine with real weights as well as the database, and skips without one. Reports
recall / MRR / nDCG rather than asserting a bar; the assertions are comparative,
proving the harness can see a deliberate sabotage. See
measuring retrieval quality.
RAG_EVAL_ENGINE_URL=http://127.0.0.1:8000 RAG_TEST_DATABASE_URL=postgres://… \
dotnet test tests/RagMeUp.Client.EvalTests
The engine's own DATABASE_URL must point at that same database — it ingests, the
harness reads. Do not run this concurrently with the integration suite against one
database: both truncate the corpus tables. Set RAG_EVAL_DATABASE_URL to separate
them.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
0.6.0: citations you can trust, and a busy engine that says so.
NO CODE CHANGE REQUIRED, no schema change. Everything here is additive.
NEW: quote resolution. The read path used to stop at ReadSpans, leaving
every consumer to write the same loop: number the spans for the prompt,
have the model answer with a span number and a verbatim quote, find that
quote in the source, and drop what cannot be found. QuoteLocator does it.
var spans = QuoteLocator.Number(await rag.ReadSpans(conn, top.Passages));
var prompt = QuoteLocator.Render(spans);
// ... the model answers with {"span": 2, "quote": "..."} ...
var report = QuoteLocator.Resolve(spans, claims);
Each Citation carries real character offsets and the document's own text at
them, never the model's string, so a citation is correct by construction
rather than correct because the model copied carefully. Matching tolerates
line-break hyphenation, which a compounding language makes ordinary rather
than exceptional in any corpus that came out of a PDF.
The report's three counts are a partition: Resolved +
ResolvedAfterDehyphenation + Unresolved == Requested. Print them. A quote
that could not be located is a reportable fact, and a cell left empty by
one is not a cell with a hidden answer.
NEW: RagClient.MeasureCoverage(conn, candidateChunkIds) reports how many
documents a candidate set reaches and how many characters of them are
reachable through it, with overlapping chunks counted once. For anything
that compares sources: an absence is only comparable between sources where
comparable text is held, and that qualification has to travel beside the
results rather than live in a design document.
NEW: EngineUnavailableException, thrown for every 503, carrying the
engine's Retry-After. The engine now bounds how many model calls it runs at
once and refuses the rest instead of running out of device memory; this is
how an application sees that as backpressure rather than as a fault. It
derives from InvalidOperationException, so existing handlers are unaffected.
Retrying remains the application's policy: the library reports, it does not
sleep. Requires an engine built from this release or later to see the
header; older engines' 503s simply carry no RetryAfter.
--- 0.5.1 (previous) ---
0.5.1: fixes RagSchema.Upgrade crashing on Npgsql 9 and later.
0.5.0 compiled its type-cache flush against Npgsql 8, whose
NpgsqlConnection.ReloadTypesAsync() overload Npgsql 9 replaced with one
taking a CancellationToken. Any application that resolved Npgsql 9 or later
(which NuGet does automatically) got MissingMethodException from
RagSchema.Upgrade after the migration had been applied.
REQUIRES NPGSQL 9 OR LATER, up from 8.0.5, and there is no way to fix this
without that: the package ships compiled calls that have to resolve against
whatever Npgsql the application unified to. An application pinned to Npgsql
8 gets a restore error naming the downgrade, and should stay on 0.4.0.
Nothing else changed. No API change, no schema change; 0.5.0's notes below
still describe what this version does.
--- 0.5.0 (previous) ---
The client now migrates the database, and checks the engine first.
NO CODE CHANGE REQUIRED, and no schema change: the schema this version
applies is exactly the 0.4.0 schema, recorded as schema version 1. An
existing database is adopted at version 1 on first run, in place, with its
corpus untouched.
NEW, and worth one line in Program.cs:
await using (var ddl = await ownerSource.OpenConnectionAsync())
await RagSchema.Upgrade(ddl, engineHttp);
That applies anything the database is missing, and throws
RagSchemaTooOldException — before touching the database — when the engine
requires a schema version newer than this package carries. The message
names the version to install, so "update the NuGet package" stops being
something a developer has to infer from a Postgres error about a missing
column.
THE CONNECTION IS NOT THE ONE THE READ PATH USES. Rank and ReadSpans are
supposed to run as the restricted, RLS-scoped role; this one needs DDL and
should be the owner. Open it, upgrade, close it. Open it BEFORE building
the data source the read path uses, too: db/schema.sql creates the vector
extension, and Npgsql caches the server's type list per connection string.
ALSO NEW: RagSchema.Version (the schema this package carries),
RagSchema.CurrentVersion (where a database stands) and RagSchema.Pending
(the scripts, for applications that would rather hand them to DbUp or
FluentMigrator than have a second thing applying DDL at startup).
ENGINE SIDE: GET /targets gains a `schema_version` object reporting the
version the engine requires, the newest it knows, and the client version
that carries it. An engine that does not report it is treated as version 1
rather than as an error.
psql still works. db/schema.sql is unchanged and remains the fresh-install
route for applications that are not .NET; db/migrations/ holds deltas from
version 2 onward and is empty today.
--- 0.4.0 (previous) ---
Engine-derived structure moves into a reserved metadata namespace.
No API change from 0.3.0: no member signature moved, so this is a drop-in
recompile. The break is in the database contract.
UPGRADE FROM 0.3.0 — do these together:
1. Update the engine image at the same time as this package. A 0.3.0 client
selects the parent_start/parent_end columns and fails outright against a
database created from the current schema.sql; a 0.4.0 client against a
0.3.0 database just sees no structure and falls back.
2. Re-ingest documents whose heading paths or section spans you rely on.
0.3.0 wrote them to a top-level `heading_path` key and to two columns;
0.4.0 reads chunks.metadata->'rag'. Old rows are not migrated, they
simply read as "no structure" until re-ingested.
3. Optionally drop the now-unused columns:
alter table chunks drop column parent_start, drop column parent_end;
Leaving them is harmless — nothing reads or writes them.
WHY, because it is a reversal: 0.3.0 added columns to app-owned tables and
depended on them in the same release. With one engine serving several
applications' databases that makes every engine upgrade a synchronised DDL
event across independently-operated systems — and with AutoUpdate=registry,
an unattended image pull becomes an outage for whichever app had not
migrated. Optional engine data now lives in chunks.metadata->'rag', which
costs no DDL and is inert to anyone not reading it. The rules are in
docs/architecture/schema-evolution.md; what applications may rely on is in
docs/manual/schema-compatibility.md.
METADATA SHAPE: your own keys are untouched — the engine only ever writes
inside `rag`. But if you compare chunk metadata for EQUALITY rather than
containment, or enumerate its keys, `rag` now appears on structured
documents. Use `metadata @> …`, or `metadata - 'rag'` to recover exactly
what you wrote.
--- 0.3.0 (previous) ---
Reranking granularity, engine-owned length guard, small-to-big reads.
BREAKING: Rerank(...) and ReadSpans(...) take a new optional parameter
before CancellationToken; pass ct as a named argument. Rerank defaults to
ScoreGranularity.Chunk (scores each member chunk, folds with max) instead of
scoring the welded run. ConfidenceDiagnosis gained NoDiscrimination.
RerankCalibration.UsablePassageChars and RerankOutcome.Diagnostics replace
the passage-length constant consumers were hard-coding.