Contextual video match
An article goes in, a ranked playlist of video comes out, and every match carries the entities, keywords and categories that produced it. Both sides are embedded twice, once by a text model and once by a multimodal one, so nothing is embedded at query time and a clip in another language still matches.
Applies empty and reads Your own video library and a handful of live article URLs. Connect your own data before querying.
- warm retrieval, no reranker
- 287ms
- multimodal and text segments from 22 source videos
- 122 / 118
- publisher URLs carrying page signals end to end
- 233
- dimensions per side, in two spaces
- 1408 + 1024
- click to first answer
- 32.8 s
How these were measured
- 287ms warm retrieval, no reranker: Measured on the v8 benchmark of the pipeline this template is modelled on and recorded in that retriever's own header. Reranking was left out on purpose: a BGE reranker on CPU added about 30 seconds. A GPU reranker is a different trade and has not been measured here.
- 122 / 118 multimodal and text segments from 22 source videos: From the reference deployment's benchmark record dated 2026-04-30: 22 video files between 13 and 149 MB produced 122 multimodal segments and 118 text segments. Two counts because the two extractors segment independently.
- 233 publisher URLs carrying page signals end to end: The article side was run over 233 real publisher URLs, each returning entities with a salience score, keywords, an IAB path, sentiment with a confidence and a brand-safety flag. That is a coverage figure, not an accuracy figure.
- 1408 + 1024 dimensions per side, in two spaces: mixpeek://multimodal_extractor@v1/vertex_multimodal_embedding is 1408 dimensions and mixpeek://text_extractor@v1/multilingual_e5_large_instruct_v1 is 1024, both cosine. Verified against GET /v1/discovery/extractors on 2026-09-13.
- 32.8 s click to first answer: from clicking apply to the first search that answers, timed on production on 6 November 2026 by the harness that runs nightly
What’s inside
- Extractors
- multimodaltext
- Retrievers
- contextual-match·feature_search → deduplicate → code_execution
- Marketplace retrieversHosted checks that run beside it, no key
- IAB contextual targeting
- Solution
- Advertising →
See it work
- a kitchen scene with someone cooking at a hobcontextual-match
scene segments ranked by how well the frame and the words fit the query
- commuters on a crowded platform at rush hourcontextual-match
returns the seconds that match, so a placement lands inside them
- quiet interior, one person, natural lightcontextual-match
the scene embedding carries composition, so mood-shaped queries work
cleared- video-scenes -> scene-transcripts
- scene segments
- transcript
- route: cleared
How it's wired
Applies 2 buckets, 4 collections and 1 retrievers today
Drag nodes to rearrange. Drag the background to pan, ctrl or cmd plus scroll to zoom. Click a node for its settings.
What it costs
Processing is priced the way a batch is charged: each bucket's minutes, images, pages or tokens times its rate card lines. The batch dry run in Studio gives the binding quote.
Models
- Multilingual E5 Large InstructServed on MixpeekEmbedding
- Vertex AI multimodalembedding@001Provider APIEmbedding
Collections and their documents
Reads video from content
| Field | Type | Required |
|---|---|---|
| document_id | string | set by Mixpeek |
| collection_id | string | set by Mixpeek |
| file_location | as sent | no |
| segment_id | as sent | no |
| start_time | as sent | no |
| end_time | as sent | no |
| tenant_id | as sent | no |
mixpeek://multimodal_extractor@v1/vertex_multimodal_embeddingfrom Vertex AI multimodalembedding@001mixpeek://text_extractor@v1
Reads text from content
| Field | Type | Required |
|---|---|---|
| document_id | string | set by Mixpeek |
| collection_id | string | set by Mixpeek |
| article_url | as sent | no |
| published_at | as sent | no |
| tenant_id | as sent | no |
mixpeek://multimodal_extractor@v1/vertex_multimodal_embeddingfrom Vertex AI multimodalembedding@001mixpeek://text_extractor@v1
Reads text from transcription
| Field | Type | Required |
|---|---|---|
| document_id | string | set by Mixpeek |
| collection_id | string | set by Mixpeek |
| segment_id | as sent | no |
| video_segment_url | as sent | no |
| start_time | as sent | no |
| end_time | as sent | no |
mixpeek://multimodal_extractor@v1/vertex_multimodal_embeddingfrom Vertex AI multimodalembedding@001mixpeek://text_extractor@v1
Reads text from content
| Field | Type | Required |
|---|---|---|
| document_id | string | set by Mixpeek |
| collection_id | string | set by Mixpeek |
| article_url | as sent | no |
| published_at | as sent | no |
| tenant_id | as sent | no |
mixpeek://multimodal_extractor@v1/vertex_multimodal_embeddingfrom Vertex AI multimodalembedding@001mixpeek://text_extractor@v1
Retrievers
- contextual-matchfeature_search then deduplicate then code_execution over scene-transcripts, video-scenes, article-text, article-multimodal; input article_text_doc_id, article_multimodal_doc_id, article_entities, article_keywords, article_iab_categories
Spend limits
- Daily spend budget in the manifest
- 1 USD a day; new batches pause once it is spent
- Acceptance run ceiling
- 0.5 USD
- Model cost
- Unmeasured for 2 of 2 models
Monthly cost at your volume is in the calculator. Served models start at zero replicas, so an idle namespace runs no model.
Reward signals
Nothing in this template writes interaction signals back. Saying so is better than implying a flywheel that is not wired, and the ranking is deterministic given the same corpus.
How reviewer decisions move the thresholds
None. No editor thumbs, no accepted-playlist marks are recorded by the template as shipped.
None from the application. Mixpeek records retriever executions server-side, which is telemetry about queries rather than feedback about which match was right.
mxp_retriever_executionsThe loop that would close it is the customer's own answer key: a set of articles with the playlist they consider correct. That is an evaluation input rather than a signal the template can collect on its own.
Match video to an article by what the article is ABOUT, not by the words it shares. The hierarchy the buyer cares about is entity first, then keyword, then IAB category, and the retriever returns the overlap on each of the three beside every result, so an editor can read why a clip was chosen.
- In the reference deployment, one request takes an article that is already indexed and returns ranked video segments, with start and end time, so a result is a moment rather than a file.
- The one-click deploy now points both searches at the article's own document, deduplicates the segments the two indexes both returned, and runs the overlap scoring. Nothing is embedded at request time. What deploys today lists each stage, and the one term that cannot fire yet is the taxonomy weight, which reads a field a taxonomy materialisation writes and this template does not create.
- Every result carries entities_matched, keywords_matched, iab_matched and the per-signal overlap, which is what makes the ranking auditable instead of a black box.
- A clip whose transcript is garbled or in another language still matches, because entity overlap falls back to the text burned into the frames.
- In the reference deployment, both sides of the match are indexed ahead of time, so the request embeds nothing and the measured warm latency is 287ms.
For: Ad platforms and publishers placing video against editorial pages
- videos
- One object per video. The template cuts each into segments and indexes transcription, on-screen text and a multimodal embedding per segment.
- articles
- One object per page: the article text, plus article_url, published_at and tenant_id as fields. The tenant field is what a query-time scope filter reads.
- video-scenes
- Segment documents with a 1408-dimension multimodal embedding, timing, transcription, OCR text, and the entities, keywords and IAB categories read off the segment.
- scene-transcripts
- The spoken side of each segment as a 1024-dimension text embedding, so a text-first query reaches it directly.
- article-multimodal
- The article in the same 1408-dimension space as the video, which is what makes the cross-modal half of the match a document reference rather than a query embedding.
- article-text
- The article as a 1024-dimension text embedding, with its URL, publish time and tenant carried as fields.
One file spins up the namespace. Generated from the workflow above. Also served at /templates/contextual-video-match.namespace.yaml.
# contextual-video-match: one manifest spins up the namespace.
# Platform manifest schema (GET /v1/discovery/schema). Validate with POST /v1/manifest/validate,
# apply with POST /v1/manifest/apply or the Deploy button. Wiring comes from the flow diagram:
# edges are bucket -> collection sources, collection -> retriever scope, retriever -> view.
# Applying a SECOND time, over a namespace this template already created: use
# POST /v1/manifest/apply?mode=create_missing, which creates what is missing and leaves
# what exists alone. The default, create_only, fails the WHOLE apply and rolls it back if
# any resource already exists, so an upgrade looks like a dead end without this. Use
# mode=upsert to also patch resources that exist but have drifted from this file.
version: '1.0'
metadata:
name: contextual-video-match
description: "Namespace template contextual-video-match. Generated from the flow diagram on mixpeek.com/templates/contextual-video-match."
namespaces:
- name: contextual-video-match
description: "Everything below lives in this namespace."
feature_extractors:
- name: multimodal_extractor
version: v1
- name: text_extractor
version: v1
# Daily spend budget, in dollars of what you are charged (1,000 credits a dollar).
# New batches pause once the namespace has spent this much today; queries are not capped.
# Raise it later on the namespace: PATCH /v1/namespaces/<id> {"spend_budget": {"daily_usd": N}}.
budget:
daily_usd: 1
# Data sources. A storage connection carries credentials, so it is created in Studio
# (or POST /v1/organizations/storage-connections) and synced into the bucket named here.
# video-corpus: s3, continuous, the video library you want matched against, in your account -> bucket videos
# article-feed: manual, continuous, one object per page: the article text, its URL, its publish time and the tenant it belongs to -> bucket articles
buckets:
- name: videos
namespace: contextual-video-match
description: "Meant to be fed by video-corpus (s3, continuous). No source is connected yet: applying this manifest creates the bucket only. Connect the source to this bucket in Studio (Syncs) to start the feed."
schema:
properties:
content:
type: video
- name: articles
namespace: contextual-video-match
description: "Meant to be fed by article-feed (manual, continuous): upload files here or write them through the API. Nothing arrives until you do."
schema:
properties:
content:
type: text
# Connect your own storage. Create the two secrets, uncomment, and apply again;
# apply live-tests the connection, so it must have real credentials to succeed.
# Until then the buckets above accept direct uploads.
# storage_connections:
# - name: video-corpus-connection
# provider: s3
# description: "Read-only access to the s3 location holding the video library you want matched against, in your account."
# config:
# region: us-east-1
# credentials:
# type: access_key
# access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }}
# secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# bucket_syncs:
# - name: video-corpus-sync
# bucket: videos
# connection: video-corpus-connection
# source_path: "videos/"
# sync_mode: continuous
# polling_interval_seconds: 300
# skip_duplicates: true
# file_filters:
# include_patterns: ["*.mp4", "*.mov", "*.webm"]
# schema_mapping:
# mappings:
# content:
# target_type: blob
# source: {type: file}
# blob_type: video
collections:
- name: video-scenes
namespace: contextual-video-match
description: "multimodal_extractor@v1 over bucket videos. Feeds scene-transcripts, contextual-match."
source:
type: bucket
bucket: videos
feature_extractor:
name: multimodal_extractor
version: v1
parameters:
run_transcription: true
run_video_description: true
run_ocr: true
description_prompt: "Identify the named entities and topical keywords present in this video segment, from what is spoken, shown on screen, and visible in the frame. Transcribe any text burned into the frame, such as captions, lower thirds, tickers and signage, verbatim."
response_shape:
type: "object"
properties:
entities:
type: "array"
items:
type: "string"
description: "Named entities (people, organizations, places) in canonical English form, deduplicated. 3-10 items."
keywords:
type: "array"
items:
type: "string"
description: "Topical keywords or short phrases describing what the segment is about. 3-8 items."
iab_categories:
type: "array"
items:
type: "string"
description: "IAB Content Taxonomy tier-1 category names that describe this segment, written exactly as IAB names them, for example \"News & Politics\", \"Arts & Entertainment\", \"Sports\", \"Automotive\". 1-3 items."
ocr_text:
type: "string"
description: "Text burned into the frame, transcribed verbatim: captions, lower thirds, tickers, signage. Empty string when the segment shows no text."
input_mappings:
video: content
field_passthrough:
- source_path: file_location
- source_path: segment_id
- source_path: start_time
- source_path: end_time
- source_path: tenant_id
enabled: true
- name: article-multimodal
namespace: contextual-video-match
description: "multimodal_extractor@v1 over bucket articles. Feeds contextual-match."
source:
type: bucket
bucket: articles
feature_extractor:
name: multimodal_extractor
version: v1
input_mappings:
text: content
field_passthrough:
- source_path: article_url
- source_path: published_at
- source_path: tenant_id
enabled: true
- name: scene-transcripts
namespace: contextual-video-match
description: "text_extractor@v1 over collection video-scenes. Feeds contextual-match."
source:
type: collection
collection: video-scenes
feature_extractor:
name: text_extractor
version: v1
input_mappings:
text: transcription
field_passthrough:
- source_path: segment_id
- source_path: video_segment_url
- source_path: start_time
- source_path: end_time
enabled: true
- name: article-text
namespace: contextual-video-match
description: "text_extractor@v1 over bucket articles. Feeds contextual-match."
source:
type: bucket
bucket: articles
feature_extractor:
name: text_extractor
version: v1
input_mappings:
text: content
field_passthrough:
- source_path: article_url
- source_path: published_at
- source_path: tenant_id
enabled: true
retrievers:
- name: contextual-match
namespace: contextual-video-match
description: "Matches scene-transcripts and video-scenes against a document from article-text and article-multimodal, with no query-time embedding."
collections:
- scene-transcripts
- video-scenes
- article-text
- article-multimodal
input_schema:
article_text_doc_id:
type: string
required: true
description: "Document id of the article in article-text, whose stored text vector the scene transcripts are matched against"
article_multimodal_doc_id:
type: string
required: true
description: "Document id of the same article in article-multimodal, whose stored multimodal vector the video scenes are matched against"
article_entities:
type: array
required: false
description: "Entities already extracted from the article; drives the 0.25 overlap term"
article_keywords:
type: array
required: false
description: "Keywords already extracted from the article; drives the 0.15 overlap term"
article_iab_categories:
type: array
required: false
description: "IAB categories for the article; drives the 0.10 taxonomy term and the Entertainment-against-News penalty"
stages:
- stage_name: search
stage_id: feature_search
parameters:
searches:
- feature_uri: "mixpeek://text_extractor@v1"
collection_identifiers:
- scene-transcripts
query:
input_mode: document
document_ref:
collection_id: article-text
document_id: "{{INPUT.article_text_doc_id}}"
top_k: 30
min_score: 0.15
- feature_uri: "mixpeek://multimodal_extractor@v1/vertex_multimodal_embedding"
collection_identifiers:
- video-scenes
query:
input_mode: document
document_ref:
collection_id: article-multimodal
document_id: "{{INPUT.article_multimodal_doc_id}}"
top_k: 30
fusion: rrf
final_top_k: 30
- stage_name: deduplicate
stage_id: deduplicate
parameters:
strategy: field
fields:
- source_video_url
- start_time
keep: first
- stage_name: score
stage_id: code_execution
parameters:
language: python
output_field: scoring
timeout_ms: 5000
code: |
_ae = {{ INPUT.article_entities }}
_ak = {{ INPUT.article_keywords }}
_ac = {{ INPUT.article_iab_categories }}
art_ents = set(str(e).lower() for e in (_ae or []))
art_kws = set(str(k).lower() for k in (_ak or []))
art_cats = set(str(c).lower() for c in (_ac or []))
result = []
for doc in docs:
ve = set(str(e).lower() for e in doc.get('entities', []))
vk = set(str(k).lower() for k in doc.get('keywords', []))
vc = set(str(c).lower() for c in doc.get('iab_categories', []))
vt = ((doc.get('transcription') or '') + ' ' + (doc.get('ocr_text') or '')).lower()
te = set(e for e in art_ents if e in vt)
all_ents = (art_ents & ve) | te
eo = len(all_ents) / max(len(art_ents), 1) if art_ents else 0
ko = len(art_kws & vk) / max(len(art_kws), 1) if art_kws else 0
to = len(art_cats & vc) / max(len(art_cats), 1) if art_cats else 0
s = doc.get('scores', {}).get('search', doc.get('score', 0))
cs = 0.50 * s + 0.25 * min(eo * 1.5, 1.0) + 0.15 * min(ko * 1.5, 1.0) + 0.10 * to
tp = 0.7 if vc and vc & {'entertainment', 'arts & entertainment'} and art_cats & {'news & politics', 'law government & politics'} else 1.0
cs *= tp
result.append({'contextual_score': round(cs, 4), 'entity_overlap': round(eo, 4), 'keyword_overlap': round(ko, 4), 'taxonomy_overlap': round(to, 4), 'entities_matched': list(all_ents)[:5], 'keywords_matched': list(art_kws & vk)[:5], 'iab_matched': list(art_cats & vc)[:3], 'taxonomy_penalty': tp})
tags:
- template:contextual-video-match
alerts:
- name: contextual-match-returning-nothing
namespace: contextual-video-match
description: "Emails the org notifications address (Settings -> Notifications; unset means opted out) when contextual-match has come back empty on its last five runs: the articles are matching no scene, so the video index or the article documents need a look."
source: system
system_condition:
type: retriever_no_results
params:
min_samples: 5
notification_config:
channels:
- channel_type: email
include_matches: true
include_scores: true
enabled: true
# Plan 11.14: two public-domain films and two public-domain texts from the
# starter sample set, ingested after apply, and the use case they must satisfy.
seed:
- bucket: videos
max_objects: 2
objects:
- blobs: {content: samples/starter/videos/norelco-speed.mp4}
- blobs: {content: samples/starter/videos/mexican-hat-dance.mp4}
- bucket: articles
max_objects: 2
objects:
- blobs: {content: samples/starter/texts/frankenstein.txt}
- blobs: {content: samples/starter/texts/a-christmas-carol.txt}
acceptance:
max_cost_usd: 0.5
steps:
- name: video-scenes-indexed
kind: documents
collection: video-scenes
expect: {min_documents: 2}
- name: articles-indexed
kind: documents
collection: article-text
expect: {min_documents: 2}Read from the manifest, so a part it ships commented out never shows as ready.
What each part needs
- video-scenes
- Deploys and processes documents as they arrive.
- article-multimodal
- Deploys and processes documents as they arrive.
- scene-transcripts
- Deploys and processes documents as they arrive.
- article-text
- Deploys and processes documents as they arrive.
- contextual-match
- Deploys and answers queries once its collections hold documents.
- feature_search
- The one-click manifest includes this stage, and every search runs from a document query.
- deduplicate
- The one-click manifest includes this stage.
- code_execution
- The one-click manifest includes this stage.
- video-corpus
- Create the s3 credentials as manifest secrets, then uncomment the sync. It ships commented out because apply tests a connection as it creates it.
- article-feed
- You upload these files yourself. There is no connection to sync.
- attribute_filter
- The one-click manifest has no attribute_filter stage. Add it to the retriever after deploy.
| Component | Role | Source | Today |
|---|---|---|---|
| multimodal_extractor@v1 | Segments each video and embeds the segment, the article, and both sides' structured fieldsRuns on both sides. Its response_shape is what produces entities, keywords and IAB categories as document fields, which is what the overlap scoring reads. The @v1 pin is required rather than merely current: multimodal_extractor@v2 exposes gemini-embedding-2 and does NOT carry vertex_multimodal_embedding, so moving to v2 on the assumption that a higher version is a superset silently drops the 1408-dimension space this template matches in. | Built in | Deploys ready |
| text_extractor@v1 | Embeds the spoken transcript and the article text into one 1024-dimension spaceMultilingual E5 large instruct. Covering roughly 100 languages is the reason a non-English clip can match an English article at all. | Built in | Deploys ready |
| feature_search | Two searches, fusedBoth searches run from a document reference, matching against the article's own indexed vectors rather than an embedded query string, with top_k 30 on both sides and min_score 0.15 on the E5 side only. Vertex multimodal similarity between text and video runs low: a matching traffic article scored about 0.02 against traffic footage, so a 0.15 floor there would drop every scene. The E5 side ranks scene-transcripts from article-text; the multimodal side ranks video-scenes from article-multimodal. | Built in | Deploys ready |
| deduplicate | Collapses the same segment arriving from both searchesOn the source video and the segment start time, keeping the first. Without it the fused list spends slots on one segment twice. | Built in | Deploys ready |
| code_execution | The entity, keyword and category overlap scoring0.50 search + 0.25 entity + 0.15 keyword + 0.10 taxonomy, with a 0.7 multiplier when an Entertainment video meets a News article. It emits every overlap it used, which is where the explainability comes from. | Built in | Deploys ready |
| attribute_filter | Tenant scope and publisher include and exclude rules at query timeDesigned into the shape and not exercised in the reference deployment, where every video belonged to one tenant. | Built in | Not available yet |
Apply the manifest and the namespace comes up with four collections and one retriever.
Same manifest against a customer-hosted deployment, which is the path when the article corpus cannot leave your account.
- Permissions
- A key with namespace create and collection create. The storage connection is a separate grant and the manifest ships it commented out, because apply live-tests a connection and would roll the whole namespace back on a bad credential.
- Footprint
- Two vector indexes per side, 1408 and 1024 dimensions. Storage is dominated by the video segments: the reference deployment held 122 segment documents from 1.5 GB of source video.
None ship with this template, and the accuracy question is open rather than answered. The reference deployment's own requirements doc lists match accuracy as the criterion that decides the evaluation and records it as not yet scored, because scoring it needs the buyer's own answer key.
| Category | Rule |
|---|---|
| Search floor | min_score 0.15 on the E5 transcript search only. The multimodal search has no floor, because text-to-video similarity on that model sits far below 0.15 even for a matching article. Re-derive either floor on your own corpus before trusting it. |
| Overlap weighting | 0.50 search, 0.25 entity, 0.15 keyword, 0.10 taxonomy. Entity and keyword overlap are each scaled by 1.5 and clamped at 1.0, so a partial match is not flattened. |
| Taxonomy mismatch | 0.7 multiplier when the video is Entertainment and the article is News. One rule, not a matrix, and it is the only hand-written penalty in the scoring. |
287ms warm on the reference corpus without a reranker. That is a sample-scale measurement: the production figure at a publisher's real volume has not been run, and the requirements doc records it as a projection.
How accurate is the matching?+
Unscored, and this page will not put a number on it. The reference deployment demonstrated that the ranking is sensible and explainable on 22 clips and one article, which shows behaviour rather than a rate. Scoring accuracy needs a set of articles with the playlists you consider correct, and that is the first thing to supply if accuracy is what you are deciding on.
Why embed both sides twice?+
Because the two models disagree in useful ways. The text model reaches what was said; the multimodal model reaches what the footage looks like and what is written on it. Fusing two document-reference searches keeps both, and because both sides are indexed ahead of time, the request embeds nothing and the latency is retrieval only.
Does it work on video in another language?+
It does in the reference deployment, and the mechanism is worth knowing: the text model covers roughly 100 languages, and entity overlap also falls back to matching against the text burned into the frames. A clip with a garbled transcript still matched an English article that way.
Can I scope results to one tenant or exclude categories?+
The shape takes tenant scope and include and exclude rules as query-time inputs rather than baking them into the index, which is the right side of that trade. It has been demonstrated on the platform and not exercised against a real tenant map, so treat it as designed rather than proven.
Is a reranker part of this?+
No, and it was left out deliberately. A BGE reranker on CPU added about 30 seconds to a 287ms pipeline. If precision at the very top of the list matters more than latency, a GPU reranker is the version to measure, and nobody has measured it here.