Skip to content

Knowledge Base (RAG)

Upload your documents, and the LLM answers from them instead of from memory. Retrieved passages become the grounding context the faithfulness axis scores against, and the answer's claims are verified citation by citation — so "the model made it up" becomes a measurable event rather than a suspicion.

Formats: PDF, Word, PowerPoint, Markdown, HTML, Excel, CSV, plain text.


API Reference

Every RAG route is mounted at /v1/glad/rag/ on G1-Proxy — reached as /gw/v1/glad/rag/… through the unified port on 8080, or directly on :8800 in a split deployment.

Collections belong to Applications

Send X-Geodesia-App: <app_id> on every call. Operations on a collection owned by a different Application return 404, not 403 — the API does not leak the existence of another tenant's data. Omit the header (or send default) and you get the unscoped, single-tenant view.


The upload flow, end to end

The one thing worth reading before the endpoint list: uploading is asynchronous. POST …/documents returns 202 Accepted as soon as the bytes are in, and the parse → chunk → embed → index pipeline runs in the background. You poll /ingest/progress until it reports done.

That is deliberate. Embedding runs at roughly a second per chunk on CPU; a synchronous upload would hold the connection open for minutes and get cut by any proxy with an origin timeout — which surfaces to the user as an unexplained network failure rather than a slow upload.

APP="support_bot"
BASE="http://localhost:8080/gw/v1/glad/rag"

# 1. create a collection
CID=$(curl -s -X POST "$BASE/collections" \
  -H "Content-Type: application/json" -H "X-Geodesia-App: $APP" \
  -d '{"name": "company-policies"}' | jq -r .collection_id)

# 2. upload — returns 202 immediately
curl -s -X POST "$BASE/collections/$CID/documents" \
  -H "X-Geodesia-App: $APP" \
  -F "file=@/path/to/policy.pdf" -F "name=Refund policy 2026"

# 3. poll until the background ingest finishes
until [ "$(curl -s "$BASE/ingest/progress" -H "X-Geodesia-App: $APP" | jq -r .stage)" = "done" ]; do
  curl -s "$BASE/ingest/progress" -H "X-Geodesia-App: $APP" | jq -c '{stage, detail, pct}'
  sleep 2
done
import time, httpx

c = httpx.Client(base_url="http://localhost:8080/gw/v1/glad/rag",
                 headers={"X-Geodesia-App": "support_bot"}, timeout=120)

coll = c.post("/collections", json={"name": "company-policies"}).json()
cid = coll["collection_id"]

with open("policy.pdf", "rb") as fh:
    r = c.post(f"/collections/{cid}/documents",
               files={"file": ("policy.pdf", fh, "application/pdf")},
               data={"name": "Refund policy 2026"})
assert r.status_code == 202          # accepted, not finished

while True:
    p = c.get("/ingest/progress").json()
    print(p["stage"], p["detail"], f"{p['pct']}%")
    if p["stage"] == "error":
        raise RuntimeError(p["error"])
    if p["stage"] == "done":
        break
    time.sleep(2)

hits = c.post(f"/collections/{cid}/query",
              json={"query": "What is the refund window?", "top_k": 5}).json()
print(hits["n_sources"], "sources")
print(hits["context"][:400])
const BASE = "http://localhost:8080/gw/v1/glad/rag"
const APP = { "X-Geodesia-App": "support_bot" }

const coll = await fetch(`${BASE}/collections`, {
  method: "POST",
  headers: { ...APP, "Content-Type": "application/json" },
  body: JSON.stringify({ name: "company-policies" }),
}).then(r => r.json())

// Multipart: let the browser set the boundary — do NOT force a JSON content-type.
const form = new FormData()
form.append("file", file)                    // a File from an <input type="file">
form.append("name", "Refund policy 2026")

const up = await fetch(`${BASE}/collections/${coll.collection_id}/documents`, {
  method: "POST", headers: APP, body: form,
})
if (up.status !== 202 && !up.ok) throw new Error(await up.text())

