Source: Similarity-Calculations Β· Similarity-Calculations.md Β· updated 2026-07-23 Β· πŸ”’ secret gist

Synced verbatim from gist.github.com/bl9.

Is binary_clip Using Cosine Similarity? A Ground-Up Explanation

This document answers one question about the mew2026 OpenSearch index:

The binary_clip field is configured with space_type: innerproduct. Is that effectively cosine similarity, or plain dot product? And does using faiss with 16x compression mean the vectors are already normalized?

Short answer, proven with your own data in Section 8: the vectors are not normalized (their lengths are ~9–11, not 1), so this field computes raw dot product, which is not cosine similarity. faiss and the compression level have nothing to do with normalization.

This version assumes no prior background. Every term is defined, the origin of the name is explained (why "L2", where "Euclidean" comes from, what "inner product" means), and every concept has a worked numeric example. If you already know the basics, skip Section 0 and start at Section 1.


Table of Contents

  1. Background: the words and ideas you'll need
  2. The building block: what a vector is
  3. What a "norm" is, and where "L2" comes from
  4. Three ways to measure similarity (with a hand-worked example)
  5. The key insight: normalization makes all three agree
  6. Why innerproduct specifically requires you to normalize
  7. Why faiss and 16x compression do NOT mean "normalized"
  8. How to verify it yourself (the exact steps)
  9. The proof, using your five sampled vectors
  10. Conclusion
  11. How to fix it
  12. Glossary
  13. References

0. Background: the words and ideas you'll need

Before any math, here is the cast of characters. Each is expanded later; this is the map.

Embedding. A machine-learning model reads something complex (an image, a sentence) and outputs a fixed-length list of numbers that captures its "meaning" in a form computers can compare. That list is called an embedding or embedding vector. Similar inputs get similar lists. Your binary_clip field stores one 512-number embedding per news photo.

CLIP. The model that produced these embeddings. CLIP stands for Contrastive Language–Image Pre-training (OpenAI, 2021). It was trained on image–caption pairs so that a picture and its description land at nearby points. A useful consequence: you can search images by text, because both live in the same 512-dimensional space.

Vector / dimension. "Vector" is just the math word for an ordered list of numbers. Each number is one dimension or component. We live in 3 dimensions (left-right, up-down, forward-back), but math places no limit β€” 512 dimensions simply means "a list of 512 numbers." You can't picture 512-D, but every formula that works in 2-D works identically in 512-D; the examples below use 2-D so you can check them by hand.

Norm. A norm is any rule that measures the "size" or "length" of a vector, written with double bars: β€–vβ€–. There are several norms; the one everyone means by default is the L2 / Euclidean norm (Section 2).

Similarity metric. The rule for scoring how alike two vectors are. In OpenSearch this is the field's space_type. The three that matter β€” dot product, cosine, Euclidean β€” are Section 3.

Normalization. Rescaling a vector so its length becomes exactly 1, keeping its direction. This is the single operation the entire question turns on (Section 4).

faiss. The software library OpenSearch uses under the hood to store vectors and find nearest neighbors quickly. The name stands for Facebook AI Similarity Search. It is a search engine for vectors, not a data-cleaning tool β€” it does not modify your numbers (Section 6).

HNSW. The specific index structure faiss builds: Hierarchical Navigable Small World graph. It's a shortcut that avoids comparing your query against every stored vector. It affects speed, not the numbers (Section 6).

Quantization / "16x compression". Storing each number in fewer bits to save memory, by rounding it to a coarse set of allowed values. "16x" means 16Γ— smaller than the original. It approximates your numbers; it does not rescale them (Section 6).

With that map in hand, we start from the smallest piece.


1. The building block: what a vector is

CLIP turns an image into an embedding vector: a list of 512 numbers. Picture each vector as an arrow from the origin (the zero point) into space. Two properties describe any arrow:

  • Direction β€” which way it points. For CLIP, direction carries the meaning: similar images point similar ways.
  • Magnitude / length β€” how long the arrow is, written β€–vβ€–. For CLIP this is mostly a model artifact and carries little meaning.

