Relevance Scoring - What Really Affects It
Understanding MarkLogic's Ranking Algorithm
Relevance is one of the areas where MarkLogic feels magical right up until it does not. You run a query, the first few results look sensible, and then one document appears far too high or far too low. That is normal. MarkLogic relevance is powerful, but it is not simplistic.
The most useful high-level truth is this: MarkLogic 11 defaults to score-logtfidf, and that default is usually the right place to start. It rewards term density while discounting terms that are common across the corpus. In practice, rare and distinctive matches rise while weak and generic matches sink.
The problems usually start when teams reason about ranking from only one factor. "This document has the term more times" is not enough. "This weight is 2.0 so final score doubles" is not exactly how it works. "NOT clauses lower score" is usually the wrong model entirely.
This lesson gives you a production-friendly model and live examples from llamaverse data.
The examples in this article assume the llamaverse (v2.4.0+) is deployed. The llamaverse sample data is freely available from github.com/cleverllamas/llamaverse — see the llamaverse article for full setup instructions.
The Relevance Stack in One Table
| Factor | What it does | Why it matters |
|---|---|---|
| Term frequency (TF) | Rewards repeated matches in a fragment | Dense matches generally feel more relevant |
| Inverse document frequency (IDF) | Discounts terms that appear in many fragments | Common words should not dominate ranking |
| Length normalisation | Adjusts TF-based scoring for fragment size | Prevents long documents from winning by volume alone |
| Query structure | Combines clauses differently | and, or, and-not, boost, and near have different behaviour |
| Weight | Changes contribution of sub-expressions | Lets you encode domain priorities |
| Quality | Adds editorial or business priority | Useful for trusted or curated content |
| Filtered vs unfiltered | Changes candidate validation behaviour | Can affect visible ordering and counts |
| Scoring algorithm | Defines the formula | Default is usually best, but not always |
Scoring Algorithms You Should Know
| Algorithm | Description | Typical use |
|---|---|---|
score-logtfidf | Default. Log-scaled TF multiplied by IDF | General search relevance |
score-logtf | Log-scaled TF without IDF | When corpus rarity should matter less |
score-simple | Presence-focused scoring with straightforward weighting | Rule-like ranking and teaching weight effects |
score-random | Random score assignment | Sampling and experiments |
score-zero | Sets score to zero | Fast path when ranking is irrelevant |
BM25 is a MarkLogic 12 feature and is covered separately in BM25 Relevance Ranking - Modern Full-Text Search.
Random Sampling with score-random
When you want a quick random sample of matching documents for analysis or QA, score-random works well with cts:uris. The sample below uses an explicit start argument ("") and truncate=5 to return five random URIs from the wild-llamas profile lexicon set.
xquery version "1.0-ml";
(: Return 5 random URIs from the wild-llamas lexicon view. :)
(: score-random randomises scoring; truncate=5 limits the returned set. :)
for $uri in cts:uris(
"",
("score-random", "truncate=5"),
cts:and-query((
cts:collection-query("wild-llamas"),
cts:directory-query("/cleverllamas/llamaverse/raw/wild-llamas/llamas/", "1")
))
)
return $uri
/cleverllamas/llamaverse/raw/wild-llamas/llamas/58b1f1a2-5a52-44ac-b035-bf0842fa8ac8.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/5f7fbb40-2ada-4cb5-991a-a0da63ba5ebd.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/7d279d62-3d34-40d5-98c8-8a168c8568e6.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/c0705d0c-81bc-4259-8666-dde05cd7c5d0.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/f7365e46-3dfb-423c-b883-3445f7319290.json
Corpus Reality Check First
Before tuning, inspect what your corpus actually contains. In wild-llamas, these common hobby terms match a similar number of fragments:
xquery version "1.0-ml";
(: Quick corpus check: estimate how many fragments match each term. :)
(: This gives context for TF/IDF discussions before tuning. :)
for $word in ("hiking", "cooking", "reading", "painting")
return concat(
$word,
":",
xs:string(cts:estimate(cts:and-query((
cts:collection-query("wild-llamas"),
cts:word-query($word)
))))
)
hiking:1002
cooking:1027
reading:1015
painting:987
Because these terms are all common, IDF does not strongly separate them. This is exactly why relevance tuning based on intuition alone often stalls. If your query terms are all frequent, weighting and query shape usually matter more than rarity.
A Real score-logtfidf Snapshot
xquery version "1.0-ml";
(: Inspect representative score tiers for a multi-term query. :)
let $query := cts:or-query((
cts:word-query("hiking"),
cts:word-query("cooking"),
cts:word-query("reading")
))
let $hits := cts:search(fn:collection("wild-llamas"), $query, "score-logtfidf")[1 to 250]
let $rows :=
for $score in fn:distinct-values(for $d in $hits return cts:score($d))
let $doc := fn:head($hits[cts:score(.) = $score])
order by xs:int($score) descending
return concat(
"name=", fn:string($doc/name),
" | score=", cts:score($doc),
" | fitness=", cts:fitness($doc),
" | snippet=", fn:substring(fn:string($doc/description), 1, 78), "...",
" | uri=", xdmp:node-uri($doc)
)
return fn:string-join($rows[1 to 6], " ")
name=Jeffrey | score=172032 | fitness=0.6298797 | snippet=Jeffrey is a hazel-eyed llama with blonde hair, standing 161 cm tall. Original... | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/585c4307-40c0-4b1c-a1a1-0d3383439a22.json
name=Rebekah | score=147456 | fitness=0.5831552 | snippet=Rebekah is a brown-eyed llama with white hair, standing 104 cm tall. Originall... | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/e952a983-d2ce-48cd-bad4-3512c70e1ced.json
name=Katrina | score=131072 | fitness=0.549804 | snippet=Katrina is a amber-eyed llama with brown hair, standing 166 cm tall. Originall... | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/db624451-4182-41a8-b034-bf031508ee02.json
name=Charles | score=114688 | fitness=0.5142946 | snippet=Charles is a green-eyed llama with white hair, standing 131 cm tall. Originall... | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/4429e229-490a-4398-992e-84d0f4737d6d.json
Two things are worth noticing:
- Score and fitness are signals for ordering, not business metrics.
- Equal scores are normal when multiple documents have effectively identical term evidence.
To make the output easier to reason about, the sample prints name and snippet first, with URI last, and it shows representative score tiers instead of five near-identical top rows.
Query Shape Changes Ranking Behaviour
Many relevance bugs are really query-shape bugs. Candidate generation and clause interaction change dramatically between and, or, and and-not.
xquery version "1.0-ml";
(: Compare candidate counts for and/or/and-not query shapes. :)
let $and := cts:and-query((cts:word-query("hiking"), cts:word-query("cooking")))
let $or := cts:or-query((cts:word-query("hiking"), cts:word-query("cooking")))
let $not := cts:and-not-query(cts:word-query("hiking"), cts:word-query("cooking"))
return (
concat("and:", fn:count(cts:search(fn:collection("wild-llamas"), $and))),
concat("or:", fn:count(cts:search(fn:collection("wild-llamas"), $or))),
concat("not:", fn:count(cts:search(fn:collection("wild-llamas"), $not)))
)
and:259
or:1770
not:743
Interpretation:
andis narrow and precision-oriented.oris broad and recall-oriented.and-notremoves candidates; it does not add positive relevance.
That last point is important: NOT clauses constrain the result set. They are not a scoring bonus.
Weighting with score-simple
When teaching or debugging weight influence, score-simple is easier to reason about than TF-IDF formulas.
xquery version "1.0-ml";
(: score-simple makes weight effects easier to reason about numerically. :)
declare function local:line(
$label as xs:string,
$doc as node()?,
$query as cts:query
) as xs:string? {
if (fn:empty($doc)) then ()
else
let $score := cts:score(
fn:head(
cts:search(
fn:collection("wild-llamas"),
cts:and-query(($query, cts:document-query(xdmp:node-uri($doc)))),
"score-simple"
)
)
)
return concat(
"pattern=", $label,
" | name=", fn:string($doc/name),
" | score=", $score,
" | uri=", xdmp:node-uri($doc)
)
};
let $query := cts:or-query((
cts:word-query("hiking", (), 2.0),
cts:word-query("cooking", (), 0.5)
))
let $both := fn:head(cts:search(
fn:collection("wild-llamas"),
cts:and-query((cts:word-query("hiking"), cts:word-query("cooking"))),
"score-simple"
))
let $hikingOnly := fn:head(cts:search(
fn:collection("wild-llamas"),
cts:and-not-query(cts:word-query("hiking"), cts:word-query("cooking")),
"score-simple"
))
let $cookingOnly := fn:head(cts:search(
fn:collection("wild-llamas"),
cts:and-not-query(cts:word-query("cooking"), cts:word-query("hiking")),
"score-simple"
))
let $rows := (
local:line("both-terms", $both, $query),
local:line("hiking-only", $hikingOnly, $query),
local:line("cooking-only", $cookingOnly, $query)
)
return fn:string-join($rows, " ")
pattern=both-terms | name=Patricia | score=10240 | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/eaa6c416-ef46-4f91-b861-1e99b28ae18b.json
pattern=hiking-only | name=Lisa | score=8192 | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/67384a21-62fe-4f80-a964-72783cfb076f.json
pattern=cooking-only | name=Jay | score=2048 | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/bad82b5c-c0cc-470d-b175-1f2703893024.json
Practical guidance:
| Weight | Practical effect |
|---|---|
0 | Filter-only clause (no relevance contribution) |
0.5 | Reduced influence |
1.0 | Default influence |
2.0 | Stronger influence |
5.0+ | Aggressive; easy to over-tune |
| Negative | Penalises matches; use sparingly |
If ranking suddenly looks odd after a tuning change, inspect boosts first.
Debugging with Score, Fitness, and Quality Together
cts:score() is the ranking signal used in ordering. cts:fitness() is a normalised query-match signal. cts:quality() is the stored editorial/business bias. Looking at all three together is one of the fastest ways to explain "surprising" result order.
xquery version "1.0-ml";
(: Print all three signals together for debugging ranking behaviour. :)
let $profile-uris := (
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/0a6911e5-0d17-44e1-a114-cb747490f469.json",
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/8520c251-7abe-4eb4-a0e8-6706bf5c2397.json",
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/24a25bb9-b35f-48b3-b01c-d8f4a64d7a2d.json",
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/58fc1645-17d2-4732-aded-1e88f637f967.json",
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/e3f48f8f-ea2b-4382-8313-02430bf34a45.json",
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/507cf4de-4469-497f-9888-7756c592b119.json",
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/19be9dcc-fd35-4c54-b503-63a6a8a1043d.json",
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/cd37f616-e707-47f3-99c0-d20def77c1a6.json",
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/d50a73a0-c096-4f79-99a0-8bd05a426416.json",
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/74a7c489-3515-45f9-88bd-2e8261a7e720.json"
)
let $query := cts:and-query((
cts:document-query($profile-uris),
cts:or-query((
cts:word-query("hiking"),
cts:word-query("cooking"),
cts:word-query("reading")
))
))
let $hits := cts:search(fn:doc(), $query, "score-logtfidf")[1 to 250]
let $rows :=
for $score in fn:distinct-values(for $d in $hits return cts:score($d))
let $doc := fn:head($hits[cts:score(.) = $score])
order by xs:int($score) descending
return concat(
"name=", fn:string($doc/name),
" | score=", cts:score($doc),
" | fitness=", cts:fitness($doc),
" | quality=", cts:quality($doc),
" | uri=", xdmp:node-uri($doc)
)
return fn:string-join($rows[1 to 5], " ")
name=Debra | score=19456 | fitness=0.3676929 | quality=6 | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/507cf4de-4469-497f-9888-7756c592b119.json
name=Charles | score=19371 | fitness=0.3797549 | quality=1 | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/cd37f616-e707-47f3-99c0-d20def77c1a6.json
name=Sheila | score=18859 | fitness=0.3797549 | quality=-1 | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/e3f48f8f-ea2b-4382-8313-02430bf34a45.json
name=Jade | score=18688 | fitness=0.3676929 | quality=3 | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/19be9dcc-fd35-4c54-b503-63a6a8a1043d.json
name=Sean | score=18432 | fitness=0.3676929 | quality=2 | uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/24a25bb9-b35f-48b3-b01c-d8f4a64d7a2d.json
In llamaverse v2.4.0+, this sample is scoped to the curated quality-profile URI set, so quality is intentionally non-zero for selected documents. This makes it easier to see when textual evidence and editorial/business priority are aligned or in tension. When multiple rows share the same score, the ordering among those ties is secondary and should not be over-interpreted.
Filtered vs Unfiltered
Filtered and unfiltered are often misunderstood. Both use index-based matching, but filtered search validates candidates more strictly. Some query shapes and data structures will show clear differences. Others will not.
For this near-query over wild-llamas, counts are currently identical:
xquery version "1.0-ml";
(: Filtered and unfiltered can differ for some query shapes and data sets. :)
(: For this near-query over wild-llamas they are currently identical. :)
let $query := cts:near-query((
cts:word-query("hiking"),
cts:word-query("cooking")
), 1)
return (
concat("filtered:", fn:count(cts:search(fn:collection("wild-llamas"), $query, "filtered"))),
concat("unfiltered:", fn:count(cts:search(fn:collection("wild-llamas"), $query, "unfiltered")))
)
filtered:162
unfiltered:162
The key operational rule is still valuable: when debugging ranking, always confirm which mode you are inspecting.
Battle-Tested Field-Tested
- Start with
score-logtfidfand filtered search for MarkLogic 11 and older; use BM25 for MarkLogic 12 and above. - Verify corpus term distribution before touching weights.
- Tune query structure before adding aggressive boosts.
- Keep quality as an explicit editorial/business signal, not a patch for weak query design.
- Print score, fitness, and quality together during debugging.
- Evaluate changes with real result lists, not only intuition.
Relevance tuning succeeds when you iterate with observable evidence rather than mental models alone.
Need Some Help?
Looking for more information on this subject or any other topic related to MarkLogic? Contact Us (info@cleverllamas.com) to find out how we can assist you with consulting or training!
- The Relevance Stack in One Table
- Scoring Algorithms You Should Know
- Random Sampling with score-random
- Corpus Reality Check First
- A Real score-logtfidf Snapshot
- Query Shape Changes Ranking Behaviour
- Weighting with score-simple
- Debugging with Score, Fitness, and Quality Together
- Filtered vs Unfiltered
- Battle-Tested Field-Tested