Skip to main content
search at depth

Query DSL in Depth: Match, Bool, Function Score, and When to Use Each

7 min read Chapter 22 of 60

Query DSL in Depth

The OpenSearch Query DSL is not one query language. It is a tree of composable query clauses, each with different scoring behavior, caching characteristics, and performance profiles. Using a match query where a term query suffices wastes analysis cycles. Using a must clause where a filter clause suffices wastes scoring computation and prevents cache utilization. Using a multi_match with the wrong type produces rankings that puzzle users and developers alike.

This chapter catalogs the query types that matter for the documentation search platform, explains when each is correct, and builds the query test set that chapters 9 and 10 use for relevance evaluation.

Match Queries

match

The match query analyzes the input text using the field’s analyzer and searches for the resulting tokens. It is the correct default for user-entered search text against analyzed fields.

// Standard match query: analyzes "connection pooling" into tokens,
// matches documents containing either or both tokens

Query matchQuery = Query.of(q -> q
    .match(m -> m
        .field("body")
        .query("connection pooling")
        .operator(Operator.Or)  // default: match either term
    )
);

/*
JSON equivalent:
{
  "match": {
    "body": {
      "query": "connection pooling",
      "operator": "or"
    }
  }
}
*/

The operator parameter changes behavior significantly:

  • OR (default): documents matching either “connection” OR “pooling” are returned. Higher recall, lower precision.
  • AND: only documents matching both “connection” AND “pooling” are returned. Lower recall, higher precision.

For the documentation platform, OR is the correct default. A search for “connection pooling configuration” should return documents about connection pooling even if they do not mention “configuration.”

match_phrase

Matches the exact sequence of tokens with optional word-distance tolerance (slop):

// match_phrase: requires tokens in exact order
// "connection pooling" matches, "pooling connection" does not

Query phraseQuery = Query.of(q -> q
    .matchPhrase(mp -> mp
        .field("body")
        .query("connection pooling")
        .slop(0)  // exact phrase, no intervening words
    )
);

With slop: 2, “connection pool based pooling” would match because the terms “connection” and “pooling” are within 2 positions of each other. Use phrase queries for method signatures, configuration keys, and error messages where word order carries meaning.

multi_match

Searches the same query across multiple fields. The type parameter determines how per-field scores are combined:

// HARDENED: multi_match with cross_fields for documentation search
// Treats title and body as a single combined field for scoring.
// Prevents accidental title-match bias from best_fields.

Query multiQuery = Query.of(q -> q
    .multiMatch(mm -> mm
        .query(userQuery)
        .fields("title^3", "body", "code_snippets^0.5")
        .type(TextQueryType.CrossFields)
    )
);
multi_match typeBehaviorUse Case
best_fields (default)Score = max score across fieldsDocument strongly about the query in one field
most_fieldsScore = sum of scores across fieldsMatch in multiple fields indicates higher relevance
cross_fieldsTreats fields as single combined fieldSearch across title + body as unified content
phraseRuns match_phrase on each fieldExact phrase search across multiple fields

For the documentation platform, cross_fields produces the most consistent rankings because documentation titles and bodies are semantically continuous: a term appearing in the title and a different term appearing in the body should contribute to a single combined score.

Bool Queries

The bool query composes other queries with four clause types:

// HARDENED: Complete bool query for documentation search
// Scored clauses in must/should. Non-scored clauses in filter/must_not.

Query searchQuery = Query.of(q -> q
    .bool(b -> b
        // filter: non-scoring, cacheable, applied first
        .filter(f -> f.term(t -> t.field("tenant_id").value(tenantId)))
        .filter(f -> f.term(t -> t.field("version").value("4.0")))

        // must_not: exclude without affecting score
        .mustNot(mn -> mn.term(t -> t.field("content_type").value("changelog")))

        // must: scored, all must match
        .must(mu -> mu.multiMatch(mm -> mm
            .query(userQuery)
            .fields("title^3", "body")
            .type(TextQueryType.CrossFields)
        ))

        // should: scored, optional, boosts matching documents
        .should(sh -> sh.match(m -> m
            .field("api_method")
            .query(userQuery)
            .boost(5.0f)
        ))
        .minimumShouldMatch("0")  // should clauses are optional
    )
);

