Skip to main content

Embeddings

Internal developers only

The Capture MCP SDK is currently only available to Capture's internal developers. We are working hard on the cybersecurity part, so that we can also offer it to everyone who builds on top of Capture.

Capture can embed text on behalf of your app: EU-hosted models, billed per company against the identity of your app. You do not need an embedding provider of your own, credentials for one or a price table.

The embeddings client calls the app-authenticated embeddings endpoint of Captain with the identity of your app. It needs no MCP server, no callback port and no tools. It does need the address of Captain in your settings: capture_ai_base_url in Python and CaptureAiBaseUrl in .NET. Include any path prefix the deployment puts the AI service under. The routes are appended to the full path you give, so copy the address exactly as you were given it.

Set up and embed​

from capture_mcp_sdk import CaptureAppSettings, CaptureEmbeddingsClient, EmbeddingInputType

async with CaptureEmbeddingsClient(settings) as client:
models = await client.models() # read once at startup
one = await client.embed("bearing 3 running hot", company_id=42)
many = await client.embed_many(texts, company_id=42) # chunked, order preserved
wide = await client.embed_many(texts, company_id=42, dimensions=3072)
query = await client.embed(question, company_id=42, input_type=EmbeddingInputType.QUERY)

You write no token code. The endpoint needs a client_credentials token for the scope of the platform API. The SDK requests it, caches it until shortly before it expires and reads it again on a retry. Do not add a token path of your own: a hand-rolled one forgets the scope, and a token you attach yourself is neither cached nor refreshed.


Read the models at startup​

Read the models and the batch limits from the client. Never hardcode either. It reports what this deployment serves: the model ids, the default, the default and maximum width of each model, and the request limits. Read it once at startup, to create your vector column and to size your own work. The SDK reads it once for each process and reuses it, and it is also what batches are split against. A batch is one usage record, so a constant in your own code would make the same job cost something different depending on the client that ran it.

The limits refuse, they never truncate: a request over a limit is an error, not a silently shortened result. You do not have to split a batch yourself.

models = await client.models() # servable models, default model, widths and limits

Store the model and the width with every vector​

Every response echoes the model and the width it was produced at. Store both, for every row. If the default model or width of the deployment changes, the vectors you stored earlier sit in a different space and return plausible-looking, wrong neighbours, with no error anywhere.

Then pin them when you search. Pinning turns that silent drift into a refusal you can act on, and re-index on.

Store result.model and result.dimensions next to every vector you persist, and pin the pair on later requests with the model and dimensions arguments.


Choose dimensions: mind the pgvector limit​

The width is optional and defaults to the own width of the model. When you do set it, the number has to survive your index, not just your column:

Storage typeHNSW / IVFFlat index limit
vector2000 dimensions
halfvec4000 dimensions

A vector of 3072 dimensions therefore cannot be indexed as vector. Store it as halfvec(3072), or ask for a width of 2000 or less. Nothing complains when you insert the rows. The failure only shows up when CREATE INDEX runs, which is usually long after the embedding bill.

-- 3072 wide: halfvec, or the index will not build
CREATE TABLE note_embedding (
note_id bigint PRIMARY KEY,
model text NOT NULL, -- persist what produced the vector
dimensions integer NOT NULL,
embedding halfvec(3072) NOT NULL
);
CREATE INDEX ON note_embedding USING hnsw (embedding halfvec_cosine_ops);

If your model is matryoshka-style (dimension_reducible on its catalog entry), a smaller width is a genuine trade instead of a downgrade: less storage and a faster index, for slightly less recall.

wide = await client.embed_many(texts, company_id=42, dimensions=3072)

InputType​

Use document when you embed something you will store, and query when you embed the text a user typed to search with. It defaults to document. Models that are asymmetric embed the two roles differently, so using one setting for both costs you recall for nothing. It also selects the role the spend is attributed under.

To calibrate how much this matters today: every model that is served right now is one of the text-embedding-3-* models of OpenAI, which are symmetric. They return the same vector for either setting. The split in the bill is real now. The effect on recall becomes real the moment an asymmetric model is served. Set it correctly either way, because your stored vectors outlive the model that produced them.

