EmbeddingGemma 2 Video Search on a Laptop iGPU

Google's open EmbeddingGemma 2 finds the right clip for a sentence 22 times out of 23, fully offline, at 2.3 s per clip on the Intel Iris Xe inside a laptop with OpenVINO INT8. Then the same vectors are asked which way time runs: a clip and its reverse land at cosine 0.997, and neither EmbeddingGemma 2 nor VideoPrism, a model built for video, can tell them apart.

Luis Condados · · 26 min read Intermediate
The finished result, and its blind spot: EmbeddingGemma 2 scores a laptop clip 0.754 for “someone opens a laptop” and the same frames in reverse order 0.749, and the two video vectors sit at cosine 0.998. Clip by Monstera Production on Pexels.
The finished result, and its blind spot: EmbeddingGemma 2 scores a laptop clip 0.754 for “someone opens a laptop” and the same frames in reverse order 0.749, and the two video vectors sit at cosine 0.998. Clip by Monstera Production on Pexels.

Type a sentence, get the video clip it describes: no cloud, no API key, on the graphics chip built into an ordinary laptop.

You have met this kind of search already:

  • Photo and video apps that find “dog on the beach” in your camera roll without anyone tagging the pictures.
  • Stock-footage sites, where a search for a phrase returns clips whose descriptions never used those words.
  • Newsroom and broadcast archives, which index hours of footage so an editor can find one shot by describing it.
  • Retrieval for AI assistants, where the assistant looks up the most relevant video or document before answering.

A free model from Google now does this on a laptop, offline, and does it well, but it cannot tell a video from the same video played backwards. EmbeddingGemma 2 put the right clip first for 22 of 23 English queries over 43 short clips, in PyTorch and in all nine OpenVINO configurations tested. Quantized to INT8 and run on the laptop’s Intel Iris Xe, one 8-frame clip takes 2.3 s (2.25 to 2.39 s over three sessions) against 10.8 s for the reference PyTorch model on the CPU, and a text query takes 24 ms; giving each frame fewer tokens brings the clip to 1.0 s with the same search results (one session). A clip and its reverse land at cosine 0.997, and over 32 reversible clips the model picks the right direction 16 times, a coin flip. VideoPrism, a model with attention across frames, does no better (19/32).

EmbeddingGemma 2 [1] is an open embedding model (Apache-2.0): it turns a video, an image or a sentence into a list of 768 numbers, a vector, placed so that a video and a sentence that describe the same thing point the same way. Finding a clip is then one multiplication per clip. It is the multimodal successor of the text model EmbeddingGemma [2], and not the same thing as Google’s API-only Gemini Embedding 2. This post asks two questions about it: what does it cost on hardware already on a desk, as YOLO-NAS and 3D vehicle detection did before it; and does it see which way time runs.

The 43 clips

The corpus is 43 short clips from Pexels [14], 4 to 8 seconds each, cut to 640×360 with the audio removed. 32 are reversible: played backwards, they show another real action (a person sits down ↔ stands up). Each has a forward and a reversed caption. The other 11 are one-way: backwards they show something that cannot happen, such as a glass jar un-shattering. On top of the captions, 23 English search queries were written before any vector was computed, each with the clip (or clips) that should answer it.

Clips43, 4–8 s, 640×360, no audio; ids in clips.json
Reversible / one-way32 / 11
Search queries23, each with its target clip(s)
Reversed copiesthe same frames in reverse order, and the file re-encoded backwards

One clip carries every worked example in this post: a hand opens a laptop on a wooden desk (Pexels 7431681, the animation at the top). Its reversed caption is “someone closes a laptop”, the same creator filmed the real closing on the same desk (7431863), and the latency benchmark times this clip. When a section says “the laptop clip”, it means these 8 seconds.

What happens to a video

The animation below runs four of the 23 queries; each shows the three clips that scored highest, best first. The score is the cosine between the query’s vector and the clip’s.

