Near Queries and Proximity Search

Understanding How MarkLogic Measures Nearness

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

The examples in this article use the llamaverse (v2.3.1+). The llamaverse sample data is freely available from github.com/cleverllamas/llamaverse — see the llamaverse article for full setup instructions.

Near queries are one of the most useful and most misunderstood tools in MarkLogic search. Teams reach for them because they express a human-readable search intent: "find documents where these words occur close together." That sounds like exactly what users mean — and it often is. The confusion starts when people assume "close together" is a fuzzy, document-level concept. It is not. In MarkLogic, nearness is index-driven, measured in word positions, and constrained by fragment boundaries. Understanding those three facts explains almost every surprising near-query result.

What cts:near-query() Does

A near query matches when the supplied subqueries occur within a given number of word positions of each other. The function signature is:

xquery version "1.0-ml";

(: Example call using the full cts:near-query signature.                     :)
(: cts:near-query($queries, $distance, $options, $distance-weight)          :)
cts:near-query(
	(
		cts:word-query("enjoys"),
		cts:word-query("hiking")
	),
	3,
	("ordered"),
	1.0
)
ParameterMeaningNotes
$queriesSubqueries that must occur near each otherCan be word queries, value queries, or other subqueries
$distanceMaximum word distance between matchesLarger values broaden results and increase cost
$options"ordered" or "unordered"Default is "unordered" unless specified
$distance-weightHow strongly proximity affects relevance scoreOnly meaningful when word positions are enabled

Distance is measured in word positions, not characters. MarkLogic tokenises content when it builds the word index, and nearness is computed in terms of those token positions. Two words separated by three other words have a distance of roughly four, depending on how the engine counts position steps.

The Default Is Unordered

A common surprise: if you omit the $options parameter, the near query is unordered. That means cts:near-query((cts:word-query("A"), cts:word-query("B")), 5) matches documents where A is within 5 words of B, regardless of which appears first. If the business question is directional — "A should appear before B" — you must explicitly supply "ordered".

Basic Near Query on Llamaverse Profiles

Each llamaverse llama profile has a description property following a consistent template: the llama enjoys named hobbies and is known for a named personality type. Searching for llamas whose description mentions enjoys within three word positions of hiking gives a precise, positional result:

xquery version "1.0-ml";

(: Find llamaverse llama profiles where "enjoys" occurs within 3 words of        :)
(: "hiking". Distance is measured in word positions, not characters.             :)
let $query := cts:near-query((
  cts:word-query("enjoys"),
  cts:word-query("hiking")
), 3)
return (
  fn:count(cts:search(fn:collection("wild-llamas"), $query)),
  for $doc in cts:search(fn:collection("wild-llamas"), $query)[1 to 3]
  return xdmp:node-uri($doc)
)
{"uri":"/cleverllamas/llamaverse/raw/wild-llamas/llamas/4429e229-490a-4398-992e-84d0f4737d6d.json", "description":"Charles is a green-eyed llama with white hair, standing 131 cm tall. Originally from South Stephaniebury, Jersey, Charles enjoys hiking, painting, reading. Known for their calm and gentle personality, Charles is a beloved member of the llama community."}

902
/cleverllamas/llamaverse/raw/wild-llamas/llamas/4429e229-490a-4398-992e-84d0f4737d6d.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/8520c251-7abe-4eb4-a0e8-6706bf5c2397.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/e3f48f8f-ea2b-4382-8313-02430bf34a45.json

902 of the 3,000 llamaverse profiles mention hiking as a hobby immediately after "enjoys". This is a genuinely selective positional match — not just co-occurrence.

To test the opposite condition, you can require both terms but exclude matches where they are within three word positions:

xquery version "1.0-ml";

(: Negative proximity test: keep documents that contain both "enjoys" and     :)
(: "hiking", but exclude documents where those words are within 3 positions.  :)
let $bothTerms := cts:and-query((
  cts:word-query("enjoys"),
  cts:word-query("hiking")
))

let $withinThree := cts:near-query((
  cts:word-query("enjoys"),
  cts:word-query("hiking")
), 3)

let $negativeQuery := cts:and-not-query($bothTerms, $withinThree)

