Bleve Query Builder APIs

Search Engineering intermediate Building with AI ~20 min read

Overview

Bleve is a full-text search library for Go that provides indexing and querying capabilities similar to Elasticsearch but as an embedded library rather than a standalone service. Building complex queries with Bleve’s raw query types—TermQuery, BooleanQuery, NumericRangeQuery, FuzzyQuery—requires constructing deeply nested structs and managing pointer fields, boost values, and field names. Query builder APIs wrap these structs with type-safe helper functions that make composition natural and errors impossible at compile time.

The problem this solves is readability and correctness. A raw Bleve boolean query that combines a term match, a date range filter, and a fuzzy fallback might be 40 lines of struct initialization with scattered & operators for pointer fields. The same query expressed through a builder API is 5–8 chained method calls that read like the query’s intent. More importantly, the builder enforces constraints at compile time: you cannot create a numeric range with min greater than max, and you cannot forget to set a required field.

In a typical search service, query builders are generated from OpenAPI specifications that describe the search API’s query schema. The Go builders wrap Bleve’s native query types, while the TypeScript builders produce JSON payloads that are sent to the search API over HTTP. Both share the same query semantics and produce equivalent results, enabling clients in either language to construct identical queries.

Core Concepts

Builder Pattern for Query Construction

Instead of constructing query structs directly, callers use builder objects that provide fluent method chaining: NewTermQuery("status").Field("state").Boost(1.5). Each method returns the builder itself, enabling composition without intermediate variables. The final .Build() call validates the query and returns the Bleve query object or an error if constraints are violated.

Boolean Query Composition

Complex queries are built by combining simpler ones with boolean logic: Must (AND), Should (OR), and MustNot (NOT). The builder API makes nesting natural: Bool().Must(term, range).Should(fuzzy).MustNot(excluded). Bleve’s BooleanQuery also supports MinShould for minimum-match semantics (“at least 2 of these 5 clauses must match”).

Type-Safe Field References

Raw queries use string field names (“created_at”, “title”), making typos runtime errors. Generated builders can use typed constants or enums for field names, catching invalid field references at compile time. The TypeScript client uses string literal union types to restrict field values to those defined in the index mapping.

OpenAPI-Generated Query Types

The search API’s query schema is defined in an OpenAPI specification. Code generators produce Go structs and TypeScript interfaces from this spec, ensuring both clients and the server agree on query structure. The builders wrap these generated types, adding convenience methods without diverging from the canonical schema.

Query Serialization & Transport

Go builders produce native Bleve query objects for in-process search. TypeScript builders produce JSON objects sent over HTTP. Both must serialize to the same JSON format so that queries built in either language are interchangeable. The JSON format follows Bleve’s query marshaling convention where each query type has a discriminator field.

Query Types in Bleve

Bleve supports a rich set of query types, each targeting a different matching strategy:

How It Works

Go Query Builder Implementation

The Go builder wraps Bleve’s native query types with a fluent API. Each builder method sets a field on the underlying query struct and returns the builder for chaining. The Build() method performs validation and returns the query:

// Go: Query builder API wrapping Bleve query types
package querybuilder

import (
    "fmt"
    "time"

    "github.com/blevesearch/bleve/v2/search/query"
)

// TermQueryBuilder builds a Bleve TermQuery with fluent API.
type TermQueryBuilder struct {
    term  string
    field string
    boost float64
}

func NewTermQuery(term string) *TermQueryBuilder {
    return &TermQueryBuilder{term: term, boost: 1.0}
}

func (b *TermQueryBuilder) Field(f string) *TermQueryBuilder {
    b.field = f
    return b
}

func (b *TermQueryBuilder) Boost(v float64) *TermQueryBuilder {
    b.boost = v
    return b
}

func (b *TermQueryBuilder) Build() (query.Query, error) {
    if b.term == "" {
        return nil, fmt.Errorf("term query: term cannot be empty")
    }
    q := query.NewTermQuery(b.term)
    if b.field != "" {
        q.SetField(b.field)
    }
    q.SetBoost(b.boost)
    return q, nil
}