Four searches in turn. For “a man inflating a purple balloon” the top clip is a man blowing up a balloon (0.775), then a water balloon bursting and a man putting on a jacket. The other queries are striking a match, espresso dripping into a cup and a swimmer diving
Four of the 23 queries, top 3 of 43 clips each, scored by the PyTorch reference model. Clips by Coverr, K, Pixabay, Taryn Elliott, Tima Miroshnichenko, Kampus Production and others on Pexels [14]; full credits at the end.
1 Frames1 per second, 640×360:8 frames for the laptop clip2 Vision towereach frame alone → 120 tokens;90% of the time on the iGPU3 Encoder, 24 layersreads all 8×120 tokens at once;order enters only here4 Average, normalisemean over tokens → one768-number unit vector5 Query, same encoder“a laptop showing a worldmap” → 768 numbers, 24 ms6 Dot product, rankone per clip, highest first:0.01 ms for 43 clips
What happens between a clip and a ranked list. Steps 1 to 4 run once per clip, when the library is indexed; steps 5 and 6 run per search.
  1. Frames. The clip is sampled at one frame per second, so the 8-second laptop clip becomes 8 pictures. The processor can also write a timestamp before each frame (add_timestamps); it is off by default in the transformers processor and off in every result in this post except the one test that says so.
  2. Vision tower. Each picture is cut into patches and turned into 120 tokens (short vectors, one per region of the image; the count is a setting, see fewer tokens per frame) by an image network that sees that frame and nothing else. This is the expensive step: about 90% of the time on the iGPU.
  3. Encoder. All the tokens, 8×120 for the laptop clip, go into one 24-layer transformer that lets every token look at every other one. Each token also carries its position in the sequence, so this is the only place where frame order can enter.
  4. Average and normalise. The encoder’s outputs are averaged into one vector of 768 numbers and scaled to length 1, so comparing two vectors is a plain dot product.
  5. Query. The sentence goes through the same encoder (with the prefix task: search result | query: from the model card [1]) and comes out in the same space.
  6. Dot product and rank. One multiplication-and-sum per clip; the highest scores are the results.

In code, with the companion repository’s ov_model.py (it wraps the OpenVINO graphs; the notebook has this cell and runs locally, since it needs the iGPU and the downloaded clips):

from pathlib import Path

import numpy as np
from ov_model import OVEmbedder, igpu
from smoke_export import sample_frames

model = OVEmbedder("int8", igpu())  # INT8 on the Intel iGPU
paths = sorted(Path("data/clips").glob("*_fwd.mp4"))
V = np.stack([model.embed_videos([sample_frames(p)])[0] for p in paths])

q = model.embed_text("a laptop showing a world map")
scores = V @ q  # every vector has length 1, so this is the cosine
for i in np.argsort(-scores)[:3]:
    print(f"{scores[i]:.3f}  {paths[i].name}")

sample_frames takes one frame per second with PyAV. Indexing the 43 clips is the slow part (about 100 s); every later query is the last four lines. On the INT8 iGPU model it prints 0.748 and 0.705 for the two laptop clips and then the suitcase clip at 0.549; the worked example below uses the PyTorch reference, which agrees on the first two and has the suitcase fourth.

Does it find the clip?

The score between a query and a clip is the cosine of the angle between their vectors: 1 when they point the same way, 0 when they have nothing in common. Because both vectors have length 1, the cosine is the dot product, the sum of the products of their 768 pairs of numbers [9, §6.3.1]. Cosine similarity between image and text vectors is also what CLIPScore uses to grade generated images.

score(q,v)=cos⁡θ=q⋅v=∑i=1768qivi\text{score}(q, v) = \cos\theta = q \cdot v = \sum_{i=1}^{768} q_i v_i

Over all 23 queries, scored as the rank at which the first right clip appears:

ModelRight clip first (top-1)In the top 3MRR
EmbeddingGemma 2, PyTorch fp3222/2323/230.978
EmbeddingGemma 2, all 9 OpenVINO configurations22/2323/230.978
VideoPrism-LvT-B (below)23/2323/231.000

MRR (mean reciprocal rank) averages 1/rank over the queries: 1.0 means every right clip came first, and one query at rank 2 costs 0.5/23 = 0.022. The one miss is “dominoes toppling”: the dominoes clip comes second, behind a glass jar shattering (0.656). Both are fast, chaotic falls of small objects, which is a reasonable confusion.

Now you try

Pick a query and see the ranked clips; the right ones are in green. The dimension buttons keep only the first 768, 512, 256 or 128 numbers of every vector, which the vector database section comes back to.

Search the 43 clips: pick a query and a vector size
Loading…

Running it on a laptop

The model ships for PyTorch. To run it on the Iris Xe it was converted with OpenVINO, Intel’s inference toolkit [11], in two pieces: the vision tower and the encoder, each with fixed input sizes, since the GPU plugin compiles a graph for one shape. Then NNCF [13] made two smaller versions: int8w, with the weights stored as 8-bit integers and the arithmetic still in floating point (compress_weights at its default, asymmetric INT8), and int8, with weights and activations both in 8-bit (quantize with the transformer model type, which picks NNCF’s mixed preset, symmetric weights and asymmetric activations, and runs SmoothQuant first), calibrated on 16 of the 43 clips. Every configuration was run on the CPU and on the iGPU, the iGPU both at its default half precision (f16) and at full precision (f32) [12].

