Conduit processors for AI pipelines: chunking + embedding with pluggable providers (OpenAI,
Voyage, Ollama, Cohere). Part of Conduit v0.20 (WS8) —
see docs/design-documents/20260724-ai-pipeline-components.md in ConduitIO/conduit for the
full design.
Two processors, two packages, one repo (design doc §3/§4, Open Questions §1 — kept together since they're always deployed as a pair in the canonical RAG pipeline):
ai.chunk(chunk/) — splits a record's text into N chunk records. Pure in-memory, no host capability. See "ai.chunk — chunking processor" below.ai.embed(embed/) — generates vector embeddings via a pluggable provider, using the host-mediated network-egress capability. See "ai.embed — embedding processor" below.
The RAG pipeline's first stage: Postgres CDC → chunk → embed → pgvector. This is the
first reviewable slice: all three strategies (fixed_size, sentence, recursive), the
fan-out/chunk_id/metadata contract, and tombstone handling are implemented and tested. Not in
this slice: acceptance tests and the end-to-end RAG-sync template (see "Slicing note" below).
Selected via the strategy config key; see chunk/doc.go and chunk/span.go for the full
algorithm documentation.
| Strategy | What it does | Honors overlap? |
|---|---|---|
fixed_size (default) |
A sliding window of chunkSize runes, advancing by chunkSize - overlap runes each step. |
Yes — the only strategy that does. |
sentence |
Splits on sentence-ending punctuation (., !, ?) followed by whitespace or end-of-text — a dependency-free heuristic, not an NLP tokenizer (no abbreviation handling). Sentences are packed greedily up to chunkSize without ever splitting one; a single sentence longer than chunkSize becomes its own oversized chunk rather than being cut mid-sentence. |
No — natural boundaries, never repeated. |
recursive |
Tries paragraph (\n\n) boundaries first; any piece still over chunkSize is recursively split on sentence boundaries, then word (whitespace) boundaries, then — only if a single word still exceeds chunkSize — hard-split at the rune level. Unlike sentence alone, every chunk is guaranteed to fit chunkSize. |
No — natural boundaries, never repeated. |
Offsets and chunkSize are Unicode characters (runes), never bytes. Every strategy converts
its input to []rune exactly once and only ever slices that rune slice, so a chunk boundary can
never land inside a multi-byte character — see chunk/span_test.go's unicode tests.
One input record produces zero, one, or many output records:
- Empty input text → zero chunks (
sdk.MultiRecord{}, equivalent to a filter — nothing to embed downstream). - Non-empty text → one output record per chunk, in order. Each chunk record carries:
-
The chunk's text under a NAMED
"text"field of aStructuredDatapayload (outputField, default.Payload.After.text) — never raw bytes. This is the composable RAG record shape: the siblingai.embedprocessor's own defaultinputField(.Payload.After.text) reads this field directly, and its defaultoutputField(.Payload.After.vector) adds the embedding vector alongside it without clobbering the text, so the canonicalchunk → embed → pgvectorpipeline needs zeroinputField/outputFieldconfiguration by default (seeai.embed's config reference and the example pipeline below). -
Keyset to the chunk'schunk_id(see below) — gives a destination connector's default upsert-by-Keybehavior the right identity for free. -
Metadata (exact keys — this is the cross-component contract the pgvector destination consumes):
Key Value ai.chunk.idThe deterministic chunk_id:{source_record_key}:{chunk_index}(0-based).ai.chunk.source_keyThe source record's Key, stringified.ai.chunk.indexThe chunk's 0-based index among its source record's chunks. ai.chunk.offsetThe chunk's start offset in the source document, in runes. ai.chunk.lengthThe chunk's length, in runes.
-
chunk_id is deterministic, never random or time-based — derived solely from the source
record's Key and the chunk's 0-based index. Two independent runs over an identical source
record produce byte-for-byte identical chunk_ids; this is what makes the downstream pgvector
upsert idempotent under retry/redelivery (design doc §6). TestProcessor_DeterministicChunkIDs
in chunk/processor_test.go is the test that proves this property — it runs two fresh
Processor instances (simulating two independent attempts) over the same record and asserts
identical chunk_ids in the same order.
A source record with no Key cannot produce a stable chunk_id. Rather than fabricate one
(which would silently break the idempotency guarantee above), this is a coded, per-record error
(ai.chunk_missing_source_key) routed to the pipeline's DLQ/error policy — never a silent drop,
never a random fallback ID.
A delete of the source record (Operation == OperationDelete) is never chunked. Instead the
processor re-emits the exact same record — same Operation, Key, Payload (still a normal
opencdc delete any destination understands on its own) — with one addition:
ai.chunk.source_key metadata naming the deleted row.
This is deliberately not a per-chunk_id delete: a single source row can have produced a
different number of chunks at different points in its history (the document shrank or grew
between edits), so this processor doesn't know — and doesn't guess — the full set of chunk_ids
that need deleting. The vector destination resolves "every chunk_id ever derived from this
source_key" itself by matching on the ai.chunk.source_key column (design doc §5). A tombstone
with no Key is a coded error (ai.chunk_missing_source_key), the same as any other record —
never silently dropped.
| Key | Type | Default | Description |
|---|---|---|---|
strategy |
string | fixed_size |
fixed_size | sentence | recursive. |
chunkSize |
int | 1000 |
Target maximum chunk size, in runes. |
overlap |
int | 100 |
Trailing runes repeated at the start of the next chunk. Only honored by fixed_size; must be < chunkSize for that strategy (Config.Validate, enforced at Configure time). |
inputField |
string | .Payload.After |
Record field read as the text to chunk. |
outputField |
string | .Payload.After.text |
Record field each chunk's text is written to, under a named "text" key of a StructuredData payload — see the fan-out section above. |
version: "2.2"
pipelines:
- id: rag-chunk-example
status: running
connectors:
- id: source
type: source
plugin: builtin:generator
settings:
format.type: raw
format.options.text: "a longer document to split into chunks..."
- id: destination
type: destination
plugin: builtin:log
processors:
- id: chunk
plugin: standalone:ai-chunk # installed via `conduit processors install ai-chunk@<version>`
settings:
strategy: recursive
chunkSize: "500"
- id: embed
plugin: standalone:ai-embed
settings:
openai.authSecretRef: openai-api-key
model: text-embedding-3-small
# inputField/outputField are intentionally left at their defaults here:
# ai.chunk's default outputField (.Payload.After.text) and ai.embed's
# default inputField (.Payload.After.text) / outputField
# (.Payload.After.vector) already compose — no field configuration
# needed to wire chunk -> embed -> a pgvector destination (whose own
# default vectorField is "vector").- Acceptance tests. This slice has unit tests covering every strategy (including overlap,
boundary cases, and unicode/multibyte correctness), determinism, fan-out, tombstone handling,
and record shapes — a
conduit-connector-sdk-style acceptance suite is a follow-up. - The bundle end-to-end test. Postgres CDC → chunk → embed → pgvector, CI-tested with records
asserted at the vector store (design doc §8/Testing) — depends on
conduit-connector-pgvector, out of scope here. - Token-count-aware chunking.
chunkSizeis a rune count, not a model-specific token count — matching the design doc's "character count" framing forfixed_size(§3). A token-aware variant is not in this slice.
This is the first reviewable slice of the embedding processor. Scope:
- The
ai.embedstandalone-WASM processor, usingconduit-processor-sdk's host-mediated network-egress capability (egress.Do) for outbound HTTP — a WASI Preview 1 guest has no socket API of its own. - All four providers the design doc names are implemented: OpenAI
(
POST /v1/embeddings), Voyage (POST /v1/embeddings, OpenAI-shaped), Cohere (POST /v1/embed, object-formembeddings.float), and Ollama (local,POST /api/embeddings, one input per call — see the config table'sollama.baseURLrow). Each is a hand-rolled-JSON adapter overegress.Do(see below). - The chunking processor is not part of this slice.
The design doc's provider table frames OpenAI/Cohere's zero-new-dependency justification around
reusing the already-vendored go-openai/cohere-go clients the core engine's built-in
openai.embeddings/cohere.embed processors use. That justification does not carry over here:
this processor is a standalone WASM guest (WASI Preview 1, no socket API — the entire reason
the host-egress capability exists), and every vendor SDK does its own net/http dialing
internally, which cannot run inside the guest sandbox. The only coherent implementation is:
hand-roll each provider's request/response JSON as thin structs (the endpoints are simple
JSON-in/JSON-out) and call egress.Do for the actual transport, which the host performs under
its allowlist/DNS-rebinding/timeout/size-cap policy. See embed/openai.go, embed/ollama.go, and
embed/doc.go.
- Batching is strictly within one
Processcall, never across calls. The processor sub-batches only the records the engine hands it in the call currently executing into as few provider calls asmaxTextsPerBatch(clamped to the provider's own limit) allows. It never accumulates records across separateProcessinvocations — seeembed/doc.gofor why cross-call accumulation is an ack-correctness hazard, not merely a simplification. - A full-batch provider failure fails every record in that batch. No record is embedded or
passed through with a placeholder vector; every record becomes an
ErrorRecordcarrying a codedai.embedding_provider_error, so the pipeline's configured DLQ/retry policy applies. Nothing is silently dropped or acked. - A partial-batch result is honored 1:1. If a provider's response indicates some inputs succeeded and others failed within the same call, this processor never widens the failure to the whole batch and never silently drops the failed ones — each record's outcome is reported individually.
- A record whose configured
inputFieldcan't be resolved never reaches the provider at all and fails on its own (ai.embedding_field_resolution_error), without affecting its sub-batch siblings. - 429 / rate-limit responses get bounded exponential backoff, honoring a
Retry-Afterheader when the provider sends one. ExhaustingmaxRetriessurfacesai.embedding_provider_error— the batch is not silently dropped or acked. A non-429 4xx (e.g. a bad API key) is not retried — it fails fast so a misconfigured pipeline doesn't burn its retry budget. tokensUsedmetadata is never estimated. OpenAI (usage.total_tokens), Voyage (usage.total_tokens), and Cohere (meta.billed_units.input_tokens) all report token usage once per call, not per input, so for a sub-batch of more than one record thetokensUsedmetadata is the whole sub-batch's token count, verbatim from the provider, taggedai.embedding.tokensUsedScope: batch— not divided per record. SummingtokensUsedacross records in the same sub-batch will over-count; group by(provider, model, tokensUsedScope)and dedupe by batch if you need an accurate per-pipeline total. A single-record sub-batch getstokensUsedScope: record, which is exact for that one record. Ollama's/api/embeddingsreports no usage figure at all (and Cohere may omitbilled_unitson some plans), so such a record gets notokensUsed/tokensUsedScopemetadata — never a fabricated0.- Ollama accepts exactly one input per call.
maxTextsPerBatchis clamped to Ollama's provider-reported ceiling of 1 (ollamaProvider.MaxBatchSize), so the sub-batcher issues onePOST /api/embeddingscall per record for this provider — expected behavior given the vendor API's shape, not a missed batching optimization.
| Key | Type | Default | Description |
|---|---|---|---|
provider |
string | (empty) | Explicit provider: openai | voyage | cohere | ollama. See Resolution below. |
model |
string | (empty, required once resolved) | Provider embedding model, e.g. text-embedding-3-small. |
inputField |
string | .Payload.After.text |
Record field read as the text to embed — matches ai.chunk's default outputField so the two processors compose with no field configuration. |
outputField |
string | .Payload.After.vector |
Record field the embedding vector (a native numeric array, never a JSON-encoded byte string) is written to, alongside the preserved inputField text. Also the field conduit-connector-pgvector's destination reads by default (its own vectorField config defaults to "vector"). |
maxTextsPerBatch |
int | 96 |
Sub-batch ceiling within one Process call, clamped to the provider's own per-request limit. |
requestTimeout |
duration | 30s |
Per host-mediated HTTP call deadline. |
maxRetries |
int | 5 |
Max retry attempts per sub-batch on 429/5xx before failing with ai.embedding_provider_error. |
retryBackoff.min |
duration | 500ms |
Backoff floor when no Retry-After header is present. |
retryBackoff.max |
duration | 30s |
Backoff ceiling. |
retryBackoff.factor |
float | 2 |
Exponential backoff multiplier. |
openai.authSecretRef |
string | (empty) | Name of a host-managed secret holding the OpenAI API key. Never a raw key value — see Credentials below. |
openai.baseURL |
string | https://api.openai.com |
OpenAI API base URL override. Must be within the pipeline's egress allowlist. |
voyage.authSecretRef |
string | (empty) | Name of a host-managed secret holding the Voyage API key. Never a raw key value. |
voyage.baseURL |
string | https://api.voyageai.com |
Voyage API base URL override. Must be within the pipeline's egress allowlist. |
voyage.inputType |
string | document |
Voyage input_type: document for stored RAG chunks, query for query-side embedding. Empty omits the field. Mismatching it degrades retrieval quality without erroring — see below. |
voyage.outputDtype |
string | float |
Voyage output_dtype. This slice supports float only (pgvector's float path); int8/binary are out of scope. |
cohere.authSecretRef |
string | (empty) | Name of a host-managed secret holding the Cohere API key. Never a raw key value. |
cohere.baseURL |
string | https://api.cohere.com |
Cohere API base URL override. Must be within the pipeline's egress allowlist. |
cohere.inputType |
string | search_document |
Cohere's required input_type, validated against search_document | search_query | classification | clustering. Use search_document for stored RAG chunks; mismatching it degrades retrieval quality without erroring — see below. |
ollama.baseURL |
string | (empty, defaults to http://localhost:11434 once ollama is selected) |
Local Ollama server base URL. No auth secret — Ollama takes no API key. Must resolve within the pipeline's egress allowlist as an explicit (IP,port) carve-out for a loopback/private target. |
Mirrors conduit generate's provider resolution (design doc §2):
- Explicit config:
providerkey. - Explicit via environment:
CONDUIT_EMBED_PROVIDER. - Auto-detect exactly one candidate: a provider is a candidate if its
<provider>.authSecretRef(or, for Ollama,ollama.baseURL) config field is set. Zero candidates →ai.no_provider_configured. More than one →ai.ambiguous_provider_configuration.
Auto-detection is judged on config-level signals, not credential values or environment variables for a provider's own API key — see the note on credentials below for why.
openai.authSecretRef (and likewise voyage.authSecretRef, cohere.authSecretRef) names a
secret; Conduit's host resolves it and injects it as the Authorization header immediately before
dispatch. The processor never sees the raw API key — this is a property of the underlying
host-egress capability (conduit-processor-sdk's egress package), not something this processor
opts into. There is no config path for a guest-supplied credential value.
ollama has no credential config at all: a local Ollama server takes no API key, so
ollamaProvider never sets AuthSecretRef on its egress.Request. Reachability instead depends
on the pipeline's egress allowlist granting the server's (IP,port) as an explicit carve-out.
input_typeis a semantic, not a structural, setting. Voyage'svoyage.inputTypeand Cohere's (required)cohere.inputTypetell the model whether it's embedding stored documents or a query. This processor embeds chunks that will be stored, so both default to the document side (document/search_document). Setting the query side on a document-embedding pipeline produces valid vectors of the right dimension — nothing errors — but they live in the wrong semantic space and silently degrade retrieval quality. That's why these are explicit, defaulted, validated config keys, not hidden constants. Cohere additionally requiresinput_type(its v3 models400without it); an invalid value is rejected at startup with a codedai.invalid_configerror namingcohere.inputType.- Model → embedding dimension must match your pgvector column. The downstream
conduit-connector-pgvectordestination validates the configureddimensionagainst the targetvector(N)column at startup and refuses to start on mismatch (ai.vector_dimension_mismatch) — it never pads or truncates. This processor reports the true dimension it produced inai.embedding.dimensionmetadata; size yourvector(N)column to the model's output dimension. Common defaults: OpenAItext-embedding-3-small= 1536,text-embedding-3-large= 3072; Voyagevoyage-3.5= 1024,voyage-3-lite= 512; Cohereembed-english-v3.0/embed-multilingual-v3.0= 1024,embed-*-light-v3.0= 384. Verify against current provider docs before sizing.
On success, each record gets:
- The embedding vector as a native array of
float64elements ([]anyin Go terms — not ajson.Marshal'd byte string) atoutputField, default.Payload.After.vector, alongside the original text still atinputField(default.Payload.After.text). The native-array shape is deliberate: a[]bytevalue set on a nested structured field survives entirely in-process but is silently corrupted once the record crosses a protobuf boundary (the WASM guest↔host boundary this processor always runs behind, or a destination gRPC boundary downstream) —structpb(google.protobuf.Struct, whichopencdc.StructuredData.ToProtouses) has no "bytes" leaf kind and base64-encodes a[]byteinto aSTRINGinstead, which a vector destination likeconduit-connector-pgvector'sinternal.ParseVectordoes not accept. A[]anyoffloat64becomes astructpbListValueofNumberValues, which round-trips losslessly and is exactly the shapeParseVectordocuments accepting. - Metadata:
ai.embedding.provider,ai.embedding.model,ai.embedding.dimension,ai.embedding.tokensUsed(only when the provider reported usage — see the tokensUsed note above),ai.embedding.tokensUsedScope(recordorbatch).
version: "2.2"
pipelines:
- id: rag-embed-example
status: running
connectors:
- id: source
type: source
plugin: builtin:generator
settings:
format.type: structured
format.options.text: "sample chunk text"
- id: destination
type: destination
plugin: builtin:log
processors:
- id: embed
plugin: standalone:ai-embed # installed via `conduit processors install ai-embed@<version>`
settings:
openai.authSecretRef: openai-api-key # configured separately as a Conduit secret
model: text-embedding-3-small
# inputField/outputField left at their defaults (.Payload.After.text /
# .Payload.After.vector) — set explicitly only if the upstream
# processor doesn't follow ai.chunk's default output shape.
maxTextsPerBatch: "96"# Host-arch build, for running tests (both processors):
go build ./...
# The real target — standalone WASM, one binary per processor:
GOOS=wasip1 GOARCH=wasm go build -tags wasm -o chunking.wasm ./cmd/chunking
GOOS=wasip1 GOARCH=wasm go build -tags wasm -o embedding.wasm ./cmd/embedding
go vet ./...
go test -race ./...ai.chunk (chunk/) needs no host capability and no egress dependency — only ai.embed
(embed/) uses the network-egress capability described below.
go.mod currently has a replace pointing at a local, unreleased checkout of
conduit-processor-sdk's feat/wasm-host-egress branch — the egress package ai.embed
depends on isn't tagged yet (ai.chunk doesn't use it and is unaffected by this note).
This must be repointed to a tagged conduit-processor-sdk release before this repo's PR
merges. A .golangci.yml exclusion scoped to go.mod documents this; remove both the
replace and the exclusion together once a tagged SDK release ships the egress package.
- Acceptance tests. Now in this repo (
embed/acceptance_test.go): a processor-specific contract suite — a provider matrix (openai/voyage/cohere/ollama driven end-to-end against a mock egress) plus a record-shape matrix (raw/structured/tombstone/unresolvable-field) — becauseconduit-processor-sdkships no generic processor acceptance harness (unlikeconduit-connector-sdk). A live smoke tier (embed/live_test.go) hits the real vendor endpoints, gated behind thelive_embedbuild tag AND a per-provider key — run it withgo test -tags live_embed ./embed/...and the keys set; a plaingo test ./...never compiles it, so an ambient key can't turn the default run into a real paid call. Still deferred: a generic SDK processor harness (sdk.ProcessorAcceptanceTest) — a Tier-1 SDK design tracked separately, not coupled here — and a non-blocking live CI job; the mock tier is the gate, the live tier is a smoke test. - The bundle end-to-end test. Postgres CDC → chunk → embed → pgvector, CI-tested with records
asserted at the vector store (design doc §8/Testing) — depends on the chunking processor and
conduit-connector-pgvector, both out of scope here. - Reduced-dimension / quantized embeddings. Voyage/Cohere Matryoshka
output_dimensionandint8/binarydtypes are out of scope; this slice pins float and each model's default dimension (see "Input type and vector dimension" above). - Fuzz targets for the provider response parsers (
parseIndexedEmbeddings,parseCohereResponse) — a Phase-1 fuzz-gate follow-up, noted, non-blocking. - Metrics/observability wiring (
conduit_embedding_tokens_total,conduit_embedding_call_duration_seconds, design doc §9) — record-leveltokensUsedmetadata ships in this slice; the pipeline-level metrics counters are a host/engine-side follow-up.