NEVER TRAINED ON · PRIVATE BY DEFAULT · BUILT BY MOZILLA

GITHUB

Integrate with your model stack

Redact PII before your model searches the web

Build a loop where Rampart redacts personal details on your machine, Tabstack researches the redacted question, and your own model writes the reply. Four short Python files.

Support chats, intake forms, benefits questions, patient messages. The messages people send your app are full of personal details: names, addresses, account numbers, IDs. And many of those questions need a current answer from the web, like a program’s income limits, a bank’s wire rules, or a state’s renewal process.

The answer depends on what they’re asking, not on who’s asking. This tutorial builds a loop that takes personal details out before the question leaves your machine: Rampart’s open model redacts personal details on your machine, Tabstack runs the search-to-answer loop on the redacted question, your own model in Ollama writes the reply, and the details go back in on your side.

Rampart, from National Design Studio

Rampart is an open PII redaction library from National Design Studio. It runs in the browser, in the moment between typing a message and sending it, and replaces personal details with placeholders like [GIVEN_NAME_1] before anything reaches a server. The mapping back to real values never leaves the device.

At its core is a 14.7 MB MiniLM model that recognizes names, phone numbers, bank account and routing numbers, tax and government IDs, passports, driver’s licenses, and every part of a street address. Rules cover card numbers, SSNs, emails, URLs, and IP addresses. Together they work across seven Latin-script languages and report 98.42% private-term recall on Rampart’s held-out test set. National Design Studio is clear about what it is for: harm reduction for people entering their own information.

Two choices make it a natural fit for web research. It keeps city, state, and ZIP by default, because location is what most “what applies to me” questions turn on. And the model is published openly on Hugging Face, so it runs from Python on your server as well as in the browser. That is how we use it here.

What Rampart’s model catches

Here is what the code in this tutorial sends onward for a few kinds of questions. Everything in brackets stays on the machine.

Intake What leaves your machine
Benefits My name is [GIVEN_NAME_1] [SURNAME_1], I live at [BUILDING_NUMBER_1] [STREET_NAME_1], Brookvale, CT 06482, and I make $1,950 a month. What housing assistance in Connecticut am I eligible for?
Banking Hi, this is [GIVEN_NAME_1] [SURNAME_1] from Acme Corp, account [BANK_ACCOUNT_1], routing [ROUTING_NUMBER_1]. Why was my wire held?
Travel documents Passport [PASSPORT_1], driver’s license [DRIVERS_LICENSE_1] issued in California. Can I renew my passport by mail from Fresno?
Health I’m [GIVEN_NAME_1] [SURNAME_1], DOB 03/14/1988, member ID [GOVERNMENT_ID_1], diagnosed with type 1 diabetes. Does Anthem cover insulin pumps in Ohio?
Spanish Mi nombre es [GIVEN_NAME_1] [SURNAME_1], vivo en [STREET_NAME_1] [BUILDING_NUMBER_1], Madrid. ¿Qué ayudas hay para alquiler?

The names and IDs are gone, and the state, program, amount, and topic are still there, so each question can still be answered. The health row also shows what the model leaves alone, like a date of birth, which we cover in tuning.

What you’ll build

flowchart LR
  Q["Question<br/>with PII"] --> R["Redact PII<br/>Rampart model<br/>on your machine"]
  R -- "redacted question" --> T["Research<br/>Tabstack /research"]
  T -- "cited report" --> M["Write the reply<br/>Ollama<br/>on your machine"]
  M -- "reply with placeholders" --> U["Restore details<br/>on your machine"]

Four short files:

  • redact.py finds PII with Rampart’s model and swaps it for placeholders.
  • research.py sends the redacted question to Tabstack and gets back a cited report.
  • reply.py has your model write the answer from that report.
  • ask.py runs the loop and restores the details.

Set up

You need Python 3.9 or later, Ollama with a model pulled, and a Tabstack API key.

pip install onnxruntime tokenizers huggingface_hub numpy tabstack ollama
ollama pull llama3.1
export TABSTACK_API_KEY=...

The first run downloads Rampart’s model files from Hugging Face and caches them.

Step 1: Redact PII with Rampart’s model

The model labels each token: B-GIVEN_NAME starts a name, I-STREET_NAME continues a street, O is ordinary text. We join those labels into spans, skip the ones in KEEP, and run a short list of rules for the classes Rampart handles with rules instead of the model: card numbers, SSNs, IP addresses, emails, and URLs. Long messages run in overlapping 512-token windows, the model’s limit.

