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.
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.
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.
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.
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.
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:
bleve.Index, and returns results as protobuf responsesThe 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;
}
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
}
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()
}
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.
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.
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.
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
}
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;
}
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)
Ignoring message size limits: gRPC defaults to a 4MB max message size. Search results with stored fields can easily exceed this. Configure MaxCallRecvMsgSize on the client and MaxRecvMsgSize on the server to accommodate large responses, or use streaming for bulk operations.
Not setting deadlines: Without deadlines, a hanging index server will block client goroutines indefinitely. Always set context deadlines, and consider per-RPC timeout interceptors as a safety net.
Serialization overhead: JSON serialization of queries and results adds latency. For latency-sensitive paths, consider using protobuf-native query representations instead of JSON-in-protobuf. Profile before optimizing—the network round trip usually dominates.
Connection lifecycle issues: gRPC connections can silently become stale behind load balancers that terminate idle connections. Use keepalive parameters to detect and refresh dead connections:
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second,
Timeout: 3 * time.Second,
PermitWithoutStream: true,
})
Losing type safety across the boundary: Marshaling Bleve queries as raw JSON bytes loses compile-time type checking. Validate query structure on the server side before executing, and return descriptive InvalidArgument errors for malformed queries.