What your actual data looks like (first 8 of the 512 numbers from document MT1IMGOST00034M2TL):

[-0.0992932, 0.3014491, -0.123002, -0.3986786, 0.4534476, -0.470669, 0.1424519, 0.0870281, ...]

Most components are small (Β±0.1–0.5). But a few, further along, are large (one is βˆ’3.33). That mixture is the tell we'll return to.

Why "dimension" here isn't the everyday meaning. In daily life "dimension" means a physical axis. In linear algebra it just means "one slot in the list." 512 dimensions = 512 slots. Nothing is spatial about it; it's bookkeeping. The reason we still use spatial words ("length", "direction", "angle") is that the formulas are the same ones you learned for 2-D and 3-D β€” they extend to any number of slots unchanged.


2. What a "norm" is, and where "L2" comes from

A norm answers "how big is this vector?" It's a function that takes a vector and returns a single non-negative number: its size. There is more than one sensible way to measure size, so there is a family of norms.

The Lα΅– family (this is where the letters and numbers come from)

The general formula, with a chosen exponent p:

β€–vβ€–β‚š = ( |v₁|α΅– + |vβ‚‚|α΅– + ... + |vβ‚™|α΅– ) ^ (1/p)

Different values of p give different norms:

  • p = 1 β†’ the L1 norm (a.k.a. Manhattan or taxicab distance): just add up the absolute values. It's called "taxicab" because it's how far a taxi drives on a grid of streets β€” you can't cut diagonally through buildings.
  • p = 2 β†’ the L2 norm (a.k.a. Euclidean norm): square each, sum, take the square root. This is ordinary straight-line length.
  • p = ∞ β†’ the L∞ norm: just the single largest absolute component.

So "L2" literally means "the Lα΅– norm with p = 2." The 2 is the exponent in the formula. And the "L" honors Henri Lebesgue, the French mathematician after whom the Lα΅– spaces of functions are named; the vector norms inherit the letter. (Pedantic note: for finite lists like ours the strictly correct symbol is a lowercase β„“ β€” "little-ell-2" or β„“Β² β€” while capital L is for continuous functions, but in engineering "L2" is used for both and everyone understands it.)

Why L2 is also called "Euclidean"

Euclid of Alexandria (~300 BCE) wrote the Elements, the foundation of geometry. Straight-line distance in flat space is "Euclidean" in his honor. The L2 formula is nothing more than the Pythagorean theorem (aΒ² + bΒ² = cΒ²) extended to many dimensions:

length of arrow = sqrt( sum of squares of its components )

That's why squaring (exponent 2) and then square-rooting appears: it's Pythagoras. The squaring is what makes it "straight-line" distance rather than grid distance.

One worked example, all three norms

Take v = [3, 4]:

L1  (p=1):  |3| + |4|                 = 7          (add absolute values)
L2  (p=2):  sqrt(3Β² + 4Β²) = sqrt(25)  = 5          (Pythagoras β€” the default "length")
L∞ (max):   max(|3|, |4|)             = 4          (largest component)
import numpy as np
v = np.array([3.0, 4.0])
np.sum(np.abs(v))     # L1  -> 7.0
np.linalg.norm(v)     # L2  -> 5.0   (np.linalg.norm defaults to p=2)
np.max(np.abs(v))     # Linf-> 4.0

The same three on the real 8-number slice above: L1 = 2.076, L2 = 0.8543, L∞ = 0.4707 β€” different measurements of the same vector.

For the rest of this document, "norm" means L2 (the default everywhere in vector search), and the L2 norm is the single most important calculation here: the test in Section 8 is literally "compute the L2 norm of your real vectors and check whether it equals 1."


3. Three ways to measure similarity (with a hand-worked example)