for (;;) {
  const p = await fetch(`${BASE}/ingest/progress`, { headers: APP }).then(r => r.json())
  if (p.stage === "error") throw new Error(p.error)
  if (p.stage === "done") break
  await new Promise(r => setTimeout(r, 2000))
}

const hits = await fetch(`${BASE}/collections/${coll.collection_id}/query`, {
  method: "POST",
  headers: { ...APP, "Content-Type": "application/json" },
  body: JSON.stringify({ query: "What is the refund window?", top_k: 5 }),
}).then(r => r.json())
console.log(hits.n_sources, hits.context)

What comes back from the query

{
  "context": "Our return policy allows refunds within 30 days of purchase…",
  "sources": [
    {
      "text": "Our return policy allows refunds within 30 days of purchase…",
      "score": 0.94,
      "document_id": "doc_b5c2e1a3",
      "title": "Refund policy 2026",
      "page": 3
    }
  ],
  "n_sources": 4
}

context is the concatenated passage text, ready to hand straight to context on a chat request. sources is the same material itemised, for citations and for showing the user where the answer came from.


Endpoints

GET /v1/glad/rag/status

Whether the stack is up, which parser is active, and what it accepts.

{
  "ok": true,
  "parser": "docling",
  "supported": [".csv", ".docx", ".html", ".md", ".pdf", ".pptx", ".txt", ".xlsx"],
  "n_collections": 3
}

parser is docling when the full multi-format parser is available and fallback when it is not — worth checking, because the fallback preserves reading order and tables far less well. A failure returns {"ok": false, "error": "…"} with HTTP 200, so branch on ok, not on the status code.

POST /v1/glad/rag/collections

Body: {"name": "company-policies"}. name is the only field — it defaults to "Untitled". Returns the created collection.

GET /v1/glad/rag/collections

Returns {"collections": [ … ]} — an object, not a bare array.

GET /v1/glad/rag/collections/{collection_id}

One collection with its documents. 404 if it does not exist or belongs to another Application.

DELETE /v1/glad/rag/collections/{collection_id}

Deletes the collection, its documents and its embeddings. Returns {"ok": true}; 404 if unknown.

POST /v1/glad/rag/collections/{collection_id}/documents

Multipart. Returns 202 and ingests in the background.

Field Type Required Description
file file The document. Supported extensions are whatever /status reports.
name string Display title. Falls back to the filename.

Response: {"status": "accepted", "file": "policy.pdf"}.

Status Meaning
202 Accepted; poll /ingest/progress.
400 Empty file.
404 Unknown collection, or one owned by another Application.
413 Over the upload cap — 50 MB by default, GW_RAG_MAX_UPLOAD_BYTES.
415 Unsupported file type.

One ingest at a time

Progress is tracked as a single global state, so /ingest/progress describes the most recent upload, not a specific one. Upload documents sequentially — wait for done before starting the next — or you will not be able to tell whose progress you are reading.

GET /v1/glad/rag/ingest/progress

{ "active": true, "file": "policy.pdf", "stage": "embedding", "detail": "31/47", "pct": 66, "error": null }

stage runs parsingchunkingembeddingindexingdone, or lands on error with error set. idle means nothing has been uploaded yet.

DELETE /v1/glad/rag/collections/{collection_id}/documents/{doc_id}

Removes the document and cascade-deletes its chunks. {"ok": true}, or 404 for an unknown collection or document.

POST /v1/glad/rag/collections/{collection_id}/query

Retrieve without sending a chat turn — useful for testing a collection and for building your own pipeline.

Field Type Default Description
query string The question.
top_k integer (server default) Passages to return after reranking.
rerank boolean (server default) Cross-encoder rerank after retrieval. Better relevance, one extra forward pass.

Omitting top_k or rerank uses the server's configured defaults rather than a fixed number — send them explicitly if you need determinism.


Using RAG in Chat Requests

To use a knowledge base in a chat request, add the rag field:

