Every useful database content, document behind an internal AI agent names someone, a company, server names,…
A relationship manager might ask “what did this client complain about last quarter?”, an operations engineer might aska “what happened on this server last month?”, and the answer sits in notes, emails and incident tickets, CRMs and ERPs data, full of client names, staff names, IBANs, hostnames and IP addresses: exactly the text you would rather not send to a model.
Usually I see two answers for that. Keep AI away from that data, which in practice means there is no project. Or redact before sending: replace every name with [REDACTED] and send the rest. That one looks safe and fails quietly, because once every client is [REDACTED], the assistant can no longer tell one client from another because it is like joining two tables without a key. It answers, but without being relevant enough.
I built the layer in between, on PostgreSQL: row-level security, labels, the token columns and audit live in the database, the tokenizing and the gate in the application, the key and the mapping on a second server. Here is what it does, on a question from the lab (condensed from the run’s output):
question asked : What did Emmental Immobilien AG complain about regarding custody fees?
question sent : What did CLIENT_43aa5d8366b5 complain about regarding custody fees?
model answer : CLIENT_43aa5d8366b5 complained that the custody fees had increased without notice. [doc 10]
for a reviewer : Emmental Immobilien AG complained that the custody fees had increased without notice. [doc 10]
The model chose its own search, kept the token, answered correctly and cited its source. It never held the name. This is pseudonymization, not anonymization: the tokens are reversible by design (for everything downstream of the filter, only through a separate server), and the text around a token can still say a lot about someone. The goal is narrower and testable: no known sensitive value reaches the provider, and the answers stay as relevant as on raw text.
In my lab I take the example of a bank, because in Switzerland, sending client data covered by banking secrecy to an external API is a disclosure question under Article 47 of the Banking Act before it is a quality question, and FINMA expects banks to manage the data risks and third-party dependencies that come with AI. So the question is: can we build something secure enough to still use agentic capabilities? If it works in a sector as regulated as banking, most organizations can use it too. Retrieval techniques make governance possible; the other question is how far they go.
The lab has three synthetic banks, 240 clients, 18 relationship managers, 481 accounts, 30 servers and 1,521 free-text documents. It is small on purpose: big enough to measure, small enough to rebuild on a laptop and read every row. Every name in it is generated; where a generated company name turned out to match a firm in the Swiss commercial register, the generator swaps it, and any remaining resemblance is coincidental. This post walks through the data model, the vault that holds the tokens, the layers between the data and the model, a deliberate labelling gap and what catches it, the relevance measured with and without filtering, what a tracing tool ends up storing, and the query plans underneath.
The interesting part is not hiding the names. It is knowing precisely which data is sensitive: the labels are what stop the leak, the same labels are what give the relevance back, and a name that no label knows is exactly where the guarantee ends.
This demonstrates:
- Row-level security on the vectors, not only on the documents, with the tenant set per transaction.
- A label registry and a coverage query that fails toward a finding when a sensitive column is missing.
- Deterministic tokens computed with a keyed hash on a separate PostgreSQL server, which is the only way back for everything downstream of the filter.
- An egress gate that scans every request to the model, logs a hash of it, and blocks on a single finding.
- Relevance kept: with the same search and the same tools, answers on tokens are as good as answers on raw text.
Lab Environment Details
Bank server:
PostgreSQL 18.3, pgvector 0.8.6, pgAudit 18.0, postgresql_anonymizer 3.2.2.
Vault server:
PostgreSQL 18.3 with pgcrypto.
Embeddings computed locally on CPU:
dense voyage-4-nano at 1,024 dimensions, sparse SPLADE (prithivida/Splade_PP_en_v1) through fastembed.
Agent:
OpenAI gpt-6-luna through Chat Completions with function tools.
Tracing:
Langfuse 4.50 self-hosted from its official Docker Compose file (ClickHouse 25.12, PostgreSQL 17, Redis 7, MinIO), Python SDK 4.16. All in Docker on WSL2, in a 6 GB VM.
The lab is public and available with a runbook that replays every output below: lab 16 in pgvector_RAG_search_lab.
Building it takes about 40 minutes of embeddings on a CPU, and replaying every step about an hour and a half; the dry runs need no API key.
The data model
Two servers, three schemas. The bank server holds the business data, its filtered copies and the governance tables. The vault server holds only the key and the token mapping. No foreign key, foreign server or dblink crosses between them.

