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.
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"]
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, withk = 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.
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
A §2C §3B §1B §4A §5D §1
vector lane
kNN over chunk vectors
B §1A §5B §4A §2D §1C §3
fused ranking (hybrid, RRF at k = 60)
score = Σ 1 / (60 + rank + 1), summed over the lanes you left on
refused: a query must name a lane. The service never guesses what the caller meant.
- 1 Keller v. Ortiz, §1
B §1Issue: whether the tenant or landlord bore responsibility for the failed cooling unit. 0.03175 - 2 Rivera v. Halden Properties, §2
A §2The lease assigns maintenance of rooftop HVAC and cooling units to the tenant. 0.03151 - 3 Rivera v. Halden Properties, §5
A §5Roof access for inspections, and who may enter to service the equipment. 0.03102 - 4 Keller v. Ortiz, §4
B §4Damages from water intrusion after the cooling unit failed. 0.03101 - 5 Municipal Code §118.4
C §3Refrigeration permits: filing requirements for commercial cooling systems. 0.03080 - 6 State v. Bremm, §1
D §1Notice 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"]
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.