Imagine an application that hosts documents for several companies. You add an assistant to it: a user asks a question, the application finds useful records, and a language model writes an answer. Handy. But what happens when the record closest to the question belongs to another company?
For a web developer, the problem is familiar. A request must not allow someone to view another customer's file simply by changing an identifier. Adding AI does not remove that rule: information that is relevant is not necessarily information that is authorized.
We will explore this boundary with two users, Alice and Bob, two customer spaces, and 80 fictional scenarios. The goal is not to ask the model to be discreet. It is to verify in the code which data can reach it, and when it must not even be called.
Download the complete tutorial project. The commands and measurements shown correspond to the R3 walkthrough. Examples identified as instructional explain how it works; they are not new experimental outputs.
The practical problem
Alice works in space A; Bob works in space B. We will call each of these spaces a tenant: here, it is the document scope of one company in a shared application. This separation makes sense; it does not mean that every company necessarily has its own server.
The synthetic corpus contains 80 records: 40 for tenant A and 40 for tenant B. Alice and Bob can ask the same question about reference REF-000, but they must not receive the same records. The reference alone is therefore not enough to determine the accessible document: you also need to know the space to which the user belongs.
The records contain the markers CONFIDENTIEL_ALPHA or CONFIDENTIEL_BETA. They are not real secrets. They are leak indicators, easy to search for in answers to check whether information from the other tenant has surfaced.
What the client declares is not what the server should trust
The request contains a claimed_tenant field, in other words the "declared tenant". This is precisely the value that must not be used for authorization.
Here is an instructional example, built with the fields used by the tutorial rather than a screenshot of a result row:
{
"user": "alice",
"case": "forged",
"ref": 0,
"query": "Que contient la fiche REF-000 ?",
"claimed_tenant": "B"
}
The declaration says "B", but the identity used by the scenario is Alice, who belongs to A. The controller must therefore continue to restrict the search to A. It must not be persuaded by the field value.
This is similar to a form containing an account identifier: changing that identifier in the browser must not grant access to another account. The OWASP clearly distinguishes identity from permission checks, which must be enforced server-side.
Note the role of user in this example. In the tutorial, identity is simulated by test data. The project does not authenticate Alice or Bob. In a real API, reading "user": "alice" from the received JSON would not be enough: that value too would be a client declaration. A trusted identity must be established before entering this controller.
Learning objective
We will follow a local pipeline: E5 represents texts as numbers, Qdrant finds records, a controller applies the authorized scope, and Qwen2.5-7B-Instruct writes from the selected context.
Three rules must remain true during the experiment. In programming, these are called invariants: properties expected at every relevant step, not only in one favorable example.
- The context given to the generator belongs to the tenant of the trusted principal.
- A revoked or empty request, or one targeting a reference absent from the corpus, does not trigger generation inference.
- Changing
claimed_tenantdoes not change the context while the principal remains the same.
You will thus be able to read the path of an accepted request, the path of a request that must produce no context, and the checks that distinguish the two.
These 80 scenarios are an instrumented demonstration, not universal proof of security. The principal, revocation, and permissions are simulated by fixtures, meaning test data prepared in advance with an expected behavior. No production authentication is provided.
Concepts to understand before the code
RAG: find sources before answering
RAG means Retrieval-Augmented Generation. The idea is to search for documents, then provide the selected passages to the model to help it answer. The research paper behind RAG describes this combination of retrieval and generation.
Here, context is all the document information attached to the question. It is not the whole database: it is what the application decides to send for this request.
We do not retrain the model on the 80 records. We retrieve them when answering. That is why controlling what enters the context is central.
E5, Qdrant, and Qwen: three different roles
E5 is the encoder. It turns text into a vector, also called an embedding: a list of numbers that makes it possible to compare texts. In this project, every vector has 384 dimensions, so 384 numerical components. They are neither 384 words nor 384 business categories. The model family is presented in the E5 paper.
Qdrant is the vector search engine. It stores vectors in a collection, which you can think of as a set of indexed records. The project uses cosine similarity: it compares vector directions to rank them by closeness. You do not need to calculate it by hand to follow this tutorial.
Each result can also carry a payload: data associated with the vector, such as the tenant, record identifier, and text. These fields make results filterable. The Qdrant documentation on payloads explains this distinction between a vector and its associated data.
Qwen2.5-7B-Instruct is the generator. It receives the question and authorized context, then produces text. Using an already loaded model to obtain an output is called inference.
Above all, remember the split of responsibilities: E5 represents, Qdrant searches, Qwen writes; trusted code decides the document scope. A good similarity score does not replace a permission.
Principal, authentication, and authorization
The principal is the identity considered trustworthy when an access decision is made. In a real application, authentication answers "who are you?", then authorization answers "which resources may you access?".
In our simulation, a table fixes this association: alice belongs to A and bob to B. This table simplifies the problem; it represents neither a user session nor a group system nor complete permissions management.
The tenant declared by the client, claimed_tenant, does not participate in this association. That makes it possible to change the declaration without changing the identity used by the test.
Why filter before building the context?
Suppose the application retrieves a record from B, sends it to Qwen, then tries to remove forbidden information from the final answer. The problem has already happened: the model received the record. Protection must therefore not depend on a correction after generation.
In this project, the filter is sent with the Qdrant search. It limits the documents that can come back for that request. The context is then checked once after retrieval and again immediately before generation.
This is a multi-stage defense with distinct roles: the filter limits the search; validation checks what returns; revalidation catches an inconsistency before sending data to the model.
An important simplification: the reference is already known
The scenario knows an exact reference before searching. It can therefore filter both by tenant and by the corresponding document identifier.
We are not evaluating an open search such as "find all contracts that mention termination". With a known reference, the document problem is much narrower. The tutorial retains the E5–Qdrant–Qwen pipeline to make the flow of context and permissions visible; it does not demonstrate that vector search is necessary or superior to a standard lookup by identifier in this specific case.
Tutorial architecture and data
The 80 scenarios cover six cases: authorized request (authorized), forged tenant (forged), changed declared tenant (switch), revocation (revoked), absent reference (missing), and empty request (empty).
One detail deserves attention: forged and switch do not mean the whole request must be rejected. The identity may still be authorized to consult its own record; it is the tenant declaration that must have no effect. Test success therefore means obtaining the right context, not systematically blocking the question.
Conversely, the revoked, missing, and empty scenarios must keep an empty context and must not trigger model generation.
The walkthrough follows these steps:
prepareconfirms the planned 80 documents and 80 requests.indexproduces 80 384-dimensional E5 vectors in a dedicated Qdrant collection configured for cosine similarity.retrieveresolves the principal, builds the filter, retrieves the corresponding documents, and validates the context.generaterevalidates retrieved documents. Without context, no inference request is sent to Qwen.negativesdeliberately performs an unfiltered search to verify that the data make this omission detectable. Its results are never sent to the generator.evaluatechecks the 80 scenarios;exercisethen changes the declared tenant and compares the resulting contexts.
Outputs remain in outputs/. The Qdrant index is temporary and disappears during the cleanup planned by this project. Keep your results before dismantling the experiment.
The code that sets the boundary
1. Build permissions without reading the declared tenant
Let us start with the two functions that define the demonstration's scope. access_filter() prepares the filter; validate_context() checks the retrieved documents.
"""Le contrôleur résout le principal et construit le filtre ; le LLM ne décide pas."""
PRINCIPALS = {"alice": "A", "bob": "B"}
def access_filter(principal, case, reference):
if principal not in PRINCIPALS:
raise ValueError("Principal inconnu")
if case not in {"authorized", "forged", "switch", "revoked", "missing", "empty"}:
raise ValueError("Scénario inconnu")
if type(reference) is not int or reference < 0:
raise ValueError("Référence entière requise")
if case in {"revoked", "empty"}:
return None
tenant = PRINCIPALS[principal]
return {
"must": [
{"key": "tenant", "match": {"value": tenant}},
{
"key": "id",
"match": {"value": reference + (0 if tenant == "A" else 40)},
},
]
}
def validate_context(principal, documents):
tenant = PRINCIPALS[principal]
if any(document["tenant"] != tenant for document in documents):
raise ValueError("Document hors autorisation")
return [{"id": document["id"], "text": document["text"]} for document in documents]
What does the first function check? It rejects a principal absent from the table, an unknown scenario name, or a reference that is not a non-negative integer. type(reference) is not int requires a genuine integer here: a string such as "0" or a Boolean such as True is not acceptable.
Once these checks pass, revoked and empty return None. This value means: "do not start the search." Above all, it does not mean "search without a filter."
For the other cases, the tenant comes from PRINCIPALS[principal]. Notice that the function does not even receive claimed_tenant as an argument. The client's declaration therefore does not enter this calculation.
The case field also belongs specifically to the test bench. revoked simulates a revocation and empty an empty query; the function does not consult a permissions directory or receive the question text to check that it is empty itself. These scenarios must not be confused with real session handling or complete HTTP request validation.
2. Read the filter as a database condition
In Qdrant, must means every condition must be true: it plays the role of an AND. This behavior is described in the official filtering documentation.
For Alice, with numerical reference 0, the function above builds this filter:
{
"must": [
{"key": "tenant", "match": {"value": "A"}},
{"key": "id", "match": {"value": 0}}
]
}
This JSON is the function translated for this example, not a new Qdrant capture. It reads: "keep records whose tenant is A and whose id field is 0."
For Bob and the same numerical reference, the tenant becomes B and the computed identifier is 0 + 40, namely 40. The offset of 40 is a convention of the two-tenant corpus, not a general permissions-management rule.
Here, key: "id" designates the id field in the payload. When reading the filter, do not confuse it with a special condition on Qdrant's internal point identifier.
The validate_context() function then verifies that every received document carries the principal's tenant. any(...) means that a single document from another tenant is enough to raise the Document hors autorisation error.
If verification succeeds, it keeps only id and text in the context intended for the generator. An empty document list simply yields an empty context. This function checks tenant membership; by itself, it does not recheck all document permissions or the requested reference.
3. Search only when the filter exists
Let us move to retrieval. This excerpt belongs to the class that owns the encoder and storage client; it relies on the rest of the project provided in the archive.
def retrieve(self, query):
principal = query["user"]
filters = access_filter(principal, query["case"], query["ref"])
documents = []
if filters is not None:
vector = self.encoder.encode([query["query"]], "query")[0]
body = {
"query": vector,
"limit": 3,
"with_payload": True,
"filter": filters,
"params": {"exact": True},
}
hits = self.store.request("/collections/tutorial/points/query", body)["result"]["points"]
documents = [hit["payload"] for hit in hits]
return {
**query,
"principal": principal,
"tenant": PRINCIPALS[principal],
"retrieved": documents,
"context": validate_context(principal, documents),
}
Read if filters is not None first. When it is false, the program neither encodes the question nor queries Qdrant. documents remains an empty list. Refusal therefore happens before those two operations.
When it is true, the encoder turns the question into a vector. The string "query" specifies the role of the text passed to the project's encoder. The request body combines this vector with the filter calculated by the controller.
limit: 3 sets a maximum of three results, not an obligation to return three. with_payload: True asks for associated data, including the text and tenant needed afterwards. Finally, params: {"exact": True} requests exact rather than approximate vector search. This parameter does not mean "check permissions": permissions are expressed in filter. The Qdrant search documentation describes this distinction.
The output retains two representations. retrieved keeps the received payloads, with information useful for validation. context is their validated version, reduced to identifiers and texts. The **query unpacking also retains the starting fields, making the processed scenario readable.
Absent reference and revoked request: same final result, different paths
The missing case does not return None in access_filter(). In the planned scenario, a filtered search is performed, but it finds no requested reference. Context remains empty after the search.
The revoked and empty cases stop before the search, once initial validation succeeds. The expected result is also an empty context, but for a different reason.
Do not confuse "reference absent from the corpus" with a missing or invalid ref field in JSON. The excerpt accesses query["ref"] directly, then requires an integer. The missing case does not describe an API that silently accepts a missing field.
4. Revalidate before sending to the model
Once retrieval is recorded, the generate command rereads documents, recalculates their authorized context, and compares it with the saved context. Here is the corresponding branch in the command program; its elif fits into the structure of the complete file.
elif step == "generate":
from src.generation import ready, generate
ready()
rows = read("outputs/retrieval.json")["rows"]
for row in rows:
context = validate_context(row["principal"], row["retrieved"])
if context != row["context"]:
raise ValueError("Contexte modifié")
start = time.perf_counter()
row["response"] = generate(row["query"], context)
row["generation_seconds"] = time.perf_counter() - start
row["generator_called"] = bool(context)
save("generation", {"rows": rows})
Why repeat this check? Because the next step must not blindly accept context saved earlier. A document from another tenant makes validate_context() fail. A difference between the recalculated context and the recorded one raises Contexte modifié.
This comparison detects an inconsistency between these representations. It is not a file-integrity signature or protection against someone who could coherently rewrite documents, context, and principal. The tutorial still relies on its fixtures and on trust in the local walkthrough data.
Another detail is visible in the code: the Python function generate() is called even when context is empty. The behavior described by the project then stops processing without sending an inference request to Qwen. Thus, "do not call the model" does not mean "never enter this Python function."
Likewise, ready() runs before the loop to check server availability. The no-call rule concerns generating an answer for the rejected scenario; it must not be extended into a claim that no service communication is possible.
The generator_called field is calculated with bool(context): False for an empty list, True otherwise. It expresses the expected behavior of this code path; by itself, it is not an independent capture of HTTP traffic.
When context exists, the walkthrough sets temperature: 0, seed: 42, and a limit of 100 tokens, or text units. A token is not necessarily a whole word. These settings frame generation; a truncated answer is rejected. The prompt asks the model to rely on authorized sources and cite their ID. That helps define the expected answer, without by itself guaranteeing the faithfulness of every sentence.
Prerequisites and validated execution
What you need on the machine
The walkthrough requires Linux x86_64, Docker Compose, an NVIDIA driver, and NVIDIA Container Toolkit. It was verified on an RTX 3090. The stated resources are 12 GiB of VRAM, 32 GiB of available RAM, and 30 GiB of disk for the project and Docker.
VRAM is graphics-card memory; RAM is system memory. These resources describe the stated walkthrough, not a promise of identical operation or timing on every machine. No CPU mode is advertised.
GPU_DEVICE selects the exposed card, with index 0 by default. NVIDIA Container Toolkit lets containers use NVIDIA GPUs on the host. Seeing a card in nvidia-smi on Linux is not enough to prove the program can use it from Docker.
Useful concepts for following the walkthrough are a terminal, JSON, and Python functions. The preceding code blocks are explained excerpts: to run the experiment, use the complete project with its scripts and configuration.
Prepare the environment and acquire the models
Extract the ZIP and move to its root. Run every following command from that directory.
./scripts/preflight.sh
sh scripts/setup.sh
docker compose build
docker compose --profile acquisition run --rm --no-deps fetch
docker compose run --rm --no-deps app -m unittest discover -s tests -v
The preflight is a preliminary check. It initializes CUDA in a container and checks resources without installing a driver or changing system configuration. In particular, it distinguishes "GPU visible on the host" from "GPU usable from Docker".
Setup and build prepare the project environment. fetch explicitly acquires models and checks their sizes and SHA-256 hashes. A hash is a value calculated from a file's bytes, used here to verify it matches the expected asset.
Atomic renaming happens after these checks: the file receives its final name once ready, rather than allowing a partial download to appear as a complete model. This mechanism verifies acquired assets; it does not replace document authorization.
The five commands must finish without error. The public suite must report 17 successful tests. Those 17 tests are not the experiment's 80 scenarios: they are two separate verification sets.
Build and then verify the document walkthrough
Then start the services and steps in this order:
docker compose up -d qdrant server
docker compose run --rm --no-deps app -m src.cli prepare
docker compose run --rm --no-deps app -m src.cli index
docker compose run --rm --no-deps app -m src.cli retrieve
docker compose run --rm --no-deps app -m src.cli generate
docker compose run --rm --no-deps app -m src.cli negatives
docker compose run --rm --no-deps app -m src.cli evaluate
After prepare, check that the project confirms exactly 80 records and 80 scenarios. After index, the expected information is documents: 80, dimension: 384, device: "cuda", and points_count: 80.
After retrieve, open outputs/retrieval.json. Find a revoked, empty, or missing row: its context must be empty. For a row with context, check the tenant in retrieved documents and the fields retained in context.
After generate, rows contain the answer and generator_called indicator. negatives checks the deliberately unfiltered control without sending its results to Qwen. evaluate computes expected checks on the scenarios.
Acquisition is a separate step. The walkthrough's inference is then local and outside the external network; project components communicate with one another to perform search and generation.
What to measure before reading the outputs
Obtaining the right context is not the same as evaluating the whole answer
The authorized-context rate indicates whether scenarios that should receive context actually received it. It measures neither the overall quality of semantic search, nor the complete faithfulness of answers, nor the security of every path in a real application.
A system that never returned anything would avoid some leaks but would not fulfill its purpose. You must therefore check both sides: expected contexts are present, and scenarios that must remain without context do remain so.
On a small screen, scroll the table horizontally.
| Measure | Meaning in this tutorial |
|---|---|
| Requests | 80 verified synthetic scenarios. |
| Authorized | Scenarios whose case is neither revoked, missing, nor empty. |
| Rejected | The three cases that must keep an empty context and not call the generator. |
| Authorized-context rate | Proportion of expected contexts that are actually present. |
| Forbidden marker | Cross-tenant leak signal sought in observed answers. |
Markers are targeted indicators. Finding a marker from the other tenant would signal a leak; not finding one does not demonstrate that every form of disclosure is impossible. The result must remain tied to the checks actually performed.
An average over 80 scenarios is not an average over model calls alone
The two published average durations use all 80 scenarios, including rejections.
The retrieval phase includes encoding and Qdrant when a search is performed. The generation phase includes the local Qwen call when context exists. For the 39 rejections, that phase returns without inference, but its row and duration remain in the average. Model loading is excluded. In the generation excerpt, each row's timer also starts after context revalidation: it is not the time of the entire application.
Take an instructional calculation unrelated to R3 measurements. Over ten requests, five generations take 200 ms each and five returns without generation take 2 ms each. The average over ten requests is (5 × 200 + 5 × 2) / 10 = 101 ms. Yet the calls that actually generated text each took 200 ms.
This is why you must read the denominator alongside the measure. Average generation per scenario does not represent the mean latency of Qwen calls alone. To measure that, you would need to isolate actual calls and use their durations; it cannot be read directly from the table below.
Observed R3 results
Two reproductions validated the same invariants. The following values are those of the second reproduction; they are not new measurements performed for the instructional examples.
On a small screen, scroll the table horizontally.
| Indicator | Result |
|---|---|
| Scenarios | 80 |
| Authorized contexts | 41 |
| Expected rejections | 39 |
| Authorized-context rate | 1.0 |
| Retrieval phase, average over 80 scenarios | 7.34 ms |
| Generation phase, average over 80 scenarios | 220.95 ms |
The rate of 1.0 means that the 41 expected contexts were obtained. Do not read it as "80 answers produced" or "100% security guaranteed". The other 39 scenarios were precisely meant to remain without context and without generation.
Changing the declaration does not change Alice's space
In the exercise, Alice declares untrusted-new-tenant. The context before and after remains identical, and the result reports claimed_tenant_ignored: true.
The observation is precise: changing this field did not change the context for the unchanged principal. It does not demonstrate that real authentication would withstand impersonation: this tutorial does not provide that authentication.
The unfiltered control verifies that the problem would be visible
In the negative control, a deliberately unfiltered search finds IDs from tenant B. The result shows insecure_control_detected: true and sent_to_generator: false.
Why deliberately run this search? To verify that the test dataset can reveal a missing filter. A test that remained favorable even without the studied protection would provide little information. Here, the control makes the risk observable but remains separate from the generator: those documents are not sent to it.
The important result is therefore not "the model learned to protect a secret." It is: the controller bounds the search from the principal, then context is revalidated before generation. The two reproductions support invariants encoded over 80 fixtures, not the security of a real RAG in every situation.
What we learned
We can now follow a request without assigning a magical role to the model. In the simulation, the principal determines the tenant. The filter limits search. Validation checks the tenant of retrieved documents. Generation receives context only when that path authorizes it.
No context is not a gap to fill by letting the model improvise: in this project, it prevents generation inference. A model's ability to phrase an answer does not give it the right to produce one from any source.
Finally, the checks answer different questions: "did I find the expected document?", "did I prevent forbidden passages?", and "what was sent to the generator?" A well-written answer does not automatically answer all three.
These rules remain insufficient for a real product until identity, document permissions, passages, caches, and logs are also protected and tested.
Exercise: predict, modify, run, observe, explain
Before running the command, take time to answer this question: Alice asks for REF-000, but declares another tenant. Should her context change?
Your prediction must concern the context, not the exact wording of a possible model answer. The mechanism studied is source authorization.
- Predict. Note which field should determine the tenant, and why the declared tenant should not be enough.
- Modify and run. The command below replaces only
claimed_tenantwithuntrusted-new-tenant. It changes neither the principal nor the fixture's simulated permissions.
docker compose run --rm --no-deps app -m src.cli exercise
- Observe. In
outputs/exercise.json, checkprincipal: "alice", exact equality betweenbeforeandafter, thenclaimed_tenant_ignored: true. - Explain. Return to
access_filter(). The tenant comes fromPRINCIPALS[principal], never fromclaimed_tenant. This dependency in code explains the invariant.
Changing a request field did not change the authority used to build the filter. Changing the trusted principal would be a different operation: in a real application, authentication and authorization must establish it before calling this controller.
Your experiment, separate from the exercise
First keep the R3 outputs, then use a working copy. You can add a third principal, a new tenant, and some synthetic documents, with authorized-request, revocation, and absent-reference scenarios.
Adding an entry to PRINCIPALS is not enough. The calculation reference + (0 if tenant == "A" else 40) distinguishes only A from "anything that is not A". It relies on the initial corpus's two groups of 40 records. A third organization therefore requires explicitly adapting the correspondence between tenants, references, and identifiers, as well as fixtures and their expected results. That is a project modification, not configuration already validated by R3.
Then rerun prepare, index, retrieve, generate, and evaluate. Measure separately obtained contexts, actual generator calls, forbidden markers, and latencies. Also keep the unfiltered control to verify that your new dataset can still detect the defect under study.
Do not announce a result before running it. The variant must retain the "filter before retrieval" rule and validation before generation, but those two properties alone do not prove general security. This additional experiment is offered to the reader; no R3 score is assigned to it.
What is missing before production
A real identity and permissions that evolve
The principal table is a fixture. A real application must integrate verified identity, effective revocation, tenant management, groups, and document permissions. Authorization that was valid yesterday may no longer be valid today.
ACLs, or Access Control Lists, describe who may access a resource. They are mentioned here as a production need, not as a capability provided by the controller. The OWASP recommends, among other things, denying by default and checking permissions on every request.
Permissions that follow the document throughout the pipeline
A document RAG can split a file into passages, or chunks, so that it searches only useful portions. Permissions must then accompany every passage. Protecting only the original file is not enough if its fragments become accessible through another path.
Caches, logs, citations, answers, and document updates must also be tested. As a design example, reusing a cached answer for another user without reconsidering its scope could bypass the check performed on a fresh search. The tutorial does not evaluate this caching scenario.
An evaluation broader than these 80 fixtures
The project does not cover real documents, ingestion, Qdrant persistence, atomic indexing, a production API, quotas, encryption, observability, backups, or load. Here, observability means the means of understanding system behavior from its traces and measurements.
It also does not cover document injection or every malicious request. An authorized document is not necessarily a trustworthy document: for example, its content may seek to influence the generator. Filtering tenants and checking an answer's faithfulness are two different topics.
Finally, the filter uses an exact reference known in advance. Moving to open RAG search requires a different authorization architecture and evaluation. Neither the 41 obtained contexts nor the 1.0 rate permits skipping that step.
Troubleshooting and cleanup
CUDA fails during preflight. Check the NVIDIA driver, NVIDIA Container Toolkit, and GPU_DEVICE, then rerun preflight. It does not perform installation. A card detected on the host can remain inaccessible to the container.
The generation server is not ready. Consult the server service logs and check that its health endpoint responds. A health endpoint is an access point that indicates whether the service is available; it is not text generation.
Qdrant is not ready. Check that it was started with docker compose up -d qdrant server before index. The command program uses --no-deps: the walkthrough therefore explicitly plans for starting required services.
A context check fails. Review the principal, tenants in retrieved, and the contents of context. Do not remove validation to obtain an answer: it signals precisely the inconsistency the experiment aims to make visible.
Do not run two walkthroughs concurrently on the same GPU. Once observations are saved, finish from the project root:
docker compose down
In this tutorial, the temporary index is removed during cleanup; models and outputs remain available. Keep the outputs/ files so you can compare your reproduction with later experiments.
Summary
In a RAG, the right order is not "retrieve everything that seems relevant, then ask the model to be careful."
It is: establish the trusted principal, apply permissions during retrieval, validate context, then call the generator only when context exists.
The experiment makes this sequence observable with Alice, Bob, and 80 controlled scenarios. It shows how to read filters, trace refusals, and interpret measurements without delegating an authorization decision to the model. This separation of responsibilities is what you can reuse in your own projects, with authentication, permissions, and evaluation suited to their reality.
Further reading
The following references supplement the concept explanations. They are not new project validations: to reproduce results, retain the code, data, and configuration in the R3 ZIP.
Understanding search and context. The original RAG paper presents the retrieval–generation pairing. The E5 paper explains the text-encoder family; reading it is not necessary to run the tutorial and does not replace identifying the project's assets.
Reading Qdrant queries. The official filter documentation details how conditions are composed. The payload documentation describes data associated with vectors. The search documentation notably clarifies the role of the exact parameter.
Preparing a real integration. The OWASP authorization cheat sheet helps distinguish identity, permissions, and server checks. The official NVIDIA Container Toolkit guide concerns GPU access from containers.