The bank schema: business data, its filtered copies, the mention index and the versioned embeddings. Each sensitive structured column sits next to its token column; the free text has redacted and tokenized copies (title copies omitted here).
Each document is stored three times, raw, redacted and tokenized. That is not how you would run production; it is how you measure what each choice costs, on the same rows, with the same model:
CREATE TABLE bank.documents (
doc_id int PRIMARY KEY,
bank_id text NOT NULL REFERENCES bank.banks,
doc_type text NOT NULL CHECK (doc_type IN ('advisor_note', 'incident', 'email')),
title text NOT NULL,
body text NOT NULL,
created_at timestamptz NOT NULL,
title_redacted text,
body_redacted text,
title_tokenized text,
body_tokenized text,
key_version int
);
Embeddings are versioned: every row says which model, which text state and which vault key produced it. Why that matters, and the event-driven pipeline that keeps vectors current, is the subject of my embedding versioning post and its lab. A partial unique index allows at most one active version per state, so a new set can be built next to the old one and switched when it is ready:
CREATE UNIQUE INDEX embedding_versions_one_active ON bank.embedding_versions (state) WHERE is_active;
-- shortened: the repository version adds a hash of the embedded text
CREATE TABLE bank.embeddings (
version_id int NOT NULL REFERENCES bank.embedding_versions,
doc_id int NOT NULL REFERENCES bank.documents,
bank_id text NOT NULL,
dense vector(1024) NOT NULL,
sparse sparsevec(30522) NOT NULL,
PRIMARY KEY (version_id, doc_id)
);
CREATE INDEX embeddings_dense_hnsw ON bank.embeddings USING hnsw (dense vector_cosine_ops);
CREATE INDEX ON bank.embeddings (version_id, bank_id);
The bank_id on embeddings is deliberate. The vector table is the one people forget to protect: it gets the same tenant column, and the same row-level security policy, as the documents.
The governance schema gov is plain tables. A registry says which column is sensitive and where its token goes, and an egress log records every request that leaves, with a hash and the size of the payload and what the scanner found, never the payload itself. The gateway may read the log, insert into it and set each request’s outcome, nothing else:
CREATE TABLE gov.sensitive_columns (
table_name text NOT NULL,
column_name text NOT NULL,
category text NOT NULL, -- CLIENT, PERSON, EMAIL, IBAN, HOST, IP, FREE_TEXT
token_column text, -- where the deterministic token is written
PRIMARY KEY (table_name, column_name)
);
GRANT SELECT, INSERT ON gov.egress_log TO gateway;
GRANT UPDATE (outcome) ON gov.egress_log TO gateway; -- the rest of the log is append-only
The vault, which everything else depends on
The key, and the mapping from a token back to its value, are what turn a token into a name. Anyone who only ever sees tokens (the model, the agent’s role, a tracing store) has no other way back, and a reviewer gets names only through it, so they live on a second PostgreSQL server, with its own roles and credentials, and no postgres_fdw or dblink on the bank side. The two containers also sit on separate Docker networks. On one laptop that is as far as it goes: the bank’s superuser can run programs on its server, and the vault’s port is still reachable through the Docker host. In production the vault belongs on its own host, behind a firewall that only lets the gateway in.
One honest limit of this lab’s layout: the bank tables keep each token next to its raw value (client_token beside client_name), because the pipeline needs both to filter the text. So the pipeline role and the bank’s administrators can map tokens back without the vault; what keeps the agent out is column privileges, below. One more route stays open in the lab: the agent’s role can read the raw-text embeddings kept for the measurement, and a sparse vector is indexed by words, so it partly exposes the words it was built from, names included. A stricter design moves the token columns out of the raw tables.

