RagMeUp.Client 0.6.0

dotnet add package RagMeUp.Client --version 0.6.0
                    
NuGet\Install-Package RagMeUp.Client -Version 0.6.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="RagMeUp.Client" Version="0.6.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="RagMeUp.Client" Version="0.6.0" />
                    
Directory.Packages.props
<PackageReference Include="RagMeUp.Client" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add RagMeUp.Client --version 0.6.0
                    
#r "nuget: RagMeUp.Client, 0.6.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package RagMeUp.Client@0.6.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=RagMeUp.Client&version=0.6.0
                    
Install as a Cake Addin
#tool nuget:?package=RagMeUp.Client&version=0.6.0
                    
Install as a Cake Tool

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 /documents writes 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 /rerank calls 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.6.0 118 8/22/2026
0.5.1 105 8/20/2026
0.5.0 109 8/18/2026
0.4.0 142 8/7/2026
0.3.0 99 8/7/2026
0.2.0 123 8/4/2026
0.1.0 119 8/2/2026

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.