# redact.py
import json
import re
from typing import Callable, Optional

import numpy as np
import onnxruntime as ort  # type: ignore[import-untyped]
from huggingface_hub import hf_hub_download
from tokenizers import Tokenizer  # type: ignore[import-untyped]

REPO = "nationaldesignstudio/rampart"
KEEP = {"CITY", "STATE", "ZIP_CODE"}  # the answer needs these; Rampart keeps them too
MIN_SCORE = 0.4  # start a span
EXTEND_SCORE = 0.15  # continue a span across punctuation, as Rampart does

tokenizer = Tokenizer.from_file(hf_hub_download(REPO, "tokenizer.json"))
tokenizer.enable_truncation(max_length=512, stride=64)  # long messages run in overlapping windows
with open(hf_hub_download(REPO, "config.json")) as f:
    id2label = json.load(f)["id2label"]
label2id = {label: int(i) for i, label in id2label.items()}
model = ort.InferenceSession(hf_hub_download(REPO, "onnx/model_q4.onnx"))

GAP = re.compile(r"[\s\-.()/]*")


def luhn_ok(number: str) -> bool:
    digits = [int(d) for d in re.sub(r"\D", "", number)][::-1]
    total = sum(d if i % 2 == 0 else (d * 2 - 9 if d > 4 else d * 2) for i, d in enumerate(digits))
    return total % 10 == 0


# Rampart catches these with rules rather than the model, so we do too.
# Each rule is (pattern, label, optional check). Append your own for IDs the model doesn't know.
Rule = tuple[re.Pattern[str], str, Optional[Callable[[str], bool]]]
RULES: list[Rule] = [
    (re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "SSN", None),
    (re.compile(r"\b(?:\d{4}[ -]?){3}\d{4}\b|\b\d{4}[ -]?\d{6}[ -]?\d{5}\b"), "CARD_NUMBER", luhn_ok),
    (re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "IP_ADDRESS", None),
    (re.compile(r"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b"), "EMAIL", None),
    (re.compile(r"\bhttps?://[^\s<>\"')\]]*[^\s<>\"').,;:!?\]]"), "URL", None),
]


def find_pii(text: str) -> list[tuple[int, int, str]]:
    spans: list[tuple[int, int, str]] = []
    first = tokenizer.encode(text)
    for enc in [first, *first.overflowing]:
        feeds = {
            "input_ids": np.array([enc.ids], dtype=np.int64),
            "attention_mask": np.array([enc.attention_mask], dtype=np.int64),
            "token_type_ids": np.array([enc.type_ids], dtype=np.int64),
        }
        logits = model.run(None, feeds)[0][0]
        scores = np.exp(logits) / np.exp(logits).sum(axis=-1, keepdims=True)

        window: list[tuple[int, int, str]] = []
        for (start, end), row in zip(enc.offsets, scores):
            if start == end:
                continue
            label = id2label[str(int(row.argmax()))]
            kind = label[2:]
            last = window[-1] if window and GAP.fullmatch(text[window[-1][1]:start]) else None
            # Join word pieces and multi-part values ("Cedar Lane", "203-555-0142") into one span.
            if last and (kind == last[2] or (label == "O" and row[label2id["I-" + last[2]]] >= EXTEND_SCORE)):
                window[-1] = (last[0], end, last[2])
            elif label != "O" and row.max() >= MIN_SCORE:
                window.append((start, end, kind))
        spans += window

    rules = [
        (m.start(), m.end(), label)
        for pattern, label, check in RULES
        for m in pattern.finditer(text)
        if check is None or check(m.group())
    ]

    # Rule matches win over the model, longer spans win over shorter ones, and none overlap.
    chosen: list[tuple[int, int, str]] = []
    for span in sorted(rules, key=lambda s: s[0] - s[1]) + sorted(spans, key=lambda s: s[0] - s[1]):
        if span[2] not in KEEP and all(span[1] <= c[0] or span[0] >= c[1] for c in chosen):
            chosen.append(span)
    return sorted(chosen)


class Redactor:
    """Swaps PII for numbered placeholders and keeps the mapping in memory."""

    def __init__(self) -> None:
        self.values: dict[str, str] = {}  # placeholder -> original value

    def redact(self, text: str) -> str:
        out, last = [], 0
        for start, end, kind in find_pii(text):
            value = text[start:end]
            placeholder = next((p for p, v in self.values.items() if v == value), None)
            if placeholder is None:
                count = sum(p.startswith(f"[{kind}_") for p in self.values) + 1
                placeholder = f"[{kind}_{count}]"
                self.values[placeholder] = value
            out += [text[last:start], placeholder]
            last = end
        return "".join(out + [text[last:]])

    def restore(self, text: str) -> str:
        for placeholder, value in self.values.items():
            text = text.replace(placeholder, value)
        return text