Two things went wrong along the way, and one did not:

  • The model card warns that float16 breaks the model [1]. On this iGPU it did not: f16 matched PyTorch at cosine 0.9999 on the worst of the 43 clips.
  • f32 on the iGPU returned NaN for short clips. Most encoder layers only look 512 tokens around each token. Pad a 4-frame clip (about 490 tokens) to a fixed 1,024 and the last padding tokens have only other padding in view; with every key masked, the attention divides by zero. f16 uses a finite mask value and hides it. The fix is to pad to the nearest of several lengths spaced 512 apart (ov_model.py).
  • INT8 moved the vectors a little and the results not at all. int8 kept a median cosine of 0.994 to PyTorch, 0.974 on the worst clip, and returned the same 22/23; int8w stayed at 0.9991 or above. The only other OpenVINO port I found [16] reports the opposite for its pipeline: asymmetric INT8 weights and 8-bit activations both wrecked the vision tower (per-token cosine 0.12 and 0.019), so it ships weight-only symmetric INT8. Its model came through ONNX and it measured each token, where this post converts from PyTorch and measures the final clip vector; I did not reproduce its setup, so if you quantize this model another way, check the vision output before trusting it.
One 8-frame clip, frames → vector (ms, median of 3 sessions) 0 2705 5410 8114 10819 10819 PyTorch CPU 9691 fp32 CPU 3745 int8 CPU 3161 fp32 iGPU 3954 int8w iGPU 2298 int8 iGPU
The laptop clip, 8 frames at 640×360. iGPU bars are at its default f16. Full table below; the whiskers live in output/latency.png.
ModelDeviceVision, 8 framesEncoder, 1 clipText queryClip, frames → vector
PyTorch fp32CPU53 (51–59)10,819 (10,534–11,443)
fp32CPU9,5991,00653 (52–54)9,691 (8,506–11,020)
fp32iGPU f162,76528729 (29–29)3,161 (3,069–3,234)
fp32iGPU f325,06038036 (35–44)5,393 (5,026–5,483)
int8wCPU4,57653523 (23–28)5,087 (4,848–5,200)
int8wiGPU f163,61430535 (35–36)3,954 (3,874–4,102)
int8CPU3,25140519 (18–19)3,745 (3,703–3,775)
int8iGPU f162,06023224 (24–25)2,298 (2,248–2,388)
int8iGPU f322,40324822 (22–23)2,672 (2,611–2,770)

Milliseconds, median across three sessions with the min–max in brackets (laptop timings drift by up to 20% between sessions). Each session: 3 warm-up runs, then the median of 10. Source: output/benchmark.md, which also has the int8w iGPU f32 row and the batched columns.

Three results in the table are worth a sentence each:

  • int8w is slower than fp32 on the iGPU (3,954 vs 3,161 ms, in all three sessions), while on the CPU it is 1.9× faster than fp32 (5,087 vs 9,691 ms). Storing weights as integers saves memory traffic, and the iGPU apparently pays more to convert them back than it saves; I did not profile it.
  • Batching barely helps. Eight frames per call or one at a time: 2,060 vs 2,011 ms. Four clips per encoder call: 200 ms per clip instead of 232, 14% off the smaller part.
  • f16 is the faster precision on the iGPU for every variant (int8: 2,298 vs 2,672 ms), and for this model it costs nothing in accuracy.

Fewer tokens per frame

Since the vision tower is 90% of the time, the cheapest speed-up is to give it less picture. The processor’s token budget (max_soft_tokens) sets how many tokens each frame becomes: 140 is the video default, which a 640×360 frame turns into the 120 tokens used everywhere above. Each budget needs its own vision export; the encoder is shared. At budgets 70 and 280 the OpenVINO fp32 model matched PyTorch at cosine 1.000000 on two clips before anything was timed.

BudgetTokens per frameClip, frames → vector, int8 iGPU (ms)Search top-1, int8Search top-1, fp32int8 vs fp32, median cosinePaired test, fp32
70661,01322/2322/230.99317/32
140 (default)1202,53322/2322/230.99416/32
2802649,0385/2323/230.67612/32

The laptop clip, 8 frames; one session with all three budgets in it, 3 warm-ups and the median of 10. The 140 row (2,533 ms) sits 6% above the three-session range in the table above, inside the up-to-20% drift between laptop sessions, so compare the budgets with each other, not with that table. Search over the 43 clips; fp32 is the iGPU at f32. Source: output/budget.md.

  • Budget 70 is 2.5× faster (2,533 → 1,013 ms per clip) and finds the same 22/23, with int8 as close to fp32 as at the default. The clip vectors move a little (median cosine 0.979 to the default’s), not enough to change a single ranking here. On a bigger or finer-grained library this is where to check first, since a frame at 66 tokens keeps less detail.
  • Budget 280 is 3.6× slower and breaks int8. In fp32 it is the only setting that also gets the dominoes query right (23/23), but the int8 model at 280 lands at a median cosine of 0.676 to fp32 and finds 5/23. The next section shows why: it is not the budget, it is the sequence length.
  • Direction stays at chance at every budget (17, 16 and 12 of 32), so more pixels per frame do not help the model read the arrow of time either.

