Search

Search is two services plus a ledger. module-opensearch-sink turns finished documents into indexing requests on a Kafka topic; opensearch-manager is the fleet's only OpenSearch writer and the owner of all index topology; and per-document outcomes flow back as receipts into a Postgres indexing ledger in the repository.

The indexing path

The sink is a terminal pipeline module. Nothing calls it on the data path: a worker loop dials the engine and pulls work units over the ModuleWorkService bidi stream, each carrying an inline PipeDoc. The sink's node config names plan_ids; an IndexPlanCache resolves each plan from opensearch-manager (Caffeine cache, 10-minute TTL, 1000 entries), all-or-nothing — a missing plan, a plan that is not READY, or an empty list is a permanent failure naming every offender. Physical index names are taken only from the plan's server-derived controlled_indexes: topology is referenced, never invented by the sink.

A converter flattens the PipeDoc into a search projection (title, body, tags, source URI/MIME, NLP analysis, quality scores, ownership, embedding vector sets) — the source binary stays in repository storage and never rides Kafka. The publisher then emits one IndexingRequestEvent per plan to the indexing-requests topic. Transport is chosen by serialized size: payloads up to 256 KiB (opensearch-sink.indexing.inline-threshold-bytes) go inline as a google.protobuf.Any; larger ones are saved to repository S3 and travel as a claim-check DocumentReference.

pipestream-protos/repo/proto/ai/pipestream/repository/v1/indexing_receipts.proto:231

message IndexingRequestEvent {
  // Unique request/event identifier (UUID/ULID).
  string event_id = 1;
  // Reference to the saved StreamIndexDocumentsRequest PipeDoc in
  // repository-service. Set when the payload is too large to inline
  // (claim-check path).
  ai.pipestream.data.v1.DocumentReference document_ref = 2;
  // The plan that selected this index.
  string plan_id = 3;
  // Inline payload (StreamIndexDocumentsRequest packed as Any).
  google.protobuf.Any inline_payload = 5;
  // The originating PipeDoc's doc_id. Always set, regardless of
  // transport (inline or claim-check) — it's what the registered
  // UuidKeyExtractor turns into the deterministic Kafka partition
  // key. Same doc_id → same partition → same ordering, every time.
  string document_id = 6;
}

Identity is deterministic throughout: the partition key is UUID.nameUUIDFromBytes(document_id), the event id is uuid("evt:<doc_id>:<plan_id>"), and the request id is uuid("req:<doc_id>:<index_name>") — redeliveries dedup naturally downstream instead of duplicating writes.

One writer, with a brake

Opensearch-manager (unified HTTP+gRPC port 18103) is the only consumer of indexing-requests and the only issuer of _bulk calls in the fleet. Before touching a batch, its consumer blocks on ClusterStrainGauge.awaitHealthy() — a daemon polling _cluster/health for pending-task depth and wait time, with hysteresis: a configurable run of consecutive healthy samples is required to clear the brake, and probe failures keep the last known state, so flaky telemetry can neither wedge indexing nor release an active brake. While the cluster is strained, offsets stay uncommitted and Kafka holds the backlog. Events are then dereferenced concurrently (a virtual thread each), grouped by indexing strategy, and submitted through windowed bulk queues.

opensearch-manager/src/main/resources/application.properties:257-289

bulk-indexing.queue-count=${BULK_QUEUE_COUNT:20}
bulk-indexing.capacity=${BULK_QUEUE_CAPACITY:500}
bulk-indexing.flush-interval-ms=${BULK_FLUSH_INTERVAL_MS:500}
bulk-indexing.max-concurrent-flushes=${BULK_MAX_CONCURRENT_FLUSHES:4}

These numbers were tuned against a live cluster: lowering the flush interval from 2000 to 500 ms dropped per-document wall-clock from ~3.9 s to ~1 s, while raising concurrent flushes from 4 to 16 changed nothing — the ceiling is the cluster's HNSW build rate, ~1.4k chunk-docs/s, not client parallelism (measured 2026-07-10). The chunker's sentences_internal group is ~65% of chunk volume and is excluded from indexing by default (index_internal_chunks=false) partly for this reason. Only rejected_execution item failures retry, with bounded linear backoff; everything else is terminal on first sight. The reason for the single-writer design is stated plainly in the architecture doc: a sink writing its own documents would issue one tiny request per document and give up an order of magnitude of throughput, and a fleet of writers could never share a backpressure signal.