The placeholder table lives in memory in your process. The same value always gets the same placeholder, so a name that comes up again in a follow-up message keeps its placeholder.

Step 2: Research the redacted question with Tabstack

/research plans searches, reads sources, and returns a Markdown report with the pages it cited, all inside one call. Your model never fetches a page or reads raw HTML, and Tabstack only receives the redacted question.

# research.py
from tabstack import Tabstack

client = Tabstack()  # reads TABSTACK_API_KEY


def research(question: str) -> tuple[str, list[dict[str, str]]]:
    stream = client.agent.research(query=question, mode="fast")
    for event in stream:
        if event.event == "complete":
            pages = event.data.metadata.cited_pages or []
            sources = [{"title": page.title or "(untitled)", "url": page.url} for page in pages]
            return event.data.report, sources
        if event.event == "error":
            raise RuntimeError(f"Research failed: {event.data.error.message}")
    raise RuntimeError("Research stream ended without a result")

On the redacted benefits question, a fast call came back in about 17 seconds with a report on Connecticut income limits, the Section 8 Housing Choice Voucher Program, and the state Rental Assistance Program, citing five sources including the Connecticut Department of Housing. Use mode="balanced" when a question needs more sources.

Tabstack is built by Mozilla. Every request is covered by two commitments on our Data Handling page: never trained on, and private by default.

Step 3: Write the reply with your model

Your model gets the redacted question and the finished report. Tell it to keep placeholders exactly as written, so the details can go back in.

# reply.py
import ollama

MODEL = "llama3.1"

SYSTEM_PROMPT = (
    "Answer the user's question using only the research report. "
    "Keep every placeholder, such as [GIVEN_NAME_1], exactly as written."
)


def write_reply(question: str, report: str) -> str:
    response = ollama.chat(
        model=MODEL,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"Question:\n{question}\n\nResearch report:\n{report}"},
        ],
    )
    return response.message.content or ""

Any model you run in Ollama works here. Swap MODEL for Qwen, Mistral, or whatever you already use.

Step 4: Run the loop

# ask.py
import sys

from redact import Redactor
from reply import write_reply
from research import research


def ask(question: str) -> None:
    redactor = Redactor()  # one per conversation; the mapping never leaves this process
    safe_question = redactor.redact(question)
    print(f"Sent: {safe_question}\n")

    report, sources = research(safe_question)
    reply = write_reply(safe_question, report)

    print(redactor.restore(reply))
    print("\nSources:")
    for source in sources:
        print(f"- {source['title']}: {source['url']}")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit('Usage: python ask.py "your question"')
    try:
        ask(" ".join(sys.argv[1:]))
    except Exception as err:
        sys.exit(f"Could not answer: {err}")

Run it with any question from the table above, or one of your own:

python ask.py "Hi, this is Tom Becker from Acme Corp, account 0042981173, routing 021000021. Why was my wire held?"

The first line shows exactly what left your machine. The answer that follows has the real details back in it, with the sources it came from.

Tune it for your data

Three adjustments cover most intakes.

Decide what stays. KEEP is the list of labels that pass through untouched. City, state, and ZIP are the default because local rules depend on them. A question about a federal program may not need the ZIP; a question about a city service needs the city. Set KEEP per use case, not once for the whole app.

Send structured fields by choice. When the intake is a form, you already know which field holds the name. There is no need to guess with a model: send the fields the answer needs, run the model only over the free text, and never send the rest.

# intake.py
from redact import Redactor

SEND = {"state", "zip_code", "household_size", "monthly_income"}  # fields the answer needs
FREE_TEXT = ("question", "notes")  # fields people type into


def build_question(form: dict[str, str], redactor: Redactor) -> str:
    lines = [f"{field.replace('_', ' ')}: {value}" for field, value in form.items() if field in SEND]
    lines += [redactor.redact(form[field]) for field in FREE_TEXT if field in form]
    return "\n".join(lines)

Research a form the same way as a question:

from intake import build_question
from redact import Redactor
from reply import write_reply
from research import research

