Search

ProtoMolt's search is not a separate system with its own configuration. The schema declares how each field should be indexed, and two sibling services read that declaration: one answers full-text and vector queries, the other answers aggregate questions. They share one index.

Overview. When a document is stored, ProtoMolt also builds a search index for it: a keyword catalogue for exact matches, an analyzed text index for full-text search, and, if the schema says so, a set of vector embeddings for meaning-based search. A query can run on the words alone, on the vectors alone, or on both at once and merge the two ranked lists. The same index also answers counting questions ("how many per month?"), so the analytics do not need a copy of the data.
flowchart LR
        schema["Schema fields carry index hints"] --> mapping["IndexMapping from the hint chain"]
        mapping --> svc["search role, gRPC 9094"]
        svc --> lex["lexical lane"]
        svc --> vec["vector lane, kNN"]
        svc --> hyb["hybrid lane, RRF k=60"]
        svc --> idx[("one Lucene index per subject")]
        metric["metric role, gRPC 9095"] --> idx
        idx --> snap["S3 commit-point snapshots"]
        snap --> ro["read-only remote node"]
The 10,000-foot view: descriptor hints define one index; a search role and a metric role both read it.

One query, three lanes

The search service (role search, gRPC port 9094) is backed by Apache Lucene, the Java search library. It serves a fixed set of mapping subjects: each subject names an index mapping, and optionally a chunk lane, that the service was mounted with. Every query must pick one of three lanes, explicitly:

  • Lexical. Analyzed term queries over the mapping's TEXT fields. This is classic keyword search: it matches the words and their grammatical forms.
  • Vector. k-nearest-neighbor search (kNN, find the k most similar vectors) over the embedding vectors of the document's chunks. The query text is embedded by the same provider that embedded the chunks, so both sides live in the same vector space. Shipped providers: model2vec (in-process), TEI (gRPC), and OVMS (KServe v2 gRPC).
  • Hybrid. Run both lanes and merge the two ranked lists with reciprocal-rank fusion (RRF): each result's score is the sum of 1 / (k + rank) over the lists it appears in, with k = 60. RRF only looks at ranks, not raw scores, so a document that ranks well in both lanes rises above a document that ranks first in only one.

There is no default lane. An unset lane, an unknown subject, a field outside the subject's mapping, a vector query against a subject with no chunking policy, or a k over 10,000 is refused by name, never silently defaulted. The contract's header puts it in one line: "the service never guesses what a caller meant." A reranking extension point, with providers and a certification harness, exists but is not wired into the query lanes.