{
  "model": "my-model",
  "stream": false,
  "messages": [{"role": "user", "content": "What is our refund window?"}],
  "rag": {
    "collection_id": "c_a3f7b2d1",
    "top_k": 5,
    "rerank": true,
    "verify": true,
    "verify_deep": true
  }
}

RAG Chat Request Fields

Field Type Default Description
collection_id string ID of the collection to retrieve from.
top_k integer 5 Maximum chunks to retrieve and inject into the prompt.
rerank boolean true Apply the cross-encoder reranker. Slightly slower but significantly more accurate for ambiguous queries.
verify boolean true Run claim-level grounding verification after the answer is generated.
verify_deep boolean true When true, verification uses the hallucination detection model for each claim (more accurate). When false, falls back to lexical overlap (faster, less accurate).

RAG in the Response

When RAG is active, the geodesia.rag field in the response contains retrieval and verification details:

"geodesia": {
  "rag": {
    "collection_id": "c_a3f7b2d1",
    "n_sources": 3,
    "sources": [
      {
        "text": "Our return policy allows refunds within 30 days...",
        "score": 0.94,
        "document_title": "policy.pdf",
        "page": 3
      }
    ],
    "verification": {
      "n_total": 2,
      "n_grounded": 2,
      "ungrounded": false,
      "claims": [
        {
          "claim": "refunds within 30 days",
          "grounded": true,
          "citation": "Our return policy allows refunds within 30 days..."
        }
      ]
    }
  },
  "brake": false
}
Field Description
n_sources Number of chunks retrieved
sources List of retrieved chunks with text, relevance score, and document metadata
verification.n_total Total claims extracted from the answer
verification.n_grounded Claims supported by the retrieved chunks
verification.ungrounded false when all claims are grounded — triggers hallucination suppression
verification.claims Per-claim grounding status and the matching citation

How It Works

1. You upload a document → Docling parses it → chunked into ~480 tokens with 64-token overlap
2. Each chunk is embedded with BGE-M3 (multilingual) → stored in LanceDB
3. On a RAG-enabled chat request:
   a. Retrieve top-K chunks most relevant to the user's question (dense retrieval + reranking)
   b. Inject the retrieved context into the upstream LLM's prompt
   c. The LLM answers using the context
   d. Geodesia verifies each claim in the answer against the retrieved chunks
   e. If all claims are verified with citations → halluc_context flag suppressed
   f. If any claim is ungrounded → halluc_context flags normally

Collections

Documents are organised into collections. A collection is a named group of documents that shares an embedding index. You can have multiple collections for different topics or customers.


Configuration

RAG-specific environment variables:

Variable Default Description
GW_RAG_DIR runs/rag_store Directory where the LanceDB embedding store is saved. Must be writable.
GW_RAG_DEVICE cuda:0 Device for the embedding model. Use cpu on machines where the GPU is fully occupied by the LLM.
GW_RAG_EMBED_MODEL BAAI/bge-m3 Hugging Face model ID for the text embedding model. BGE-M3 is multilingual and recommended.
GW_RAG_RERANK 1 Set to 0 to disable the reranker globally.
GW_RAG_RERANK_MODEL BAAI/bge-reranker-v2-m3 Hugging Face model ID for the cross-encoder reranker.
GW_RAG_TOPK 5 Default number of chunks to retrieve (overridable per-request).
GW_RAG_OVERFETCH 20 Number of candidates retrieved by the dense retriever before reranking. Higher = more recall at the cost of reranker speed.
GW_RAG_CTX_MAXCHARS 6000 Maximum characters of retrieved context injected into the prompt. Long contexts are truncated.
GW_RAG_MAX_CLAIMS 12 Maximum number of claims extracted from the answer for claim-level verification.

GPU allocation

If your GPU is fully occupied by the LLM, set GW_RAG_DEVICE=cpu. BGE-M3 on CPU is slower for large uploads (~10–30 seconds per document) but runs fine. After the initial indexing, retrieval from CPU is typically fast enough for real-time use.