Semantic search¶
Knowledge index for AI Assistants for Backstage. It copies everything your
Backstage search indexes (TechDocs, the catalog, and any other source a
collator brings in, such as Confluence or a wiki), embeds it into the
assistants plugin's Postgres database, and gives assistants a
search-knowledge tool that finds it by meaning, by near spelling, and by
exact words:
- "how do I fix errored-out messages" finds Dead-letter handling › Replaying dead-lettered messages.
- "Pilon deployment" finds the Pylon Gateway › Deployment runbook.
- "dlq-replay" finds the page that names the job.
It reads no source itself. Each time a search collator finishes a run, the module copies that run's documents, so the index follows your search schedule and a rate-limited source such as Confluence is read once, by search.
Catalog and TechDocs results are filtered to entities the user can read in the catalog. Results from other sources are visible to every assistant user, as they are in Backstage search.
Package: @drewswiredin/backstage-plugin-assistants-backend-module-semantic-search,
a module for the assistants backend plugin.
Requirements¶
- The assistants backend plugin, with its database on Postgres 13 or later.
- Backstage search (
@backstage/plugin-search-backend) in the same backend, with the collators for the sources you want: any search engine works. - The
vector(pgvector) andpg_trgmextensions in the assistants plugin's database. - An embedding model from an
openai,azure, oropenai-compatibleprovider inassistants.providers: for example OpenAItext-embedding-3-small, an Azure embedding deployment, or Ollamanomic-embed-text.
Install¶
1 | |
1 2 3 4 5 6 7 8 | |
The one backend.add installs two modules: the index and search-knowledge
under the assistants plugin, and a document decorator under the search
plugin that copies what each search run collected. To add them separately,
import assistantsModuleSemanticSearch and searchModuleAssistantsKnowledge
by name. Without the search plugin the module logs a warning at startup and
the index stays empty.
Enable the extensions¶
The module enables both extensions itself when its database role may. pg_trgm
is a trusted extension, so the role that owns the database can enable it.
vector needs a database admin. Ask one to run this in the assistants plugin's
database (backstage_plugin_assistants by default):
1 2 | |
| Postgres | What the admin does |
|---|---|
| AWS RDS / Aurora | Run the SQL as the master user. |
| Google Cloud SQL / AlloyDB | Run the SQL as a cloudsqlsuperuser user. |
| Azure Database for PostgreSQL Flexible | Add VECTOR and PG_TRGM to the azure.extensions server parameter, then run it. |
| Self-hosted | Install pgvector (e.g. postgresql-16-pgvector), then run the SQL as a superuser. |
The module checks the extensions at startup. If either is missing it logs an
error with the CREATE EXTENSION statement for each and stays off; the
assistants keep their other tools. Restart the backend once the admin has
enabled them.
Configure¶
1 2 3 4 5 6 7 8 9 | |
On Azure OpenAI, deploy an embedding model and name the deployment:
embeddingModel: azure:<embedding-deployment>.
Rank by source¶
Every source shares one index, so a large one (thousands of tickets that all mention the same system) can crowd out the one runbook section that answers the question. Name the search document types to weigh them:
1 2 3 4 5 6 7 8 9 10 11 12 | |
The keys are the type names Backstage search uses: software-catalog,
techdocs, or a collator's own type. Types left out are copied and ranked
with weight 1. Set default: false to make a source opt-in: the model then
searches it only when it passes types: ['jira'], and the tool description
says so. Set enabled: false to stop copying a source; its chunks are
deleted on its next search run.
The index fills as search runs: each document type arrives when its collator
next runs, on the schedule you gave that collator. A source's first run embeds
all of it; later runs embed only new and changed sections and delete removed
ones. Changing embeddingModel empties the index, and each type refills on its
next search run.
A type added to types takes its name, weight, and metadata on its next run,
keeping the embeddings it already has; 0.2.0 indexes upgrade the same way. To
run a collator now rather than wait for its schedule, trigger its search task
with a user or service token:
1 2 | |
The task id is search_index_<type>, lowercased, with the type's first
hyphen turned into an underscore (search_index_software_catalog); the backend logs Knowledge index
refreshed with the type and counts when the copy finishes.
Give an assistant the tool¶
Add search-knowledge to the assistant's tools in the assistant editor. It
pairs with read-techdocs (from builtinActions: true), which opens the full
page behind a TechDocs result; other results carry a link. Until the first
search run is copied, the tool
answers with an error that points the model at keyword search: Backstage's
query action when the assistant has it, otherwise search-techdocs and
search-catalog.
The tool's description carries the search strategy, so agents that see only
tool descriptions (some MCP clients drop server instructions) still follow it:
search-knowledge first, keyword search when the results don't clearly answer
or the question is a bare name or acronym, and nothing reported missing until
both come back empty. The built-in search-catalog and search-techdocs
descriptions say the same.
The action registers under the assistants plugin, so assistants must be in
backend.actions.pluginSources, as it already is for the built-in tools:
1 2 3 4 | |
How it works¶
Indexing. A search decorator sits on every document type's pipeline. It passes each document on to the search engine unchanged and keeps it; when the collator finishes, it hands the run's documents to the index. A failed run hands over nothing, and a run with no documents changes nothing. Runs are copied one at a time, in the backend that ran the collator.
Each document becomes one or more chunks of up to ~1600 characters, titled
with its heading path (Site › Page › Section for TechDocs) and embedded with
that title. The facts collators attach (entity ref, type, owner, lifecycle)
join the text, so a catalog entity with no description is still found. A
document gated by a catalog entity (its authorization.resourceRef) keeps that
entity for the permission check; a document gated by any other kind of
resource is skipped, since the index can only check entity read permission.
Search never tells a decorator which document type it is decorating, so the
module adds a pass-through decorator to each type named in types. Search
builds a run's pipeline in one pass, so that decorator names the run's type.
A named type's run is stored under its name; a run of any other type joins
the stored collection that already holds most of its locations. Chunks the
run no longer has are deleted. A collection no search run has refreshed in
30 days (a source removed from search, or an unnamed one whose locations all
changed at once) is dropped. Each row stores the embedding, a generated
full-text vector, a trigram index over its text, its type, and the short
fields its collator attached. The table is a rebuildable copy, not migrated
data.
Retrieval. search-knowledge takes the question, up to three alternative
phrasings (it uses the first three distinct ones and ignores the rest), and optionally the types to search (otherwise every type that is
on by default, plus unnamed sources). It builds one semantic list per
phrasing, a keyword list, a rare-word list, and a fuzzy list, each keeping
its best 30 chunks of every type, so a large source cannot fill a list on its
own:
| List | Matches | Postgres |
|---|---|---|
| Semantic | Meaning, per phrasing | pgvector cosine distance (HNSW index up to 2000 dimensions) |
| Keyword | Any query word, ranked by match density | tsvector / websearch_to_tsquery |
| Rare word | Query words found in three chunks or fewer | tsvector / websearch_to_tsquery |
| Fuzzy | Near spellings of words absent from the index | pg_trgm word similarity |
Query words include adjacent pairs written as one word, so "tally up" also
matches "TallyUp". Reciprocal rank fusion merges the lists with one vote per
method: the semantic lists share one vote, so extra phrasings broaden recall
without outvoting an exact match. Each chunk's fused score is multiplied by
its type's weight. The rare-word list keeps a name or acronym mentioned once
in a paragraph about something else from being outranked by sections that
are only near the question in meaning. The top candidates' entities are
fetched from the catalog as the user; results for entities the user cannot
read are dropped, results with no entity are kept, and repeated text is shown
once. The ranker then picks the results: by default best first, with at most
maxResults of a capped type while other types can fill the slots.
Each result carries its title, location, type, excerpt, the entity ref for
catalog and TechDocs results, and metadata: the short fields its collator
attached (strings and comma-joined string lists up to 200 characters,
numbers, booleans; at most 16), such as a
ticket's status or a page's last update.
Custom ranking¶
A backend module can replace the ranker, for example to favour recently updated tickets:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | |
The ranker receives the query, its phrasings, the types searched, and up to 60 candidates the caller may read. Each candidate has its fused score after weights, its rank in each retrieval list, its type, entity ref, and metadata. It runs after the permission check, so it cannot surface what the caller cannot read; results it returns that were not candidates are dropped.
Reference¶
| Key | Default | Meaning |
|---|---|---|
assistants.semanticSearch.embeddingModel |
(required) | <providerId>:<model> from assistants.providers. |
assistants.semanticSearch.maxResults |
8 |
Results per answer unless the model asks for a number (up to 20, or up to this when higher). |
assistants.semanticSearch.types.<type>.weight |
1 |
Multiplies the type's fused score; 0 never returns it. |
assistants.semanticSearch.types.<type>.default |
true |
Searched when the model names no types; false makes the type opt-in. |
assistants.semanticSearch.types.<type>.maxResults |
At most this many of the type's results while other types can fill the slots. | |
assistants.semanticSearch.types.<type>.enabled |
true |
false stops copying the type and deletes its chunks on its next run. |
assistants.semanticSearch.types.<type>.description |
What the source holds, shown to the model in the tool description. |
The index refreshes when search does; set each collator's schedule in its own config.