Bleve gRPC Remote Index Proxy

Search Engineering intermediate Building with AI ~20 min read

Overview

Bleve is a full-text search library written in Go that provides indexing and querying capabilities as an embedded library. By default, Bleve operates in-process—your application directly calls Bleve functions to index documents and execute queries against a local index on disk. But what happens when you need to distribute search across multiple nodes, or decouple the indexing service from the query service?

A gRPC Remote Index Proxy wraps Bleve’s bleve.Index interface behind a gRPC service, allowing remote clients to perform index operations (create, update, delete, search) over the network as if they were calling a local index. The proxy translates gRPC requests into native Bleve API calls, executes them against the local index, and streams results back. This pattern enables distributed search architectures without rewriting any search logic—the same Bleve query DSL works whether the index is local or remote.

In a typical deployment, this pattern is used to allow multiple services to share search infrastructure. A dedicated indexing service manages the Bleve indexes on disk, while application services interact with search through a thin gRPC client that implements the same bleve.Index interface. This separation enables independent scaling of indexing and querying workloads.

Core Concepts

Interface-Based Proxy Pattern

Go’s interface system makes the remote proxy transparent. Both the local Bleve index and the remote gRPC client implement bleve.Index, so calling code doesn’t know (or care) whether the index is on the local filesystem or across the network. This is the Liskov Substitution Principle applied to distributed systems: any code that accepts a bleve.Index works identically with either implementation.

gRPC Service Definition

The proxy is defined as a Protocol Buffer service with RPCs corresponding to Bleve’s core operations: Index, Delete, Search, DocCount, and Close. Protocol Buffers handle serialization of search requests and results, while gRPC provides HTTP/2 transport with multiplexing, flow control, and bidirectional streaming.

Serialization Boundary

Bleve’s query types (TermQuery, MatchQuery, ConjunctionQuery, etc.) must be serialized to cross the network boundary. The proxy uses a JSON-encoded query representation inside the protobuf message, preserving Bleve’s existing JSON query DSL. This avoids duplicating every query type as a separate protobuf message type while maintaining full query expressiveness.

Connection Pooling & Load Balancing

In production deployments, gRPC clients maintain connection pools to multiple index server replicas. gRPC’s built-in load balancing (round-robin, pick-first, or custom policies) distributes queries across replicas, while connection multiplexing allows many concurrent RPCs over a single TCP connection.

How It Works

Architecture Overview

The system has three layers: the gRPC client (implements bleve.Index), the gRPC transport (protobuf messages over HTTP/2), and the gRPC server (delegates to a real Bleve index). Each layer has a clear responsibility:

Protocol Buffer Definition

The service definition maps Bleve operations to RPCs. Each search request carries the serialized query, pagination parameters, and field selections:

syntax = "proto3";
package search;

service SearchIndex {
  rpc Search(SearchRequest) returns (SearchResponse);
  rpc Index(IndexRequest) returns (IndexResponse);
  rpc Delete(DeleteRequest) returns (DeleteResponse);
  rpc DocCount(DocCountRequest) returns (DocCountResponse);
  rpc Close(CloseRequest) returns (CloseResponse);
}

message SearchRequest {
  bytes query_json = 1;      // JSON-encoded bleve.SearchRequest
  int32 size = 2;
  int32 from = 3;
  repeated string fields = 4;
}

message SearchResponse {
  uint64 total = 1;
  double max_score = 2;
  uint64 took_ms = 3;
  repeated SearchHit hits = 4;
}

message SearchHit {
  string id = 1;
  double score = 2;
  bytes fields_json = 3;     // JSON-encoded stored fields
}

message IndexRequest {
  string id = 1;
  bytes document_json = 2;   // JSON-encoded document
}

message IndexResponse {
  bool success = 1;
}

message DeleteRequest {
  string id = 1;
}

message DeleteResponse {
  bool success = 1;
}

message DocCountRequest {}
message DocCountResponse {
  uint64 count = 1;
}

message CloseRequest {}
message CloseResponse {
  bool success = 1;
}

Server Implementation

The server wraps a local Bleve index and translates gRPC calls into native Bleve API calls. Error handling maps Bleve errors to appropriate gRPC status codes:

package server

