Advanced background

Know Your Embedding Model

Every threshold in Pelorus — query matching, chunk fallback, cluster proximity — is expressed in the units of one specific embedding model. This page explains how modern embedding models actually behave, so you can tune those settings deliberately, know what to expect from the default model, and know exactly what to re-check if you integrate a different one.

Why this matters for tuning. Similarity scores are not portable. A threshold of 0.80 means one thing for one model and something entirely different for another. Numbers on this page and in the default config are empirical properties of a model, not universal constants.

What a similarity score really is

Pelorus compares vectors with cosine similarity, and the scores it exposes are the raw values: mathematically a range of -1 to 1, where 1 is an identical direction, 0 is orthogonal, and -1 is opposite. That is the geometry. It is not how scores behave in practice.

Modern embedding models do not spread text across the whole vector space. Trained embeddings share a large common component — a direction present in nearly every vector — so every pairwise comparison starts from a positive floor before content differences enter at all. This property (often called anisotropy) has practical consequences:

In theoryIn practice
Scores range from -1 to 1.Real corpora essentially never produce negative scores.
Unrelated text scores near 0.Completely unrelated text typically scores well above 0 — for some model families, 0.3 to 0.6.
The full range is meaningful.The usable range is compressed; most of the action happens in a narrow band near the top.
Opposite meaning scores near -1.Opposites score high — they share topic and context (see why opposites don't score −1).

The takeaway: absolute score values only mean something relative to the score distribution of your own model on your own data. Rank order is reliable at coarse separations; raw magnitudes are model-specific, and a very small gap between two scores is not a ranking signal at all (see below).

What the score means — and what it doesn't

A score is the embedding model's geometric opinion about two pieces of text. It is not a calibrated probability that the match is correct, and it is not a measure of answer quality. Pelorus reads it accordingly, and the way it does is worth knowing before you build anything on the numbers Pelorus returns.

  • Scores are served at four decimal places. That is a wire contract, not a precision claim. The same question can be answered by the engine or by the Pelorus client library, and the two runtimes must emit byte-identical output; full-precision floats disagree in their trailing digits, and those digits are float32 and serializer noise rather than information. Vector stores commonly serialize fifteen or sixteen digits of a float32 value — everything past roughly the seventh is an artifact of how the number was written down.
  • Scores are compared against lines, not against each other. Every serving decision is a threshold test — the 0.60 chunk-supplement floor, cluster proximity, the 0.98 identity threshold — or a banded tie-break, where differences inside the noise band are treated as ties and broken deterministically. Nothing in serving promotes one result over another because it scored a fraction higher.
  • Small differences are not signal. An edit that changes what a question means moves the score in the second or third decimal; float noise lives around the seventh. A gap below 0.001 is under the embedding model's discrimination floor, so ranking on it ranks accidents, not relevance.
A rough bearing, not a survey marker. Build decisions on "clearly above the line", never on "a hair above the other guy". When you evaluate Pelorus, read delivery: did the content that answers the question arrive? Recall, hit rate, and answerability are the numbers that mean something. Fine-grained rank-order metrics over the returned chunk rows are diagnostic only — rows expanded from a single Extract all carry that Extract's one score, so such a metric grades cite-list order rather than retrieval quality.

Embeddings read topic, not polarity

Embedding models are trained to place text that appears in similar contexts close together. The direction of a vector encodes what a text is about, not what stance it takes. Antonyms embed close together because they share nearly identical contexts, and negation barely moves the score: "customers who churned" and "customers who did not churn" score as near-duplicates.

This shapes which query pairs a similarity score can and cannot tell apart:

Query pairScore behaviorWhat it means in Pelorus
Symptom vs. solution framing
"my laptop is running slow" vs "how do I speed up my computer"
Same query, different words — scores land mid-range.The matches you want. An overly high query threshold sacrifices exactly these; this is the argument against maxing thresholds out.
Polarity twins
"enable SSO" vs "disable SSO"
Near-identical surface forms — scores at the very top of the range.Usually benign here: both draw on the same corpus neighborhood, so they can share an Extract or cluster. The consuming LLM resolves polarity at generation time by reading the actual query.
Parameter swaps
"reset the admin password" vs "reset a user's password"
Also score as near-twins.The pair to watch. Unlike polarity twins, these may need genuinely different source material. Curate clusters and Extract scope with this case in mind.
Negation
"orders that shipped" vs "orders that did not ship"
Nearly indistinguishable.Same as polarity twins: the Extract layer tolerates it if both needs are served by the same data; the Extract's scope should cover both readings.

This is why Pelorus matches queries to queries and returns Extracts — data scope — rather than caching final answers. At the data layer, "same topic" is usually the right grouping criterion, and topic is exactly what embeddings measure well. The distinctions a similarity score cannot surface are handled by the layers above: cluster curation, trained cluster classifiers, and the consuming LLM reading the real query against the returned Extract.

Why opposites don't score −1

A natural objection: if the model really captured negation, shouldn't "orders that shipped" and "orders that did not ship" point in opposite directions and score near -1? They don't — and anisotropy is only the smaller half of the reason. The deeper point is that opposite meaning is not opposite direction.

Pointing in the opposite direction means disagreeing in every semantic feature at once. A statement and its negation agree on almost everything — topic, entities, domain, register, sentence shape — and differ in exactly one feature: polarity. A faithful geometric encoding of that is what models actually produce: the two texts sit at nearly the same point, displaced by a small offset along a low-magnitude polarity direction. The true antipode of "orders that shipped" would be a text maximally unrelated to it in every respect at once, which barely exists as coherent language.

Notably, the polarity information is usually present in the vectors — probing research shows a trained linear classifier can separate negated from non-negated sentences. What cannot see it is cosine similarity. Cosine reduces two vectors to a single number: a sum of per-dimension agreement, weighted by the vectors' own magnitudes. The shared topical mass dominates that sum, and the small polarity disagreement is outvoted. In toy numbers: shared content contributes +0.97, a genuinely opposed polarity component contributes −0.02, and cosine reports 0.95. The opposition is in there — it nudged the score down — but one scalar cannot distinguish "0.95 because the wording differs" from "0.95 because the meaning is inverted." The signal survives; the attribution is destroyed.

A trained probe recovers the distinction because it gets to weight dimensions — amplify the polarity direction, ignore the topical mass. Cosine applies no weights, so it reports whatever the largest components say, and those encode topic. The same property is what cross-encoder rerankers exploit: by letting two texts attend to each other token-by-token instead of comparing two pre-compressed vectors, they can react to a "not" directly.

Two stacked effects, one conclusion. The embedding model places a statement and its negation close together (they occur in near-identical contexts), and cosine similarity cannot surface the small difference that remains (topic outweighs polarity in the sum). Anisotropy then compresses what little gap is left. All three point the same way: never ask a raw similarity score to adjudicate polarity — that is a job for the layers above the score.

The full catalog of look-alike traps

The four examples above are the most common cases of a broader pattern: question pairs whose answers differ but whose vectors barely do. Pelorus maintains an internal taxonomy of these failure modes, each one measured against the default model. Knowing the whole catalog matters for curation: each trap has a different cause, and each is handled in a different place.

TrapExample pairWhat causes itWhat handles it
Polarity / negation"enable SSO" vs "disable SSO"; "orders that shipped" vs "…did not ship"Opposites share nearly all context; the polarity offset is too small for cosine to surface (see above).The Extract covers both poles with the distinction explicit; the consuming LLM reads the real query.
Parameter families"reset the admin password" vs "reset a user's password"; cabin classes, destinations, payment methodsOnly an enumerable parameter differs. Family members sit mid-range from each other — close enough to confuse, too far for one vector to cover them all.The Extract carries the whole family (the full table, all rows), plus exemplar phrasings per instance so each variant can match.
Dates and policy versions"what is the cancellation policy" before vs after a rule change; seasonal windowsEmbeddings do not reason over dates — every dated variant of a question is essentially one point in the space.The Extract carries every date branch with its condition stated ("before June 1: X; on/after: Y"), never just the current one.
Numbers and thresholds"cancel 9 days out" vs "20 days out" when the fee flips at 14Models capture rough magnitude (2 < 3) but not exact values, and are surprisingly format-sensitive (0.5 vs 0.50).The Extract carries the full threshold schedule, including boundary behavior (inclusive/exclusive).
Included vs excluded lists"what's covered" vs "what's excluded"; all / only / exceptQuantifier words barely move the vector; list questions look alike.The Extract enumerates the complete list and states whether it is exhaustive.
Must / should / may"is X required" vs "is X recommended"Modal words occur in near-identical contexts and score as near-synonyms — the one measured pair that can clear even a very high identity threshold.The Extract preserves the corpus's exact normative wording per clause, so even a cross-match returns corpus-true modality.
Same name, different things"Navigator cabin" vs "Navigator tier"One surface form, two referents (polysemy). Measured good news: the surrounding query words keep the senses apart at the identity tier — the risk lives at the chunk tier, where both senses surface.The Extract answers both senses and states the distinction.
Acronyms and aliasesan internal acronym vs its spelled-out nameRare tokens embed far from their expansions — measured below even the chunk-retrieval floor. The one trap that fails by miss, not false hit; no threshold tuning recovers it.The Extract binds every corpus alias/expansion as an exemplar phrasing, so any spelling of the question has a surface to land on.
If/then conditionsvoluntary vs involuntary cancellation; party size; membership statusThe generic policy text and each conditional variant embed together.The Extract carries the complete decision tree with each condition explicit; the consuming LLM walks it with what the user stated.
Multi-part answers"total training time" when the corpus lists per-module timesThe assembled answer exists in no single chunk, and its vector lands far from every fragment it must sum.The Extract is exactly where assembly is allowed: it pre-computes the chain, cited to every hop.
Reversed roles"flights from A to B" vs "from B to A"; "does X require Y" vs "does Y require X"Sentence embeddings largely ignore word order (bag-of-words behavior): same words, same vector, different truth value.For directional relations, the Extract states the direction and gives both directions' answers.
Hub vectors(no pair — a global effect)In high-dimensional spaces, some vectors become "universal near-neighbors," scoring close to many unrelated queries. Invisible to any pairwise check.A per-model diagnostic: Pelorus's hubness analysis flags hub-prone items so curation can intervene — this is not threshold-fixable.
The two headline measurements. Probed against the default model at Pelorus's serving thresholds, the catalog reduces to two genuine dangers: must/should is the only pair that can falsely match at the identity tier (benign, because the Extract preserves exact wording), and the acronym/alias gap is the only true blind spot (a miss no threshold can fix — the reason exemplar phrasings exist). Everything else lives at the chunk-supplement tier, which additive serving already covers. Those numbers are model-specific: re-verify them if you change the embedding model.

Symmetric and asymmetric matching

Retrieval-tuned embedding models distinguish two situations, and many encode the distinction directly in how text is embedded:

  • Asymmetric (query → document): a short question must match a long passage that answers it. Models trained for this often apply different treatments to the two sides — prefixes like query: / passage:, or an input-type parameter — effectively projecting queries and documents slightly differently.
  • Symmetric (query → query): both sides are the same kind of text, as in paraphrase or semantic-similarity tasks. The correct usage is to embed both sides identically.

Pelorus performs both comparisons with a single embedding of the incoming query. Chunk retrieval is asymmetric: chunks are embedded with document treatment at indexing time. Query matching is symmetric: stored queries and incoming queries receive the same query-side treatment, so query-to-query comparison stays consistent. This is also why query-to-query similarity is usually a cleaner signal than query-to-chunk similarity — it compares like with like.

Two comparisons, two distributions. Because query matching and chunk matching are different comparison types, their scores live in different distributions — which is why query_extract_threshold and chunks_threshold are separate settings with different defaults, and should be tuned independently.

The default model: bge-small-en-v1.5

The no-key demo and default local configuration use BAAI/bge-small-en-v1.5 via fastembed — a small, fast, well-benchmarked model that runs locally with no provider API key. Useful facts from the model's own guidance:

  • Relative order matters more than absolute values. The vendor's explicit guidance is to choose similarity thresholds from the score distribution on your own data.
  • The top of the range is compressed. BGE-family scores run high: unrelated text can still score above 0.6, and the vendor suggests similarity-filter thresholds like 0.8, 0.85, or even 0.9. Expect the meaningful action to happen roughly between 0.6 and 1.0.
  • Query instruction is optional for v1.5. The v1.5 release was tuned so retrieval works well without the query instruction; the instruction mainly helps short-query-to-long-passage retrieval.

This is the context behind the default retrieval settings: a high query threshold in a compressed score range, and two lower chunk thresholds for the asymmetric comparison — a wider floor for a plain chunk-fallback hit, and a narrower one for a chunk riding along with a matched Extract.

retrieval_config:
  query_extract_threshold: 0.98    # symmetric query-to-query, compressed range
  chunks_threshold: 0.45           # asymmetric query-to-chunk fallback
  chunk_supplement_threshold: 0.60 # min similarity for a chunk to ride along an extract hit

With score bands this narrow, close calls — such as whether a strong chunk should surface alongside a matched Extract — can hinge on differences of a few hundredths. Treat small margins as meaningful and tune them against observed results, not intuition about what "0.98" should mean.

Tuning from your own distribution

The reliable way to set thresholds is empirical: look at where your traffic actually lands. The read-only analysis surface exposes everything needed to do this in a notebook — query_vectors(), chunk_vectors(), similarities(), and project_query() — and the Query Map lets you drop an ad hoc query onto the layout to see where it lands relative to queries and clusters.

  • Histogram the scores. Run a representative query set with thresholds relaxed and plot query-to-query and query-to-chunk scores separately. Identify the floor (unrelated), the paraphrase band, and the near-duplicate band for each.
  • Set thresholds between bands, not above them. The query threshold should admit the paraphrase band — that is where the valuable "same need, different words" matches live.
  • Watch the misses. Queries that fall back to chunks and queue new Extract demand just below a cluster boundary are a signal the boundary or the Extract needs attention, not just the threshold.
  • Re-check after change. Re-ingesting the corpus with different chunking, upgrading the model, or shifting traffic all move the distribution. Thresholds are tied to it.

Distance measures and vector normalization

A similarity score is produced by three choices, not one: the embedding model, the distance measure the vector store compares with, and whether the stored vectors are L2-normalized (scaled to unit length). Most vector stores let every collection or index choose its own measure, and the choice varies from store to store — so the same corpus, embedded by the same model, can produce incomparable scores in two different indexes. Every threshold on this page assumes one specific configuration.

MeasureWhat it comparesDirectionStatus in Pelorus
Cosine similarityThe angle between two vectors — pure direction, scale-invariant.Higher = closer (max 1)Supported — the calibrated configuration, over L2-normalized vectors. All bundled embedders emit unit vectors, and the built-in store's collections are created with cosine.
Dot product (normalized vectors)Mathematically identical to cosine when both vectors are unit length.Higher = closerEquivalent today — a backend using dot over normalized vectors is in the same units. Declared support is planned as a validation statement, not new math.
Dot product (unnormalized — "MIPS")Angle and magnitude together. Vector length (often correlated with text length or frequency) enters the score; scores are unbounded.Higher = closer (no fixed max)Planned — requires per-corpus threshold calibration and a re-derived geometry profile; fixed thresholds like 0.98 are meaningless here.
Euclidean (L2) distanceStraight-line distance between vector endpoints. On unit vectors it ranks identically to cosine (d² = 2(1−cos)) — only the units and direction change.Lower = closer (min 0)Planned — on normalized vectors this is a threshold translation (0.98 cosine ≈ 0.20 distance), not a recalibration.
Manhattan (L1) distanceSum of per-dimension differences. Produces genuinely different neighbor sets in high dimension.Lower = closerRoadmap — needs a full per-measure geometry profile and threshold recalibration.

(Binary/sparse measures — Hamming, Jaccard — apply to quantized or keyword vectors, not the dense embeddings Pelorus serves on, and are out of scope here.)

Why normalization is the hinge. When every vector is unit length, all information lives in direction: cosine and dot product become the same number, and Euclidean distance becomes a monotone transform of it — one calibrated space, three equivalent readouts. Unnormalized vectors break the equivalence: magnitude leaks into dot-product scores (biasing toward longer or more frequent text, and amplifying the hub-vector effect above), and a threshold that worked on one corpus silently means something else on the next.

The direction trap. Similarity measures score higher = closer; distance measures score lower = closer. A raw distance compared against a similarity threshold like 0.98 is not a miscalibration — it is nonsense, and it fails silently (typically by matching almost nothing, or everything). This is the single most common integration mistake, which is why Pelorus treats the measure as a property to validate, not assume.

How Pelorus validates this. The RAG Connector contract makes these properties queriable instead of assumed: a connector reports its embedding space's measure and normalization, and can enumerate its backend's collections/indexes — each with its own measure, dimension, normalization, and chunking, read from the live backend rather than cached configuration (the built-in connector does exactly this against its configured collection). A connector may also honestly report a property as unknown: unreported values are treated as "assumed, unvalidated" rather than silently trusted, and any property that is asserted and contradicts the calibrated configuration (a non-cosine measure, unnormalized vectors) is flagged before serving thresholds are applied to it.

What this means for Extracts. Everything measured on this page — the score bands, the trap catalog, the identity and supplement thresholds — was measured in cosine units over normalized vectors. Changing the distance measure or normalization is the same class of event as changing the embedding model: the score distribution, the geometry profile, and every threshold must be re-derived in the new units before Extract matching can be trusted.

Integrating another embedding model

The embedding model is a pluggable choice, pinned per Dataset Group so stored vectors and query embeddings share one space. Swapping it is supported by the architecture — the vector store is a rebuildable projection of the system of record — but nothing tuned against the old model survives the change. Before adopting a different model, establish its technical details:

CheckWhy
Score semanticsConfirm what the model/store combination returns: raw cosine, cosine distance, or a normalized 0–1 score. A 0.8 threshold means three different things across those three conventions (see distance measures).
Query vs. document treatmentDoes the model use prefixes, instructions, or an input-type parameter? Chunks must consistently get document treatment, and queries and incoming queries must consistently get the same query treatment. Mixing treatments skews scores silently.
Symmetric-task guidanceCheck the vendor's recommendation for symmetric (query-to-query) usage — some models want no instruction on either side for this case.
Empirical score rangesRe-derive the floor, paraphrase band, and near-duplicate band on your corpus. Every threshold — query, chunk, and each cluster's proximity setting — must be re-tuned in the new units.
Cluster models and the mapCluster centroids, trained cluster classifiers, and the Query Map projection are all artifacts of the old space. Rebuild and re-validate them after re-indexing.
Pin the versionPin the exact model name and version in configuration. A silent model upgrade splits your embedding space between old and new vectors.
Rule of thumb. Changing the embedding model is a re-indexing event, not a settings change. Rebuild the vector store from the system of record, re-derive the score distribution, then re-tune thresholds and cluster boundaries in the new model's units.