int8 on longer clips

Every clip so far has at most 8 frames, about 980 tokens for the encoder. Budget 280 made the sequence 2,130 tokens long, and taking the int8 model apart one piece at a time located the failure: the int8 vision tokens on the iGPU match the CPU’s, while the int8 encoder on the iGPU drifts. The same encoder on the CPU does not, and neither does the fp32 encoder on the iGPU. A longer clip at the default budget does the same thing, which matters more: 2 frames per second on a 7-second clip is already 1,710 tokens.

Configuration2 fps (1,344–1,710 tokens)4 fps (2,686–3,418 tokens)
int8, iGPU, f160.980, 0.986, 0.6160.553, 0.607, 0.576
int8, iGPU, f320.981, 0.986, 0.6580.573, 0.629, 0.581
int8, CPU0.996–0.9970.996–0.997
int8 vision + fp32 encoder, iGPU, f160.997–0.9980.997–0.998

Cosine of each clip vector to fp32 on the CPU, first three clips of the corpus. Source: output/long_sequences.md.

  • f16 is not the cause: the int8 encoder on the iGPU drifts at f32 too. Somewhere between 1,466 and 1,710 tokens it goes from slightly off (0.98) to unusable (0.62).
  • The failing operation is the attention’s Q·Kᵀ product. NNCF quantizes the query and the key that feed each attention, and with both in int8 the GPU plugin of OpenVINO 2026.4.1 breaks the attention into an int8 matrix product (gemm_mmad_int8_slm, the sliding-window mask fused in), a softmax and a second product. In the real graph cut after the first layer’s attention, every row from token 1,024 on comes out of that attention as NaN on the iGPU and correct on the CPU; the four global layers, which have no window, show no NaN. Removing the quantization of either the query or the key restores the full encoder (cosine 0.9998 to the CPU). A standalone attention with the same quantized inputs did not reproduce it, so the trigger involves more of the graph than I isolated. I reported it to OpenVINO with that graph slice as the repro [18].
  • The fix costs little. Keep the vision tower in int8 (it is where the time goes) and run the encoder in fp32: on the laptop clip, 2,417 → 2,462 ms at the default budget (+2%) and 1,052 → 1,117 ms at budget 70 (+6%), one session.
  • Every int8 number earlier in this post stands, because those clips stay under 1,000 tokens. What it means for you: if your clips are longer than about 12 frames, do not run the int8 encoder on this iGPU without checking it against fp32, which is the check that found this.

Which way does time run?

Reverse a clip and the model sees the same 8 pictures in the opposite order. If it reads order, the reversed clip should move toward its reversed caption: the laptop being opened should drift toward “someone closes a laptop”. The two versions share everything else (desk, mug, map, hand), so the test has to cancel what they share. The paired test does: it asks whether the step from the reversed clip to the forward clip points the same way as the step from the reversed caption to the forward caption.

clip reversedclip as filmed“…closes a laptop”“…opens a laptop”Paired test: do the two arrows point the same way?(v_fwd − v_rev) · (t_fwd − t_rev) > 0 → right way round. Chance: 50%.
The clip pair (blue) sits at cosine 0.998, much closer than drawn; the caption pair (amber) further apart. Only the direction of each short arrow matters.

One clip is one coin flip. Over all of them:

Reading (EmbeddingGemma 2, 1 fps)Same frames reversedFile played backwards
Paired test, 32 reversible clips16/32 = 50% (95% CI 32–68%)17/32 = 53%
Forward caption scores the forward clip higher, 32 reversible clips18/32 = 56%16/32 = 50%
Same, 11 one-way clips7/115/11
Linear classifier, one clip held out at a time45/86 = 52%44/86 = 51%
Cosine, clip vs its reverse (median, min)0.9974, 0.98790.9890, 0.9538

The confidence intervals are exact binomial (Clopper–Pearson) intervals [10]. The classifier is a logistic regression trained to tell a clip’s vector from its reverse’s, no captions involved, tested on a clip it never saw; if the direction were anywhere in the 768 numbers, a linear readout would find it. Source: output/results_pytorch_fp32_fps1.md.

Everything is at chance. For scale: two different clips of the corpus sit at a median cosine of 0.613, the clip and its reverse at 0.997. Reversing a video moves its vector about 0.7% as far as swapping it for another video.

Now you try

Go through the 32 reversible clips. Watch how often one caption wins on both versions, as on the laptop, and how small the paired difference is next to the scores themselves. The model switch shows VideoPrism, from the section after next.