// NumericRangeBuilder builds a Bleve NumericRangeQuery.
type NumericRangeBuilder struct {
    field        string
    min          *float64
    max          *float64
    minInclusive bool
    maxInclusive bool
    boost        float64
}

func NewNumericRange() *NumericRangeBuilder {
    return &NumericRangeBuilder{minInclusive: true, maxInclusive: true, boost: 1.0}
}

func (b *NumericRangeBuilder) Field(f string) *NumericRangeBuilder {
    b.field = f
    return b
}

func (b *NumericRangeBuilder) Min(v float64) *NumericRangeBuilder {
    b.min = &v
    return b
}

func (b *NumericRangeBuilder) Max(v float64) *NumericRangeBuilder {
    b.max = &v
    return b
}

func (b *NumericRangeBuilder) ExclusiveMin() *NumericRangeBuilder {
    b.minInclusive = false
    return b
}

func (b *NumericRangeBuilder) ExclusiveMax() *NumericRangeBuilder {
    b.maxInclusive = false
    return b
}

func (b *NumericRangeBuilder) Build() (query.Query, error) {
    if b.min != nil && b.max != nil && *b.min > *b.max {
        return nil, fmt.Errorf("numeric range: min (%f) > max (%f)", *b.min, *b.max)
    }
    q := query.NewNumericRangeInclusiveQuery(b.min, b.max, &b.minInclusive, &b.maxInclusive)
    if b.field != "" {
        q.SetField(b.field)
    }
    q.SetBoost(b.boost)
    return q, nil
}

Boolean Query Builder

The boolean query builder composes sub-queries into must/should/mustNot clauses. It accepts any query.Query or builder that implements a Build() method, enabling nested composition:

// Go: Boolean query builder with composable sub-queries
package querybuilder

import "github.com/blevesearch/bleve/v2/search/query"

// Buildable is any type that can produce a Bleve query.
type Buildable interface {
    Build() (query.Query, error)
}

// BoolQueryBuilder composes sub-queries with boolean logic.
type BoolQueryBuilder struct {
    must     []Buildable
    should   []Buildable
    mustNot  []Buildable
    minShould int
    boost     float64
}

func Bool() *BoolQueryBuilder {
    return &BoolQueryBuilder{boost: 1.0}
}

func (b *BoolQueryBuilder) Must(queries ...Buildable) *BoolQueryBuilder {
    b.must = append(b.must, queries...)
    return b
}

func (b *BoolQueryBuilder) Should(queries ...Buildable) *BoolQueryBuilder {
    b.should = append(b.should, queries...)
    return b
}

func (b *BoolQueryBuilder) MustNot(queries ...Buildable) *BoolQueryBuilder {
    b.mustNot = append(b.mustNot, queries...)
    return b
}

func (b *BoolQueryBuilder) MinShould(n int) *BoolQueryBuilder {
    b.minShould = n
    return b
}

func (b *BoolQueryBuilder) Build() (query.Query, error) {
    bq := query.NewBooleanQuery(nil, nil, nil)

    for _, m := range b.must {
        q, err := m.Build()
        if err != nil {
            return nil, err
        }
        bq.AddMust(q)
    }
    for _, s := range b.should {
        q, err := s.Build()
        if err != nil {
            return nil, err
        }
        bq.AddShould(q)
    }
    for _, mn := range b.mustNot {
        q, err := mn.Build()
        if err != nil {
            return nil, err
        }
        bq.AddMustNot(q)
    }

    if b.minShould > 0 {
        bq.SetMinShould(float64(b.minShould))
    }
    bq.SetBoost(b.boost)
    return bq, nil
}

