Embedding¶
Built and verified
Embedder, EmbedInput, EmbedOutput (the abstract embedding value),
DenseEmbedding, Space and IncompatibleSpaces (defined in
coniti.embedding; all but EmbedInput are exported from coniti) and the
local_hash and random_projection embedders (in coniti.impl.embedding)
are built and tested, including the embedding algebra (operators on
embeddings) described at the end of this page. The optional dspy embedder
is built, but tested only against a fake dspy module. The exact meaning of
some operators is provisional, see below.
An Embedder gives each message a vector: the state space the rest of the
system works in. The embedding choice is a spec, so each deployment can pick what
it can afford, for example 64 dimensions for real time and 512 for batch.
from coniti import DenseEmbedding, Embedder, EmbedOutput
from coniti.embedding import EmbedInput
from coniti.impl.embedding import LocalHashEmbedder
Contract¶
An embedder subclasses Embedder and implements two abstract members: the dim
property and _embed(EmbedInput) -> EmbedOutput. Callers use the public
methods, which check the contracts around your code:
| Method | Returns |
|---|---|
embed(text_or_EmbedInput) |
An EmbedOutput. A plain string is turned into an EmbedInput. |
embed_many(items) |
A list of outputs. Override _embed_many to batch; the output count must match the input count. |
vector(text) |
The vector as a float64 array of shape (dim,). |
matrix(texts) |
A float64 array of shape (n, dim), or (0, dim) for no texts. |
EmbedInput(text, metadata) carries the text and optional metadata.
embed returns a DenseEmbedding by default: an immutable value that holds the
vector and the space it lives in, with operators (see
the algebra). EmbedOutput is the abstract base of every
embedding kind.
The returned vector is re-checked after _embed, even if an implementation
built the output in a way that skipped validation. It must be a float64 numpy
array of shape (dim,) with only finite values. A wrong shape, NaN, an integer
dtype or a non-array raises ContractError instead of passing through.
Outputs can be extended. A subclass of DenseEmbedding (or of EmbedOutput,
implementing every operator hook) with extra fields, for example a second vector,
is accepted and the extras are kept. The base fields are what the rest of coniti
reads. Operators keep the extras of their left operand (except random_projection, below).
local_hash¶
Kind local_hash, class LocalHashEmbedder. Offline, deterministic, and needs
no model.
| Param | Default | Allowed |
|---|---|---|
dim |
256 | 2 to 65536 |
seed |
0 | any integer |
It lower-cases the text, splits it into \w+ tokens, and feature-hashes each
token with keyed BLAKE2b into one of dim buckets with a +1 or -1 sign. The
result is scaled to unit length.
embedder = LocalHashEmbedder() # dim 256, seed 0
a = embedder.vector("The cat sat")
b = embedder.vector("the cat, sat!") # same tokens after lower-casing
float(a @ b) # about 1.0 (unit vectors, so this is cosine)
- It captures word overlap, not meaning. “car” and “automobile” are unrelated to it.
- Case and punctuation do not matter.
- Text with no word tokens (empty, or only punctuation) maps to the all-zero vector, the one case where the length is not 1.
- The same text,
dimandseedalways give the same vector. A differentseedgives a different space.
| Kind | Status | Notes |
|---|---|---|
local_hash |
Done | As above. The default embedder of the Container. |
random_projection |
Done | See below. |
dspy |
Done, optional extra | Model-backed, lazy import. Tested only against a fake dspy module. See below. |
dspy¶
Verified only against a fake
This embedder was tested with a fake dspy module. It has not been run
against a real dspy installation or a real model, and no network was used.
Kind dspy, class DSPyEmbedder (in coniti.impl.embedding). It embeds text
with dspy.Embedder(model). It is an optional extra:
| Param | Default | Allowed |
|---|---|---|
model |
"openai/text-embedding-3-small" |
non-empty string |
dim |
256 |
integer >= 1 |
- Lazy. Importing the module and building the embedder never import
dspy(it is loaded withimportlibon first use), so thedspykind is registered and configurable even without the extra. The firstembedcreates the client. Ifdspyis missing, that call raisesImportErrortelling you to runpip install 'coniti[dspy]'. - Batched. All texts of one call go to the model in a single call.
- Truncated and normalised. Each returned vector must be one-dimensional with at least
dimvalues; the firstdimare kept (suits models whose leading dimensions are meaningful) and scaled to unit length. - Strict. Non-numeric output, a wrong number of vectors, a vector shorter than
dimor one with NaN or infinity raises aContractError. - Provider credentials and network access are whatever your
dspymodel needs; coniti adds nothing.
random_projection¶
Kind random_projection, class RandomProjectionEmbedder. It wraps another
embedding spec and gives a lower-dimensional view of it, for example 64
dimensions for real time on top of a 512-dimension embedder.
| Param | Default | Allowed |
|---|---|---|
inner |
{kind = "local_hash"} |
any embedding spec (a nested spec, see below) |
dim |
64 | 1 or more, and smaller than the inner embedder’s dim |
seed |
0 | 0 or more |
A dim that is not smaller than the inner dim is refused when the embedder is
built (ConfigError from the container, wrapping a ValueError).
It multiplies the inner vector by a fixed Gaussian matrix of shape
(dim, inner_dim), drawn from the seed once and scaled by 1/sqrt(dim), then
scales the result to unit length. Random projection approximately keeps the
angles between vectors, so cosine similarity in the small space tracks similarity
in the inner space. The rationale for this technique is stated here from general
knowledge; it was not independently sourced.
Two embeddings from one call. embed returns a ProjectedOutput, a
DenseEmbedding with extra fields:
| Field | Meaning |
|---|---|
vector, space |
The projection, in the projected space. This is what the engine and the structures read. |
source_vector |
The inner embedder’s own vector. |
source_space |
The inner embedder’s space. |
Algebra rule. Operators on projected outputs (+ - * / | &, negation, sum(),
mean()) return a plain DenseEmbedding in the projected space. The source vector is
not carried through: projecting and then normalising is not linear, so a
combined source_vector would be meaningless. Comparing with == also compares the
source vector and space. (Operators normally keep the extras of the left operand;
this is the one deliberate exception.)
Nested specs round-trip. inner is a full spec, so it appears in
coniti.toml, in to_dict and in the spec id, and rebuilds identically:
[embedding]
kind = "random_projection"
params = { dim = 64, seed = 3, inner = { kind = "local_hash", params = { dim = 512 } } }
Real output (run against the 0.1.0 release candidate):
from coniti import Container, Embedder
cfg = {"embedding": {"kind": "random_projection", "params": {
"dim": 64, "seed": 3, "inner": {"kind": "local_hash", "params": {"dim": 512}}}}}
e = Container(cfg, search_cwd=False).get(Embedder)
x, y = e.embed("plan the launch"), e.embed("launch checklist")
type(x).__name__ # 'ProjectedOutput'
x.vector.shape, x.source_vector.shape # ((64,), (512,))
x.space # Space(embedding_id='emb-cad856795c37', dim=64)
x.source_space # Space(embedding_id='emb-5660ba0753e2', dim=512)
float(x @ y), float(x.source_vector @ y.source_vector) # 0.414 vs 0.4082 (cosine, projected vs source)
type(x + y).__name__ # 'DenseEmbedding' (no source_vector)
The memory record keeps only the base vector, so a stored record holds the 64
numbers, not the 512 (see the note below).
Known limitation
A memory record stores only the base vector, so the memory file does too:
extra fields from an extended output, such as source_vector, are not
written to it. An embedding itself serialises fully with to_json() (extras
included, see contracts); only the memory record drops them.
Embedding algebra¶
Built and verified; some meanings are provisional
The operators below are built and tested (Requirement 16). Which operations
exist and the rules for ==, sum(), spaces and invalid numbers are settled.
Two decisions are provisional, may change and are marked below.
Embeddings behave as atomic values, so you combine and compare them with Python
operators instead of method calls. Every part of coniti uses the same operations.
The example is real output from the default local_hash embedder (256
dimensions):
from coniti import Container, DenseEmbedding, Embedder
e = Container(search_cwd=False).get(Embedder)
x, y, z = (e.embed(t) for t in ["plan the launch", "launch checklist", "apple pie recipe"])
x.space # Space(embedding_id='emb-74006f08273e', dim=256)
centroid = (x + y + z) / 3 # or: DenseEmbedding.mean([x, y, z])
shifted = x - y
score = x @ y # similarity: 0.4082 (x.similarity(y) is the same)
x @ z # 0.0: no shared words
both = x | y # union
shared = x & y # intersection
size = abs(x) # norm: 1.0 (local_hash vectors are unit length)
total = sum([x, y, z]) # works because 0 + x is accepted
| Operator | Meaning for DenseEmbedding |
Returns |
|---|---|---|
a + b, a - b |
Element-wise sum and difference | embedding |
a * k, k * a, a / k |
Scale by a finite real number k |
embedding |
-a |
Negation | embedding |
a @ b, a.similarity(b) |
Cosine similarity in [-1, 1]; 0 if either is the zero vector | float |
abs(a) |
Euclidean norm (computed with scaling, so huge values do not overflow) | float |
a \| b |
Union (provisional): the unit vector of the sum of the two unit vectors | embedding |
a & b |
Intersection (provisional): per dimension, the smaller magnitude with the shared sign where both signs strictly agree, else 0 | embedding |
sum(items), DenseEmbedding.sum(items) |
Sum; the class method raises ValueError when empty |
embedding |
DenseEmbedding.mean(items) |
Mean, scaling by 1/n first; ValueError when empty |
embedding |
Results keep the space (and any extra fields) of the left operand and are new
immutable values. The abstract EmbedOutput declares each operator as a
protected hook (_add, _sub, _scale, _divide, _neg, _union,
_intersect, _similarity, _norm); the public operator checks the operand
type, the space and the scalar, then calls the hook. A new embedding kind
implements the hooks and decides what the operators mean in its space.
Rules (settled)
-
==is exact. It returns a plainbool: same type, same space, identical vector. Similarity is on@, never on==, because Python treats any non-zero result of__eq__as true, which would breakin,list.indexand!=.hashis consistent with==(and-0.0is stored as0.0), so embeddings work in sets, dicts andin: -
Values are immutable.
x.vectoris read-only; writing to it raisesValueError. - No invalid numbers. A scalar must be a finite real number:
x * Trueandx * "a"raiseTypeError,x * float("nan")raisesValueError,x / 0raisesZeroDivisionError. An operation whose result would overflow to infinity raisesOverflowErrorinstead of returning it (x * 1e308 * 10). sum()andmean()work as shown.statistics.meanis not used because it only accepts real numbers. The default start value0is accepted (via__radd__).embedding + ndarrayis aTypeError. numpy does not take over: useembedding.vectorfor raw arrays.-
Every embedding records its space,
Space(embedding_id, dim): the spec id the registry gave the embedder, and the dimension.Embedder.embedstamps it. Combining embeddings from different spaces raisesIncompatibleSpaces(aContractError): -
The abstract
EmbedOutputcannot be built directly, including from JSON:EmbedOutput.from_dict(...)raisesTypeError. UseDenseEmbeddingor another concrete kind.
Provisional decisions (provisional, may change). Two open questions needed a maintainer answer. Until then the code makes a conservative choice, covered by tests, that can be changed without touching callers of the operators:
- Across spaces (provisional, may change). Operations work only within one space; mixing raises
IncompatibleSpaces(decision a). The alternative is an explicit bridge, such as projecting both embeddings into a shared space. - Union and intersection for dense vectors (provisional, may change). The built meanings are the normalised sum and the sign-agreeing minimum above. The alternative is set operations over the active (non-zero) dimensions. There is no single standard meaning.
Treat the results of | and & as subject to change.