The vault server holds only the key and the token mapping. tokenizer can compute tokens but read neither table; reidentifier reads the mapping, never the key (the vault’s admin, a superuser in the lab, reads both).
Nobody reads the key, not even the role that creates tokens. Tokens are computed inside the vault by a SECURITY DEFINER function, which runs with its owner’s rights (in the lab the owner is the vault’s admin, a superuser, which a production setup would replace with a dedicated non-superuser owner), with a pinned search_path:
-- shortened: the repository version also raises when no key is active
CREATE FUNCTION vault.tokenize(p_category text, p_values text[])
RETURNS TABLE (value text, token text)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = vault, public, pg_temp
AS $$
#variable_conflict use_column
DECLARE
k bytea;
kv int;
BEGIN
SELECT secret, key_version INTO k, kv FROM vault.keys WHERE active;
RETURN QUERY
WITH src AS (
SELECT DISTINCT v AS value FROM unnest(p_values) AS v WHERE v IS NOT NULL
), tok AS (
SELECT s.value,
upper(p_category) || '_' ||
substr(encode(hmac(convert_to(upper(p_category) || ':' || lower(btrim(s.value)), 'UTF8'),
k, 'sha256'), 'hex'), 1, 12) AS token
FROM src s
), ins AS (
INSERT INTO vault.mapping (token, key_version, category, value)
SELECT DISTINCT ON (t.token) t.token, kv, upper(p_category), t.value
FROM tok t ORDER BY t.token, t.value COLLATE "C"
ON CONFLICT DO NOTHING
)
SELECT t.value, t.token FROM tok t;
END;
$$;
Functions are executable by PUBLIC by default, so the vault script revokes that before granting anything, and the public schema in that search_path is not writable by either role (the default since PostgreSQL 15, and revoked explicitly in the script):
REVOKE ALL ON ALL TABLES IN SCHEMA vault FROM PUBLIC;
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA vault FROM PUBLIC;
GRANT USAGE ON SCHEMA vault TO tokenizer, reidentifier;
GRANT EXECUTE ON FUNCTION vault.tokenize(text, text[]), vault.active_key_version() TO tokenizer;
GRANT SELECT ON vault.mapping TO reidentifier;
GRANT EXECUTE ON FUNCTION vault.active_key_version() TO reidentifier;
From the run, by role:
tokenizer: SELECT * FROM vault.keys;
ERROR: permission denied for table keys
tokenizer: SELECT * FROM vault.tokenize('client', ARRAY['Brenta Kontor AG', 'brenta kontor ag ']);
value | token
-------------------+---------------------
brenta kontor ag | CLIENT_7119873645a3
Brenta Kontor AG | CLIENT_7119873645a3
reidentifier: SELECT * FROM vault.keys;
ERROR: permission denied for table keys
Three things the token is, and one it is not. It is the category plus the first 12 hex characters of an HMAC-SHA256 of the normalized value, a keyed hash nobody can reproduce without the key; whoever holds the tokenizer credential can still submit a guess and compare tokens (and the guess lands in the mapping), which is why that credential stays with the gateway. It is 48 bits, so by the birthday bound a collision within one category becomes a 1% risk around 2.4 million values; the largest category here has 481. And this version does not detect a collision: ON CONFLICT DO NOTHING would skip it silently, where a production version should raise.
What it is not is entity resolution. The hash input is category and normalized string, with no tenant and no entity ID, so two different clients with the same name would share a token, and the same name in two banks gets the same token. The synthetic data has no such pair, which is why the results below are not affected, but a real CRM has them. A production version would tokenize a tenant-scoped canonical ID, and map aliases to it explicitly. The lab does that in one place only: a server’s hostname and its full domain name share one token.
hostname | fqdn | host_token | fqdn_token
----------------+-------------------------------+-------------------+-------------------
alb-pg-prd-01 | alb-pg-prd-01.alder.internal | HOST_f774b8916acd | HOST_f774b8916acd
Architecture: two servers, one way in
| Layer | What it answers | PostgreSQL doing the work |
|---|---|---|
| 1. Row-level security | Which bank’s rows a session sees | CREATE POLICY on every business table, embeddings included |
| 2. Labelling | What is sensitive, and where the gaps are | A registry, SECURITY LABEL FOR anon, a coverage view |
| 3. Deterministic tokens | Same value, same token, in documents and questions | hmac() in the vault, token columns, a mention index |
| 4. Egress gate | What left, proved by a scan of every request | A gateway function in Python, logging to gov.egress_log |
| 5. Vault | Who may turn tokens back into values | A second server, SECURITY DEFINER, revoked defaults |
| 6. Audit | What the agent’s role executed | pgAudit session logging on app_agent |

Where each layer sits: four on the bank server, the gate in the gateway process, the key and mapping on the vault server. The dotted paths go to the vault: tokens computed once and written back to the bank, and re-identification, used only for a reviewer.