// Example usage: composing a complex query
func ExampleComplexQuery() (query.Query, error) {
    return Bool().
        Must(
            NewTermQuery("published").Field("status"),
            NewNumericRange().Field("year").Min(2020).Max(2025),
        ).
        Should(
            NewTermQuery("machine learning").Field("tags").Boost(2.0),
            NewTermQuery("deep learning").Field("tags").Boost(1.5),
        ).
        MustNot(
            NewTermQuery("retracted").Field("status"),
        ).
        MinShould(1).
        Build()
}

TypeScript Query Builder

The TypeScript builder produces JSON payloads matching Bleve’s query schema. It uses discriminated unions for type safety and produces the same query structure as the Go builder:

// TypeScript: Query builder for Bleve-compatible JSON queries
type FieldName = "title" | "body" | "status" | "tags" | "year" | "created_at";

interface BleveQuery {
  term?: { term: string; field?: string; boost?: number };
  bool?: {
    must?: BleveQuery[];
    should?: BleveQuery[];
    must_not?: BleveQuery[];
    min_should?: number;
  };
  numeric_range?: {
    field: string;
    min?: number;
    max?: number;
    inclusive_min?: boolean;
    inclusive_max?: boolean;
  };
  fuzzy?: { term: string; field?: string; fuzziness?: number };
}

class TermQueryBuilder {
  private query: BleveQuery["term"];

  constructor(term: string) {
    this.query = { term };
  }

  field(f: FieldName): this {
    this.query!.field = f;
    return this;
  }

  boost(v: number): this {
    this.query!.boost = v;
    return this;
  }

  build(): BleveQuery {
    return { term: this.query };
  }
}

class BoolQueryBuilder {
  private must: BleveQuery[] = [];
  private should: BleveQuery[] = [];
  private mustNot: BleveQuery[] = [];
  private minShould?: number;

  addMust(...builders: { build(): BleveQuery }[]): this {
    this.must.push(...builders.map((b) => b.build()));
    return this;
  }

  addShould(...builders: { build(): BleveQuery }[]): this {
    this.should.push(...builders.map((b) => b.build()));
    return this;
  }

  addMustNot(...builders: { build(): BleveQuery }[]): this {
    this.mustNot.push(...builders.map((b) => b.build()));
    return this;
  }

  setMinShould(n: number): this {
    this.minShould = n;
    return this;
  }

  build(): BleveQuery {
    return {
      bool: {
        must: this.must.length > 0 ? this.must : undefined,
        should: this.should.length > 0 ? this.should : undefined,
        must_not: this.mustNot.length > 0 ? this.mustNot : undefined,
        min_should: this.minShould,
      },
    };
  }
}

// Usage: same query as Go example, produces identical JSON
const query = new BoolQueryBuilder()
  .addMust(
    new TermQueryBuilder("published").field("status"),
  )
  .addShould(
    new TermQueryBuilder("machine learning").field("tags").boost(2.0),
    new TermQueryBuilder("deep learning").field("tags").boost(1.5),
  )
  .setMinShould(1)
  .build();

Practical Applications

Search API Implementation

A Go search service following this pattern exposes an HTTP API that accepts query JSON payloads. Internally, the API handler deserializes the JSON into builder objects, calls Build() to produce Bleve query objects, and executes them against the index. This architecture means the API handler contains zero query construction logic—all validation and construction is in the builder layer, making the handler a thin translation from HTTP to Bleve.

Faceted Search with Boolean Composition

When users apply facet filters in the search UI (category, date range, status), the frontend builds a boolean query using the TypeScript builder. Each active filter becomes a Must clause, and the user’s text query is the top-level Must clause. The builder ensures that adding or removing a filter produces a valid query without manual JSON manipulation.

Autocomplete with Prefix and Fuzzy Fallback

The autocomplete feature uses a Should query combining a PrefixQuery (for exact prefix matches, boosted high) with a FuzzyQuery (for typo-tolerant matches, boosted lower). The builder makes it easy to adjust fuzziness and boost ratios without rewriting the query structure:

