# The documentation assistant

> Ask questions in plain language and get answers grounded in your installed modules' documentation.

_Updated: 2026-07-21_

Railbase ships a built-in assistant that answers questions about your instance
and the modules installed on it. It is deliberately narrow: it answers from an
**indexed documentation corpus** — the product documentation embedded in the
core (this very guide, indexed automatically on first start), the docs your
installed plugins shipped, plus whatever an operator indexes — it **cites** the
passages it used, and when nothing in that corpus matches it **says so**
instead of guessing.

## Two audiences, two places

The assistant appears twice, because the two audiences read different
documentation.

| Who | Where | Sees |
|---|---|---|
| **Administrators** | The **floating assistant button** (bottom-right on every admin page) | Docs written for the `admin` audience |
| **Everyone else** | The site at **`/assistant`** (sidebar entry **Assistant**) | Docs written for the `site` audience |

In the admin panel the assistant is a companion, not a destination: the round
button in the bottom-right corner opens it in a side panel wherever you are, so
you can ask about the screen you are on without leaving it. On the site,
`/assistant` is a **reserved route** — no installed plugin can take that URL —
and it requires a signed-in app user; an anonymous request is rejected.

A doc entry declares its audience as `admin`, `site`, or `both`. Entries marked
`both` are answerable from either place; the others stay on their own side. That
is why an operator and a member of staff can ask the same question and get
answers drawn from different material.

> [!NOTE]
> The assistant answers from **documentation**, not from your business records.
> It does not read your ledgers, tickets, or uploaded files to compose an answer.

## What it answers from

The corpus is built from the documentation **inside the plugin bundles you have
installed**. A bundle's manifest declares its docs in a `docs` array, and on a
successful install the core indexes every entry that carries its text **inline in
the manifest** — chunking it, embedding it, and storing it in your Vault.
**There is no separate ingest step, no rebuild, and nothing to switch on.** Ask a
question right after installing such a module and its docs are already
answerable. An entry that only names a file shipped alongside the manifest is
skipped by auto-ingest; index that text yourself from **Settings → AI → Docs**
(see below).

Indexed plugin docs are stored **tenant-less**, so they answer for every company
on a multi-tenant instance. Anything indexed against a specific tenant is only
retrieved for that tenant.

When you **uninstall** a plugin, its indexed documentation is removed with it, so
the assistant stops answering from — and citing — a module that is no longer
there.

## Answers carry citations

Every grounded answer comes back with its sources listed beneath it, under a
**Sources** heading. Each entry shows the document it came from —
plugin docs are identified as `<slug>:<path>`, for example
`compliance:docs/admin.md` — and the plugin that shipped it; the site also shows
the document's title.

By default the assistant retrieves the **six** best-matching passages for your
question and answers from those. The citations tell you exactly which ones, so
you can go and read the original before acting on the answer.

## It refuses rather than guesses

If retrieval finds nothing relevant, the assistant does **not** call the language
model at all. It returns a fixed refusal:

> I don't know based on the available documentation.

The admin console marks that reply **No matching documentation**; the site marks
it **No matching source — answering from general knowledge is disabled**. The
refusal is structural, not a matter of prompt wording: with no retrieved passage
there is no generation step, so there is nothing to hallucinate from.

> [!NOTE]
> This is the behaviour to expect when you ask about something no installed
> module documents. It is not a failure — it means the answer genuinely is not in
> your corpus, and you should not treat a nearby-sounding answer as if it were.

## Conversations

The assistant is a **thread**, not a one-shot form. Each answer is recorded with
the question that produced it, and the next turn is asked with recent history in
view — so follow-ups that lean on pronouns work:

1. *"When do backups run?"*
2. *"Can I change that?"* — "that" resolves against the previous turn.

The model sees a short **rolling window of the last six messages**, so a long
thread never bloats the request. The stored thread keeps far more of the history
for you to read back, and is capped at **200 messages**, with the oldest trimmed
away beyond that. Retrieval still runs fresh on every turn: each new question is
grounded and cited on its own.

Controls on both surfaces:

- **New conversation** starts a fresh thread.
- **Recent conversations** lists your threads, newest first; click one to reload
  it with its full history.

Deleting a thread is an API operation —
`DELETE /api/ai/assistant/conversations/{id}` on the site,
`DELETE /api/_admin/ai/conversations/{id}` in the admin console. Neither screen
shows a delete button.

Threads are **owner-scoped**. A conversation belongs to one tenant, one surface
(admin or site), and one person. An administrator cannot open an app user's
thread, an app user cannot open an administrator's, and one user cannot open
another's — a thread that is not yours reads as *not found*, not as *forbidden*.

## Retrieval works without an embedding model

Retrieval normally depends on an embeddings-capable model, and many providers —
including the Mac's built-in Apple model — offer chat but no embeddings. Railbase
therefore ships a **built-in lexical embedder** (`railbase-lexical`) and falls
back to it whenever no embedding-capable backend is connected. It matches on
whole words plus character trigrams, so it holds up on keyword-heavy technical
documentation and on inflected languages, where different forms of the same word
still match.

The practical effect: **plugin docs are indexed and searchable out of the box**,
with no model provider connected at all.

Producing the written answer is a separate step, and that one does need a
connected model provider — set one up under **Settings → AI → LLM** (bring your
own key, or point at a local OpenAI-compatible endpoint). Without one, the
assistant returns an error rather than an answer.

> [!NOTE]
> If you later connect a neural embedding backend, **re-index your documents**.
> Vectors produced by different embedders are not comparable, so passages indexed
> with one embedder will not be found by queries embedded with another. Re-index
> from **Settings → AI → Docs**; a plugin's manifest docs are re-ingested when
> the plugin is reinstalled or updated.

## Managing the corpus

**Settings → AI → Docs** shows the **indexed corpus**: every indexed source with
its chunk count. From the same screen an operator can:

- **Index a document** by supplying a source id, an optional title, an audience
  (`admin`, `site`, or `both`) and the text — useful for your own house rules or
  internal runbooks that no plugin ships.
- **Remove** a source from the corpus.

Anything you index there becomes answerable and citable exactly like plugin
documentation.