The lane race. One query fans out to the lanes you choose, and the fused ranking updates as results arrive. Toggle a lane off and watch a one-lane favorite get overtaken. The corpus below is a small canned example (court opinions, as in the platform's own tests); ranks are illustrative, not measured. Without JavaScript, the full hybrid result is simply shown.

query

“who was responsible for maintaining the rooftop cooling units”

for the vector lane, the text is embedded by the same provider that indexed the chunks

lexical lane

TermQuery over the analyzed TEXT fields

  1. A §2
  2. C §3
  3. B §1
  4. B §4
  5. A §5
  6. D §1

vector lane

kNN over chunk vectors

  1. B §1
  2. A §5
  3. B §4
  4. A §2
  5. D §1
  6. C §3

fused ranking (hybrid, RRF at k = 60)

score = Σ 1 / (60 + rank + 1), summed over the lanes you left on

  1. 1 Keller v. Ortiz, §1 B §1 Issue: whether the tenant or landlord bore responsibility for the failed cooling unit. 0.03175
  2. 2 Rivera v. Halden Properties, §2 A §2 The lease assigns maintenance of rooftop HVAC and cooling units to the tenant. 0.03151
  3. 3 Rivera v. Halden Properties, §5 A §5 Roof access for inspections, and who may enter to service the equipment. 0.03102
  4. 4 Keller v. Ortiz, §4 B §4 Damages from water intrusion after the cooling unit failed. 0.03101
  5. 5 Municipal Code §118.4 C §3 Refrigeration permits: filing requirements for commercial cooling systems. 0.03080
  6. 6 State v. Bremm, §1 D §1 Notice requirements a tenant must satisfy before withholding rent. 0.03008

Watch B §1 and A §2: lexical puts the lease clause first, vector puts the issue statement first, and fusion rewards the chunk that both lanes rate highly. With only the lexical lane on, C §3 (the statute) climbs to second place, even though it is barely about the question, because keyword overlap is exactly what that lane measures. The stacked bar shows each lane's share of the fused score.

The fusion constant and the whole merge fit in two lines:

dev-tools/protomolt search/service, LuceneSearchStore.java (abridged)

/** Reciprocal-rank fusion constant for the hybrid lane. */
private static final int RRF_K = 60;
...
scores.merge(key, 1.0 / (RRF_K + rank + 1), Double::sum);

The mapping is declared on the schema, not in a config file

Most search systems keep their index definitions (which fields are text, which are keywords, which are vectors, which analyzers to apply) in a separate mapping file or a database, maintained next to the schema. ProtoMolt puts them on the schema itself, as a field option, index, from the ai.protomolt.proto.index.hints.v1 package. The option says the field's index type (keyword, text, integer, date, vector, and friends), the analyzer for text, whether numeric fields should be sortable or facetable (doc values for sorting and grouping), how a range or a taxonomy path should be emitted, and, for vectors, the dimensions, the similarity measure (cosine, dot product, L2, max inner product), and engine-neutral HNSW tuning (the m and ef_construction graph parameters, which engines map onto their native knobs). A TREE_PATH field is indexed as its ancestor chain, so a document filed under a/b/c also matches a query for a. The mounted Lucene store does not yet honor these tuning knobs and builds vector fields with Lucene's default parameters; the OpenSearch mapping generator does honor them.

Three pieces cooperate, in a separation the docs state explicitly: a hint says how a field should be indexed, a mapping resolves hints for a concrete message type, and an engine plugin interprets the mapping for one backend. Hints come from a chain of sources, composed so the first hit wins:

flowchart LR
          cat["CatalogIndexingHintSource, side-car entries for schemas you cannot annotate"] --> chain["composed with orElse"]
          opt["ProtoOptionsIndexingHintSource, descriptor options"] --> chain
          chain --> inf["InferringIndexingHintSource, falls back to the proto type"]
          inf --> factory["IndexMappingFactory.defaults walks the descriptor"]
          factory --> map["IndexMapping: paths, engine field names, hints, repeated flag, sensitivity class"]
          map --> plugins["SearchEngineIndexerProvider plugins, discovered via ServiceLoader"]
          plugins --> lucene["lucene"]
          plugins --> dist["distributed-lucene adapter"]
          plugins --> more["opensearch, solr, qdrant"]
The hint chain: a programmatic side-car, then descriptor options, then inference from the proto type. The first source with an answer wins.

IndexMappingFactory.defaults(hintSource).create(descriptor) walks a message descriptor and produces an IndexMapping: the message's full name plus a list of indexed fields, each carrying two naming vocabularies (the dot-separated protobuf path and the name written into the engine document), the resolved hint, a repeated flag, and the field's sensitivity class. A schema you own declares its hints inline; a schema you cannot touch gets entries in the programmatic catalog, keyed by message and field name; and whatever neither covers is inferred from the proto type. Declaring a real index, then, looks like this:

docs/search/indexing.md: one message declaring a keyword id, an analyzed title with a keyword sub-field, and a vector field

Show the actual definition

message Doc {
  string doc_id = 1 [(ai.protomolt.proto.index.hints.v1.index) = { type: INDEX_FIELD_TYPE_KEYWORD }];
  string title  = 2 [(ai.protomolt.proto.index.hints.v1.index) = {
    type: INDEX_FIELD_TYPE_TEXT
    analyzer: "english"
    sub_fields: [{ name: "raw", type: INDEX_FIELD_TYPE_KEYWORD }]
  }];
  repeated float embedding = 3 [(ai.protomolt.proto.index.hints.v1.index) = {
    type: INDEX_FIELD_TYPE_VECTOR
    vector_dims: 768
    vector_similarity: VECTOR_SIMILARITY_COSINE
    hnsw: { m: 16, ef_construction: 128 }
  }];
}

Chunking and embedding, declared the same way

A text field can also carry a chunking policy: at index time the service splits the field's text into chunks (the shipped chunker packs sentences up to a token budget, over one of two pinned boundary rule sets, chosen by id) and embeds each chunk, so the same Lucene index holds keyword fields, analyzed text, doc values, and HNSW vectors. There is no separate metadata file: the .proto is the metadata. The policy computes a SHA-256 digest over a canonical rendering of every component, and chunk identity is doc_id#digest#ordinal. Change any policy value and you get a new digest, a new chunk generation, and a re-index via replay. Two indexes agree on chunk boundaries and vector spaces exactly when their digests agree.

dev-tools/protomolt search/index/spi, indexing_hints.proto: the chunking policy option attached to a text field

Show the actual definition

// A chunking policy: derive chunks and vectors from the annotated source text
// field at index time (chunk + embed on the server). Attach to a string field.
// The policy digest identifies the derivation pipeline; index consumers pin it
// the way they pin a schema.
message ChunkingPolicy {
  ChunkingSpec chunking = 1;
  EmbeddingSpec embedding = 2;
  string vector_field = 3;   // empty = engine convention ("<field>#<model>")
  bool store_chunk_text = 4;
}

How a document lands in the index

Indexing is workflow-driven. A durable run's index step calls SearchIndexService/IndexDocument after parsing, fetching the document's core-plus-parsed planes over the repository wire contract (with a 30-second call deadline). The service maps the parent document under the subject's mapping, derives the chunk children from the chunk lane, and writes the whole family as one Lucene block: children first, parent last, which is Lucene's block contract. Each chunk child gets its vector field, named by the <field>#<model> convention. At query time a vector hit lands on a chunk child, and the block join relates it back to the parent document, so the caller gets the document rather than a bare chunk.

dev-tools/protomolt search/service, LuceneSearchStore.java (abridged)

// Children first, parent last: Lucene's block contract. The
// update deletes every previous block member carrying the
// identity term and adds the new block in one atomic step.
subject.writer().updateDocuments(
        new Term(served.docIdField(), docId), block);

The write is an atomic replace by identity: updateDocuments deletes every previous block member carrying the document's identity term and adds the new block in one step, so replays never duplicate. The out-of-the-box subject, repo-document, indexes identity plus folded search metadata, skipping the storage and provenance planes, with an optional chunk lane over the folded body.

Visible now, durable soon after

Lucene distinguishes visibility from durability, and the service exploits the split. Every write refreshes the NRT (near-real-time) searcher, so a document is queryable immediately. Fsync commits, the expensive part, batch up: every 64 writes, every 2 seconds, or on close. The service's own docs state the worst case: a crash loses at most the last interval's writes, and a replay re-derives them from the repository, which stays the source of truth.

Snapshots, and the cheap read-only node

The store has a SnapshotStore seam, with an S3 implementation. A snapshot copies a commit point to a bucket, keyed subject/mapping-digest/policy-digest, with the segments_N file written last as the atomic marker: a reader either sees the whole commit point or none of it. A read-only variant of the service opens no IndexWriter, answers UNIMPLEMENTED on the indexing RPC, restores from snapshots, and can follow the writer on a refresh interval. That is what makes a remote read-only analytics node cheap to run: mount the search and metric roles together, point them at the snapshot bucket, and the node serves queries and aggregates with no local indexing at all. The docs summarize the relationship: the repository stays the source of truth, and a snapshot is a cache. The S3 implementation is still a prototype.

One guard sits upstream of all of this: a VectorizationPolicy (by default, unclassifiedOnly()) rejects, at mount time, fields to embed that carry a sensitivity classification.

Metrics over the same index

The metric service (role metric, gRPC port 9095) answers aggregate questions, group-by-style measures and dimensions, over the same index. Its members are declared on the schema too, via the metric field option and metric_message message option from ai.protomolt.proto.metric.v1: each declares its role (dimension or measure), its aggregate (count, sum, average, min, max, count-distinct), and the public name a query may use. What is queryable is exactly what the schema declares; a request for anything else is rejected by name.

The bridge between the two services is Lucene doc values, the columnar per-document storage that the search service already writes when a hint says sortable or facetable. The Lucene metric executor runs a single pass of collectors over those doc values, so aggregation is a read path over existing storage. The README's phrase is "no copy and no ETL." Concretely, the metric role reaches into the co-mounted search role and borrows its live searchers:

dev-tools/protomolt metric/lucene, MetricServiceModule.java (abridged)

// The search role must wire first so its store contribution exists
...
List<LuceneSearchStore> stores =
        context.contributions().all(LuceneSearchStore.class);

So the metric role will not start without the search role on the same node: its fast path is literally the search role's index. A second executor runs one DuckDB SELECT ... GROUP BY over the Parquet files an Iceberg sink wrote to the document lake, for lake-scale aggregates. Backend selection is explicit: an unspecified backend resolves to the mount's engine on a single-engine mount, and on a multi-engine mount it is refused, naming the mounted engines.

Three rules keep the numbers trustworthy. COUNT_DISTINCT counts exactly, up to 100,000 tracked values per measure, and rejects a request past that bound instead of estimating. Declared rollups rebuild a whole aggregate table and atomically replace it: exact or refused, a result that fills the group budget never lands. And security is rewritten into the compiled query, not bolted on after: a principal's denied members refuse by name, and injected row filters (equality conditions from the access policy) are ANDed in at compile time. A principal carrying metric access rules is refused outright from rebuilding rollups, because a rebuilt rollup is an unrestricted reduction they could then read around their own row filters.

docs/design/metric-mapping.md: one field declaring both its index shape and its metric role

Show the actual definition

int64 amount_cents = 4 [
  (ai.protomolt.proto.index.hints.v1.index) = {
    type: INDEX_FIELD_TYPE_INT64
    sortable: true
  },
  (ai.protomolt.proto.metric.v1.metric) = {
    role: MEMBER_ROLE_MEASURE
    aggregate: AGGREGATE_SUM
    name: "revenue"
  }
];

Read side and read side agree because the declaration is shared: the same field that declares sortable: true is the one the metric service sums, and every metric response carries a physical_plan (the collector description or the rendered SQL) as evidence of how the answer was produced.

The search console

Ships with the service: a small web console (role search-console, HTTP port 8096) that is one pure-JDK HTTP server rendering one page, no framework and no build step. Its subject and lane pickers are populated from ListSubjects, so the page offers exactly what the service serves and no more. On guarded nodes it requires a browser login (an HttpOnly cookie, 12-hour session) and the principal must hold the search-query scope.

Pipestream Search: the distributed engine

Pipestream Search is a separate engine written in Rust, under active daily development. Most engines that search by meaning rather than by keyword build a graph of each item's nearest neighbours and walk it towards the question, which is fast and which can miss a good answer sitting in a part of the graph the walk never reached. This engine stores every vector in a compact four-bit form and reads all of them on every query, with an optional second pass that rescores the best candidates against the exact original numbers. Its keyword side is a word-list index scored by BM25, using counts gathered from the whole corpus and sent to every machine, so that scores computed in different places can be compared with each other.

The coordinator keeps the only ranked list in a query and tells the shards the score of its current tenth-best answer, over the gRPC stream and a signed UDP channel beside it, so the machines can stop considering hopeless candidates together and the answer the fleet returns is the answer one machine holding everything would have returned. Measured on a corpus of 86.6 million court-opinion chunks across eight shards, two of them Raspberry Pi boards, a vector query returns in 280 ms at the median.

It reads the same index mappings declared on the descriptor as everything above, because the declaration is the contract rather than any one backend's configuration. Three pages cover it in depth: the engine overview, the feature tour, and an interactive demo with real explain trees.