// Go: Autocomplete query with prefix + fuzzy fallback
func BuildAutocompleteQuery(input string, field string) (query.Query, error) {
    return Bool().
        Should(
            NewPrefixQuery(input).Field(field).Boost(5.0),
            NewFuzzyQuery(input).Field(field).Fuzziness(1).Boost(1.0),
        ).
        MinShould(1).
        Build()
}

Saved Searches & Query Templates

Users can save complex queries as templates with placeholder variables. The builder API enables parameterized query construction: a template defines the query structure using builders, and at execution time, variables are substituted before calling Build(). This avoids the security risks of string interpolation in query JSON.

Implementation Patterns

Validation at Build Time

The Build() method is the single point of validation. All constraint checks happen here: required fields are set, ranges are valid, boost values are positive. Callers get clear error messages at the point of query construction, not at query execution time when the error is harder to trace back to the source.

Immutable Builders for Thread Safety

Each builder method returns a new builder instance rather than mutating the receiver. This makes builders safe to share across goroutines and enables partial query templates: create a base builder with common clauses, then fork it with additional clauses for different use cases without affecting the base.

Code Generation from OpenAPI

The query schema is defined once in an OpenAPI specification. Code generators produce Go structs, TypeScript interfaces, and builder boilerplate from this spec. When the schema changes (new query type, new field), regenerating the builders guarantees both clients stay in sync with the server. The generated code is checked into the repository and reviewed like any other code change.

Query Explain for Debugging

Builders support an Explain() method that returns a human-readable string describing the query tree. This is invaluable for debugging: when search results don’t match expectations, printing the explanation shows exactly what was queried and how clauses were combined. The explain output mirrors Bleve’s internal query representation:

// Go: Query explanation for debugging
func (b *BoolQueryBuilder) Explain() string {
    var buf strings.Builder
    buf.WriteString("BooleanQuery {\n")
    for _, m := range b.must {
        buf.WriteString("  MUST: " + explainBuildable(m) + "\n")
    }
    for _, s := range b.should {
        buf.WriteString("  SHOULD: " + explainBuildable(s) + "\n")
    }
    for _, mn := range b.mustNot {
        buf.WriteString("  MUST_NOT: " + explainBuildable(mn) + "\n")
    }
    if b.minShould > 0 {
        buf.WriteString(fmt.Sprintf("  MIN_SHOULD: %d\n", b.minShould))
    }
    buf.WriteString("}")
    return buf.String()
}

Common Pitfalls

Confusing TermQuery and MatchQuery

TermQuery matches the exact term as stored in the index (after indexing-time analysis). MatchQuery applies the field’s analyzer to the input text before matching. Using TermQuery with unanalyzed user input (e.g., uppercase text when the index stores lowercase) returns zero results. Use MatchQuery for user-facing text search and TermQuery for exact-value filters on keyword fields.

Missing Field on Queries

If no field is specified on a term or match query, Bleve searches the default field (usually _all). This may return unexpected results if the _all field is not configured or contains data from all fields. Always specify the target field explicitly, and have the builder enforce this with a required Field() call.

Boost Value Inflation

Boost values are relative, not absolute. Setting one clause to boost 100 while others are at 1.0 doesn’t make it 100x more important—Bleve’s scoring normalizes across query clauses. Effective boosting uses small ratios (1.0 to 5.0). Large boost values can cause numeric overflow in scoring and produce unpredictable ranking.

Boolean Query with Empty Clauses

A BooleanQuery with no Must, no Should, and no MustNot matches nothing. A BooleanQuery with only MustNot clauses also matches nothing (there is no positive match to negate against). The builder should validate that at least one positive clause (Must or Should) exists, or that a MatchAll query is implicitly added.

JSON Serialization Mismatches

Go and TypeScript must produce identical JSON for the same logical query. Common mismatches include: Go using omitempty on struct tags (omitting zero values that TypeScript includes), different null handling (nil vs. undefined vs. null), and number precision differences. Integration tests that build the same query in both languages and compare JSON output catch these issues.

Further Reading