Write outcomes flow back as DocumentIndexedEvent receipts on the indexing-receipts topic — one per input event, carrying per-physical-index sub-outcomes and point-in-time provenance (parser chain, chunker/embedder/semantic configs), so "why does this document have these embeddings?" survives later config mutation. Each receipt carries a ULID attempt_id minted at outcome-materialization time so its lexicographic order reflects delivery order even under emitter backpressure; repository-service folds receipts into the document_index_state ledger with an UPSERT that ignores any attempt id not strictly greater than the stored one. Six outcomes are defined, including PARTIAL_SUCCESS ("at least one physical-index write succeeded, at least one failed terminally... distinct from SUCCESS, which would lie about completeness") and SKIPPED, which is explicitly not a failure but a terminal nothing-to-index state.

Governed IndexPlans

An IndexPlan is a Postgres row — one per base index name — describing name, strategy, vector-set membership, and HNSW/index-setting overrides. Its lifecycle is validate → persist PENDING → provision through exactly one creation path (IndexKnnProvisioner) → flip READY or FAILED with a last_error. The indexing hot path never creates indices or mappings: it calls only requireIndex/requireKnnField and fails loudly. Provisioning drops its own positive caches and probes the cluster live before flipping a plan to READY, and an IndexRecipeMatrixTest pins the triangle invariant: the family a plan declares equals what ListPlanIndices enumerates equals what physically exists, every KNN field at its own embedder's dimension. Calling UpdateIndexPlan on a FAILED plan resets it to PENDING and re-runs the idempotent provisioner — that is also the recovery lever after an out-of-band index deletion.

pipestream-protos/opensearch/proto/ai/pipestream/opensearch/v1/index_plan.proto:139

// HNSW + engine knobs. Every field is `optional` — unset means "use the
// manager's server-side default." Defaults:
//   engine          = "lucene"
//   method_name     = "hnsw"
//   space_type      = "cosinesimil"
//   m               = 16
//   ef_construction = 100
//   ef_search       = 100
message HnswParameters {
  optional string engine = 1;
  optional string method_name = 2;
  optional string space_type = 3;
  optional int32 m = 4;
  optional int32 ef_construction = 5;
  optional int32 ef_search = 6;
}

Higher-level governance RPCs include CreateIndexShape/ MaterializeIndexShape ("design a whole index family in one idempotent call") and ValidatePlanProducibility, which walks a pipeline graph to confirm every vector set a plan expects has an upstream producer. Vector sets themselves are recipes — chunker config, embedding model config, source field, and a source_cel selector expression — whose unique identity includes a hash of the source expression: the same recipe over different text is a different vector set. OSM only length-checks source_cel; there is deliberately no CEL compiler in the manager, because expression semantics are the engine's concern.

Centralized naming

Callers supply only the base name (validated [a-z0-9][a-z0-9_-]*, immutable); IndexNaming derives everything else. Chunk side indices are <base>--chunk--<chunkConfigId>, separate-strategy vector indices are <base>--vs--<chunker>--<embedder>, and chunk-combined KNN columns are em_<embeddingModelId>. The same derivation backs provisioning, the ListPlanIndices read model, and delete fan-out, so the three cannot drift. There is no tenant prefix: account scoping is a document field (ownership.account_id). Name derivation is pinned under hostile locales — a unit test runs it under Turkish and Arabic-Indic locales with Locale.ROOT throughout, and the sink's Gradle test task forwards user.language/ user.country into the forked test JVM.

Three physical indexing strategies

Plans choose one of three physical layouts for vectors, defined in opensearch_document.proto:

Honest gap. The NESTED query template is covered by unit and integration test tiers, but the query-writer's guide labels the nested-KNN clause shape as the one path not exercised in its live session. CHUNK_COMBINED and SEPARATE_INDICES are the layouts exercised against live clusters.

Admin search and experiments

AdminSearchService.AdminSearch runs keyword (BM25), semantic, or hybrid queries (SEARCH_MODE_KEYWORD | SEMANTIC | HYBRID), fused by reciprocal rank fusion with rank constant 60 or by weighted scores. Semantic arms embed the query server-side via DJL serving using the same model as the lane; an unreachable embedder fails UNAVAILABLE rather than degrading silently. On top of that sit search experiments — CreateSearchExperiment/RunSearchExperiment run one BM25 arm plus one arm per vector set over the same query and return per-arm and fused results — and experiment groups for query-time A/B switching with no redeploy: CreateExperimentGroup, CloneExperimentGroup, RetireExperimentGroup, SetActiveVariant, and ResolveVariant.

opensearch-manager/docs/query-writers-guide.md — executed live 2026-08-06 over a 1000-document court corpus (14k chunks, two embedding lanes)

