Relevance Scoring - What Really Affects It

Understanding MarkLogic's Ranking Algorithm

personClever Llamas
CleverLlamasMinimum Llamaverse Version: 2.4.0
databaseMinimum MarkLogic Version: 6

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

FactorWhat it doesWhy it matters
Term frequency (TF)Rewards repeated matches in a fragmentDense matches generally feel more relevant
Inverse document frequency (IDF)Discounts terms that appear in many fragmentsCommon words should not dominate ranking
Length normalisationAdjusts TF-based scoring for fragment sizePrevents long documents from winning by volume alone
Query structureCombines clauses differentlyand, or, and-not, boost, and near have different behaviour
WeightChanges contribution of sub-expressionsLets you encode domain priorities
QualityAdds editorial or business priorityUseful for trusted or curated content
Filtered vs unfilteredChanges candidate validation behaviourCan affect visible ordering and counts
Scoring algorithmDefines the formulaDefault is usually best, but not always

Scoring Algorithms You Should Know

AlgorithmDescriptionTypical use
score-logtfidfDefault. Log-scaled TF multiplied by IDFGeneral search relevance
score-logtfLog-scaled TF without IDFWhen corpus rarity should matter less
score-simplePresence-focused scoring with straightforward weightingRule-like ranking and teaching weight effects
score-randomRandom score assignmentSampling and experiments
score-zeroSets score to zeroFast 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:

  1. Score and fitness are signals for ordering, not business metrics.
  2. 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:

  • and is narrow and precision-oriented.
  • or is broad and recall-oriented.
  • and-not removes 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:

WeightPractical effect
0Filter-only clause (no relevance contribution)
0.5Reduced influence
1.0Default influence
2.0Stronger influence
5.0+Aggressive; easy to over-tune
NegativePenalises 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

  1. Start with score-logtfidf and filtered search for MarkLogic 11 and older; use BM25 for MarkLogic 12 and above.
  2. Verify corpus term distribution before touching weights.
  3. Tune query structure before adding aggressive boosts.
  4. Keep quality as an explicit editorial/business signal, not a patch for weak query design.
  5. Print score, fitness, and quality together during debugging.
  6. 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!