One question end to end: the name never leaves the gateway and the model sees tokens only. The last two steps run only when a reviewer needs the real name; otherwise the answer stays in tokens.
The agent never writes SQL. It gets three tools, search_documents, client_profile and host_documents, each a plain SECURITY INVOKER function that runs as app_agent, so row-level security and column privileges apply inside it. In the terms of my RAG, MCP, Skills post, this is an agent with tool access, but every tool is a retrieval function under the database’s rules, so the DBA keeps the control, MCP and skills still being weaker on the governance aspects. app_agent has no privilege on any raw sensitive column (it reads tokens and attributes that do not identify anyone on their own, such as currency, domicile or balance), so a tool cannot return a client’s name even by mistake. Those attributes do reach the provider, tied to a token, which is part of why this is pseudonymization. (It can read the raw-text embeddings, which exist here only for the measurement; a production setup would not keep them.)
SELECT has_column_privilege('app_agent', 'bank.clients', 'client_name', 'SELECT') AS agent_reads_client_name,
has_column_privilege('app_agent', 'bank.clients', 'client_token', 'SELECT') AS agent_reads_client_token;
agent_reads_client_name | agent_reads_client_token
-------------------------+--------------------------
f | t
Where the trust sits matters as much as the layers. The guarantee holds for what the agent can reach through those tools and that role. The gateway is the trusted part: it chooses the tenant, it holds the tokenizer and reidentifier credentials, and it decides what goes to the provider. It must derive the tenant from the authenticated user, never from an argument the model wrote, because row-level security enforces whatever bank the session declares without asking whether the session was entitled to declare it. A compromised gateway is outside what this design protects.
What PostgreSQL enforces, what it doesn’t, and why the ecosystem matters
The database enforces these by itself, on every query, whatever the application does: row-level security on the documents, the mention index and the vectors; column privileges that keep raw values away from the agent’s role; the revoked defaults and the SECURITY DEFINER function that keep the key unreadable in the vault; and pgAudit’s record of what the agent executed. It also holds the label catalog and reports what is missing from it through the coverage view, though a report only helps when someone reads it.
It does not do the rest, and I won’t pretend it does. The gate that scans every outbound request is a short Python function in the gateway. The tenant is chosen by the application. The labels are only as complete as the people who maintain them, and a name that is in no table passes. The tracing store and the model provider sit outside the database. And the vault is isolated by separate servers and networks (in the lab, separate containers on one host), not by SQL.
So why build it on PostgreSQL rather than around a separate vector store? Three reasons. First, one access model: the vectors live in the same engine as the rows they came from, under the same policy and the same privileges, so there is no second copy of every rule to keep in sync. Second, every database-side layer is core PostgreSQL or an extension, not a new product: pgvector for dense and sparse vectors, postgresql_anonymizer for the labels, pgcrypto for the keyed hash, pgAudit for the audit, all under the PostgreSQL License, maintained in the open and readable by the people who have to trust them. Third, everything except the chat model runs on your own machines: the two PostgreSQL servers, the embedding models (Apache 2.0, on CPU), and even the observability, since Langfuse (MIT, apart from its enterprise edition, the one its documentation lists retention under) runs on PostgreSQL and ClickHouse (Apache 2.0), plus a queue and an object store with their own licences. At run time the only external call in the lab is the chat model, and it is the call the gate scans, and in the governed setup blocks. For a bank that has to manage the third-party dependencies FINMA points to, that is what sovereignty means here: the model can move on premises without changing the data, the labels, the vault or the gate.
One design choice: entity first, then meaning
A hex string like CLIENT_43aa5d8366b5 means nothing to a semantic model, so dense search barely uses it. But the labels give me something better than semantics: while filtering the text, the tokenizer records which document mentions which entity, in bank.document_mentions. When a question names an entity, the search starts with the documents that mention it, ranks those by meaning, then adds the rest:
-- excerpt of bank.retrieve()
ent AS (
-- documents mentioning every entity the question names
SELECT m.doc_id FROM bank.document_mentions m
WHERE p_method = 'entity' AND cardinality(p_tokens) > 0 AND m.token = ANY(p_tokens)
GROUP BY m.doc_id HAVING count(DISTINCT m.token) = cardinality(p_tokens)
), ent_scored AS (
SELECT e.doc_id,
1.0 + 1.0 / (60 + row_number() OVER (ORDER BY e.dense <=> p_dense))
+ 1.0 / (60 + row_number() OVER (ORDER BY e.sparse <#> p_sparse)) AS score
FROM bank.embeddings e JOIN v USING (version_id)
WHERE e.doc_id IN (SELECT doc_id FROM ent)
)
Inside the entity’s documents the order is reciprocal rank fusion of dense and sparse, the same as everywhere else. The 1.0 + puts them ahead of everything that does not mention the entity. It is a choice, not a constant: if your questions often name an entity only in passing, you would add a boost instead of ranking first. The mention index is a plain table with a B-tree on token, under the same row-level security as the rest. Raw text can use it too, which matters for a fair comparison below. Run on its own, as app_agent with bank_a set (inside the function it is a CTE, and the function’s own plan is a single opaque function scan):
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT m.doc_id FROM bank.document_mentions m
WHERE m.token = ANY(ARRAY['CLIENT_43aa5d8366b5'])
GROUP BY m.doc_id HAVING count(DISTINCT m.token) = 1;
GroupAggregate (actual time=0.072..0.075 rows=6.00 loops=1)
Group Key: doc_id
Filter: (count(DISTINCT token) = 1)
-> Sort (actual time=0.066..0.067 rows=6.00 loops=1)
-> Bitmap Heap Scan on document_mentions m (actual time=0.042..0.044 rows=6.00 loops=1)
Recheck Cond: (token = ANY ('{CLIENT_43aa5d8366b5}'::text[]))
Filter: (bank_id = current_setting('app.bank_id'::text, true))
-> Bitmap Index Scan on document_mentions_token_idx (actual time=0.025..0.025 rows=6.00 loops=1)
Index Cond: (token = ANY ('{CLIENT_43aa5d8366b5}'::text[]))
Execution Time: 0.185 ms
(Shortened.) The token lookup is one index probe and six rows. Note where the row-level security predicate lands: here it is a Filter after the lookup, because the B-tree is on token alone. Keep that in mind for the vector plan below.
Row-level security on the vectors
Every business table that carries bank_id gets the same policy, the vector table included:
ALTER TABLE bank.embeddings ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bank.embeddings
USING (bank_id = current_setting('app.bank_id', true));
The bank is set per transaction with set_config(..., true), the function form of SET LOCAL, so a transaction-mode pooler cannot hand one bank’s setting to the next request. As app_agent:
bank=> SELECT count(*) AS documents_without_tenant FROM bank.documents;
documents_without_tenant
--------------------------
0
bank=> BEGIN;
bank=> SELECT set_config('app.bank_id', 'bank_a', true);
bank=> SELECT bank_id, count(*) AS documents FROM bank.documents GROUP BY bank_id;
bank_id | documents
---------+-----------
bank_a | 508
bank=> COMMIT;
bank=> SELECT count(*) AS documents_after_commit FROM bank.documents;
documents_after_commit
------------------------
0
Be precise about why both counts are zero, because they are not the same case. On PostgreSQL 18.3, current_setting('app.bank_id', true) returns NULL when the setting was never defined in the session, and an empty string once an earlier transaction has set it locally: the custom setting now exists, with an empty value. Neither matches a bank_id, so a session that forgets its bank gets nothing. If you would rather have one case, NULLIF(current_setting('app.bank_id', true), '') in the policy makes both NULL. And a transaction-local value only overrides: if something set the parameter at session level earlier, that value is back after the commit, which is why the pooling argument assumes connections that never set it at session level.
The raw client name is refused before row-level security even matters: SELECT client_name FROM bank.clients as app_agent fails with permission denied. Neither role owns the tables; if your application connects as the table owner, add FORCE ROW LEVEL SECURITY, or the owner sees every row. The pipeline role, gateway, has BYPASSRLS because it serves all three banks, and the agent never gets near it.
Labelling, the gap, and what caught it
The registry says what is sensitive; a procedure turns it into security labels, with postgresql_anonymizer as the label provider, so the catalog (pg_seclabels) is the one place to ask whether a column is labelled. The coverage view asks it for every text column of the business tables:
-- shortened: the repository version also skips three technical tables and the
-- derived columns (tokens, filtered copies, text hashes)
CREATE VIEW gov.label_coverage AS
SELECT c.table_name, c.column_name, sl.label, sc.category,
(sl.label IS NOT NULL) AS labelled
FROM information_schema.columns c
LEFT JOIN pg_seclabels sl
ON sl.provider = 'anon' AND sl.objtype = 'column'
AND sl.objoid = format('bank.%I', c.table_name)::regclass
AND sl.objsubid = c.ordinal_position
LEFT JOIN gov.sensitive_columns sc
ON sc.table_name = c.table_name AND sc.column_name = c.column_name
WHERE c.table_schema = 'bank' AND c.data_type = 'text'
AND c.column_name NOT IN ('bank_id', 'doc_type', 'client_type', 'currency',
'role', 'environment', 'domicile', 'category', 'token');
The choice that matters is the direction of the list: it excludes what is known to be harmless, so a new text column shows up as a gap until someone labels it or excludes it on purpose. It fails toward a finding, not toward silence. It only sees text columns; a sensitive value stored as inet, date or jsonb needs its own rule. I left one sensitive column out on purpose, the clients’ phone numbers:
bank=# SELECT table_name, column_name, category, labelled FROM gov.label_coverage ORDER BY labelled, table_name;
table_name | column_name | category | labelled
-----------------------+---------------+-----------+----------
clients | contact_phone | | f
accounts | iban | IBAN | t
clients | client_name | CLIENT | t
...
(10 rows)
Tokenizing then replaces every labelled value in the free text, using a dictionary of every value the labelled columns hold, matched as whole words ignoring case, longest first, with the grouped spelling of each IBAN added. One caveat before the numbers: in this lab the dictionary and the scanner read hand-written lists of columns that match the registry, rather than reading the registry itself. A production version should derive both from it.

Building the governed corpus: the registry drives the labels and the coverage check, the vault computes the tokens, and one dictionary pass filters the text, fills the mention index and feeds the local embedding models.
$ python python/scan_corpus.py
raw 7171 values in 1521 documents: CLIENT 3024, PERSON 1833, EMAIL 960, HOST 630, IBAN 480, PHONE 154, IP 90
redacted 154 values in 154 documents: PHONE 154
tokenized 154 values in 154 documents: PHONE 154
The raw corpus carries 7,171 detected occurrences of sensitive values. After tokenization what is left is exactly the gap: 154 phone numbers from the column nobody labelled. Now the governed agent, asked about a client whose note carries that phone number, in a dry run that builds the real request and stops before it leaves:
$ python python/agent.py --dry-run --bank bank_a \
"What did Emmental Immobilien AG complain about regarding custody fees?"
question sent : What did CLIENT_43aa5d8366b5 complain about regarding custody fees?
[tool] search_documents returned:
doc 10 advisor_note: PERSON_65a94b3e0336 met CLIENT_43aa5d8366b5. The client complained that the custody fees rose without notice. Next step agreed with CLIENT_43aa5d8366b
doc 12 advisor_note: PERSON_65a94b3e0336 met CLIENT_43aa5d8366b5. The client complained that the custody fees rose without notice. Next step agreed with CLIENT_43aa5d8366b
doc 11 email: From EMAIL_288e9f2f11a1. Dear client, following our call, CLIENT_43aa5d8366b5 complained that the custody fees rose without notice. Please confirm the
doc 14 email: From EMAIL_288e9f2f11a1. Dear client, following our call, CLIENT_43aa5d8366b5 requested an ESG screening of current equity holdings. Please confirm th
doc 13 advisor_note: PERSON_65a94b3e0336 met CLIENT_43aa5d8366b5. The client requested an ESG screening of current equity holdings. Next step agreed with CLIENT_43aa5d8366
[egress] 3729 chars, filtering=tokenized, scanner hits=1 ['PHONE'] BLOCKED
stopped at the egress gate: 1 sensitive value(s) in outbound payload: ['PHONE']
Every retrieved document is the right client and every name, email and account is a token, but doc 12 still carries the phone number. The gate stopped the request: that is what prevented the transmission. It caught the number because the scanner’s own list includes the phone column (I left it out of the registry on purpose, so I knew), and a Swiss phone-number pattern would have matched it anyway. Then the label fixed the filtering:
bank=# INSERT INTO gov.sensitive_columns VALUES ('clients', 'contact_phone', 'PHONE', 'phone_token');
bank=# CALL gov.apply_labels();
$ python python/tokenize_corpus.py
...
$ python python/agent.py --dry-run --bank bank_a "What did Emmental Immobilien AG complain about regarding custody fees?"
... (same question sent, same five documents)
[egress] 3731 chars, filtering=tokenized, scanner hits=0
payload cleared the gate (dry run: not sent)
Same documents, two characters longer (a 16-character phone number became an 18-character token), zero detected values. The gate stops what it recognizes; the label makes the filter remove it before it ever gets there. Both are needed, and neither knows a name that is in no table: nine documents each name a lawyer who exists in no column (Hugo Vogel in one, Selina Python in another), and those nine pass. Deterministic filtering is exactly as good as your labelling. Named-entity recognition can be added as a second net, but it puts a probabilistic step back into the path of a guarantee; I would rather know the ceiling and work on coverage.
Did filtering cost relevance?
Retrieval
36 labelled questions, 30 naming a client or a server and 6 generic, run against the raw, redacted and tokenized embeddings as app_agent with its bank set:
$ python python/measure.py
state method set recall@5 nDCG@5 recall@10 nDCG@10
raw hybrid entity 0.956 0.953 1.000 0.973
raw entity entity 1.000 0.991 1.000 0.991
redacted hybrid entity 0.192 0.185 0.408 0.284
tokenized dense entity 0.431 0.447 0.625 0.541
tokenized sparse entity 0.822 0.827 0.897 0.860
tokenized hybrid entity 0.650 0.670 0.775 0.731
tokenized entity entity 1.000 0.990 1.000 0.990
(Shortened; the six generic questions score 1.000 everywhere, which says they are too easy. measure.py prints the full table.) Redaction destroys entity questions, 0.192, because the documents no longer say who. Opaque tokens alone recover part of it: the dense model barely uses a hex string (0.431), the sparse model does (0.822). Entity-aware search over the mention index brings tokenized retrieval to 1.000, the same as raw with the same search. Redaction cannot use it: a redacted question names no one.
Answers
Retrieval is not what a relationship manager reads, so the model answered all 36 questions, citing documents as [doc N], with deterministic scoring and no model judging another model. To isolate the cost of the tokens, the two setups compared here differ in the text and almost nothing else: both use the same entity-aware search, under the same row-level security and candidate limits, and the same single search tool. One reads raw text, the other tokens. (Two small differences remain: the entity is found by its token on one side and by its name on the other, and the raw side’s requests are logged but never blocked by the gate; none was blocked on either side in this run.) Both ran together on 3 October:
$ python python/evaluate_answers.py --label fair2 --modes raw_entity,tokenized_search
mode set cited rec cited prec context rec expl. entity blocked
raw_entity entity 0.722 0.989 1.000 1.0 0
tokenized_search entity 0.719 0.993 1.000 1.0 0
egress: {'tokenized_search': {'sent': 74, 'values_sent': 0, ...}, 'raw_entity': {'sent': 73, 'values_sent': 871, ...}}
(Shortened to the entity questions; the generic ones score 1.000 in both.) Both setups had every expected document in front of the model (context recall 1.000), and both cited the same share of them, 0.722 against 0.719. With the same search, the tokens cost nothing measurable. What changed is what left: 871 detected occurrences of sensitive values for the raw setup, none for the tokenized one.
For reference, the naive setup (raw text, plain hybrid search across all three banks, filtered to one bank afterwards) put fewer expected documents in front of the model, context recall 0.981, and sent 1,134 detected occurrences in 75 requests, in a separate run the same day. The naive setup differs from the entity-aware ones in two ways at once, the search method and searching all banks before filtering, so these runs cannot say which one accounts for the gap; what it does show is that the tokens are not the cost.
Cited recall counts expected documents cited, which is document coverage, not answer completeness: when three notes repeat the same complaint, citing one of them can be a complete answer. So the honest claim is narrower than “better answers”: on this corpus, filtering keeps the answers grounded in the right documents while nothing known leaves.
What a tracing tool keeps
The egress log tells me what left and how big it was, and pgAudit tells me what app_agent executed. Neither tells the story of one question: what was asked, which documents came back, under which role, what the model answered, and whether it was right. So I traced every question with Langfuse, the open-source tracing and evaluation tool for LLM applications that ClickHouse acquired in January 2026, self-hosted next to the lab. Look first at what it runs on: PostgreSQL for its transactional data, ClickHouse for traces and scores, Redis as a queue, S3-compatible storage for the raw events. AI observability is a database workload.

Langfuse 4.50, self-hosted: one trace per question, marked off for the naive setup and tokenized for the governed one in the metadata. The naive rows show client names in the input and output columns; the governed rows show tokens.
Each retrieval step records the database role, whether that role bypasses row-level security, and the document IDs, so the role question becomes SQL on the ClickHouse table that holds the spans, here on the traced run of 2 October:
clickhouse> SELECT tags[1] AS mode,
metadata_values[indexOf(metadata_names, 'db_role')] AS db_role,
metadata_values[indexOf(metadata_names, 'bypass_rls')] AS bypass_rls,
metadata_values[indexOf(metadata_names, 'doc_ids')] AS doc_ids
FROM events_full FINAL
WHERE session_id = 'langfuse' AND name = 'search_documents' AND input LIKE '%succession%'
ORDER BY mode, start_time LIMIT 1 BY mode;
┌─mode──────┬─db_role───┬─bypass_rls─┬─doc_ids────────────────┐
1. │ off │ gateway │ true │ [18, 19, 206, 100, 22] │
2. │ tokenized │ app_agent │ false │ [18, 19, 16, 17, 292] │
└───────────┴───────────┴────────────┴────────────────────────┘
The naive search ran as a role that bypasses row-level security and stayed inside its bank only because the application’s query filtered on it. And because the evaluation’s scores sit next to the spans, a quality question becomes a join: for the governed answers that cited fewer expected documents, what had the search returned? For one of the two worst in that run, a question about replication lag on a server, the expected documents were 948 to 951; the search returned all four at the top, and the model cited one. That is not a retrieval problem, and without the trace next to the score I would have gone tuning the index.
Then I pointed the egress scanner at the trace store itself, for the three-setup run of 3 October:
$ python python/scan_traces.py --label fair
ClickHouse, table events_full, session fair
mode traces spans spans with values sensitive values
off 36 150 144 2336
raw_entity 36 144 138 1850
tokenized 36 170 0 0
object storage, bucket langfuse/events/otel, session fair
(same counts, shortened)
Tracing records what the application sends and receives; that is its job. On the naive path the trace store ends up with 2,336 detected occurrences for questions that sent 1,134 to the provider: every retrieved document is stored once more as the search result, on top of the copies inside each model call, plus the questions and answers. And all of it twice, in ClickHouse and in object storage. On the governed path both hold zero detected values, and the reason is precise: the search result is recorded when the tool returns, before the gate scans the next request, so the traces are clean only because the tools return tokens. While the phone gap was open, doc 12’s number would have reached the trace store even though the gate blocked the request.

The naive setup, as Langfuse stored it (2 October): the search step’s output holds the raw text, with the client’s name and the advisor’s name and email in clear, and an IBAN and a phone number too, masked here for publication.

The governed setup, same kind of step: every name in the stored search result is a token.
So the observability layer is one more copy of the data. In the Langfuse 4.50 I ran, the table holding the spans carries a full-text index on every span’s input and output, so questions, retrieved documents and answers are searchable word by word. On a self-hosted instance that copy is kept indefinitely unless you configure retention, a setting its documentation lists only under the Enterprise Edition. The SDK has masking hooks that run before trace data leaves the application; that is where I would plug the gate’s scanner, so the trace store can never hold more than the provider was allowed to see. I have not run that variant yet.
What the plans show
The dense path as app_agent with bank_a set, using a stored vector as the query. The query has no bank predicate:
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT e.doc_id FROM bank.embeddings e
JOIN bank.embedding_versions v USING (version_id)
WHERE v.state = 'tokenized' AND v.is_active
ORDER BY e.dense <=> :'qd'::vector LIMIT 10;
Limit (actual time=1.936..1.938 rows=10.00 loops=1)
Buffers: shared hit=1762
-> Sort (actual time=1.935..1.936 rows=10.00 loops=1)
Sort Method: top-N heapsort Memory: 25kB
-> Nested Loop (actual time=0.051..1.850 rows=508.00 loops=1)
Buffers: shared hit=1759
-> Index Scan using embedding_versions_one_active on embedding_versions v (actual time=0.006..0.006 rows=1.00 loops=1)
Index Cond: (state = 'tokenized'::text)
-> Index Scan using embeddings_version_id_bank_id_idx on embeddings e (actual time=0.019..0.144 rows=508.00 loops=1)
Index Cond: ((version_id = v.version_id) AND (bank_id = current_setting('app.bank_id'::text, true)))
Buffers: shared hit=64
Execution Time: 2.234 ms
(Shortened: sort key, index searches and planning lines removed.)
Reading it from the bottom: the active version comes from the partial unique index, then an index scan on (version_id, bank_id) returns the 508 rows of one bank and one version in 64 buffers, and a top-N heapsort scores all of them exactly and keeps ten. At this size the planner does not use the HNSW index at all.
Two details are worth the read. The bank_id = current_setting(...) condition is not in the query: it comes from the policy, and because the index is on (version_id, bank_id), the planner used it as an index condition next to the version. On the mention index above, with no bank_id in the B-tree, the same predicate was a filter after the lookup: where row-level security lands in a plan depends on your indexes. And of the 1,762 buffers, the index scan accounts for 64; the rest is reading the vectors back. pgvector stores vector with the EXTERNAL storage mode, and at about 4 KB each (1,024 floats plus a small header) the dense vectors live out of line: 48 MB of TOAST against 8 MB of heap, for 9,126 rows across the six embedding versions the table holds. The sparse vectors average 773 bytes and stay inline, and the same plan on the sparse path reads 66 buffers. Over five repeated runs the dense path took 1.9 to 4.5 ms, the sparse path under 1 ms, and the hybrid function 5 to 14 ms.
With much larger tables per bank the planner may switch to the HNSW index, and a plain HNSW scan stops after ef_search candidates, some of which row-level security and the version filter then remove, since one HNSW index spans every version. That is why bank.retrieve() carries SET hnsw.iterative_scan = strict_order and SET hnsw.ef_search = 100 in its definition: the index keeps scanning until enough rows pass the filter or its configured scan limits are reached, and the settings cannot leak into the caller’s session. I did not measure where that switch happens.
Underneath, there is nothing exotic. Tenant, version and entity are predicates on ordinary indexes, the vault is another PostgreSQL server with revoked defaults, the gate is a short function writing to a table, and the vectors are TOASTed values like any other. Every layer is something a DBA already knows how to operate, back up and audit.
A note on method
Everything above was captured from runs of the lab on 1, 2 and 3 October 2026, command by command, and the runbook in the repository replays it, including the two comparison runs (step 19). The data is synthetic and generated with a fixed seed; vault tokens differ per vault (different hex, same behaviour). Embeddings ran on a CPU, between about 0.7 and 3.5 documents per second depending on the run, which is why the corpus is 1,521 documents and not a million.
The question set is small and generous: each entity question names one entity, and the generic ones are too easy. An outside review of an earlier run found questions whose wording matched only one of the ways their documents phrase the topic, which rewarded a correct “nothing found”; I fixed the wording and every number above uses the corrected set. Answers come from a model and vary between runs, which is why the compared setups were always run together. These numbers show the mechanism on this corpus; they are not a production benchmark.
“Zero” in this post always means zero detected occurrences: zero matches against the raw values of the bank’s sensitive columns and a few patterns, which a name in no table escapes. The scanner reads the payload with accents intact, so a name like Müller is matched too, though the lab data itself is plain ASCII. An outcome of sent in the egress log means the provider accepted the request; a request logged as failed may still have reached it before the error, so the log proves what was sent, not everything that was attempted. Key rotation is scripted but I have not run it end to end, and re-tokenizing overwrites the tokenized text in place, so older vector versions are not a full rollback. The agent ran on gpt-6-luna with reasoning turned off: in my runs, Chat Completions refused function tools for that model unless it was.
Takeaways
I often hear that you cannot keep sensitive data out of an AI pipeline and still get useful answers. On this lab you can, with one condition: labelling. Row-level security on the vectors decides which rows a session sees (which, as I showed in my Swiss PGDay talk, also improves precision), the labels decide what is sensitive, deterministic tokens replace it the same way every time, the gate proves what left, and the vault keeps the way back, for everything downstream of the filter, on another server. With the same search and tools, the answers on tokens were as good as the answers on raw text, and nothing known left. And the tool you add to watch all of it becomes a copy of the data unless what it records is filtered too.
This transfers to any assistant over CRM notes, a ticketing tool or a CMDB, wherever the useful text names people and systems, and it does not depend on which model you use or where it runs. Its weak points are said above: names that live in no table, a tracing store that sits outside the gate unless you put it behind, and a token that matches strings, not identities. It also makes the token mapping sensitive in its own, a secret with a required life cycle somehow.
A stale mapping increases the risk of a leak; a rotated one has to keep its history, for traceability and for questions that span a period with several mapping versions. How long to keep which version, and who may read it, is a question the business has to answer, and the real answer is probably a dynamic mapping system. This lab shows that the approach is possible, not how to run it in production.
Agentic projects put pressure on governance at a level organizations have never known, and it is understandable to feel overwhelmed, even afraid. To me this is an engineering problem, and we already hold most of the keys; the challenge is that the storm we are living through makes it hard to see clearly. Observability and open source will prevail !