The paired test, clip by clip: pick a reversible clip and a model
Loading…

Where the order goes

Order can only enter at step 3 of the pipeline, where each token carries its position. So order_probe.py computes every frame’s tokens once and lays the same tokens out in three orders: forward, reversed and shuffled. It then compares the encoder’s output token by token, before the averaging, at 1, 2 and 4 frames per second.

1 fps2 fps4 fps
Frames per clip4–89–1619–32
Per-token cosine, a frame’s tokens forward vs reversed0.9290.9090.876
Per-token cosine, forward vs shuffled0.9340.8840.819
Pooled vector, forward vs reversed0.99740.99680.9963
Linear classifier on a frame’s tokens, forward vs reversed51%51%
Search top-1, the clip22/2322/2323/23
Search top-1, bag of frames (each frame embedded alone, averaged)23/2323/2323/23

Sources: output/order_probe.md and output/order_frames.md. The classifier ran at 1 and 4 fps.

Three readings:

  • Position moves the tokens. Reversing changes a frame’s own tokens by 7 to 12% of their cosine, more at higher frame rates, against 0.3 to 0.4% for the averaged vector. But shuffling moves them as much as reversing, and a linear classifier on those tokens cannot tell forward from reversed (51%, chance 50%). The model knows where a frame sits in the sequence, and that is all. The averaging does not hide a direction the tokens carry either: no readout I tried found one in them.
  • Search does not need the frames to see each other. Embedding each frame alone and averaging the vectors finds the right clip 23/23 at every frame rate, as well as the whole clip does (the two differ by one query).
  • Timestamps do not fix it. The processor can write the time before each frame (“00:00 … 00:01 …”), the one setting that hands the model the order in words, and Google’s own video example turns it on [17]. Keeping the times ascending while the frames run backwards gives 21/32 at 1 and 2 fps, p = 0.11, not significant, and 10/32 at 4 fps.

One number in order_probe.md looks like a finding and is not one: at 4 fps the paired test gives 8/32 (p = 0.01), below chance, as if the model systematically preferred the wrong direction. Against it: the ranking reading at 4 fps is 17/32; the shuffled-frames control is 15, 14 and 17/32 at 1, 2 and 4 fps; a clip’s answer at 1 fps and at 4 fps agrees for 14 of 32 clips, a coin’s rate; and this post ran more than a dozen direction tests, so one p = 0.01 among them is expected by chance alone. I report it as an observation and draw nothing from it.

A model built for video: VideoPrism

EmbeddingGemma 2 encodes each frame on its own. VideoPrism [3] is built differently: a factorised video transformer in the ViViT style [4], with four layers of attention across frames on top of the image layers, and its video-text version (LvT-B, Apache-2.0) is trained to match clips with captions. If any open model should tell a clip from its reverse, it should be this one. It ran on the same clips, captions and tests, with 16 frames spread over each clip as it was trained.

Its transformers weights are an unmerged conversion, so they were checked against Google’s own JAX code [15] on the same inputs: cosine 1.0 for video and text, once the text is cleaned the way the official code does it (lower case, punctuation removed, a final period). Without that step, caption vectors drift to cosine 0.99 of the official ones (recorded in videoprism_embed.py).

EmbeddingGemma 2VideoPrism-LvT-B
Search top-122/2323/23
Paired test, same frames reversed16/3219/32 (p = 0.38)
Linear classifier, forward vs reversed52%56%
Reversing: 1 − cos, as a share of the gap between two clips0.0026 / 0.387 = 0.7%0.0014 / 0.458 = 0.3%
Shuffling: same0.0020 / 0.387 = 0.5%0.0124 / 0.458 = 2.7%

Sources: output/results_videoprism_lvt_b_16f.md and output/results_pytorch_fp32_fps1.md. The gap between two clips is 1 − their median cosine (0.613 and 0.542).

How far the vector moves, % of the gap between two different clips 0.0 0.7 1.4 2.0 2.7 0.7% Gemma: reverse 0.5% Gemma: shuffle 0.3% VideoPrism: reverse 2.7% VideoPrism: shuffle
Same 43 clips. VideoPrism's vector moves 9× further when the frames are shuffled than when they are reversed; EmbeddingGemma 2 treats both alike.

VideoPrism’s temporal layers do something: shuffled frames move its vector 2.7% of the way to another clip, five times EmbeddingGemma’s 0.5%, and nine times its own move under reversal. A shuffled clip jumps between moments, which those layers notice. A reversed clip is still smooth motion, frame to frame, which is the likeliest reason it looks almost the same to them (one model, one setting; I did not test the mechanism). VideoPrism can tell whether the frames flow, and not which way. Its own README hints at this: zero-shot, LvT-B scores 14.6% on the temporal subset of Something-Something v2 [15][8], a dataset of short actions such as “pushing something from left to right” whose classes often differ only in direction.

