Skip to content

Make Chunking and Embedding usable against remote embedding APIs, and add a Reranking package - #3

Merged
AdamTovatt merged 5 commits into
masterfrom
2-make-chunking-and-embedding-usable-against-remote
Aug 12, 2026
Merged

AdamTovatt merged 5 commits into
masterfrom
2-make-chunking-and-embedding-usable-against-remote

Conversation

@AdamTovatt

@AdamTovatt AdamTovatt commented Aug 12, 2026 •

Copy link
Copy Markdown
Owner

Closes #2, parts 1 to 5. Part 6 (tagging and publishing) is deliberately not here — nothing is tagged and nothing is published.

Versions

Package Version Why
VectorSharp.Chunking 1.0.0 → 2.0.0 Two behaviour changes for existing callers: MaxTokensPerChunk is now a bound, and enumerating a reader twice throws. No signature changed.
VectorSharp.Embedding 1.0.3 → 1.1.0 Purely additive: batching and usage reporting arrive as default interface implementations, so every existing provider compiles and behaves unchanged.
VectorSharp.Reranking — → 1.0.0 New package.

VectorSharp.Storage and VectorSharp.Embedding.NomicEmbed are untouched. NomicEmbed implements IEmbeddingProvider and is the reason the no-breaking-changes rule was load-bearing throughout; its test results are identical to before the branch.

The commits

accept any text reader and yield chunks that know their position (parts 1 and 2)

ChunkReader.Create widens from StreamReader to TextReader. The StreamReader overload stays: chunking-v1.0.0 is on nuget.org, and assemblies compiled against it bind that exact signature in IL, so removing it would throw MissingMethodException at run time even though their source still compiles. ReadChunksAsync yields TextChunk carrying Text, StartOffset and TokenCount; ReadAllAsync is unchanged and delegates to it. Offsets come from the segment reader's own cursor, so lookahead pushed back into the buffer is not counted until the segment holding it is returned.

batch texts into single provider requests and report token usage (parts 3 and 4)

EmbedBatchAsync and EmbedBatchWithUsageAsync are default interface implementations on IEmbeddingProvider, so an existing provider keeps working untouched. A provider advertises SupportsBatching, defaulting to false: one that has not overridden the batch methods gains nothing from grouping, since the defaults loop the single-text method and a group would run serially inside one worker instead of spreading across the pool. That is what keeps a local ONNX model on exactly today's fan-out.

Usage is optional and never fabricated. A provider that meters nothing reports null, not zero, and a call split across batches reports a total only when every batch reported one — a partial total would understate spend, which is the failure that matters when the caller is billed. A reported zero survives as a reported zero.

Found while chasing a hung test: a batch left in the channel when disposal stopped the workers stranded its caller forever. It is now failed with ObjectDisposedException.

add the VectorSharp.Reranking package (part 5)

IRerankProvider.RerankAsync(query, documents, topN, ct). Matches carry the index into the caller's list plus a score, not the text: the caller holds whatever sits behind each candidate — a row id, a chunk offset, a file path — and returning text would force a lookup by content, which is ambiguous when two candidates read the same. Same optional-usage shape as embedding. No service, queue or worker pool: reranking is one call per user query against a candidate set already in hand. Zero dependencies, including on its sibling packages.

fix the packaging tests and apply the whole-branch review

The packaging tests read the version from the project file, but the publish workflow builds and packs with -p:Version from the tag, which overrides <Version> for every project. On a release build they looked for a .nupkg the build never wrote, and would have failed the next publish. Running the workflow's exact invocation reproduces it. The version now comes from the package's own built assembly, stamped from the same property however the build was invoked.

Those tests moved to VectorSharp.Packaging.Tests with a ProjectReference to every packable project, so running them builds the packages they inspect. The package set is read from VectorSharp.slnx rather than a hand-kept list, so a package added later is covered by default and one with legitimate dependencies has to be named with its reason.

Also here: disposal releases providers and queued callers from a finally; the batching and usage-combination rules move into BatchingPolicy and EmbeddingUsage.Combine where they can be tested without a worker pool; the duplicated segment-and-push-back blocks in SegmentReader become one helper.

