How the index is built — sources, packs, scope and the store fusion
The four knowledge sources and their weights, how a page becomes passages, how shipped documentation packs are discovered and skipped, why a product's docs go missing silently, and how to prove what is indexed.
"It escalates everything" is the most common report about this addon, and the usual answer — write more articles — is right about half the time. The other half is mechanical: a documentation pack was skipped, a checksum said an unchanged corpus needed no work when it did, or scoping filtered out a product you sell.
None of those announce themselves. This page is what the index is actually built from, and how to prove what is in it.
Four sources, and they are not equal
| Source | Table / origin | Weight | Ships |
|---|---|---|---|
| Your live configuration | withdrawMethod, depositMethod, ecosystemToken, exchangeCurrency and three settings, read at answer time |
1.3 | Always on |
| Your own articles | ai_support_article, status PUBLISHED |
1.15 | Empty |
| Documentation packs | .ndjson.gz files shipped with the release |
1.0 | One per product |
| Your FAQ | The FAQ & Knowledge Base addon's rows, status on | 0.9 | Only if installed |
The top two are deliberately above the shipped documentation. The docs describe the software; only you can say what your withdrawal fee is. Where they disagree, you win — and that ordering is what the operator-policy gate depends on, so it is preserved even when store-hosted retrieval is fused in later.
The bottom three are indexed: they are chunked, stored in ai_support_chunk
and searched. The first is not, and it behaves differently enough to need its own
section.
Your live configuration is a source, and it is not in the index
The fee gate refuses to answer a question about fees, limits or processing times
unless something you authored was retrieved, because a shipped documentation page
cannot know what you charge. On an install that never wrote a fee article, that
used to escalate every one of those questions — while the answer sat in
withdrawMethod.percentageFee, was being charged to that customer, and was shown
to them on the withdrawal form.
So the pipeline reads it back. When a question is classified as being about fees, limits or processing time, and only then, the retrieval step composes passages from your own rows:
| Read | Fields |
|---|---|
depositMethod and withdrawMethod, status on |
fixedFee, percentageFee, minAmount, maxAmount, and processingTime on withdrawals |
ecosystemToken, status on |
fee and limits, per chain |
exchangeCurrency, status on |
fee — the spot withdrawal percentage for that currency |
| Settings | walletTransferFee, spotWithdrawFee, withdrawChainFee |
These passages count as operator-authored, exactly like one of your articles, so they satisfy the fee gate. That is the whole point of them: on the measured install this converted the single largest class of escalation — "what is the withdrawal fee", "what is the minimum" — into an answer.
Four rules keep that safe, and they are worth knowing because they explain the cases where it still escalates:
- It fails closed. Nothing configured, a table this install does not have, or any error at all, returns nothing — and the fee gate then escalates exactly as it did before. A fee question is never answered from a partial read.
- It derives nothing. A percentage is reported as a percentage and a minimum as a minimum. It never computes "so your fee on 100 USDT would be 1.5": the live form applies rounding, precision and chain-fee rules this step does not reproduce, and a number that disagrees with the form is worse than no number.
- It invents no timings.
processingTimeis free text you typed, quoted verbatim and only when it is non-empty. A processing-time question is answered only from a withdrawal method that has one — a fee table cannot answer it, and handing one over would ground the gate on a passage that cannot settle the question. - It is bounded and says so. At most eight rows per area. When there are more, the passage states that it is a partial list and points at the form — a silently truncated list reads as the complete set.
Currency-specific facts are only produced when the question names a code: an uppercase two-to-six letter run, matched against the tokens and currencies this install actually has, so a token you added is recognised and one you never enabled is not.
There is no setting for this. It is on whenever one of those three topics is detected, and it produces nothing on every other question.
Inside the pack corpus, help/ pages are weighted up
Pages in a product's help/ section carry a 1.2 multiplier. They are the part
of the corpus written for a person using the platform, and without the boost an
operator guide that merely shares the vocabulary out-ranks them.
That is a ranking nudge. The next one is not.
audience is a hard boundary, not a ranking preference
Every documentation page carries audience: CUSTOMER or audience: OPERATOR in
its frontmatter, and the docs build refuses to publish one that omits it. Of the
619 published pages, 127 are CUSTOMER — the help/ section of each product —
and 492 are OPERATOR: install guides, admin runbooks, troubleshooting, and
reference pages like this one.
The two share one index and are searched as two corpora. A customer question can
reach CUSTOMER passages and nothing else. An operator question — the Handbook,
the admin assistant, the Ask about this user rail — reaches OPERATOR
passages and nothing else. It is an exclusion rather than a demotion: an operator
page cannot be returned to a customer at any score, however well it matches, and
the document-frequency statistics are computed per audience so the half that
cannot be returned does not distort the ranking of the half that can.
A page that somehow arrives with no declaration is treated as OPERATOR, and
that direction is chosen rather than inherited. The cost of guessing wrong one way
is a customer question that goes unanswered; the cost of guessing wrong the other
way is your customer being shown the admin console's fee settings and, from the
surrounding prose, the name of the software vendor you have white-labelled.
Measured before the default was inverted: 54 undeclared pages produced 80
customer-reachable passages whose text named the vendor.
A pack built before the audience field existed carries no declaration, so it is
inferred: Admin, Reference, Install, Operate and Configure are operator
sections, and anything else outside a help/ path is operator too. That branch
runs only for old packs. On a current release every entry is stamped, and the
stamp is what decides.
Two things are never in the index
Release notes are excluded outright: they are version-specific and mostly describe behaviour that has since changed, so quoting one is a good way to tell somebody about a screen that no longer exists. Resolved tickets are excluded for a harder reason.
supportTicket.messages carries balances, addresses, transaction metadata and
attachment paths inside free prose, with no field structure to redact against.
Indexing one turns one customer's ticket into another customer's answer.
The reusable value of a ticket is the pattern, not the transcript, and
Teach from this ticket in the Live Inbox is the supported way to extract it:
the conversation is redacted before any model sees it, the result is created as
a DRAFT, and nothing is retrievable until a person publishes it. The response
reports how many addresses and emails were stripped, so a reviewer can see it
happened rather than trust that it did.
An article with an empty answer is skipped even when published — indexing a stub produces a confident "here is our refund policy:" followed by nothing.
How a page becomes passages
Retrieval does not search pages; it searches passages cut from them. Nothing here is configurable, and one of the rules changes how you should write an article.
| Rule | Value |
|---|---|
| Target size | ~700 tokens |
| Hard maximum | 1,200 tokens |
| Minimum to stand alone | 120 tokens — anything smaller merges forward |
| Overlap | the last ~120 tokens of a passage are carried into the next, cut at a paragraph or sentence boundary |
| Split points | #, ## and ### only |
Headings below ### stay inside their parent section, because splitting on an
h5 produces fragments too small to answer anything. Fenced code blocks and
::: callouts are captured whole and are never cut through — half a code sample
is worse than none, because the customer copies it and it fails. A single block
that is itself over the maximum, a long table or a big code listing, is emitted
whole rather than truncated for the same reason.
Every passage is prefixed with its breadcrumb — Staking › Admin › Pools › Create a pool — and the breadcrumb is part of the stored text, so it is both
searched and sent to the model. A passage lifted from the middle of a page is
otherwise contextless: neither the model nor the word index can tell whether "Set
the minimum" is about staking pools or withdrawal limits.
Your own articles and FAQ rows are not split at all, whatever their length. That is deliberate — splitting a 200-word refund policy across two passages is how half a refund policy gets retrieved — but it is also why one question per article is advice rather than style. An article covering deposits, withdrawals and verification is one passage that half-matches three questions, instead of three that fully match one each.
How packs are found
Each pack is one gzipped NDJSON file, one documentation page per line, named for
the product slug: p2p.ndjson.gz, payment-gateway.ndjson.gz. Shipping
pre-extracted pages rather than crawling a website means an air-gapped install
indexes with zero network access.
Ingestion looks for the pack directory in three places and uses the first that exists:
<working directory>/knowledge<working directory>/backend/knowledge- a path resolved relative to the addon's own module
On a normal install that resolves to backend/knowledge. If none of the three
exists, the log records "No knowledge pack directory found; skipping doc
ingest" and the assistant answers from your articles and FAQ alone.
Scoping, and the mapping that fails silently
On by default, and it is the right default: without it the assistant will happily explain the Forex addon to a customer of an install that does not have it.
With it on, the allowed set is built from the extension table — every row with
status on — plus bicrypto itself. Each extension name yields up to three
candidate slugs: the name as stored, the name with underscores swapped for
hyphens, and an explicit alias where one exists. A pack whose filename matches
none of them is skipped, and anything a previous in-scope run had already indexed
is withdrawn rather than merely left alone.
That underscore-to-hyphen derivation is right for most products and wrong for ten, which is why the alias table exists:
extension.name |
Pack slug |
|---|---|
ai_investment |
ai-investments |
ai_support |
ai-support-agent |
binary_ai_engine |
binary-trading-ai-engine |
dex |
web3-wallet-trading |
forex_trading |
forex-multi-asset-trading |
gateway |
payment-gateway |
hummingbot |
hummingbot-connector |
knowledge_base |
faq-system |
mailwizard |
mailwizard-bicrypto |
nft |
nft-marketplace |
The skip is recorded as "this operator does not have that product". There is no warning, no error and nothing on the Sources screen that says a pack was excluded on purpose — it simply is not in the source list.
Measured on a live install with 24 extensions enabled: NFT, the payment gateway, the FAQ addon, AI investments, multi-asset trading and Swap all had their entire documentation excluded. The assistant could not answer a single question about any of them, and told customers the feature was not available.
How to check: open Sources and count the rows against the addons you
actually run. A product you sell with no Documentation — <slug> row is this.
The immediate workaround is to switch aiSupportScopeToInstalledProducts off,
which indexes every pack in the directory.
Note the addon's own documentation is on that list. An install with AI Support and scoping on would otherwise filter out the pages describing itself.
Seven packs can never be indexed while scoping is on
The allowed set is built from the extension table, and seven shipped packs are
for things that are not extensions. Blockchains are rows in
ecosystem_blockchain; exchange providers are a provider setting. Neither ever
produces an extension row, so their packs match nothing in the allowed set and
are skipped on every run, on every install, permanently — not because you lack the
product, but because there is nothing for the filter to match against.
| Pack | What it is |
|---|---|
monero.ndjson.gz |
Blockchain — 3 customer help pages |
solana.ndjson.gz |
Blockchain — 2 customer help pages |
ton.ndjson.gz |
Blockchain — 2 customer help pages |
tron.ndjson.gz |
Blockchain — 2 customer help pages |
binance-provider.ndjson.gz |
Exchange provider — operator pages only |
kucoin-provider.ndjson.gz |
Exchange provider — operator pages only |
xt-provider.ndjson.gz |
Exchange provider — operator pages only |
The nine customer pages are the ones that hurt: they cover XMR, SOL, TON and TRX deposits, withdrawals and stuck transactions, which is exactly the traffic this addon is bought to absorb. The three provider packs are operator-only, so their absence is felt in the Handbook rather than by a customer.
The workaround is the same as above and it does work: switch
aiSupportScopeToInstalledProducts off and every pack in the directory is
indexed, including these. The cost is that the assistant can then also describe
addons you do not sell, so weigh it against which of the two failures your
customers actually meet.
Checksums, and why nothing re-indexed for a while
Re-chunking several hundred pages on every boot is minutes of CPU for no change,
so a pack is skipped when its stored checksum still matches the file and
it already has chunks. The checksum is a SHA-256 over an internal
INGEST_VERSION string plus the pack bytes.
The version string is mixed in because the pack bytes alone cannot express a change in how those bytes are turned into passages. A change to chunking, weighting or the audience split is invisible to a checksum over the file, so raising the version makes every stored checksum mismatch exactly once and every install re-ingests.
The upsert that records a pack wrote the new checksum, and the skip check read that value afterwards — so the comparison compared a value with itself. It was unconditionally true from the first successful ingest onward, and no pack was ever re-ingested again.
Measured after rebuilding the packs with 96 new customer help pages: the reindex reported success, advanced every checksum, and left the chunk count and last indexed timestamp untouched. Zero of the 96 pages reached the index. The failure is silent in the worst possible way, because the checksum it advances is exactly the evidence anyone would check to see whether the update landed.
The checksum is now written only after chunks are actually replaced, and the version bump repairs installs that ran the old code — a stored checksum from before the fix is already the "correct" value, so fixing the comparison alone would have left them stuck forever.
If you are on an install that was upgraded through that period and doubt what is in the index, press Re-index once. It is safe to run at any time: sources are replaced atomically inside a transaction, so retrieval never sees a half-written corpus.
Words the customer did not use
Word matching is the whole of retrieval until it isn't. "How do I take my monero out" contains not one of the words on the withdrawal page, so the deposit page out-scores it and a customer asking how to get their money out is told how to put it in. On the shipped corpus that was measurable: deposit recall 76% and withdrawal 82%, against 95–100% everywhere else, entirely on vocabulary.
So a second, small index is built beside the word index, from your corpus and nothing else — no model, no credential, no network call, no extra dependency. It learns which terms turn up in the same documents, which is how "take out" comes to sit near "withdraw". It knows only what your corpus demonstrates: if your documentation never uses two phrases near each other, it will not connect them, and for an agent that must stay grounded in that documentation that is the correct limit rather than a shortcoming.
It learns from the whole corpus, both audiences, because 619 pages of the same vocabulary teach more than 127 do — and then returns only passages the asker's audience allows. Splitting the space in two instead was built and measured, and it was three points worse at finding anything.
What it is allowed to do is deliberately tiny:
- It never manufactures a result. If word matching found no evidence at all, the search returns nothing, full stop. "Nothing found" still means nothing found — this layer cannot fill a genuinely empty result with plausible-looking passages, and if it could it would satisfy the fee gate with documents that have nothing to do with the question.
- It adds at most one passage. One, above a similarity floor, and only if it is not already in the top five by word match — a vocabulary-mismatch question has one right page, not four.
- It never reorders and never evicts. The rescue is appended and gets its own
budget on top of
aiSupportTopK, so the model can receive one passage more than that setting says. Interleaving it was measured and was a net loss: it cost whole buckets of questions whose right answer was sitting at rank five or six. - It keeps the word-match score. A rescued passage carries its own, lower score, so the retrieval floor and the confidence figure mean exactly what they meant before.
- It needs a corpus. Below about 50 passages, or fewer than 100 usable terms, it does nothing at all and retrieval is pure word matching. A term appearing in more than 18% of the corpus, or in fewer than two documents, is excluded from it.
Two operator-visible consequences. In the retrieval tester, a low-scoring last row whose words do not appear in your question is the rescue doing its job, not a bug — it is not flagged in the response, so it looks like an inexplicable extra hit. And the same similarity space is what merges near-identical phrasings on the Gaps screen, which is why a very small corpus also groups gaps less well.
Store-hosted retrieval, fused in
The shipped packs are frozen at the version this install was built from, so an operator running a three-month-old release answers from three-month-old documentation. This asks the store for passages as well, and it is current the day the documentation changes.
It needs AI_SUPPORT_GATEWAY_TOKEN and AI_SUPPORT_GATEWAY_SECRET in .env.
Without them the call is skipped entirely.
Three properties matter operationally:
It is an enhancement and never a dependency. Every failure path — switched off, no credentials, HTTP error, slow, air-gapped — returns an empty list, and the answer proceeds on local passages alone. The timeout is 2.5 seconds, short on purpose: this sits on the answer path, and waiting to maybe improve a ranking is worse for the customer than answering immediately. Failures are logged at debug, not warning, because on an air-gapped install the failure is the expected steady state and a warning per turn trains people to ignore the log.
The audience is sent explicitly on every call. The store's own default lives on a server this install does not control, so the boundary is not left to it: the same audience the local index was filtered by is named in the request. A customer question fuses in customer pages only.
The two lists are fused by rank, never by score. BM25 relevance scores are
corpus-global: 8.2 from the store's index and 8.2 from your own mean completely
different things, and sorting them together produces a ranking that looks
plausible and is arbitrary. Reciprocal rank fusion — 1/(60 + rank), summed
where a passage appears in both — only ever compares positions, which are
comparable. Your own local passages carry a 1.15 boost through the fusion, so
your articles keep their edge over the general documentation.
The two ways to rebuild
| Nightly | Manual | |
|---|---|---|
| What | aiSupportReindexKnowledge cron job |
Re-index on Sources |
| Cadence | Every 24 hours | On demand |
| Route | — | POST /api/admin/ai/support/knowledge/reindex |
| Permission | — | edit.ai.support.knowledge |
Both run the same full pass: doc packs, then your published articles, then the FAQ. Unchanged packs are skipped by checksum, so a nightly run on an unchanged corpus costs almost nothing.
Press the button rather than waiting for the night after: updating the platform or an addon, importing articles in bulk, or any time the retrieval tester finds nothing for text you can see in a published article.
Failures do not abort the run. A source that throws records the error against its row, and the reindex response returns a warning naming how many failed — the Sources list is where you read which.
Neither pass touches your live configuration, because that source is never indexed. A fee you change in the admin panel is quoted correctly on the next question, with no reindex at all.
Proving what is actually indexed
The Sources screen at /admin/ai/support/knowledge lists one row per source,
heaviest first, with its title, kind, chunk count, weight (drawn only when it
is not 1) and any error, plus the total passage count of the live index. A source
with zero chunks and no error is a source that was found and produced nothing.
The screen does not draw a last-indexed time. The API returns
lastIndexedAt per source and builtAt for the index, and neither is rendered,
so "did tonight's run actually touch this pack?" has to be answered by watching
the chunk count change or by reading ai_support_source.lastIndexedAt directly.
Underneath it is the retrieval tester, which is the direct answer to why did it refuse that?
It takes q — the question — and an optional rerank=false to skip the
re-ranking model, which is the only part of the tester that can cost anything.
It returns the confidence of the top hit, the floor it had to clear,
wouldAnswer, the corpus size, and each retrieved passage with its breadcrumb,
score and a preview.
It runs retrieval the way a customer question runs it, and does not offer an
audience switch. The 492 operator pages in the corpus are therefore invisible to
it: a phrase from p2p/guides/offers.md, from any reference/ page, or from this
one, returns nothing however perfectly the pack was ingested.
So a phrase you test with has to come from a help/ page of the product you are
checking. Testing with an operator phrase produces a clean, confident, completely
false negative.
Four shapes of result, and they mean different things:
| Result | What it means | What to do |
|---|---|---|
| Nothing found | No customer-facing source covers it — or the product's pack was never indexed | Check the source list for that product, then the alias table and the seven unmatchable packs above |
| Nothing found, and your phrase is on an operator page | Expected. The tester cannot see the operator corpus | Retest with a phrase from that product's help/ section |
| Found, under the floor | Related text exists but does not answer it | Usually a phrasing mismatch; add the customer's words to the article |
| Found, above the floor | Retrieval was fine — the refusal came from the grounding gate afterwards | See Settings |
To prove a specific product's documentation is present, search for a phrase you
know appears only on one of its help/ pages. A hit whose breadcrumb names that
page is proof; nothing at all, with the product installed and enabled, sends you
back to the scoping section above.
Related reading: Sources for the day-to-day screen, Gaps for what customers asked that nothing answered, and Permissions for who can press Re-index.