Most search engines answer the question, “which documents match this query?” A single-document matcher inverts that relationship: given one candidate document and a reusable query, it answers, “would this document have matched?” That is useful for filtering streams, testing search rules, routing content, alerting on documents as they arrive, and replaying a stored query over many documents without maintaining a full persistent index.
The Bleve Sear project demonstrates this design by implementing a Bleve-compatible index that contains only zero or one document at a time. Each update overwrites the current document, readers see the current in-memory document directly, and expensive index structures such as finite-state term dictionaries or bitmap posting lists are intentionally avoided. The result is closer to a query execution harness than a conventional search index.
The transferable technique is specialization. If the workload is “many candidate documents, same query shape,” a full inverted index can be the wrong abstraction. A matcher can preserve the normal analyzer and query semantics while replacing durable, multi-document structures with compact arrays, sub-slices, cached query state, and brute-force scans over a single analyzed document.
A normal index amortizes work across a corpus: tokenize every document once, build postings, then run many queries cheaply. A single-document matcher amortizes in the other direction. It keeps query parsing and reusable query state stable, then repeatedly swaps in one analyzed document and evaluates the query against that document’s terms, fields, numeric values, and vectors.
A matcher is easiest to adopt when it implements the same reader and index interfaces as the full search engine. In Sear’s case, the package exposes a Bleve index type, reader, document, field dictionary, document-value reader, and term-field reader. Query code can ask for term readers and dictionaries as usual, while the implementation returns tiny single-document views.
Because the live corpus size is at most one document, structures optimized for millions of terms are unnecessary. Sorted arrays, direct token-frequency maps, reusable buffers, and field-local term scans can outperform heavyweight indexes by avoiding allocation, serialization, file I/O, and cache-missing pointer graphs.
The matcher should keep the same analyzer and query behavior that production search uses, but it should not copy the production index’s durability, isolation, batching, or multi-document data structures when the workload only needs one document at a time.
The core state is a single analyzed document slot. Calling update replaces the previous document regardless of identifier. Calling delete clears the slot. A reader does not represent a historical snapshot; it sees the current slot. This is a deliberate tradeoff that avoids copy-on-write isolation and lets the matcher reuse memory aggressively.
// Sketch of a single-document matcher core.
type Matcher struct {
current *AnalyzedDocument
scratch Scratch
}
func (m *Matcher) Update(doc RawDocument) error {
analyzed, err := analyzeInto(doc, &m.scratch)
if err != nil {
return err
}
m.current = analyzed
return nil
}
func (m *Matcher) Matches(q Query) (bool, error) {
if m.current == nil {
return false, nil
}
return q.Evaluate(SingleDocumentReader{doc: m.current})
}
A full-text query engine expects readers for document IDs, term postings, field dictionaries, document values, and range scans. In a matcher, each reader can return either an empty iterator or one synthetic internal document ID. A term-field reader checks whether the current document has the requested term in the requested field, then returns a one-document posting with frequency and norm data.
The source emphasizes caching reusable parts of the query because the same query is expected to run against several documents. For example, a parsed boolean query, compiled regular expression, fuzzy automaton, or resolved field list can be built once, then reused while the document slot changes.
Alerting systems often store user queries and evaluate each new event, log entry, product, or document against those queries. A matcher avoids writing every transient candidate into a durable search index just to decide whether it triggers one saved search.
Search teams can use single-document matchers to test whether query builders, filters, analyzers, and field mappings classify known fixtures correctly. The test can preserve production query semantics while running quickly in process.
Content moderation, routing, compliance tagging, and CRM enrichment pipelines can use search queries as rules. Each incoming object is analyzed, evaluated, tagged, and discarded without building a corpus-level index.
Expose the methods the query engine needs, but make unsupported corpus operations explicit. Sear does not support batch indexing and documents that the index is not thread-safe. That honesty matters: callers should not assume snapshot isolation, concurrent mutation safety, or append-only semantics when the implementation is optimized for one mutable slot.
func (s *SingleDocIndex) Batch(batch *Batch) error {
return errors.New("single-document matcher does not support batch indexing")
}
func (s *SingleDocIndex) Reader() (*Reader, error) {
return &Reader{index: s}, nil // live view, not an isolated snapshot
}
For one document, sorted term arrays and token-frequency maps are usually enough. Prefix, range, fuzzy, and regexp dictionary operations can scan the small field-local term list. If profiling shows a hot path, add a compact helper structure for that field rather than importing the full index machinery.
Document analysis should still match production search: analyzers, token filters, field mappings, numeric encodings, and vector dimensions need to be equivalent. The optimization belongs after analysis, where the matcher stores the analyzed representation in smaller structures.