Make Chunking and Embedding usable against remote embedding APIs, and add a Reranking package - #3
Merged
AdamTovatt merged 5 commits intoAug 12, 2026
Conversation
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
marked this pull request as ready for review
August 12, 2026 08:28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2, parts 1 to 5. Part 6 (tagging and publishing) is deliberately not here — nothing is tagged and nothing is published.
Versions
VectorSharp.ChunkingMaxTokensPerChunkis now a bound, and enumerating a reader twice throws. No signature changed.VectorSharp.EmbeddingVectorSharp.RerankingVectorSharp.StorageandVectorSharp.Embedding.NomicEmbedare untouched. NomicEmbed implementsIEmbeddingProviderand 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.Createwidens fromStreamReadertoTextReader. TheStreamReaderoverload stays:chunking-v1.0.0is on nuget.org, and assemblies compiled against it bind that exact signature in IL, so removing it would throwMissingMethodExceptionat run time even though their source still compiles.ReadChunksAsyncyieldsTextChunkcarryingText,StartOffsetandTokenCount;ReadAllAsyncis 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)
EmbedBatchAsyncandEmbedBatchWithUsageAsyncare default interface implementations onIEmbeddingProvider, so an existing provider keeps working untouched. A provider advertisesSupportsBatching, 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:Versionfrom the tag, which overrides<Version>for every project. On a release build they looked for a.nupkgthe 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.Testswith aProjectReferenceto every packable project, so running them builds the packages they inspect. The package set is read fromVectorSharp.slnxrather 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 intoBatchingPolicyandEmbeddingUsage.Combinewhere they can be tested without a worker pool; the duplicated segment-and-push-back blocks inSegmentReaderbecome 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,ChunkReaderOptionsand the README.ChunkSplitterfinds 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
MaxTokensPerChunkand 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
StartOffsethold 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.Testsself-skipping itsRequiresModelcases because the.onnxmodel 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:
binunpinned makes a Release run validate a broken Debug artifact.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.