form = {
    "full_name": "Maria Garcia",
    "email": "maria@example.com",
    "state": "CT",
    "zip_code": "06482",
    "household_size": "2",
    "monthly_income": "$1,950",
    "question": "My landlord Dave Kim raised the rent 30%. What help is there?",
}

redactor = Redactor()
question = build_question(form, redactor)
report, sources = research(question)
print(redactor.restore(write_reply(question, report)))

The question that goes to Tabstack is:

state: CT
zip code: 06482
household size: 2
monthly income: $1,950
My landlord [GIVEN_NAME_1] [SURNAME_1] raised the rent 30%. What help is there?

The name and email fields are never sent at all, and the one name inside the free text is redacted.

Add rules for your own identifiers. Case numbers, patient IDs, policy numbers, and dates of birth follow formats you know. Append them to RULES:

import re

from redact import RULES

RULES.append((re.compile(r"(?i)(?<=dob )\d{1,2}/\d{1,2}/\d{4}"), "DATE_OF_BIRTH", None))
RULES.append((re.compile(r"\bCASE-\d{6}\b"), "CASE_NUMBER", None))

With those added, “I’m Priya Nair, DOB 03/14/1988, case CASE-204871. Does Ohio Medicaid cover insulin pumps?” goes out as “I’m [GIVEN_NAME_1] [SURNAME_1], DOB [DATE_OF_BIRTH_1], case [CASE_NUMBER_1]. Does Ohio Medicaid cover insulin pumps?”

Send only the question. Redaction removes identifiers, but the rest of the message still goes to Tabstack: greetings, background, details the answer doesn’t need. Your model is already running locally, so have it rewrite the redacted message as one standalone research question first.

# focus.py
import ollama

from reply import MODEL

FOCUS_PROMPT = (
    "Rewrite the user's message as one standalone research question. "
    "Keep only the facts needed to answer it, such as location, program, amounts, dates, or conditions. "
    "Drop greetings, names, placeholders like [GIVEN_NAME_1], and any detail the answer does not depend on. "
    "Reply with the question only."
)


def focus(message: str) -> str:
    response = ollama.chat(
        model=MODEL,
        messages=[
            {"role": "system", "content": FOCUS_PROMPT},
            {"role": "user", "content": message},
        ],
    )
    return (response.message.content or message).strip()

In ask.py, import focus, research and print the focused question, and keep the full redacted message for the reply, so your model can still answer the person directly:

from focus import focus  # add to the imports at the top of ask.py

safe_question = redactor.redact(question)
research_question = focus(safe_question)
print(f"Sent: {research_question}\n")

report, sources = research(research_question)
reply = write_reply(safe_question, report)

With the date-of-birth and case rules from above, one run sent Priya’s message to Tabstack as just “Does Ohio Medicaid cover insulin pumps?” and turned the redacted housing question into “What housing assistance programs in Connecticut are available to individuals with a monthly income of $1,950?” The wording changes from run to run.

Redact first and focus second. The rewrite is a model following instructions, so it can miss. In testing it often kept placeholders like [BANK_ACCOUNT_1], which is harmless because a placeholder contains no personal data. It also sometimes added details the person didn’t give, like “for individuals with diabetes”, so check the focused question in the Sent: line.

Know what Rampart leaves alone

Rampart redacts identifiers and leaves the rest of the message alone, which is usually what the answer needs: a diagnosis for a coverage question, an income for an eligibility question, an employer for a benefits question. Dates of birth, ages, usernames, and your own internal IDs also pass through until you add a rule.

Names in non-Latin scripts are out of scope for this release of Rampart, and so are indirect identifiers: details that only point to someone in combination. National Design Studio describes Rampart as harm reduction for people entering their own information, and that is the right way to use this loop too. This tutorial is a minimal Python port of Rampart’s approach, so Rampart’s published evaluation numbers apply to the library itself, not to this code.

Where to take it next

  • Redact in the browser. For a web app, use Rampart’s own JavaScript library so questions are redacted before they reach your server at all: npm install @nationaldesignstudio/rampart @huggingface/transformers.
  • Redact replies before you log them. Run the model’s output through Redactor().redact() before it goes into logs or analytics.
  • Keep one redactor per conversation. Placeholders stay consistent across turns, so follow-up questions about the same person keep working.

Start with your own question: get a Tabstack API key and make a research call.

Make a research call

One question in, a cited answer out. 10,000 free credits and no card.

START FREE

Read the guide, then make the call.

Start with 10,000 free credits. No credit card required.

curl -fsSL https://tabstack.ai/install.sh | sh