Detecting the arrow of time is a known, learnable task: Pickup et al. [6] and Wei et al. [7] trained models to do it, from motion that is asymmetric in time, physics (things fall, liquids spread), what usually happens next, and how cameras are usually moved. My guess is that a caption-matching objective rarely rewards it, because most captions say what is in the scene and not which way it moves; I did not test that.

Do you need a vector database yet?

At 43 clips, no. Ranking them is one matrix-vector product that takes 0.01 ms. The question is where that stops being true. scale.py timed the same exact search on random unit vectors (the timing does not depend on what they contain) up to a million, on one CPU thread:

Vectors768 numbers: ms per querymemory256 numbers: ms per querymemory
430.0100.13 MB0.0100.04 MB
1,0000.0642.9 MB0.0291.0 MB
10,0000.9629 MB0.209.8 MB
100,00010.5293 MB4.498 MB
1,000,0001042,930 MB45977 MB

float32 NumPy, top-10 by partial sort, median of 20 runs after 3 warm-ups, i7-12700H, one thread (with all 20 threads OpenBLAS added random 3 ms stalls). Source: output/scale.md.

Cutting the vectors helps too. EmbeddingGemma 2 is trained with Matryoshka representation learning [5], which packs the most useful information into the first numbers, so the first 512, 256 or 128 can stand in for all 768 after rescaling to length 1 [1]. On these 23 queries, 512, 256 and 128 numbers all put the right clip first 23/23 (768: 22/23; the one query that changes is the dominoes). That is one query on 43 clips, so the fair reading is that shorter vectors did no worse here. The model card measures what 43 clips cannot: on its multimodal benchmark (MMEB v2) the score goes from 59.01 at 768 numbers to 56.24 at 256 and 45.65 at 128, while text alone loses far less [1]. For video, 256 is the floor. At 256 numbers the million-clip search drops to 45 ms and 977 MB. A follow-up post compares the vector databases that take over past this point.

What this means

  • Offline video search on a laptop works today. 22/23 right first, 23/23 in the top 3, identical in every runtime and precision tested; 2.3 s per clip to index on the Iris Xe with INT8, 24 ms per query. On the iGPU, run the vision tower in int8 and the encoder in fp32, at the default f16 and a token budget of 70: about 1.1 s per clip with the same results here. The int8 encoder on this iGPU breaks on sequences past ~1,500 tokens (cosine 0.55–0.66 to fp32), which any clip over about 12 frames reaches.
  • It finds what is in a scene, not the order things happen in. A clip and its reverse are at cosine 0.997 and the direction tests sit at chance (16/32, 52%). Searches like “person falls” vs “person gets up”, “car arrives” vs “car leaves” or “door opens” vs “door closes” will return both. If your product needs that distinction, it needs a different signal: motion direction from optical flow, or a model trained on the arrow of time [6][7].
  • A video model does not fix it out of the box. VideoPrism notices shuffled frames (2.7% of the between-clip gap) but not reversal (0.3%), and 19/32 on the paired test.
  • You can drop the frames’ interaction for search. Averaging per-frame vectors matched the whole clip (23/23), which lets an index embed frames in parallel and add new frames without re-encoding a clip.
  • No vector database below roughly 100,000 clips. Exact NumPy search is 10 ms there at 768 numbers and 4.4 ms at 256.

Limitations

  • 43 clips, 23 queries. One query is 4% of the search score, so “22/23 vs 23/23” differences in this post (bag of frames, VideoPrism, shorter vectors) are one query each and not evidence of a better method. The queries and captions were written by one person, before any vector was computed.
  • The paired test is weak by construction. It reads differences of about 0.005 between cosines of about 0.75, as the laptop example shows. With 32 clips, a model that is truly right 65% of the time would pass a binomial test at α = 0.05 only about a quarter of the time (power 0.27). The classifier is the stronger test and also found nothing.
  • More than a dozen direction tests ran across the project. Uncorrected p-values are reported as computed; the 4 fps 8/32 is discussed above as one expected false alarm.
  • INT8 calibration used 16 of the 43 clips that are also evaluated. It only sets value ranges (no labels), but it is not a held-out calibration.
  • The long-clip check covers three clips at 2 and 4 fps. It is enough to show the int8 encoder breaking on the iGPU, not to place the threshold more precisely than ~1,500 tokens.
  • One laptop. Timings are an i7-12700H with Iris Xe on Ubuntu 22.04, three sessions; other iGPUs and drivers will differ. Decoding the video and sampling frames are not timed.
  • VideoPrism ran at 16 frames, EmbeddingGemma 2 at 1 fps (4–8). The probes ran EmbeddingGemma 2 at up to 32 frames without finding direction, so frame count does not explain the gap, but the two were not run at the same count. VideoPrism was not exported to OpenVINO.
  • The vector-database numbers are exact search on random vectors on one thread. They show the cost curve; a production setup adds filters, updates and concurrent users.

