DEV Community

Cover image for Why Backstage search returns `team` when you search for `tech` and what we did about it
Sid Probstein
Sid Probstein

Posted on

Why Backstage search returns `team` when you search for `tech` and what we did about it

Type "tech" into Backstage search. You get "team".

We are the team behind SWIRL, and we spent this summer building a search engine module for Backstage. This is what we found, what we measured, and what we shipped. Every comparison below cites a Backstage issue number or a number we measured ourselves.

1. The complaint, in the maintainers' own issues

Four issues describe the same class of problem. One line each, with the number, so you can read them yourself.

  • 27339: searching for "tech" returns entities named "team". The default engine adds an edit distance term to every query.
  • 6177: partial entity names do not match unless the user types a wildcard.
  • 8835: the Postgres engine cannot match part of a word at all.
  • 32795: Elasticsearch support is still pinned to version 7, which reached end of life in January 2026.

None of these are bugs in the ordinary sense. Each engine is doing exactly what it was built to do. The problem is that a service catalog is not a document corpus, and the three default engines were all chosen for other reasons.

2. Why a shared index cannot fix it

The instinct is to point every portal at one big index and move on. That does not address any of the four.

Lunr lives in the backend process memory and is rebuilt on restart. It is a browser search library doing service catalog work. Its LunrSearchEngine appends an edit distance 2 term and a trailing wildcard term to every query, which is the single mechanism behind 27339. We reproduced it: "tech" returns 38 results on a stock instance, with team-a, team-d, team-c and team-b at ranks 8, 9, 10 and 11.

Postgres uses ts_rank over tsvector. That is a good full text engine for prose. It has no infix matching, which is issue 8835, and a catalog is almost entirely identifiers: payment-gateway-api, foo-bar.com, wayback-search. Identifiers are the one thing ts_rank was not designed for.

Elasticsearch would solve the relevance problem. It also asks you to run a JVM cluster next to your portal, and Backstage support is stuck on version 7 per 32795, with version 7 out of support since January 2026. Most teams we talk to chose Postgres search specifically to avoid this.

So the fix is not a bigger index. The fix is an index whose schema knows it is holding entity names.

3. The gauntlet

We wrote nine cases before we wrote the engine. Five come straight out of the issues above. We ran them against a live Backstage on the default Lunr engine, against SWIRL in process, and finally against the published container through the real ingest and search path.

The corpus is the Backstage example catalog, 71 documents, plus 5,000 synthetic entities. The synthetic set plants the entities the issues describe and 20 entities containing "team" with no "tech".

Five gauntlet cases, default engine result on the left and SWIRL result on the right

Query Default engine SWIRL
tech 38 results, team-a to team-d at ranks 8 to 11 (27339) tech-prefixed title at rank 1, no team-only entity in the top 5
abac 0 results, no partial match (6177, 8835) abacus at rank 1, sole hit
foo-bar.com 59 results, the first twelve all user entities foo-bar.com at rank 1, sole hit
store petstore absent from all 7 results (8835) petstore in the top 3
mes 14 results, none containing the string, led by a person zero results
wayback wayback-search at rank 2 wayback-search at rank 1
petsotre (typo) petstore at rank 1 petstore at rank 1, with fuzzy matching on
wayback search (phrase) wayback-search at rank 1 wayback-search at rank 1
service filtered to kind=component, lifecycle=production no attribute filter in the query API all 25 hits match both attributes

Nine pass, zero fail, on the released image after our second fix pass. Two things worth saying plainly. The typo case is a genuine Lunr win: its built in edit distance finds petstore from petsotre with no tuning at all, and SWIRL only matches that with fuzzy matching enabled. And on the tech case the shipped path puts a tech-*-service entity at rank 1 rather than tech-radar; our relevancy pass prefers the longer title. The assertion the case exists to protect, no team-only entity in the top 5, holds. We are recording that rather than claiming it away.

The size and speed numbers, measured on 50,000 entities on one laptop:

  • 34 MB index on disk
  • 2.7 s to index all 50,000 documents, about 18,600 documents per second
  • 0.26 ms mean query time, over 900 queries against a warm index

Relevance was the hard part. Speed was never close to a constraint.

4. Two lanes

SWIRL for Backstage architecture: Backstage collators feed the indexed lane into a Tantivy index inside the SWIRL container, while the federated lane queries GitHub and Confluence live, and both lanes merge in one ranking pipeline before returning to the Backstage search page