make MaxTokensPerChunk a bound rather than a target

Through 1.x, a segment with no break point inside it was emitted whole however far over the limit it was, and that was documented as a carve-out. It was a defect: the chunks go to a model with a fixed context window, which rejects or silently truncates one that is too long, and a truncated chunk is a piece of the document that is no longer searchable with nothing in the result to say so. Such a segment is now cut at the limit, and the carve-out is deleted from TextChunk, ChunkReaderOptions and the README.

ChunkSplitter finds where to cut by measuring candidate prefixes with the caller's token counter. Every length it returns is one it measured, never one interpolated between two probes, so a tokenizer that is not perfectly monotonic in the length of its input — a byte-pair encoder can charge less for "the" than for "th" — still yields pieces that genuinely fit.

The assumption the bound rests on, stated because it is the honest part: the search probes outward and stops at the first piece that does not fit, so it assumes a counter reporting over the limit for a short piece will not report under it for a longer one. Every real tokenizer satisfies that. A counter that violates it — one charging more for a single character than the whole limit allows — gets single-character chunks over the limit, because the alternative is cutting a character in half. That case is documented on MaxTokensPerChunk and tested for what still holds there: termination, round-trip and offsets.

The pieces concatenate back into the segment they came from and each carries its own offset, so round-tripping and StartOffset hold across a cut. A cut never falls between the two halves of a surrogate pair. It does land mid-word for any subword or character counter, which is what a real embedding model uses — the README says so plainly, with an example pinned by a test rather than asserted.

Verification

Full suite: 390 passed, 0 failed, 32 skipped. The skips are VectorSharp.Embedding.NomicEmbed.Tests self-skipping its RequiresModel cases because the .onnx model files are not in the tree — unchanged from the baseline before this branch.

Each check was made to fail before being trusted, and doing so found three defects that had already passed a review round:

  • The packaging test's version anchor: reading it from the project file fails 9 tests under the workflow's own build command, and searching bin unpinned makes a Release run validate a broken Debug artifact.
  • The cut could divide a surrogate pair in half at odd limits — found because the test guarding against it still passed with the guard removed.
  • Cutting re-sliced the remainder per piece, quadratic in the chunk; caught by an allocation assertion that fails at 4.5 MB against a 20 KB input.

The last review round found a fourth, in documentation rather than code: the non-monotonic-counter test never entered the cut path, and the property it claimed was false. Both are corrected above, and the guarantee is now stated with its assumption instead of as an absolute.

ChunkReader.Create and SegmentReader take a TextReader, so text already in
memory no longer needs a MemoryStream wrapper. The StreamReader overload stays
beside it for binary compatibility with the published chunking-v1.0.0.

ReadChunksAsync yields TextChunk values carrying StartOffset and TokenCount,
and ReadAllAsync is now the string-only view of the same pass. Offsets come
from the segment reader's own cursor rather than a total summed afterwards,
and a second enumeration of a reader throws instead of silently dropping the
segment the abandoned pass had already consumed.
IEmbeddingProvider gains EmbedBatchAsync and EmbedBatchWithUsageAsync as
default interface implementations, so existing providers keep compiling and
behaving as they did. A provider says whether it batches through
SupportsBatching, which is false unless it says otherwise: one that embeds a
text at a time keeps getting one text per unit of work, spread across workers,
rather than a batch looped serially inside one of them.

EmbeddingRequest now carries a whole batch, so a batch is one provider call.
EmbeddingServiceOptions gains the two limits hosted APIs impose, a maximum
number of texts and a maximum number of characters per request.

EmbedWithUsageAsync and EmbedBatchWithUsageAsync return the vectors together
with what the provider reported spending. Usage is null when nothing was
reported and is never defaulted to zero, including when only some of the
batches a call was split into reported anything.

Also carries the caller's cancellation token through to the provider call, so
cancelling stops the request being billed rather than only releasing the
caller, and fails any batch still queued when the service is disposed, which
previously left its caller awaiting a result no worker would produce.
IRerankProvider takes a query, a candidate list and a topN, and returns
matches carrying the index into that list plus a score rather than the
document text, so the caller can get back to whatever sits behind each
candidate. Usage is optional and null when the provider meters nothing,
matching VectorSharp.Embedding: null means unknown, never free.