import (
    "context"
    "encoding/json"

    "github.com/blevesearch/bleve/v2"
    pb "myproject/proto/search"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type SearchServer struct {
    pb.UnimplementedSearchIndexServer
    index bleve.Index
}

func NewSearchServer(index bleve.Index) *SearchServer {
    return &SearchServer{index: index}
}

func (s *SearchServer) Search(ctx context.Context, req *pb.SearchRequest) (*pb.SearchResponse, error) {
    // Deserialize the Bleve search request from JSON
    searchReq := bleve.NewSearchRequest(nil)
    if err := json.Unmarshal(req.QueryJson, searchReq); err != nil {
        return nil, status.Errorf(codes.InvalidArgument, "invalid query: %v", err)
    }

    // Apply pagination from the gRPC request
    searchReq.Size = int(req.Size)
    searchReq.From = int(req.From)
    searchReq.Fields = req.Fields

    // Execute against the local Bleve index
    result, err := s.index.Search(searchReq)
    if err != nil {
        return nil, status.Errorf(codes.Internal, "search failed: %v", err)
    }

    // Convert Bleve results to protobuf response
    resp := &pb.SearchResponse{
        Total:    result.Total,
        MaxScore: result.MaxScore,
        TookMs:   uint64(result.Took.Milliseconds()),
    }
    for _, hit := range result.Hits {
        fieldsJSON, _ := json.Marshal(hit.Fields)
        resp.Hits = append(resp.Hits, &pb.SearchHit{
            Id:         hit.ID,
            Score:      hit.Score,
            FieldsJson: fieldsJSON,
        })
    }
    return resp, nil
}

func (s *SearchServer) Index(ctx context.Context, req *pb.IndexRequest) (*pb.IndexResponse, error) {
    var doc map[string]interface{}
    if err := json.Unmarshal(req.DocumentJson, &doc); err != nil {
        return nil, status.Errorf(codes.InvalidArgument, "invalid document: %v", err)
    }
    if err := s.index.Index(req.Id, doc); err != nil {
        return nil, status.Errorf(codes.Internal, "index failed: %v", err)
    }
    return &pb.IndexResponse{Success: true}, nil
}

func (s *SearchServer) DocCount(ctx context.Context, req *pb.DocCountRequest) (*pb.DocCountResponse, error) {
    count, err := s.index.DocCount()
    if err != nil {
        return nil, status.Errorf(codes.Internal, "doc count failed: %v", err)
    }
    return &pb.DocCountResponse{Count: count}, nil
}

Client Implementation

The client implements bleve.Index by forwarding calls over gRPC. Application code uses it exactly like a local index:

package client

import (
    "context"
    "encoding/json"
    "time"

    "github.com/blevesearch/bleve/v2"
    pb "myproject/proto/search"
    "google.golang.org/grpc"
)

type RemoteIndex struct {
    client pb.SearchIndexClient
    conn   *grpc.ClientConn
}

func NewRemoteIndex(addr string) (*RemoteIndex, error) {
    conn, err := grpc.Dial(addr,
        grpc.WithInsecure(),
        grpc.WithDefaultCallOptions(
            grpc.MaxCallRecvMsgSize(64*1024*1024), // 64MB for large result sets
        ),
    )
    if err != nil {
        return nil, err
    }
    return &RemoteIndex{
        client: pb.NewSearchIndexClient(conn),
        conn:   conn,
    }, nil
}

func (r *RemoteIndex) Search(req *bleve.SearchRequest) (*bleve.SearchResult, error) {
    queryJSON, err := json.Marshal(req)
    if err != nil {
        return nil, err
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    resp, err := r.client.Search(ctx, &pb.SearchRequest{
        QueryJson: queryJSON,
        Size:      int32(req.Size),
        From:      int32(req.From),
        Fields:    req.Fields,
    })
    if err != nil {
        return nil, err
    }

    // Convert protobuf response back to Bleve SearchResult
    result := &bleve.SearchResult{
        Total:    resp.Total,
        MaxScore: resp.MaxScore,
        Took:     time.Duration(resp.TookMs) * time.Millisecond,
    }
    for _, hit := range resp.Hits {
        var fields map[string]interface{}
        json.Unmarshal(hit.FieldsJson, &fields)
        result.Hits = append(result.Hits, &search.DocumentMatch{
            ID:     hit.Id,
            Score:  hit.Score,
            Fields: fields,
        })
    }
    return result, nil
}

func (r *RemoteIndex) Index(id string, data interface{}) error {
    docJSON, err := json.Marshal(data)
    if err != nil {
        return err
    }
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    _, err = r.client.Index(ctx, &pb.IndexRequest{Id: id, DocumentJson: docJSON})
    return err
}

func (r *RemoteIndex) Close() error {
    return r.conn.Close()
}

Practical Applications

Distributed Search with a gRPC Index Proxy

In such a system, the gRPC index proxy enables a microservice architecture where the search index lives on dedicated nodes with fast SSDs, while application services on commodity hardware issue search queries over gRPC. This separation means the indexing service can be optimized independently—more RAM for caching, faster disks for write throughput—without affecting the application tier.

Index Sharding

Multiple remote indexes can be queried in parallel using Bleve’s IndexAlias type. An alias that wraps several RemoteIndex clients (each pointing to a different shard) provides a unified search interface that fans out queries to all shards and merges results. This is how you scale Bleve beyond what fits on a single node.

Read/Write Separation

The gRPC layer naturally supports read/write separation. Write requests (Index, Delete) can be routed to a primary node, while read requests (Search, DocCount) are load-balanced across replicas. gRPC interceptors can implement this routing logic transparently.

Implementation Patterns

Deadline Propagation

gRPC supports deadline propagation—when a client sets a timeout, the server receives the remaining deadline and can abort work early if time runs out. For search, this means a slow query on one shard won’t block the entire fan-out indefinitely. Always set deadlines on search RPCs:

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
result, err := remoteIndex.client.Search(ctx, searchReq)
if err != nil {
    if status.Code(err) == codes.DeadlineExceeded {
        log.Warn("search timed out, returning partial results")
        return partialResult, nil
    }
    return nil, err
}

Streaming Large Result Sets

For batch operations like re-indexing or bulk export, use gRPC streaming instead of unary RPCs. A server-streaming RPC avoids loading the entire result set into memory:

service SearchIndex {
  // Stream all documents for backup/reindex
  rpc StreamDocs(StreamDocsRequest) returns (stream DocEntry);
}

message StreamDocsRequest {
  int32 batch_size = 1;
}

message DocEntry {
  string id = 1;
  bytes document_json = 2;
}

Health Checking & Graceful Degradation

Register the gRPC health checking service so load balancers can detect unhealthy index servers. When an index server becomes unavailable, the client should degrade gracefully— return cached results, reduce result set size, or fall back to a secondary index:

import "google.golang.org/grpc/health"
import healthpb "google.golang.org/grpc/health/grpc_health_v1"

healthServer := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthServer)
healthServer.SetServingStatus("search.SearchIndex", healthpb.HealthCheckResponse_SERVING)

Common Pitfalls

Further Reading