Skip to main content
search at depth

Systematic Field Weight Optimization

4 min read Chapter 27 of 60

Systematic Field Weight Optimization

The Symptom

The team has tried title^3, title^5, and title^10. Each time, someone reports that results “feel” better or worse. There is no systematic exploration of the weight space. The weights are chosen by intuition, tested by anecdote, and deployed by committee.

The Internals

Field weights in a multi_match or bool query with per-field boosts define a multi-dimensional space. For the documentation platform with four searchable fields (title, body, code_snippets, api_method), the weight space has four dimensions. Manual exploration of this space is infeasible. A systematic approach evaluates a grid of weight combinations against the query test set and identifies the configuration that maximizes NDCG@5.

This is not machine learning. It is a parameter sweep with a clear objective function (NDCG) and a small parameter space. The sweep takes minutes, not hours.

The Implementation

public class FieldWeightOptimizer {

    private final OpenSearchClient client;
    private final RelevanceEvaluator evaluator;
    private final List<QueryTestSetLoader.RelevanceJudgment> testSet;

    public FieldWeightOptimizer(OpenSearchClient client,
            RelevanceEvaluator evaluator,
            List<QueryTestSetLoader.RelevanceJudgment> testSet) {
        this.client = client;
        this.evaluator = evaluator;
        this.testSet = testSet;
    }

    public record WeightConfig(
        float titleWeight,
        float bodyWeight,
        float codeWeight,
        float apiMethodWeight
    ) {
        public List<String> toFieldList() {
            return List.of(
                "title^" + titleWeight,
                "body^" + bodyWeight,
                "code_snippets^" + codeWeight,
                "api_method^" + apiMethodWeight
            );
        }
    }

    public record OptimizationResult(
        WeightConfig weights,
        double ndcg,
        Map<String, Double> categoryNdcg
    ) {}

    public List<OptimizationResult> gridSearch(String index) throws IOException {
        List<OptimizationResult> results = new ArrayList<>();

        // Define the search grid
        float[] titleWeights = {1.0f, 2.0f, 3.0f, 5.0f, 8.0f};
        float[] bodyWeights = {1.0f};  // Hold body at 1.0 as baseline
        float[] codeWeights = {0.2f, 0.5f, 1.0f, 2.0f};
        float[] apiWeights = {2.0f, 5.0f, 10.0f, 20.0f};

        for (float tw : titleWeights) {
            for (float bw : bodyWeights) {
                for (float cw : codeWeights) {
                    for (float aw : apiWeights) {
                        WeightConfig config = new WeightConfig(tw, bw, cw, aw);
                        EvaluationResult evalResult = evaluateWithWeights(
                            index, config);

                        results.add(new OptimizationResult(
                            config,
                            evalResult.overallNdcg(),
                            groupByCategory(evalResult)
                        ));
                    }
                }
            }
        }

        results.sort(Comparator.comparingDouble(
            OptimizationResult::ndcg).reversed());

        return results;
    }

    private EvaluationResult evaluateWithWeights(String index,
            WeightConfig config) throws IOException {
        // Build query template with the given weights
        // Execute against test set
        // Return NDCG results
        return evaluator.evaluate(index, testSet, null);
    }

    public void printTopConfigurations(List<OptimizationResult> results, int n) {
        System.out.println("Top " + n + " field weight configurations:");
        System.out.println("=" .repeat(80));

        for (int i = 0; i < Math.min(n, results.size()); i++) {
            OptimizationResult r = results.get(i);
            System.out.printf(
                "#%d NDCG@5=%.4f | title^%.0f body^%.0f code^%.1f api^%.0f%n",
                i + 1, r.ndcg(),
                r.weights().titleWeight(), r.weights().bodyWeight(),
                r.weights().codeWeight(), r.weights().apiMethodWeight()
            );

            for (var cat : r.categoryNdcg().entrySet()) {
                System.out.printf("    %-15s: %.4f%n", cat.getKey(), cat.getValue());
            }
        }
    }
}

Example output:

Top 5 field weight configurations:
================================================================================
#1 NDCG@5=0.7890 | title^3 body^1 code^0.5 api^10
    method_name    : 0.8900
    concept        : 0.7600
    error_message  : 0.7200
    config_key     : 0.8100
    how_to         : 0.7650

#2 NDCG@5=0.7845 | title^3 body^1 code^1.0 api^10
    method_name    : 0.8850
    concept        : 0.7550
    error_message  : 0.7300
    config_key     : 0.8050
    how_to         : 0.7500

#3 NDCG@5=0.7810 | title^5 body^1 code^0.5 api^10
    method_name    : 0.9100
    concept        : 0.7200
    error_message  : 0.7150
    config_key     : 0.8200
    how_to         : 0.7400

The Measurement

The grid search explores 80 weight combinations (5 x 1 x 4 x 4). Each combination requires running 50 queries against the test set. Total: 4,000 queries against a Testcontainers instance. At 15ms per query, the sweep completes in about 60 seconds.

The top-5 configurations cluster around title^3, api_method^10, and code_snippets^0.5. This makes intuitive sense: API method matches are the highest-signal results for the documentation platform, and code snippet matches are noisy (many documents contain similar code patterns).

The Decision Rule

Run the grid search when initially configuring field weights or after a significant change to the analysis pipeline. The sweep is cheap (60 seconds) and replaces hours of manual experimentation.

Choose the configuration that maximizes overall NDCG@5 while maintaining acceptable per-category scores. If the top configuration has a category score below the CI threshold, choose the next configuration that satisfies all constraints.

Beware overfitting to the test set. If the top configuration scores 0.79 and the second-best scores 0.78, the difference is not significant. Choose the simpler configuration (closer to the current weights) unless the improvement is consistent across categories. The test set has 50 queries. The production query distribution has thousands of patterns. Small NDCG differences on 50 queries do not generalize.