return fn:string-join((
  fn:concat("contains both terms: ",
    fn:count(cts:search(fn:collection("wild-llamas"), $bothTerms))),
  fn:concat("within 3 words: ",
    fn:count(cts:search(fn:collection("wild-llamas"), $withinThree))),
  fn:concat("contains both but not within 3 words: ",
    fn:count(cts:search(fn:collection("wild-llamas"), $negativeQuery))),
  for $doc in cts:search(fn:collection("wild-llamas"), $negativeQuery)[1 to 3]
  return xdmp:node-uri($doc)
), "
")
contains both terms: 1001
within 3 words: 902
contains both but not within 3 words: 99
/cleverllamas/llamaverse/raw/wild-llamas/llamas/44a91224-deb4-49ba-9d05-305fff4aa367.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/8e722e37-73f1-4737-b912-482bc77df028.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/b3126a05-76c2-4385-a271-15d8a2f38089.json

In the current dataset, 1,001 profiles contain both terms, but 99 keep enjoys and hiking far enough apart to fail the distance-3 near match.

Ordered vs Unordered in Practice

The ordered option becomes practically significant when the natural order of terms in the content is known and meaningful. In the llamaverse description template, enjoys always precedes personality — the two words are in a fixed relative order. An ordered near query for that sequence within 15 words matches almost all profiles, while the reverse order matches none:

xquery version "1.0-ml";

(: Ordered and unordered near queries produce different result sets when the     :)
(: natural order of terms matters. The default behaviour is unordered.          :)
(: "enjoys" always precedes "personality" in the llamaverse description text,   :)
(: so the ordered form matches; the reverse ordered form matches nothing.       :)
let $ordered   := cts:near-query(
  (cts:word-query("enjoys"), cts:word-query("personality")),
  15, ("ordered")
)
let $reversed  := cts:near-query(
  (cts:word-query("personality"), cts:word-query("enjoys")),
  15, ("ordered")
)
let $unordered := cts:near-query(
  (cts:word-query("enjoys"), cts:word-query("personality")),
  15, ("unordered")
)
return (
  fn:concat("ordered(enjoys, personality): ",
    fn:count(cts:search(fn:collection("wild-llamas"), $ordered))),
  fn:concat("ordered(personality, enjoys): ",
    fn:count(cts:search(fn:collection("wild-llamas"), $reversed))),
  fn:concat("unordered(enjoys, personality): ",
    fn:count(cts:search(fn:collection("wild-llamas"), $unordered)))
)
ordered(enjoys, personality): 2999
ordered(personality, enjoys): 0
unordered(enjoys, personality): 2999

When querying user-supplied terms where order is not predictable, "unordered" (the default) is usually correct. When building structured search over well-known prose templates or domain vocabularies with natural directionality, "ordered" is often more precise.

Fragment Boundaries Define the Scope of Nearness

Near queries evaluate proximity within a fragment — the unit of indexing in MarkLogic. By default, a document is its own fragment, which means proximity is evaluated across the full document text. If you configure custom fragment roots, however, each fragment becomes its own positional context. A near query will not match across a fragment boundary, even if both terms are present elsewhere in the logical document.

This is not a bug. It is the indexing model working exactly as specified. The consequence is that if you have a document where a long section about history is in a different fragment from a short profile section, a near query for terms spanning those two sections will produce no match.

The cleverllamas-content database uses default document-level fragmentation with no custom fragment roots — so all near query examples in this article operate across the full document text.

For custom fragmentation configurations, the practical rule is: design fragment boundaries to align with the meaningful proximity scopes in your content. If two words should be near each other in a business sense, they need to be within the same fragment.

When you need exact XML-node control instead of full-fragment matching, cts:contains() is the safer pattern. The example below uses an XML llama description with inline annotation. The whole description element matches because the <b> text is part of that subtree, but a direct-text-node check does not match — which is exactly what you want when annotation text should not participate in the proximity test.

<llama>
  <description>lame desscription with somthing <b>inside another tag</b> that is isolated.</description>
</llama>
xquery version "1.0-ml";

let $sample :=
  <llama>
    <description>lame desscription with somthing <b>inside another tag</b> that is isolated.</description>
  </llama>

let $query := cts:near-query(
  (
    cts:word-query("inside"),
    cts:word-query("isolated")
  ),
  6,
  ("ordered")
)

return (
  "whole-description=" || xs:string(cts:contains($sample/description, $query)),
  "direct-text-nodes=" || xs:string(
    some $text in $sample/description/text()
    satisfies cts:contains($text, $query)
  ),
  "bold-annotation=" || xs:string(cts:contains($sample/description/b, $query))
)
whole-description=true
direct-text-nodes=false
bold-annotation=false

Scoping Near Queries to a Property

For JSON documents, cts:json-property-scope-query can wrap a near query to constrain proximity evaluation to the subtree of a specific property. In a flat document like the llamaverse llama profiles, this is equivalent to a plain near query because the hobbies and personality words appear only in the description field. In a multi-property document where the same vocabulary might appear under different top-level keys with different meanings, property scoping prevents false positional matches between terms that are adjacent across a property boundary:

xquery version "1.0-ml";

(: cts:json-property-scope-query constrains the near query to word matches that  :)
(: occur within the "description" property subtree. For flat JSON documents like :)
(: the llamaverse profiles, this is equivalent to the plain near query above.   :)
(: In multi-property documents, it prevents accidental proximity matches        :)
(: between terms spread across different top-level properties.                  :)
let $query := cts:json-property-scope-query(
  "description",
  cts:near-query((
    cts:word-query("enjoys"),
    cts:word-query("hiking")
  ), 3)
)
return fn:count(cts:search(fn:collection("wild-llamas"), $query))
902

For XML documents, cts:element-query serves the same purpose and is often a good habit even when element word positions are not enabled, since it clearly communicates intent and can improve query planning.

Word Positions and Index Support

Near queries depend on word positions. When word-positions is enabled in the database configuration, MarkLogic stores the precise position of every indexed word, enabling efficient and accurate resolution of positional constraints directly from the index. Without word positions, the engine loses positional resolution during index lookup and must open documents during filtered execution to verify the actual distance — making near queries substantially more expensive and making unfiltered near queries potentially inaccurate.

Important Notice

Element Word Positions: performance relies heavily on having element word positions or field word positions enabled when you expect MarkLogic to evaluate positional proximity efficiently inside element-scoped or field-scoped near queries.

The practical rule is simple: if proximity search matters to your application, enable word positions. The cleverllamas-content database has word positions enabled, which is why the examples in this article produce accurate results in both filtered and unfiltered modes.

ConfigurationFiltered near queryUnfiltered near query
Word positions enabledAccurate; position verified via indexAccurate; index position is precise
Word positions disabledAccurate but slow; document opened for every candidateMay return false positives; index cannot distinguish distance

Element word positions (a separate index option) provide the same benefit for element-scoped near queries. Without element word positions, an element-scoped near query still works but cannot use the finer-grained positional index.

Performance: Apply Cheap Constraints First

Near queries are more expensive than plain word queries because they evaluate positional relationships. The best way to manage that cost is to narrow the candidate set with inexpensive constraints before the positional check runs. Collection queries, range queries, and property value queries are all cheaper than positional evaluation. Combining them in a cts:and-query lets MarkLogic apply the cheapest constraints first:

xquery version "1.0-ml";

(: Performance pattern: combine cheap constraints with the near query.           :)
(: cts:and-query applies the cheapest constraints first, reducing the candidate  :)
(: set before the more expensive positional check runs. Adding a plain word      :)
(: query for one of the near-query terms as an and-query sibling is redundant   :)
(: logically but can guide the query planner toward the cheaper index first.    :)
let $query := cts:and-query((
  cts:collection-query("wild-llamas"),
  cts:json-property-value-query("breed", "Suri"),
  cts:near-query((
    cts:word-query("enjoys"),
    cts:word-query("hiking")
  ), 3)
))
return (
  fn:count(cts:search(fn:doc(), $query)),
  for $doc in cts:search(fn:doc(), $query)[1 to 3]
  return xdmp:node-uri($doc)
)
331
/cleverllamas/llamaverse/raw/wild-llamas/llamas/4429e229-490a-4398-992e-84d0f4737d6d.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/5afc36e8-3845-462a-b32e-b42ffa9edd57.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/cd37f616-e707-47f3-99c0-d20def77c1a6.json

The breed filter reduces the candidate set from 3,000 to 1,047 before the positional check runs, meaning the near query evaluation only touches Suri profiles. This pattern scales to large databases where unconstrained positional search would be prohibitive.

Phrase Queries vs Near Queries

Not every proximity requirement should be a near query. When the user intent is an exact known phrase — a proper name, a standard term, a fixed expression — a phrase query is usually more precise and less expensive. Near queries are appropriate when the user intent is thematic proximity: "these ideas should appear close together in some order."

RequirementBetter choiceWhy
Exact multi-word phrasePhrase queryHigh precision, lower cost
Terms close in any orderUnordered near queryFlexible, appropriate for concept association
Terms close in a required orderOrdered near queryGood when directionality matters
Terms near each other but not adjacentNear query with minimum-distance optionUse when exact adjacency is too strict
Proximity within a known structural regionProperty-scoped or element-scoped near queryAvoids accidental proximity outside the region

Common Gotchas

Distance is in word positions, not characters

A distance of 5 means five word positions, not five characters or tokens as your application might define them. Punctuation and stop words may or may not occupy word positions depending on the database language configuration.

Distance 0 means immediate adjacency for distinct words

For two distinct single-word subqueries, distance 0 requires them to be adjacent. For compound or phrase-like subqueries, distance 0 can also match overlapping text. If you want the strictest adjacency requirement, use distance 0 and test it against real content.

Fragment boundaries break proximity that looks obvious

If your database has custom fragment roots, terms that appear "close" in the source document may be in different fragments. The near query will not match across that boundary, and the result will be surprising until you account for the fragmentation model.

Ordered means the supplied sequence, not either order

cts:near-query((cts:word-query("A"), cts:word-query("B")), 5, ("ordered")) requires A before B within 5 positions. B before A within 5 positions does not match. If both orders should match, use unordered.

The minimum-distance option is easy to forget

The minimum-distance option sets a lower bound on word distance. It lets you express "these terms should be nearby, but not adjacent" — useful for cases where exact adjacency is a false positive signal (e.g. a date immediately followed by a name in a metadata line rather than in prose).

Quick Reference

OptionEffect
"ordered"Queries must match in the supplied sequence
"unordered"Queries may match in any order (this is the default)
"minimum-distance"Sets a lower bound on word distance between matches
$distance-weightPositive value boosts closer matches in relevance scoring

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!