Customizing Search Result Ranking

A search index that returns the right documents in the wrong order is barely better than one that returns nothing: if a developer’s exact-name query surfaces an archived fork above the live service, they stop trusting the box. This how-to tunes relevance so type, freshness, and title matches push the right result to the top. It builds on a working dedicated engine — see Integrating Elasticsearch as the Search Backend — and is the relevance work introduced in Search & Discovery in the Portal within Plugin Ecosystem & Custom Extensions.

Prerequisites

  • A dedicated search engine (Elasticsearch or OpenSearch) already serving portal queries; the in-memory engine offers little ranking control.
  • Documents that carry the fields you want to rank on — kind, lifecycle, title, and a timestamp — emitted by your collators.
  • A sample of real queries where the current order is wrong, so you can measure improvement rather than guess.
  • Access to the search backend module config where query weighting is set.
Prerequisites for ranking control A dedicated engine, ranked fields on documents, real sample queries, and query config enable relevance tuning. dedicated engine ranked fields present real sample queries query config access Tunable ranking measurable
You can only tune what your documents carry — if lifecycle or a timestamp is missing, add it to the collator before touching weights.

Exact Configuration

  1. Weight fields at query time. Boost title matches above body matches so an exact-name query lands on the exact entity.

    # app-config.yaml
    # Requires @backstage/plugin-search-backend-module-elasticsearch >= 1.5.0
    search:
      elasticsearch:
        # highest weight on title, then a modest body weight
        queryFields:
          - "title^4"
          - "text^1"
    
  2. Boost by entity type so services and APIs outrank scattered documentation fragments.

    // packages/backend/src/search/rankByKind.ts
    // Applied as a function_score in the query translator
    export const kindBoost: Record<string, number> = {
      Component: 3.0,
      API: 2.5,
      Template: 2.0,
      // TechDocs pages fall back to the default 1.0
    };
    
  3. Decay by freshness so abandoned content sinks even if it matches the terms well.

    {
      "gauss": {
        "lastUpdated": { "origin": "now", "scale": "90d", "decay": 0.5 }
      }
    }
    
Combining ranking signals The base text score is multiplied by a kind boost and a freshness decay to produce the final rank. text score title^4 · text^1 × kind boost Component 3× × freshness decay gauss 90d final rank right result first
Signals multiply rather than replace — a strong text match on a stale, low-value type can still lose to a fresher, higher-value one.

Validation

# Requires curl, jq
# 1. An exact service name ranks its Component first
curl -s "${PORTAL_URL}/api/search/query?term=payments" \
  | jq '.results[0].document | {kind:.kind, title:.title}'
# Expected: the payments Component, not a doc page mentioning payments

# 2. A live service outranks an archived one for the same term
curl -s "${PORTAL_URL}/api/search/query?term=checkout" \
  | jq '[.results[].document.lifecycle] | .[0]'
# Expected: "production" ahead of "deprecated"

# 3. Freshness matters: recently updated docs beat stale duplicates
curl -s "${PORTAL_URL}/api/search/query?term=onboarding" \
  | jq '.results[0].document.lastUpdated'
# Expected: a recent timestamp
Ranking validation checks The exact entity ranks first, live beats archived, and fresh beats stale for the same query. Exact entity first title boost works Live > archived lifecycle ranks Fresh > stale decay works
Validate against queries you know the right answer to — ranking is only "better" if it moves real known-good results up.

Edge Cases & Troubleshooting

Symptom Root Cause Resolution
Boosts have no visible effect The field is not indexed or not queried Confirm kind/lifecycle are mapped and referenced in the query
Everything ranks the same Function scores multiply into a near-flat curve Increase the spread between boost values
Fresh junk outranks solid old docs Freshness decay weighted too heavily Lengthen the decay scale or lower its influence
One popular page dominates every query Over-boosted on a single signal Cap any single boost; blend at least two signals
Ranking changes after every reindex Scores compared across differently-sized indexes Judge order, not absolute scores; they are relative
Ranking pitfalls by class No-effect boosts and flat curves are wiring issues; over-boosting and decay imbalance are tuning issues. Wiring boost no-op flat scores → map + spread Tuning balance over-boost decay imbalance → cap + blend signals
Most "ranking is broken" reports are one over-weighted signal drowning the others; blending at least two keeps any single one from dominating.

Frequently Asked Questions

How do I know my tuning actually helped?

Keep a small set of queries with a known correct top result and check the rank of that result before and after each change. This turns a subjective “feels better” into a measurable one, and it catches regressions where fixing one query quietly breaks five others.

Should ranking differ per user?

It can — boosting entities a user owns or their team’s docs is powerful — but add personalization only after the global ranking is solid. A per-user layer on top of a badly-ordered base just makes the bad order harder to diagnose. Get the shared ranking right first, then personalize.