Word Searches and the Optic API

Blend full-text search with structured row logic in one plan

personClever Llamas
CleverLlamasMinimum Llamaverse Version: 2
databaseMinimum MarkLogic Version: 11

MarkLogic's Optic API lets you blend structured row logic with full-text relevance in the same plan. That is especially useful when search and analytics need to stay in the same workflow rather than being solved separately and joined later.

These examples keep the search predicate, the Optic plan, and the ranked output together so the pattern is easier to reason about. They use cts.wordQuery for clarity, but the same approach works with any cts full-text constructor.

Llamaverse Context for This API Pattern

Context ItemWhat this article uses
Data setllamaverse v2.0+ sample data
Primary source documentswild llama JSON documents in the wild-llamas collection
Views usedllamaverse.wildLlamas (with optional fragmentId system column when joining)
Text target in examplesword matches in document text (for example llama descriptions)
Field usageNone in these examples
Index contextText relevance from cts search indexes plus TDE view rows for Optic joins and projections

If a where(cts.wordQuery(...)) example returns rows but op.fromSearch(...) joins do not, verify the fragmentId columns are exposed on the view side and joined with matching qualifiers.

Source Docs and Configuration Used

ItemValue used in this article
MarkLogic API docscts:word-query, op:from-search, op:where
Llamaverse source collectionwild-llamas
Llamaverse source URI pattern/cleverllamas/llamaverse/raw/wild-llamas/llamas/{uuid}.json
Required database configTDE view llamaverse.wildLlamas with fragment id support for join examples; text indexes configured for cts:word-query

What are word queries

MarkLogic provides a strong set of query constructors for words and phrases. In this article we use cts.wordQuery().

cts.wordQuery() matches documents or values containing specific words or phrases. It supports stemming, wildcards, and other search options that materially change result sets.

For the first query, stemming is enabled. That means related forms can match (for example sing, sang, sung, singing). We search for sung and limit display output with fn:subsequence(...) so results stay readable.

{
  "name": "Aaron",
  "breed": "Huacaya",
  "placeOfBirth": "Cusco, Peru",
  "secretPowerId": "d8839ba6-2b77-4bcc-9927-b86cdfecb9fb"
}
xquery version "1.0-ml";

fn:subsequence(
  cts:search(
    cts:and-query((
      cts:collection-query("wild-llamas"),
      cts:word-query("sang", "stemmed")
    ))
  ),
  1,
  3
)
'use strict';

const results = fn.subsequence(cts.search(cts.andQuery([
  cts.collectionQuery('wild-llamas'),
  cts.wordQuery('sang', 'stemmed')
])), 1, 3).toArray();

({
  sample: 'optic/cts-word-query-and-the-optic-api/assets/basic-word-search.sjs',
  kind: Array.isArray(results) ? (results.every((item) => typeof item === 'object' && 'subject' in item && 'predicate' in item && 'object' in item) ? 'triples' : 'rows') : ((results !== null && typeof results === 'object') ? 'object' : 'scalar'),
  count: Array.isArray(results) ? results.length : 0,
  data: results
});

You can use cts.wordQuery(...) inside an Optic where(...) clause to filter rows. This is the core idea: full-text search still resolves against fragments, and Optic can use that signal alongside structured constraints.

This example finds Huacaya llamas where the underlying source content matches sung with stemming enabled. The where clause combines a structured filter on breed with full-text matching. Output is limited to three rows for readability.

{
  "name": "Aaron",
  "breed": "Huacaya",
  "placeOfBirth": "Cusco, Peru",
  "secretPowerId": "d8839ba6-2b77-4bcc-9927-b86cdfecb9fb"
}
xquery version "1.0-ml";
import module namespace op = "http://marklogic.com/optic" at "/MarkLogic/optic.xqy";

op:from-view("llamaverse", "wildLlamas", "wildLlamas")
  => op:where(op:eq(op:view-col("wildLlamas", "breed"), "Huacaya"))
  => op:where(cts:word-query("sung", "stemmed"))
  => op:limit(3)
  => op:result()
'use strict';

const op = require('/MarkLogic/optic');

