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.
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.
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”).
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.
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.
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.
Bleve supports a rich set of query types, each targeting a different matching strategy:
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
}
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()
}
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();
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.
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.
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()
}
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.
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.
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.
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.
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()
}
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.
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 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.
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.
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.