In Python this is EmbeddingInputType.QUERY, in .NET CaptureEmbeddingInputType.Query.


Errors​

Branch on the machine-readable code and status. Never parse the message or the detail, which are English fallbacks for developers.

A failure arrives as a CaptureEmbeddingError. It carries the members of the problem document: code (a CaptureEmbeddingErrorCodes constant, identical to the one in the .NET client), status, permitted_dimensions on a refusal of the width and servable_models on a refusal of the model.

codeStatusWhat to do
EMBEDDING_MODEL_NOT_SERVABLE422You pinned a model this deployment will not serve. Pick one of the servable models, and re-index onto it. Do not silently fall back: the spaces differ.
EMBEDDING_DIMENSIONS_UNSUPPORTED422The model cannot produce that width. The permitted dimensions list what it can. Fix the request and your column. Retrying is pointless.
EMBEDDING_INPUT_INVALID422An empty input, or a missing or non-positive company id. Fix the call. Retrying is pointless.
EMBEDDING_REQUEST_TOO_LARGE413The batch went over a published limit. Batches are already split against the limits, so this means one input is over the cap for a single input. Split the text yourself.
(none)401, 403The credentials of the app were rejected, or the client is not linked to a Capture app. Check the client id and secret. The SDK does not retry these.
(none)429, 5xxThrottled or temporary. The SDK retries with exponential backoff that honours Retry-After. If it still shows up, slow your ingest down.

No other 4xx is retried, because a refused model or width is refused the same way the next time. Nothing on any path logs or echoes your input text or your client secret.


Test without spending money​

The mock of each SDK serves both embeddings routes in process. It gives the same refusal codes, the same statuses and deterministic vectors, so the same text gives the same numbers on every run and in both SDKs. There is no network, no Docker and no bill.

from capture_mcp_sdk import CaptureAppSettings, CaptureEmbeddingsClient
from capture_mcp_sdk.identity import CaptureAppIdentity
from capture_mcp_sdk.testing import CaptureEmbeddingsMock, MockEmbeddingLimits, MockEmbeddingModel

mock = CaptureEmbeddingsMock(
models=[MockEmbeddingModel(model="acme/embed", default_dimensions=1024, max_dimensions=3072)],
default_model="acme/embed",
limits=MockEmbeddingLimits(max_inputs=2), # small, so chunking is exercised by 5 inputs
)

settings = CaptureAppSettings(
api_base_url="https://api.example.invalid/",
capture_ai_base_url="https://ai.example.invalid/",
app_client_id="your-app-client",
app_client_secret="not-a-real-secret",
)

async with mock.http_client() as http:
identity = CaptureAppIdentity(settings, http_client=http)
client = CaptureEmbeddingsClient(settings, identity=identity, http_client=http)
batch = await client.embed_many(["a", "b", "c", "d", "e"], company_id=42)

assert len(mock.calls) == 3 # 5 inputs, 2 per call
assert mock.calls[0].inputs == ("a", "b")
assert mock.calls[0].company_id == 42

The mock also answers POST /connect/token, which is why the whole client runs without a network. It is also why the module is for tests only and must never be imported from a path that runs in production.

MemberWhat it does
mock.callsRecords every POST /embeddings: the inputs, the company_id, the model and width you pinned, the model and width that were resolved, and the refusal code when one was given.
mock.fail_with(...), mock.fail_next_with(...)Drive your error handling, for example with MockEmbeddingFailure.problem(422, MockEmbeddingErrorCodes.MODEL_NOT_SERVABLE), MockEmbeddingFailure.too_many_requests(retry_after_seconds=2) or MockEmbeddingFailure.status_only(502). fail_next_with works once, so the call after it succeeds. That is how you watch a retry recover.
with mock.serve() as server:Starts a real loopback HTTP server, for code that only takes a base URL. server.base_url is both api_base_url and capture_ai_base_url.