Reproducibility

ParameterValue
CPUIntel Core i7-12700H (14 cores, 20 threads)
iGPUIntel Iris Xe Graphics (OpenVINO GPU.0)
dGPUnot used (the laptop’s RTX 3060 stayed idle)
RAM and OS32 GB; Ubuntu 22.04.5 LTS, kernel 6.8
Library versionsPython 3.12.9, OpenVINO 2026.4.1, NNCF 3.4.0, PyTorch 2.14.1 (CPU), transformers 5.20.1, NumPy 2.4.6, PyAV 19.0.1, scikit-learn 1.9.1, SciPy 1.18.1
Modelgoogle/embeddinggemma-2, revision 914f7f8, Apache-2.0 [1]
Control modelgoogle/videoprism-lvt-base-f16r288, transformers weights from pull request #2 at fb6de9f, Apache-2.0; checked against google-deepmind/videoprism at c6eba0e [3][15]
Data43 Pexels clips [14], ids and trims in clips.json, 640×360, no audio; 23 queries and 75 captions written on 16 Oct 2026 before any embedding
Frames1 fps for EmbeddingGemma 2 (4–8 per clip; 2 and 4 fps in the probes); 16 evenly spaced for VideoPrism
Timingone 8-frame clip; 3 warm-ups discarded, median of 10 runs; 3 sessions (16, 17 and 17 Oct 2026); median and min–max across sessions
Excluded from timingvideo decoding and frame sampling; graph compilation
QuantizationNNCF 3.4.0: compress_weights INT8 (int8w); quantize post-training INT8 (int8), 16 calibration clips
Evaluationevaluate.py (search, paired test, Clopper–Pearson intervals, leave-one-clip-out logistic regression)
Commandsdownload.py, prepare.py, embed.py, evaluate.py, export_openvino.py, quantize.py, benchmark.py --session 1..3, report.py, order_probe.py, videoprism_embed.py, budget.py --export then budget.py, long_sequences.py, scale.py; order and arguments in the repository README

Every measured number in this post is in a table above or cites the file it comes from in the companion repository: output/ for results, ov_model.py for the padding NaN, and videoprism_embed.py for the VideoPrism parity check. The one exception is the snippet’s printout under the pipeline list, a single run of that notebook cell.

Further reading

  • Go deeper on retrieval: Manning, Raghavan and Schütze [9], ch. 6 for the vector space model and cosine scoring, §8.4 for how ranked results are evaluated, and ch. 7 for the shortcuts search engines take once exact scoring gets too slow.
  • The arrow of time as its own task: Wei et al. [7] train a network to detect it and show what it learns (falling, spreading, shadows), a good map of the cues a caption model ignores.
  • Related on CondadosAI: YOLO-NAS on the same laptop and YOLO26-seg vs RF-DETR-Seg on the Iris Xe for more OpenVINO INT8 on an iGPU; tracking footballers on an Intel iGPU for a video pipeline where time order matters; and the field guide to local features for cosine similarity between descriptors.
Clip credits (all 43, Pexels License)

a candle is blown out by Mikhail Nilov · a car drives away down the road by Mabel Amber · a glass jar shatters by Just For Music And Film · a hand picks up a mug by Yosef Futsum · a hand puts a cup down on the table by Cup of Couple · a hand puts down a mug by Yosef Futsum · a man blows up a balloon by Coverr · a man gets into a car by WeStarMoney Rec · a man puts on a hat by ROMAN ODINTSOV · a man puts on a jacket by Pixabay · a man walks into a room by Nishant Das · a match is struck and lights by Taryn Elliott · a pencil is snapped in half by Andrew Kota · a person lies down on a bed by KoolShooters · a person opens a door by Josh Tavernier | iCapture Media Digital Marketing Agency · a person opens the curtains by Ammad Rasool · a person sits down on a chair by MART PRODUCTION · a person stands up from a chair by PNW Production · a person walks away from the camera by Yasin Onuş · a person walks up the stairs by Kindel Media · a row of dominoes falls over by cottonbro studio · a swimmer dives into a pool by Kampus Production · a train arrives at the station by Wolfgang Weiser · a water balloon bursts by K · a wave crashes on rocks by 정규송 Nui MALAMA · a woman gets out of a car by Pavel Danilyuk · a woman gets out of bed by PNW Production · a woman opens a box by Mikhail Nilov · a woman puts on a glove by Polina Tankilevitch · a woman puts on glasses by Kaboompics · a woman puts on headphones by Kaboompics · a woman walks down the stairs by SHVETS production · an airplane lands on the runway by Hampie · an egg is cracked into a bowl by Артем Ковальчук · coffee pours into a cup by Tima Miroshnichenko · elevator doors open by Stefan · riding an escalator up by Tom Plant0 · riding an escalator up by GRace Wu · someone closes a laptop by Monstera Production · someone opens a laptop by Monstera Production · someone packs a suitcase by SHVETS production · someone zips up a jacket by cottonbro studio · water is poured into a glass by 2type

