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.
Exact Configuration
-
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" -
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 }; -
Decay by freshness so abandoned content sinks even if it matches the terms well.
{ "gauss": { "lastUpdated": { "origin": "now", "scale": "90d", "decay": 0.5 } } }
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
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 |
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.