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.

16 min readUpdated 7 August 2026knowledge, indexing, packs, retrieval, reindex

"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. processingTime is 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 breadcrumbStaking › 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:

  1. <working directory>/knowledge
  2. <working directory>/backend/knowledge
  3. 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

Only index documentation for products you actually have installed and enabled.

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

Fuse current documentation retrieved from the store with your local index.

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?

Runs retrieval for one question without calling the answering model.

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.