A similarity metric (OpenSearch's space_type) turns two vectors A and B into one number saying how alike they are. We compute all three on the same pair so the differences are visible.

Example pair: A = [3, 4], B = [4, 0]. (β€–Aβ€– = 5, β€–Bβ€– = 4.)

3a. Dot product (a.k.a. inner product)

Multiply matching components, sum them:

dot(A, B) = A₁·B₁ + Aβ‚‚Β·Bβ‚‚ = 3Β·4 + 4Β·0 = 12

Why two names β€” "dot product" and "inner product"? "Dot product" comes from the notation A Β· B (a literal dot). "Inner product" is the more general, abstract term from linear algebra for this whole category of operation; the dot product is the standard inner product on ordinary (Euclidean) space. OpenSearch's innerproduct is exactly this: A Β· B. The two names mean the same thing here.

The crucial identity β€” dot product blends direction and length together:

dot(A, B) = β€–Aβ€– Β· β€–Bβ€– Β· cos(ΞΈ)      (ΞΈ = the angle between the two arrows)

So a longer vector inflates all its dot products. This is the metric your field uses.

3b. Cosine similarity

Divide the dot product by both lengths β€” this cancels magnitude and leaves only the angle:

              dot(A, B)        12
cos(A, B) = ───────────── = ─────── = 0.6
             β€–Aβ€– Β· β€–Bβ€–       5 Β· 4

Trig refresher β€” what "cosine" and "angle" mean here. Cosine is the trigonometric function that, for an angle ΞΈ, ranges from +1 at 0Β° (arrows point the same way), through 0 at 90Β° (arrows perpendicular β€” called orthogonal, meaning "unrelated"), to βˆ’1 at 180Β° (arrows point opposite ways). "Angle between two 512-D arrows" sounds exotic, but any two arrows β€” no matter how many dimensions β€” always lie in a single flat plane together, and the angle in that plane is an ordinary 0°–180Β° angle. Cosine similarity is just the cosine of that angle. That's why it's the natural measure of direction alone: it ignores how long the arrows are and reports only how aligned they are.

3c. Euclidean distance (L2 distance)

The straight-line distance between the two arrow tips (smaller = more similar). It reuses the Pythagorean/L2 idea from Section 2, applied to the difference A βˆ’ B:

d(A, B) = β€–A βˆ’ Bβ€– = sqrt((3βˆ’4)Β² + (4βˆ’0)Β²) = sqrt(1 + 16) = sqrt(17) β‰ˆ 4.123

Your field is not using this (space_type is innerproduct, not l2); it's shown for contrast, and to make clear the Euclidean-vs-cosine question is already closed by the config string alone.

All three in code:

import numpy as np
A = np.array([3.0, 4.0]); B = np.array([4.0, 0.0])
A @ B                                            # dot       -> 12.0   ('@' is dot product in numpy)
A @ B / (np.linalg.norm(A) * np.linalg.norm(B))  # cosine    -> 0.6
np.linalg.norm(A - B)                            # euclidean -> 4.1231

Why length "leaking in" actually matters

Make A twice as long (same direction): A2 = [6, 8].

dot(A2, B) = 6Β·4 + 8Β·0 = 24        # DOUBLED β€” purely because A got longer
cos(A2, B) = 24 / (10 Β· 4) = 0.6   # UNCHANGED β€” direction didn't change

Under dot product, a document can outrank another just for being "longer," even with identical direction. Under cosine it can't. Hold onto this β€” it is exactly what's happening in your index.


4. The key insight: normalization makes all three agree

Normalizing rescales a vector to length 1 while preserving direction:

v_normalized = v / β€–vβ€–

Divide every component by the vector's own L2 length. The result is a unit vector (length 1). Geometrically, all unit vectors sit on the surface of a sphere of radius 1 centered at the origin β€” the unit sphere β€” so normalizing is "slide the arrow tip in/out until it touches the unit sphere, without turning it."

Worked example. Normalize A = [3, 4] (β€–Aβ€– = 5):

A_normalized = [3/5, 4/5] = [0.6, 0.8]
check: β€–[0.6, 0.8]β€– = sqrt(0.36 + 0.64) = sqrt(1.0) = 1   βœ“
A  = np.array([3.0, 4.0])
An = A / np.linalg.norm(A)   # -> [0.6, 0.8]
np.linalg.norm(An)           # -> 1.0

Now the payoff. Once β€–Aβ€– = 1 and β€–Bβ€– = 1:

(i) Dot product becomes cosine β€” the cosine denominator is 1Β·1 = 1:

cos(A, B) = dot(A, B) / (β€–Aβ€–Β·β€–Bβ€–) = dot(A, B) / 1 = dot(A, B)

Verify: B_normalized = [4/4, 0/4] = [1, 0], so dot(An, Bn) = 0.6Β·1 + 0.8Β·0 = 0.6, which equals the cosine we computed earlier. βœ“

(ii) Euclidean ranking becomes cosine ranking, via this identity for unit vectors:

d(A,B)Β² = β€–Aβ€–Β² + β€–Bβ€–Β² βˆ’ 2Β·dot(A,B) = 1 + 1 βˆ’ 2Β·cos(A,B) = 2 βˆ’ 2Β·cos(A,B)

Verify: d(An,Bn)Β² = (0.6βˆ’1)Β² + (0.8βˆ’0)Β² = 0.16 + 0.64 = 0.8, and 2 βˆ’ 2(0.6) = 0.8. βœ“ A bigger cosine always means a smaller distance β€” identical neighbor order.

The consequence: if your vectors are normalized, it doesn't matter whether the engine runs dot product, cosine, or Euclidean β€” they all rank identically. That's why teams normalize once at write time, then pick the cheapest metric (dot product) and get cosine behavior for free.

The flip side (your situation): if the vectors are NOT normalized, dot product β‰  cosine, and length distorts the ranking exactly as the doubling demo showed.


5. Why innerproduct specifically requires you to normalize

OpenSearch treats cosinesimil, innerproduct, and l2 as separate space_type values with different internal formulas:

space_typeWhat the engine computesDivides by β€–Aβ€–Β·β€–Bβ€–?_score reported
cosinesimilcosine of the angleYes β€” built in1 + cos(A,B) (range 0–2)
innerproductraw dot(A,B)Nodot+1 if dotβ‰₯0, else 1/(1βˆ’dot)
l2squared Euclidean distanceNo (distance-based)1 / (1 + distanceΒ²)

The decisive row: cosinesimil bakes the length-division into its own formula. Had your field used cosinesimil, OpenSearch would divide out the magnitudes on every query, giving true cosine regardless of your stored vectors' norms.

But your field is innerproduct, which has no division. It feeds the stored numbers straight into dot(). So innerproduct equals cosine only if you supplied already-normalized vectors. Choosing innerproduct delegates normalization to your ingest pipeline β€” if ingest doesn't do it, nobody does.

What "score transform" means. A raw dot product can be negative (opposite-pointing vectors). OpenSearch prefers scores to be positive and increasing-with-similarity, so for innerproduct it reports dot + 1 when the dot is β‰₯ 0 and 1/(1 βˆ’ dot) when negative. This is monotonic β€” it never changes the ranking order β€” but it does mean the _score you see is not a cosine value in [βˆ’1, 1]. Don't interpret it as one.

Concrete score example, using two of your real vectors (v1 = MT1IMGOST00034M2TL, v2 = MT1NURPHO000YDLE5G; norms 9.17 and 9.75):

raw dot(v1, v2)                 = 22.2227
cosine(v1, v2)                  =  0.2486     # the "true" semantic similarity (direction only)
innerproduct _score (dot + 1)   = 23.2227     # what your index returns today
cosinesimil _score (1 + cos)    =  1.2486     # what you'd get if normalized / using cosinesimil

ratio raw / cosine = 89.40  ==  β€–v1β€–Β·β€–v2β€– = 9.1736 Γ— 9.7454 = 89.40

That 89Γ— ratio is the length inflation β€–v1β€–Β·β€–v2β€– from the dot = β€–Aβ€–Β·β€–Bβ€–Β·cos(ΞΈ) identity β€” pure magnitude injected into the score. After normalizing both vectors, dot(v1n, v2n) = 0.2486, exactly the cosine.

Sample query (the query is identical no matter which metric the field uses β€” the metric was fixed at index-build time):

GET /mew2026/_search
{
  "size": 5,
  "query": {
    "knn": {
      "binary_clip": {
        "vector": [ /* 512-dim query embedding */ ],
        "k": 5
      }
    }
  }
}

Sample response shape (note the large _score, consistent with un-normalized innerproduct, not a 0–2 cosine score):

{
  "hits": {
    "hits": [
      { "_id": "tag:mewmew.com,2026:newsml_...", "_score": 23.2227, "_source": { } },
      { "_id": "tag:mewmew.com,2026:newsml_...", "_score": 19.8410, "_source": { } }
    ]
  }
}

6. Why faiss and 16x compression do NOT mean "normalized"

This is the specific misconception, so each config element is dissected on its own.

"method": {
  "engine": "faiss",
  "space_type": "innerproduct",
  "name": "hnsw",
  "parameters": {}
},
"mode": "on_disk",
"compression_level": "16x"

engine: faiss β€” a search library, not a normalizer

faiss (Facebook AI Similarity Search) builds the search structure and computes distances. It computes exactly the metric space_type names (dot() here) on exactly the numbers it's given. faiss performs no automatic normalization β€” in its own API you must call faiss.normalize_L2(x) on your data yourself if you want cosine, and OpenSearch does not call that for you on an innerproduct field. So faiss tells you how search runs, not whether the data was normalized.

# faiss "cosine" is literally: normalize FIRST, then use inner product.
import faiss, numpy as np
x = np.random.rand(1000, 512).astype('float32')
faiss.normalize_L2(x)           # <-- YOU do this explicitly; nothing is implicit
index = faiss.IndexFlatIP(512)  # IP = Inner Product
index.add(x)                    # only now does IP behave like cosine

name: hnsw β€” the index shape, unrelated to magnitude

HNSW (Hierarchical Navigable Small World) is a graph that lets a query reach its nearest neighbors in a few hops instead of comparing against all 10,000+ vectors. ("Small world" is the same idea as "six degrees of separation" β€” most points are only a few hops apart.) It changes which candidates are compared and how fast β€” it never rescales a vector.

compression_level: 16x β€” lossy storage, not rescaling

First, two words of background:

What a "bit" and a "float" are. A bit is a single 0/1 digit; n bits can represent 2ⁿ distinct values (2 bits β†’ 4 values, 8 bits β†’ 256). Your original numbers are float32 β€” 32-bit floating-point numbers (the standard IEEE 754 format), giving very fine precision. "Compression" here means storing each number in fewer bits by rounding it to a small menu of allowed values. That rounding is called quantization.

"16x" means 16Γ— smaller than float32, i.e. 32 Γ· 16 = 2 bits per number β€” so each of the 512 numbers is rounded to one of just 2Β² = 4 allowed levels. Quantization approximates the numbers you already have; it does not rescale the vector to length 1. A rounded copy of a length-9 vector is still ~length 9.

Compression-level β†’ bits, per the OpenSearch docs:

compressionbits/dimlevels (2ⁿ)note
32x12true binary quantization
16x24your field
8x416
4x8 (byte)256
2x16 (fp16)65 536

Why un-normalized data makes compression worse (toy 2-bit example). With only 4 levels spread across the data's range, if one outlier sits at 3.33 the levels must stretch to cover it, and the ~500 small values collapse together:

value  +0.030  -> nearest of 4 levels = +0.1667   (error 0.137)   # informative values get crushed
value  +0.210  -> nearest of 4 levels = +0.1667   (error 0.043)
value  -0.440  -> nearest of 4 levels = -0.5000   (error 0.060)
value  +3.330  -> clipped to           +0.5000   (error huge)     # the outlier wrecks the scale

Normalizing first shrinks those outliers and lets the 4 levels resolve the informative dimensions β€” so normalization would improve compression, not replace it.

Aside (on_disk random rotation). on_disk applies a random rotation before quantizing, to spread information more evenly across dimensions. A rotation preserves length (it spins the arrow without stretching it), so it also does not normalize.

mode: on_disk β€” rescoring uses the same un-normalized vectors

on_disk runs two phases: a fast approximate pass over the compressed vectors, then a rescore using the full-precision vectors from disk (default oversample_factor 2.0). Those full-precision vectors are the same un-normalized numbers, so rescoring makes the score more faithful to the raw dot product β€” moving you further from cosine, not toward it.

Bottom line: none of faiss, hnsw, 16x, or on_disk inspects or changes vector length. The only thing that makes innerproduct behave as cosine is an explicit unit-normalize in your ingest code before indexing. Whether that ran is a question about the numbers in _source β€” which we check next.


7. How to verify it yourself (the exact steps)

Step 1 β€” confirm the metric (rules out Euclidean):

GET /mew2026/_mapping/field/binary_clip

Read ...binary_clip.method.space_type. Yours = innerproduct β†’ not l2, so Euclidean is out; only the normalization question remains.

Step 2 β€” pull a few stored vectors:

GET /mew2026/_search
{
  "size": 5,
  "_source": ["binary_clip"],
  "query": { "exists": { "field": "binary_clip" } }
}

Step 3 β€” compute each vector's L2 norm. Normalized β‡’ every norm β‰ˆ 1.0000. Not normalized β‡’ the norms are something else and vary:

import numpy as np
for h in resp["hits"]["hits"]:
    v = np.asarray(h["_source"]["binary_clip"], dtype=np.float64)
    print(h["_id"][-18:], "norm =", round(float(np.linalg.norm(v)), 4))

Step 4 (optional, conclusive) β€” scale-invariance check. Query with a stored vector, then again with a Γ—3 copy of it. True cosine is scale-invariant (same order); raw dot product changes the scores/order. This is the live-query analog of the doubling demo in Section 3.

Step 5 (fallback if binary_clip is excluded from _source) β€” inspect the ingest code for a normalize step: x / np.linalg.norm(x), sklearn.preprocessing.normalize, or torch.nn.functional.normalize(emb, dim=-1). Hugging Face CLIPModel returns un-normalized image_embeds; the standard pattern is emb = emb / emb.norm(p=2, dim=-1, keepdim=True) right after the forward pass. Present β‡’ cosine-equivalent; absent β‡’ raw dot product.


8. The proof, using your five sampled vectors

You returned five documents from mew2026 with full binary_clip arrays in _source. Applying Step 3:

Results (exact, computed from your data)

Document (_id tail)DimL2 normLargest |component|RMS per component
MT1IMGOST00034M2TL5129.17363.32770.4054
MT1NURPHO000YDLE5G5129.74545.32050.4307
MT1NURPHO0003GDRTT51210.75796.17410.4754
RC2VZKAGA2RW51210.46055.03180.4623
MT1SIPA000UKMLE25129.64903.61920.4264

Summary: mean norm 9.96, range 9.17 β†’ 10.76 (spread ~1.58 across just five docs).

What "RMS per component" is and why it's here. RMS = root-mean-square = sqrt(average of the squares) = β€–vβ€– / sqrt(512). It's the "typical size" of a single number in the vector. For a correctly normalized 512-D vector the total length is 1, so each component is tiny: RMS β‰ˆ 1/√512 β‰ˆ 0.0442. Your RMS is ~0.43 β€” about 10Γ— too large, matching the norm being ~10Γ— too large. Two independent framings, same conclusion.

What the numbers prove

1. Norms are ~9–11, not ~1 β†’ never normalized. A normalize step would force every norm to exactly 1.0000. Instead they sit near 10.

2. You don't even need the exact norm. A unit vector cannot have any component with magnitude > 1: if a component were 3.3, then β€–vβ€– β‰₯ 3.3 > 1 by the norm formula (the full length is at least as big as any one leg). Every sampled vector has a component past Β±1 β€” up to 6.17. That single fact rules out normalization.

3. Norms vary doc-to-doc (9.17 vs 10.76) β†’ the dot-product fingerprint. From dot = β€–Aβ€–Β·β€–Bβ€–Β·cos(ΞΈ), differing lengths mean the score is scaled by each doc's own length, not by meaning alone. Under true cosine every vector contributes length 1 and this variation disappears.

4. The CLIP "rogue dimension." The largest-magnitude component sits at the same index (~92) and is strongly negative across documents: βˆ’3.33, βˆ’5.32, βˆ’6.17, βˆ’5.03, βˆ’3.62. Concretely, for the v1Β·v2 pair:

dimension 92:  v1[92] = -3.3277,  v2[92] = -5.3205
   product term = (-3.3277) Γ— (-5.3205) = 17.7050
   median product term across all 512 dims = 0.0363
   -> this ONE dimension contributes ~488Γ— a typical dimension

The raw dot product between these two vectors is 22.22 β€” and a single dimension supplies 17.71 of it (β‰ˆ80%). So similarity is decided mostly by one outlier dimension, not the full 512-D semantic signal. This is the well-documented CLIP outlier-dimension effect, and L2 normalization is exactly the step that tames it.


9. Conclusion

  • Metric in use: raw inner (dot) product β€” space_type: innerproduct does no length-division.
  • Euclidean? No β€” the space_type would read l2.
  • Cosine in effect? No. innerproduct equals cosine only on unit vectors; your sampled vectors have norms ~9–11.
  • Do faiss / 16x / on_disk imply normalization? No. They govern the search library, index structure, memory compression, and rescoring β€” none touches vector length. Only an ingest-time normalize would, and the norms prove it didn't run.

Net effect: the field ranks by direction and magnitude mixed together, with a single CLIP outlier dimension dominating (~80% of the score in the sampled pair) β€” not the pure angular (cosine) similarity you normally want from CLIP.


10. How to fix it

Option A β€” normalize at ingest (recommended). Unit-normalize every embedding before writing, and normalize the query vector identically. Then innerproduct becomes true cosine, outlier dims stop dominating, and quantization improves.

import numpy as np

def to_unit(vec):
    v = np.asarray(vec, dtype=np.float32)
    n = np.linalg.norm(v)
    if n == 0:
        raise ValueError("zero vector cannot be normalized")
    return v / n

emb = to_unit(clip_image_embedding)              # ||emb|| == 1 now
assert abs(np.linalg.norm(emb) - 1.0) < 1e-3     # guardrail so it can't silently regress
# ...index emb into binary_clip...

Option B β€” switch the field to cosinesimil. OpenSearch then does the division itself and you get cosine regardless of input norms:

PUT /mew2026_v2
{
  "settings": { "index": { "knn": true } },
  "mappings": {
    "properties": {
      "binary_clip": {
        "type": "knn_vector",
        "dimension": 512,
        "space_type": "cosinesimil",
        "mode": "on_disk",
        "compression_level": "16x",
        "method": { "name": "hnsw", "engine": "faiss" }
      }
    }
  }
}

Either way it's a reindex, because the metric is baked into the HNSW graph at build time β€” you can't change space_type on a live field. A typical flow: create the new index (with normalized ingest, or cosinesimil), reindex/re-embed into it, then swap an alias so callers don't change their queries:

POST /_aliases
{
  "actions": [
    { "remove": { "index": "mew2026",    "alias": "clip_search" } },
    { "add":    { "index": "mew2026_v2", "alias": "clip_search" } }
  ]
}

Sanity check after fixing β€” re-run Section 7 Step 3; every norm should now read ~1.0000, and innerproduct _score values should fall into the 0–2 band (dot ∈ [βˆ’1, 1] β†’ score = dot + 1).

Scope note: this was a 5-document sample. It's conclusive that normalization is absent (norms ~10; components > 1 are impossible for unit vectors), but for full confidence run the Step-3 norm check over a larger random sample β€” expect the same result unless different code paths write to this field differently.


11. Glossary

  • Bit β€” a single binary digit (0 or 1). n bits encode 2ⁿ distinct values.
  • CLIP β€” Contrastive Language–Image Pre-training; the OpenAI model that produced these image embeddings. Trained so images and their text captions land near each other.
  • Component / dimension β€” one number (one slot) in a vector. Your vectors have 512.
  • Cosine similarity β€” the cosine of the angle between two vectors; measures direction only, ignoring length. Range βˆ’1 to +1.
  • Dot product / inner product β€” sum of products of matching components (AΒ·B). Blends direction and length. OpenSearch calls it innerproduct.
  • Embedding β€” a fixed-length list of numbers a model outputs to represent an input's meaning.
  • Euclidean β€” pertaining to ordinary flat-space geometry (after Euclid, ~300 BCE); the L2 norm/distance is "Euclidean" because it's the straight-line Pythagorean length.
  • faiss β€” Facebook AI Similarity Search; the library OpenSearch uses to store and search vectors. Does not modify your numbers.
  • float32 β€” a 32-bit floating-point number (IEEE 754); the high-precision format the original embeddings use before compression.
  • HNSW β€” Hierarchical Navigable Small World; the graph index that finds nearest neighbors in a few hops instead of scanning everything.
  • L1 / L2 / L∞ norm β€” members of the Lα΅– norm family (exponent p = 1, 2, ∞). L1 = sum of absolute values (Manhattan); L2 = square-root of sum of squares (Euclidean, the default "length"); L∞ = largest absolute component. "L" honors Henri Lebesgue.
  • Magnitude / length / norm β€” how "big" a vector is; β€–vβ€–. Unqualified, it means the L2 norm.
  • Normalization (unit-normalize) β€” rescaling a vector to length 1 by dividing by its own norm; keeps direction, removes length.
  • Orthogonal β€” at a 90Β° angle; cosine 0; "unrelated" directions.
  • Quantization β€” rounding numbers to a small set of allowed values to store them in fewer bits (lossy compression).
  • Rescore (on_disk) β€” a second pass that recomputes scores using full-precision vectors after a fast approximate first pass.
  • space_type β€” the OpenSearch setting naming the similarity metric for a vector field (cosinesimil, innerproduct, or l2).
  • Unit sphere / unit vector β€” the set of all length-1 vectors / a vector of length 1.

12. References

  • OpenSearch β€” Spaces (space_type definitions and score formulas for cosinesimil, innerproduct, l2): https://docs.opensearch.org/latest/mappings/supported-field-types/knn-spaces/
  • OpenSearch β€” k-NN vector field type: https://docs.opensearch.org/latest/mappings/supported-field-types/knn-vector/
  • OpenSearch β€” Disk-based vector search (on_disk mode, two-phase rescoring, oversample_factor default 2.0): https://docs.opensearch.org/latest/vector-search/optimizing-storage/disk-based-vector-search/
  • OpenSearch β€” Memory-optimized vectors (compression_level, rescoring): https://docs.opensearch.org/latest/mappings/supported-field-types/knn-memory-optimized/
  • OpenSearch β€” Binary quantization (compression-level β†’ bits/dimension: 32x=1-bit, 16x=2-bit, 8x=4-bit): https://docs.opensearch.org/3.0/vector-search/optimizing-storage/binary-quantization/
  • faiss wiki β€” inner product vs L2, and normalize_L2 for cosine: https://github.com/facebookresearch/faiss/wiki
  • CLIP β€” Radford et al., Learning Transferable Visual Models From Natural Language Supervision (2021); embeddings are compared after L2 normalization: https://arxiv.org/abs/2103.00020
  • Lα΅– spaces / Lebesgue (background on the norm family and its naming): https://en.wikipedia.org/wiki/Lp_space

(All numeric results in this document were computed directly from the five binary_clip vectors you sampled from mew2026.)