No service type: reranking runs one call per query against a list the
caller already holds, so there is nothing to queue or coalesce.

Wired into the solution, the publish workflow, Publishing.md and the
root README. Packaging is asserted from the nuspec inside the built
nupkg rather than from csproj text, for every package that claims no
dependencies.

Also fixes CS1587 in the committed IEmbeddingProvider docs, where a
comment between the summary and the param tags detached them.
The packaging tests anchored on the project file's <Version>, but the
publish workflow builds and packs with -p:Version taken from the tag,
which overrides <Version> for every project. On a release build the
tests looked for a .nupkg the build never wrote and failed, blocking
the publish. The version now comes from the package's own built
assembly, which is stamped from the same property however the build was
invoked, and the configuration is pinned to the one the tests were
built in so a Release run cannot validate a leftover Debug artifact.

Those tests move to VectorSharp.Packaging.Tests, a project of their own
with a ProjectReference to every packable project, so running them
builds the packages they inspect instead of depending on something else
having built the solution first. The set of packages comes from the
solution file rather than a hand-kept list: a package added later is
covered by default, and one that legitimately has dependencies has to
be named with its reason.

Elsewhere, from the same round:

- Disposal disposes the providers and releases the queued callers from
  a finally, so a worker failing in a way that is not a cancellation
  cannot leak a native session.
- The batching policy and usage combination move out of EmbeddingService
  into BatchingPolicy and EmbeddingUsage.Combine, where the rules can be
  tested without a worker pool.
- The worker's third OperationCanceledException catch, which treats a
  provider cancelling its own token as a failure, now has a test.
- SegmentReader's two identical segment-and-push-back blocks become one
  helper, and LastSegmentStartOffset is pinned directly.
- The provider double's vector identifies its text rather than colliding
  for anagrams, which the order assertions rested on.
- Docs: the chunking example batches instead of embedding a chunk at a
  time, unbounded segments are called out, and Publishing.md says the
  tag decides the published version.
Through 1.x a segment with no break point inside it was emitted whole
however far over the limit it was. The consumer feeds chunks to a model
with a fixed context window, which rejects or silently truncates one
that is too long, and a truncated chunk is a piece of the document that
is no longer searchable with nothing in the result to say so. Such a
segment is now cut at the limit.

ChunkSplitter finds where to cut by measuring candidate prefixes with
the caller's token counter, searching outward from the smallest piece
and then narrowing. Every length it returns is one it measured, never
one interpolated between two probes, so a tokenizer that is not
perfectly monotonic in the length of its input - a byte-pair encoder
can charge less for "the" than for "th" - still yields pieces that
genuinely fit. What the bound does rest on is that a counter reporting
over the limit for a short piece will not report under it for a longer
one, since the search stops probing upward at the first piece that does
not fit. Every real tokenizer satisfies that; the documentation says so
rather than claiming the bound is absolute.

The pieces concatenate back into the segment they came from and each
carries the offset of its own text, so round-tripping and StartOffset
hold across a cut. A cut never falls between the two halves of a
surrogate pair. It does land mid-word for any subword or character
counter, which is what a real embedding model uses, and the README now
says so plainly rather than implying the cut finds a boundary.

Both halves of the search are bounded rather than linear in the input:
probing doubles outward from the answer instead of searching the whole
remainder, and cutting walks an index instead of re-slicing what is
left. Either one left unbounded is quadratic in the length of a chunk.
The cancellation token is observed once per piece, since cutting one
long segment can be tens of thousands of pieces inside a single pass of
the reader's loop.

Chunking goes to 2.0.0: the cut is a behaviour change for anyone whose
input reached it. No signature changed.
@AdamTovatt
AdamTovatt marked this pull request as ready for review August 12, 2026 08:28
@AdamTovatt
AdamTovatt merged commit 36b482e into master Aug 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make Chunking and Embedding usable against remote embedding APIs, and add a Reranking package

1 participant