References

[1] Google DeepMind. EmbeddingGemma 2 model card. Hugging Face google/embeddinggemma-2, revision 914f7f8, 2026. Model card.

[2] Schechter Vera, H., Dua, S., Zhang, B., Salz, D., et al. EmbeddingGemma: Powerful and Lightweight Text Representations. arXiv, 2025. arXiv:2509.20354.

[3] Zhao, L., Gundavarapu, N. B., Yuan, L., Zhou, H., Yan, S., Sun, J. J., Friedman, L., Qian, R., Weyand, T., Zhao, Y., Hornung, R., Schroff, F., Yang, M.-H., Ross, D. A., Wang, H., Adam, H., Sirotenko, M., Liu, T., & Gong, B. VideoPrism: A Foundational Visual Encoder for Video Understanding. ICML 2024, PMLR 235:60785–60811. Paper, arXiv:2402.13217.

[4] Arnab, A., Dehghani, M., Heigold, G., Sun, C., Lučić, M., & Schmid, C. ViViT: A Video Vision Transformer. ICCV 2021, 6836–6846. doi:10.1109/ICCV48922.2021.00676, arXiv:2103.15691.

[5] Kusupati, A., Bhatt, G., Rege, A., Wallingford, M., Sinha, A., Ramanujan, V., Howard-Snyder, W., Chen, K., Kakade, S., Jain, P., & Farhadi, A. Matryoshka Representation Learning. NeurIPS 35, 2022, 30233–30249. Paper, arXiv:2205.13147.

[6] Pickup, L. C., Pan, Z., Wei, D., Shih, Y., Zhang, C., Zisserman, A., Schölkopf, B., & Freeman, W. T. Seeing the Arrow of Time. CVPR 2014, 2035–2042. doi:10.1109/CVPR.2014.262, CVF open access.

[7] Wei, D., Lim, J. J., Zisserman, A., & Freeman, W. T. Learning and Using the Arrow of Time. CVPR 2018, 8052–8060. doi:10.1109/CVPR.2018.00840, CVF open access.

[8] Goyal, R., Ebrahimi Kahou, S., Michalski, V., Materzyńska, J., Westphal, S., Kim, H., Haenel, V., Fruend, I., Yianilos, P., Mueller-Freitag, M., Hoppe, F., Thurau, C., Bax, I., & Memisevic, R. The “Something Something” Video Database for Learning and Evaluating Visual Common Sense. ICCV 2017, 5842–5850. doi:10.1109/ICCV.2017.622, arXiv:1706.04261.

[9] Manning, C. D., Raghavan, P., & Schütze, H. Introduction to Information Retrieval. Cambridge University Press, 2008. Ch. 6 (Scoring, term weighting and the vector space model), §6.3.1 (Dot products); §8.4 (Evaluation of ranked retrieval results); ch. 7 (Computing scores in a complete search system). Free online edition.

[10] Clopper, C. J., & Pearson, E. S. The use of confidence or fiducial limits illustrated in the case of the binomial. Biometrika 26(4), 404–413, 1934. doi:10.1093/biomet/26.4.404.

[11] OpenVINO Toolkit Documentation (2026.4). Convert to OpenVINO IR. Docs.

[12] OpenVINO Toolkit Documentation (2026.4). GPU Device. Docs. The f16 default is set in the GPU plugin source, intel_gpu/runtime/options.inl, release 2026.4.1.

[13] NNCF 3.4.0. Post-training quantization and Weights compression usage guides. Quantization, weight compression.

[14] Pexels. Pexels License. pexels.com/license. Free to use and modify; the companion code downloads the clips and never redistributes them.

[15] Google DeepMind. VideoPrism (JAX/Flax code and checkpoints), commit c6eba0e. GitHub.

[16] wesjos. embeddinggemma-2-openvino-int8 (model card). Hugging Face, revision 0a86bfa, 2026. Model card.

[17] Google AI for Developers. Multimodal Embeddings with EmbeddingGemma 2 (Sentence Transformers guide), 2026. Docs.

[18] Condados, L. GPU plugin returns NaN from token 1024 in NNCF-int8 SDPA with a sliding-window mask. OpenVINO issue #38964, 2026. GitHub.