The indexed lane. Your collators run unchanged. The engine module streams their documents to the SWIRL ingest endpoint, which writes them into a Tantivy index, one index per Backstage document type. The schema is the whole point: a stemmed and ASCII folded title_exact field boosted 3x, an n-gram title_ngram field for infix matching, and a stopworded text field. That n-gram field is the answer to 6177 and 8835. Ingest is upsert and delete by id, so there is no full rebuild and no transaction timeout.

The federated lane. SWIRL queries live sources per query, GitHub and Confluence today, and their results arrive in the same ranked list as swirl-federated documents. Nothing is copied into an index. This is the thing a shared index genuinely cannot do, because the source of truth stays where it is and the query goes out fresh.

What Backstage keeps. Its collators, its search page, and its permission filtering. SWIRL replaces the engine and nothing else. Permission fields pass through untouched, so filtering on indexed results works on day one. Your search page does not change when you swap the engine.

5. Install: one container, three steps

The container. One docker compose up -d with swirlai/swirl-backstage:0.1.1. Redis runs inside the image, started by the entrypoint. SQLite holds the app database. The index lives on the /data volume. No Postgres for SWIRL, no JVM, no cluster. On our smoke test it went from cold to healthy in 14 seconds.

Step 1. Edit .yarnrc.yml in your app root, then install:

nodeLinker: node-modules
npmMinimalAgeGate: 3d
npmPreapprovedPackages:
  - '@backstage/*'
  - '@swirl-search/*'
Enter fullscreen mode Exit fullscreen mode
yarn --cwd packages/backend add @swirl-search/backstage-plugin-search-backend-module-swirl@0.1.1
Enter fullscreen mode Exit fullscreen mode

That .yarnrc.yml line is not optional and it is worth explaining, because we got it wrong first. @backstage/create-app ships npmMinimalAgeGate: 3d, a Yarn 4 supply chain control that refuses any package published in the last 72 hours unless its scope is preapproved. Only @backstage/* is preapproved by default. So for three days after every release, including this one, yarn add fails with YN0016 for every user on a stock app. Our own smoke test hit it 25 minutes after we published. Preapproving the scope is the Backstage native escape hatch. Do not set npmMinimalAgeGate: 0, which turns the control off for every package in your repo.

Step 2. Wire it into packages/backend/src/index.ts, and remove the Postgres module that create-app ships:

backend.add(import('@backstage/plugin-search-backend'));
backend.add(
  import('@swirl-search/backstage-plugin-search-backend-module-swirl'),
);
Enter fullscreen mode Exit fullscreen mode

Step 3. Point it at the container in app-config.yaml:

search:
  swirl:
    baseUrl: http://localhost:8000
Enter fullscreen mode Exit fullscreen mode

Restart the backend. The first index went live 3.2 seconds after backend start on our test app.

6. What it costs to run, honestly

One container. About 2.5 GiB resident today, which is over our own 1 GB target and we are not going to pretend otherwise. Tantivy is about 150 MB of that. The rest is torch, presidio and litellm imported across three SWIRL processes, and trimming it is on the build plan rather than done.

One replica, deployment strategy Recreate, with the index on a PersistentVolumeClaim. All your Backstage pods point at the one SWIRL service. Restarts cost nothing: the index is on disk, so SWIRL reopens it and serves again in seconds, and it never reindexes. We verified that by restarting the container mid test, with the Backstage backend left running and no collator run, and the same query returned the same 43 results.

The failure mode, stated plainly: if the SWIRL pod is down, Backstage search returns an error rather than stale results. Catalog browsing is unaffected. Active passive HA is a roadmap item, not a launch promise.

The image is 7 GB on disk. That is large, and it is the same reason as the memory number.

7. Community and Enterprise

SWIRL for Backstage Community is free and Apache 2.0, and it is everything above: the indexed lane, the federated lane, GitHub and Confluence. The line between the editions is OAuth2. Community federated sources authenticate with one scoped service account per source, so every portal user sees whatever that account can read, and we require a scope restriction before a source will activate. Enterprise adds per-user OAuth2 identity passthrough, OAuth2-only sources, semantic cache and cross source dedup; if you need per-user permissions on federated results, contact us.

8. Go type "tech" into your portal

That is the whole ask. If you run Backstage, search for "tech" and tell me what comes back. If it is a team, you have reproduced 27339, and there is now an engine that does not do that.

npm is @swirl-search/backstage-plugin-search-backend-module-swirl at 0.1.1, and the image is swirlai/swirl-backstage:0.1.1. Issues and pull requests welcome, especially from anyone running Postgres search.

Top comments (0)