$ grpcurl -plaintext -d '{"group":"phase3-embedder-shootout"}' localhost:18103 \
    ai.pipestream.opensearch.v1.AdminSearchService/ResolveVariant
{ "label": "control", "active": true,
  "experiment": { "indexName": "idx-pipeline-crawl-pipeline-crawl-e6642ccf--chunk--sentence-10-3",
                  "fusion": "FUSION_METHOD_RRF", "k": 10 },
  "lanes": [ { "fieldName": "em_ALL_MINILM_L6_V2", "knnQueryPath": "em_ALL_MINILM_L6_V2",
               "chunkerConfigId": "sentence-10-3", "dimensions": 384, ... } ] }

A cosine-space KNN query on that lane returned live hits such as 0.7979 | ...:eddb904a | sentence-10-3 | impose but for the employment relationship. As the Court.... The resolved variant tells the caller exactly which index, fusion method, and lanes to query, so switching an A/B group is a server-side flip, not a redeploy.

Traces are for observation, not decisions

Opensearch-manager also hosts the pipeline-events plane: PipelineEventsService projects the engine's StepExecutionRecord audit stream into monthly pipeline-events-<yyyy.MM> indices and serves GetDocumentTrace, FindDocumentTraces (failures sort first), GetPipelineActivity (per-node rollups with p95 durations), and StreamPipelineEvents (history replay then live fan-out, per-subscriber drop-oldest queues). This is the surface that powers per-document debugging UIs.

The doctrine of record is that eventually-consistent telemetry is for observation only: traces are rendered with freshness hints and never gated on. Quoting the rule from the project AGENTS.md (decision of 2026-07-18): "Code must never gate a state transition on trace data ('is doc X indexed yet?' read from the trace is a race by construction)." Decisions ride authoritative stores — lab drain reads the repository indexing ledger via GetCrawlIndexProgress, and replay eligibility comes from server-side verdicts. The ledger read surface is IndexingLedgerService: GetDocumentIndexState, CheckDocumentsIndexed, ListStuckDocuments, GetIndexingProvenance, StreamLedgerRows, and PurgeIndexState.

Failure in traces is a two-axis read: a document rejected by the parser is stored as status=SUCCESS, disposition=REJECT. The measured consequence is recorded in the implementation: 1023 REJECT rows out of 37928 events would never have surfaced as failures if failure were derived from status alone. DROP and SKIPPED are deliberately not failures — they are filtered documents.

Deletes fan out, with a stale-delete guard

Deletion is event-driven too. A consumer subscribes to document-deleted, asks the ledger where the document ever landed, skips any index whose latest successful write postdates the delete (an out-of-order delete cannot erase a newer re-index), and erases the whole family — the parent by _id plus delete-by-query over the --chunk--*/--vs--* siblings. Failures rethrow and dead-letter to document-deleted-dlq: erasure fails closed. A related guard inside the bulk machinery drops a rejected INDEX whose id was successfully DELETEd in the same batch, because retrying it would resurrect the document.

Operator plane, no authentication. Every gRPC/REST endpoint on port 18103 — including reflection, admin search, document fetch/delete, and traces — is unauthenticated and cross-account by design; it must be network-restricted. The one in-service lockdown is pipestream.admin-search.enabled (default true), which returns PERMISSION_DENIED from all AdminSearchService RPCs; an OIDC role gate is the planned replacement. Separately, the bidi gRPC indexing path (OpenSearchManagerService.StreamIndexDocuments) still exists but is config-gated and non-production — the Kafka topic is the supported path, and only the Kafka path emits receipts.

protomolt-search: an experimental distributed Lucene engine

Alongside the OpenSearch plane, the project develops protomolt-search (repository: distributed-search), an experimental Lucene service for streamed, cross-shard vector search. Its distinguishing feature is collaborative HNSW traversal: shards exchange a monotonically increasing global score floor while a query is running, allowing uncompetitive graph paths to stop earlier. The shared-floor HNSW work exists upstream as an Apache Lucene sandbox pull request (#16357); the engine builds on a published fork, ai.pipestream:lucene-*:11.0.0-experimental-SNAPSHOT.

Status: developer preview. The core algorithms and focused tests exist, but protomolt-search is not yet a supported production service. TLS, replication, failure recovery, a stable public API, pinned (non-snapshot) Lucene artifacts, and production observability remain release blockers. It is not part of opensearch-manager and does not serve the platform's indexing path today.

What is implemented today:

Integration levels differ per surface, and the README says to check before treating an RFC as a live endpoint: the schema compiler and typed query compiler are tested libraries whose v1alpha1 admin and search RPCs are not yet wired; the ai.pipestream.search.v1alpha1 services are proto-and-RFC contract only.

The direction is integration with the platform rather than a second stack: ProtoMolt should own descriptor loading, validation, mapping, compatibility, and protobuf-to-Lucene document projection, while protomolt-search owns shard placement, Lucene lifecycle, distributed execution, and collaborative search. The concrete boundary and migration order are documented in the repository's docs/PROTOMOLT_INTEGRATION.md.

Related