const results = op.fromView('llamaverse', 'wildLlamas', 'wildLlamas')
  .where(op.eq(op.viewCol('wildLlamas', 'breed'), 'Huacaya'))
  .where(cts.wordQuery('sung', 'stemmed'))
  .limit(3)
  .result();

({
  sample: 'optic/cts-word-query-and-the-optic-api/assets/where-word-query.sjs',
  kind: Array.isArray(results) ? (results.every((item) => typeof item === 'object' && 'subject' in item && 'predicate' in item && 'object' in item) ? 'triples' : 'rows') : ((results !== null && typeof results === 'object') ? 'object' : 'scalar'),
  count: Array.isArray(results) ? results.length : 0,
  data: results
});

Where are records for 'sung'?

This shows the practical effect of stemming. Some matched rows do not contain the literal token sung; they contain related forms such as singing or sang.

The trade-off is visibility into relevance. In where(...), you get filtering but not score. That is where op.fromSearch() helps.

Using op.fromSearch() with cts.wordQuery for scored results

If you need access to the score for each result, you should use the same cts.wordQuery as input to op.fromSearch(). The op.fromSearch() function automatically includes a special score column in the results, allowing you to sort, filter, or display results by relevance.

In this example, we use an inner join to combine op.fromSearch() results with the wildLlamas view. That keeps the Huacaya filter while still preserving text relevance from cts.wordQuery. Results are ordered by descending score so the most relevant rows appear first.

{
  "name": "Aaron",
  "breed": "Huacaya",
  "placeOfBirth": "Cusco, Peru",
  "secretPowerId": "d8839ba6-2b77-4bcc-9927-b86cdfecb9fb"
}
xquery version "1.0-ml";
import module namespace op = "http://marklogic.com/optic" at "/MarkLogic/optic.xqy";

op:from-search(
  cts:and-query((
    cts:word-query("sung", "stemmed")
  )),
  ("fragmentId", "score"),
  "singingLlamas",
  map:entry("scoreMethod", "logtfidf")
)
  => op:join-inner(
       op:from-view("llamaverse", "wildLlamas", "wildLlamas", (op:fragment-id-col("fragmentId")))
         => op:where(op:eq(op:view-col("wildLlamas", "breed"), "Huacaya")),
       op:on(
         op:view-col("singingLlamas", "fragmentId"),
         op:view-col("wildLlamas", "fragmentId")
       )
     )
  => op:order-by(op:desc("score"))
  => op:limit(3)
  => op:result()
'use strict';

const op = require('/MarkLogic/optic');

const results = op.fromSearch(
  cts.andQuery([
    cts.wordQuery('sung', 'stemmed')
  ]),
  ['fragmentId', 'score'],
  'singingLlamas',
  { scoreMethod: 'logtfidf' }
)
  .joinInner(
    op.fromView('llamaverse', 'wildLlamas', 'wildLlamas', [op.fragmentIdCol('fragmentId')])
      .where(op.eq(op.viewCol('wildLlamas', 'breed'), 'Huacaya')),
    op.on(op.viewCol('singingLlamas', 'fragmentId'), op.viewCol('wildLlamas', 'fragmentId'))
  )
  .orderBy(op.desc('score'))
  .limit(3)
  .result();

({
  sample: 'optic/cts-word-query-and-the-optic-api/assets/from-search-word-query.sjs',
  kind: Array.isArray(results) ? (results.every((item) => typeof item === 'object' && 'subject' in item && 'predicate' in item && 'object' in item) ? 'triples' : 'rows') : ((results !== null && typeof results === 'object') ? 'object' : 'scalar'),
  count: Array.isArray(results) ? results.length : 0,
  data: results
});

The returned rows show how score differentiates otherwise similar matches. Treat this as your decision point: use where(...) for strict filtering, and use op.fromSearch() when ranking needs to be visible and explainable.

When to Combine cts:word-query with Optic

Combine cts.wordQuery with Optic when you need both text search and structured row shaping in one plan.

Choose where(...) for strict inclusion and exclusion logic. Choose op.fromSearch() when ranked results with visible scores matter. Document that choice once and search behaviour stays predictable under pressure.

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!