Scoring rules:

  • must clauses contribute to the score and must match
  • should clauses contribute to the score but are optional (unless there are no must or filter clauses, in which case at least one should must match)
  • filter clauses must match but do not contribute to the score and are cached
  • must_not clauses must not match, do not contribute to the score, and are cached

Function Score

Function score wraps another query and modifies the score using custom functions. This is the mechanism for boosting documents based on field values, recency, or custom business logic.

// HARDENED: Function score that boosts recently-updated documentation
// and pages with higher user ratings

Query functionScoreQuery = Query.of(q -> q
    .functionScore(fs -> fs
        .query(fq -> fq.multiMatch(mm -> mm
            .query(userQuery)
            .fields("title^3", "body")
            .type(TextQueryType.CrossFields)
        ))
        .functions(
            FunctionScore.of(fn -> fn
                .filter(f -> f.exists(e -> e.field("updated_at")))
                .exp(d -> d
                    .field("updated_at")
                    .placement(p -> p
                        .origin(JsonData.of("now"))
                        .scale(JsonData.of("90d"))
                        .decay(0.5)
                    )
                )
                .weight(1.5)
            ),
            FunctionScore.of(fn -> fn
                .fieldValueFactor(fvf -> fvf
                    .field("view_count")
                    .modifier(FieldValueFactorModifier.Log1p)
                    .missing(1.0)
                )
                .weight(0.5)
            )
        )
        .boostMode(FunctionBoostMode.Multiply)
        .scoreMode(FunctionScoreMode.Sum)
    )
);

The decay function on updated_at reduces the score of stale documentation. A document updated 90 days ago scores at 50% of its BM25 relevance. The field_value_factor on view_count uses log1p to apply diminishing returns: the difference between 0 and 100 views matters; the difference between 10,000 and 10,100 views does not.

The Query Test Set

Every relevance change from this point forward is measured against a test set of representative queries. The test set captures the documentation platform’s search patterns:

public class QueryTestSet {

    public record QueryExpectation(
        String query,
        String description,
        List<String> expectedTopDocIds,
        Map<String, String> filters
    ) {}

    public static List<QueryExpectation> documentationTestSet() {
        return List.of(
            new QueryExpectation(
                "getConnection",
                "Exact method name search",
                List.of("acme:api-ref-jdbc-connection", "acme:guide-connection-pooling"),
                Map.of("tenant_id", "acme")
            ),
            new QueryExpectation(
                "retry policy configuration",
                "Multi-word concept search",
                List.of("acme:guide-retry-policies", "acme:api-ref-http-client"),
                Map.of("tenant_id", "acme")
            ),
            new QueryExpectation(
                "SSL certificate",
                "Security configuration search",
                List.of("acme:guide-ssl-setup", "acme:api-ref-tls-config"),
                Map.of("tenant_id", "acme")
            ),
            // ... 30-50 queries covering method names, concepts,
            // configuration, error messages, and cross-cutting concerns
            new QueryExpectation(
                "NullPointerException getUserProfile",
                "Error message with method name",
                List.of("acme:troubleshooting-npe", "acme:api-ref-user-service"),
                Map.of("tenant_id", "acme")
            )
        );
    }
}

This test set is a fixture in the codebase, not a one-time analysis. Chapter 9 implements the evaluation metrics. Chapter 10 extends the test set to measure semantic search impact. No relevance change is described as an improvement without before-and-after scores against this test set.

Query DSL composition tree showing how bool, function_score, match, and filter clauses compose into a complete search query

The query DSL composition tree shows how individual query clauses nest inside a bool query, which itself nests inside a function_score query. Scored clauses (must, should) affect the document ranking. Non-scored clauses (filter, must_not) narrow the result set without affecting scores and benefit from the node query cache. Function score modifiers sit at the outermost layer, adjusting the final score based on document metadata like recency or popularity.

The Decision Rule

Use match for user-entered text against analyzed fields. Use term for exact-value lookups against keyword fields. Never use term on a text field (the stored tokens are analyzed; the term query is not, so they will not match unless the user input happens to match the analyzed form).

Place non-scoring predicates in filter clauses, not must clauses. The filter cache eliminates repeated evaluation. The scoring overhead of must is wasted on predicates that do not vary across queries (like tenant ID).

Use cross_fields multi_match for the documentation platform where title and body represent a single logical content unit. Use best_fields when the document is about the query in one specific field (e.g., a title match is categorically more relevant than a body match).