RagMeUp.Client
0.4.0
See the version list below for details.
dotnet add package RagMeUp.Client --version 0.4.0
NuGet\Install-Package RagMeUp.Client -Version 0.4.0
<PackageReference Include="RagMeUp.Client" Version="0.4.0" />
<PackageVersion Include="RagMeUp.Client" Version="0.4.0" />
<PackageReference Include="RagMeUp.Client" />
paket add RagMeUp.Client --version 0.4.0
#r "nuget: RagMeUp.Client, 0.4.0"
#:package RagMeUp.Client@0.4.0
#addin nuget:?package=RagMeUp.Client&version=0.4.0
#tool nuget:?package=RagMeUp.Client&version=0.4.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
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
psql "$DATABASE_URL" -f db/schema.sql # needs pgvector >= 0.7.0
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.
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}, 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_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 |
whitespace windows | recursive / structure-aware selects the separator-hierarchy splitter — recommended for markdown corpora, whose structure the default windows are blind to. Changing it only affects newly ingested documents. |
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) | contextual or extraction. Both call an LLM per document at ingest, so 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. |
ENRICH_LLM_MODEL |
gpt-4o-mini |
Model used by the enrichers. |
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. |
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.4.0 — 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.