Author: Alexey Milovidov, 2026.
Store them in ClickHouse and have fun!
We will need an open dataset that is large and interesting to work with.
| dataset | rows | modality |
|---|---|---|
| Photos — YFCC100M / Multimedia Commons | 99,155,288 | images |
| Comments — Hacker News | 37,799,159 | text |
| Websites — a web crawl | 18,988,131 | screenshot + text |
Yahoo Flickr Creative Commons 100 Million — published in 2014.
Every photo is Creative Commons licensed, with metadata:
title, description, tags, user, camera, timestamp, and often coordinates.
Multimedia Commons is the derived dataset on AWS Open Data —
the images themselves, one resized JPEG per photo, in a public bucket:
s3://multimedia-commons/data/images/{a}/{bc}/{md5}.jpg
So I did:
Downloaded all metadata:
— 100M images, 13 GB compressed, 42 GB uncompressed.
Downloaded all images:
— 99.57M images, 12 TB JPEG, also in ClickHouse in String.
Calculated thumbnails:
— 99.13M images, 1.5 TB, 75x75 px RGB in Array(UInt8).
The dataset is public and self-updating in ClickHouse:
CREATE TABLE hackernews_history UUID '259cf9f0-0c4f-451d-9029-7de661f9a085'
(
update_time DateTime, id UInt32, deleted UInt8,
type Enum('story'=1,'comment'=2,'poll'=3,'pollopt'=4,'job'=5),
by LowCardinality(String), time DateTime, text String, dead UInt8,
parent UInt32, poll UInt32, kids Array(UInt32), url String,
score Int32, title String, parts Array(UInt32), descendants Int32
)
ENGINE = ReplacingMergeTree(update_time)
ORDER BY id
SETTINGS refresh_parts_interval = 60,
disk = disk(readonly = true, type = 's3_plain_rewritable',
endpoint = 'https://clicklake-test-2.s3.eu-central-1.amazonaws.com/',
use_environment_credentials = false);
If you copy-paste this CREATE TABLE query
in clickhouse-client or clickhouse-local,
it will attach the prepared dataset for you with no data copying.
The text is HTML, so it has to be cleaned before processing:
SELECT id, decodeHTMLComponent(extractTextFromHTML(text))
FROM hackernews_history
How does it look? Link
18,988,131 websites.
I took the list of top websites from CrUX (Chrome UX report),
then set up a script that requests the front page,
records as much metadata about the website as possible, saves the content,
and takes a screenshot using a headless Chrome with Playwright,
ran it in AWS Lambda, and waited a few weeks...
Result: 395 TB of goodness, 189 TB compressed!
— AWS security kindly asked me what I'm doing 😅
— colleagues kindly asked me what I'm doing 🥹
CREATE TABLE default.web
(
data JSON(
screenshot Array(UInt8),
timestamp DateTime64(3),
url String)
)
ORDER BY (data.url, data.timestamp);
A single JSON column with everything (10 MB compressed per record).
SELECT DISTINCT arrayJoin(JSONAllPaths(data)) FROM web
| Field | Type | Description |
|---|---|---|
url | String | Target URL |
timestamp | String | Collection timestamp (YYYY-MM-DD HH:MM:SS.sss) |
screenshot | Array(UInt8) | Raw RGB bitmap 1280x1280 (4,915,200 bytes) |
archive | String | Full HTML with inlined resources (images as data URLs, CSS inline) |
| Field | Type | Description |
|---|---|---|
requests | Array | All HTTP requests made |
requests[].url | String | Request URL |
requests[].method | String | HTTP method (GET, POST, etc.) |
requests[].headers | Object | Request headers |
requests[].postData | String | POST body data (if any) |
requests[].resourceType | String | Resource type (document, script, stylesheet, etc.) |
requests[].timestamp | UInt64 | Request timestamp (epoch ms) |
responses | Array | All HTTP responses received |
responses[].url | String | Response URL |
responses[].status | UInt16 | HTTP status code |
responses[].statusText | String | HTTP status text |
responses[].headers | Object | Response headers |
responses[].bodySize | UInt32 | Response body size in bytes |
responses[].body | String | Response body (text content, up to 2MB) |
responses[].bodyBase64 | String | Response body (binary content as base64) |
responses[].tls | Object | TLS security details |
responses[].imageMetadata | Object | Image metadata (for image responses) |
responses[].imageMetadata.exif | Object | EXIF data (make, model, GPS, etc.) |
| Field | Type | Description |
|---|---|---|
timing.navigation | Object | Navigation Timing API data |
timing.paint | Array | Paint timing entries (FP, FCP) |
timing.resources | Array | Resource timing entries |
timing.timing | Object | Derived metrics |
timing.timing.domContentLoaded | UInt32 | DOMContentLoaded time (ms) |
timing.timing.load | UInt32 | Load event time (ms) |
timing.timing.firstByte | UInt32 | Time to first byte (ms) |
timing.timing.dns | UInt32 | DNS lookup time (ms) |
timing.timing.tcp | UInt32 | TCP connection time (ms) |
timing.timing.ssl | UInt32 | SSL handshake time (ms) |
memory | Object | Chrome memory usage |
memory.usedJSHeapSizeMB | Float32 | Used JS heap (MB) |
memory.totalJSHeapSizeMB | Float32 | Total JS heap (MB) |
| Field | Type | Description |
|---|---|---|
dom.title | String | Page title |
dom.doctype | String | DOCTYPE name |
dom.charset | String | Character encoding |
dom.lang | String | Document language |
dom.backgroundColor | Object | Background color {r, g, b} |
dom.totalElements | UInt32 | Total DOM elements |
dom.maxDomDepth | UInt16 | Maximum DOM tree depth |
dom.tagCounts | Object | Element counts by tag name |
dom.forms | UInt16 | Number of forms |
dom.linksCount | UInt32 | Number of links |
dom.links | Array | All links with href, text, rel, target |
dom.images | UInt32 | Number of images |
dom.iframes | Array | Iframe details (src, sandbox, allow) |
dom.htmlSize | UInt32 | HTML size in bytes |
dom.textContentLength | UInt32 | Text content length |
dom.textContent | String | Page text (first 10KB) |
dom.documentWidth | UInt16 | Document scroll width |
dom.documentHeight | UInt32 | Document scroll height |
dom.meta | Object | Meta tags (name → content) |
| Field | Type | Description |
|---|---|---|
resourceHints.dnsPrefetch | Array | DNS prefetch hints |
resourceHints.preconnect | Array | Preconnect hints |
resourceHints.preload | Array | Preload hints (href, as, type) |
resourceHints.prefetch | Array | Prefetch hints |
resourceHints.modulepreload | Array | Module preload hints |
resourceHints.prerender | Array | Prerender hints |
resourceHints.speculationRules | Array | Speculation Rules API data |
| Field | Type | Description |
|---|---|---|
accessibility.landmarks | Object | Landmark counts (main, header, nav, footer, etc.) |
accessibility.headings | Object | Heading counts (h1-h6) and structure |
accessibility.images.total | UInt32 | Total images |
accessibility.images.withAlt | UInt32 | Images with alt text |
accessibility.images.withoutAlt | UInt32 | Images without alt attribute |
accessibility.forms.inputs | UInt16 | Form inputs count |
accessibility.forms.inputsWithLabel | UInt16 | Inputs with associated labels |
accessibility.aria | Object | ARIA attribute usage |
accessibility.tabindex | Object | Tabindex usage (positive, zero, negative) |
accessibility.skipLinks | Array | Skip link elements |
accessibility.lang | String | Document language |
| Field | Type | Description |
|---|---|---|
structuredData.jsonLd | Array | JSON-LD structured data |
structuredData.microdata | Array | Microdata items |
structuredData.rdfa | Array | RDFa annotations |
structuredData.openGraph | Object | Open Graph meta tags |
structuredData.twitterCards | Object | Twitter Card meta tags |
| Field | Type | Description |
|---|---|---|
pwa.hasServiceWorker | Bool | Service worker registered |
pwa.serviceWorkerStatus | String | SW status (active, installing, waiting) |
pwa.manifest | Object | Parsed web manifest |
pwa.manifestLink | String | Manifest URL |
pwa.themeColor | String | Theme color |
pwa.appleCapable | String | Apple web app capable |
pwa.appleTouchIcons | Array | Apple touch icons |
| Field | Type | Description |
|---|---|---|
scripts | Array | All script elements |
scripts[].src | String | External script URL |
scripts[].type | String | Script type (text/javascript, module) |
scripts[].async | Bool | Async attribute |
scripts[].defer | Bool | Defer attribute |
scripts[].integrity | String | SRI hash |
scripts[].content | String | Inline script content |
javascriptAnalysis | Object | JavaScript summary |
javascriptAnalysis.total | UInt16 | Total scripts |
javascriptAnalysis.external | UInt16 | External scripts |
javascriptAnalysis.inline | UInt16 | Inline scripts |
javascriptAnalysis.module | UInt16 | ES modules |
javascriptAnalysis.inlineSize | UInt32 | Total inline script size |
globals | Array | Custom global variables |
jsChecks | Object | JS variable checks for tech detection |
| Field | Type | Description |
|---|---|---|
styles.sheets | Array | External stylesheets |
styles.inlineStyles | Array | Inline style elements |
cssAnalysis.externalSheets | UInt16 | External stylesheet count |
cssAnalysis.inlineStyles | UInt16 | Inline style count |
cssAnalysis.totalRules | UInt32 | Total CSS rules |
cssAnalysis.customProperties | Array | CSS custom properties (variables) |
cssAnalysis.mediaQueries | Array | Media query conditions |
cssAnalysis.fontFaces | UInt16 | @font-face rules |
cssAnalysis.keyframes | UInt16 | @keyframes animations |
cssAnalysis.inlineStyleAttributes | UInt32 | Elements with style attribute |
| Field | Type | Description |
|---|---|---|
media.images | Array | All images with dimensions, loading, srcset |
media.pictures | Array | Picture elements with sources |
media.videos | Array | Video elements with sources, poster, autoplay |
media.audio | Array | Audio elements |
media.svgs | Object | SVG counts (inline, external) |
media.canvases | Array | Canvas elements |
mediaAnalysis | Object | Media summary |
mediaAnalysis.lazyLoaded | UInt16 | Images with loading="lazy" |
mediaAnalysis.withSrcset | UInt16 | Images with srcset |
mediaAnalysis.webpCount | UInt16 | WebP images |
mediaAnalysis.avifCount | UInt16 | AVIF images |
| Field | Type | Description |
|---|---|---|
favicons | Array | All favicon links |
favicons[].href | String | Favicon URL |
favicons[].rel | String | Link rel attribute |
favicons[].sizes | String | Icon sizes |
favicons[].bodyBase64 | String | Favicon content (base64) |
favicons[].contentType | String | MIME type |
| Field | Type | Description |
|---|---|---|
colors.Vibrant | Object | Vibrant color {r, g, b, population} |
colors.DarkVibrant | Object | Dark vibrant color |
colors.LightVibrant | Object | Light vibrant color |
colors.Muted | Object | Muted color |
colors.DarkMuted | Object | Dark muted color |
colors.LightMuted | Object | Light muted color |
| Field | Type | Description |
|---|---|---|
securityHeaders.strictTransportSecurity | String | HSTS header |
securityHeaders.contentSecurityPolicy | String | CSP header |
securityHeaders.cspDirectives | Object | Parsed CSP directives |
securityHeaders.xContentTypeOptions | String | X-Content-Type-Options |
securityHeaders.xFrameOptions | String | X-Frame-Options |
securityHeaders.referrerPolicy | String | Referrer-Policy |
securityHeaders.permissionsPolicy | String | Permissions-Policy |
securityHeaders.crossOriginEmbedderPolicy | String | COEP |
securityHeaders.crossOriginOpenerPolicy | String | COOP |
securityHeaders.crossOriginResourcePolicy | String | CORP |
securityHeaders.server | String | Server header |
securityHeaders.xPoweredBy | String | X-Powered-By |
securityHeaders.score | UInt8 | Security score (0-100) |
| Field | Type | Description |
|---|---|---|
thirdPartyAnalysis.firstPartyDomain | String | First-party domain |
thirdPartyAnalysis.thirdPartyDomains | Array | Third-party domains |
thirdPartyAnalysis.thirdPartyRequests | UInt16 | Third-party request count |
thirdPartyAnalysis.firstPartyRequests | UInt16 | First-party request count |
thirdPartyAnalysis.thirdPartyBytes | UInt32 | Third-party bytes |
thirdPartyAnalysis.firstPartyBytes | UInt32 | First-party bytes |
thirdPartyAnalysis.byCategory.analytics | Array | Analytics domains |
thirdPartyAnalysis.byCategory.advertising | Array | Advertising domains |
thirdPartyAnalysis.byCategory.social | Array | Social media domains |
thirdPartyAnalysis.byCategory.cdn | Array | CDN domains |
thirdPartyAnalysis.byCategory.fonts | Array | Font service domains |
| Field | Type | Description |
|---|---|---|
compressionAnalysis.responses.total | UInt16 | Total responses |
compressionAnalysis.responses.gzip | UInt16 | Gzip compressed |
compressionAnalysis.responses.brotli | UInt16 | Brotli compressed |
compressionAnalysis.responses.zstd | UInt16 | Zstd compressed |
compressionAnalysis.responses.uncompressed | UInt16 | Uncompressed |
compressionAnalysis.byType | Object | Breakdown by content type |
compressionAnalysis.totalTransferSize | UInt32 | Total transfer size |
| Field | Type | Description |
|---|---|---|
cookiesAnalysis.total | UInt16 | Total cookies |
cookiesAnalysis.firstParty | UInt16 | First-party cookies |
cookiesAnalysis.thirdParty | UInt16 | Third-party cookies |
cookiesAnalysis.secure | UInt16 | Cookies with Secure flag |
cookiesAnalysis.httpOnly | UInt16 | Cookies with HttpOnly flag |
cookiesAnalysis.sameSite | Object | SameSite breakdown (strict, lax, none) |
cookiesAnalysis.cookies | Array | Cookie details |
| Field | Type | Description |
|---|---|---|
storage.localStorage | Object | LocalStorage info (itemCount, keys, totalSize) |
storage.sessionStorage | Object | SessionStorage info |
storage.indexedDB | Object | IndexedDB availability |
storage.cookies.count | UInt16 | Document cookie count |
webApis | Object | Web API availability |
webApis.geolocation | Bool | Geolocation API |
webApis.notifications | Bool | Notifications API |
webApis.webgl | Bool | WebGL support |
webApis.webgl2 | Bool | WebGL 2 support |
webApis.webrtc | Bool | WebRTC support |
webApis.serviceWorkers | Bool | Service Workers support |
webApis.paymentRequest | Bool | Payment Request API |
| ... | ... | (35 total APIs checked) |
| Field | Type | Description |
|---|---|---|
dns.hostname | String | Target hostname |
dns.a | Array | A records (IPv4 addresses) |
dns.mx | Array | MX records |
dns.ns | Array | NS records |
dns.txt | Array | TXT records |
tlsCertificates | Object | TLS certificates by hostname |
tlsCertificates[host].subject | Object | Certificate subject |
tlsCertificates[host].issuer | Object | Certificate issuer |
tlsCertificates[host].validFrom | String | Valid from date |
tlsCertificates[host].validTo | String | Valid to date |
tlsCertificates[host].fingerprint | String | Certificate fingerprint |
tlsCertificates[host].subjectAltNames | String | Subject alternative names |
| Field | Type | Description |
|---|---|---|
wellKnown.robots | Object | /robots.txt |
wellKnown.robotsParsed | Object | Parsed robots.txt (userAgents, sitemaps, disallowed, allowed) |
wellKnown.sitemap | Object | /sitemap.xml |
wellKnown.securityTxt | Object | /.well-known/security.txt |
wellKnown.humans | Object | /humans.txt |
wellKnown.adsTxt | Object | /ads.txt |
wellKnown.appAdsTxt | Object | /app-ads.txt |
wellKnown.assetLinks | Object | /.well-known/assetlinks.json |
wellKnown.appleAppSiteAssociation | Object | Apple app site association |
| Field | Type | Description |
|---|---|---|
technologies | Array | Detected technologies |
technologies[].name | String | Technology name |
technologies[].categories | Array | Category names |
technologies[].website | String | Technology website |
technologies[].description | String | Technology description |
technologies[].version | String | Detected version |
technologies[].evidence | Array | Detection evidence |
| Field | Type | Description |
|---|---|---|
console | Array | Console messages |
console[].type | String | Message type (log, warn, error) |
console[].text | String | Message text |
errors | Array | Page errors |
| Field | Type | Description |
|---|---|---|
viewport.innerWidth | UInt16 | Viewport width |
viewport.innerHeight | UInt16 | Viewport height |
viewport.scrollWidth | UInt16 | Scroll width |
viewport.scrollHeight | UInt32 | Scroll height |
It can mean at least five slightly different things...
1. In a general, mathematical sense:
— embedding is an injective map from a set (of anything) into space*,
so that the map preserves some properties.
Example: a geographical map is an embedding
of a segment of sphere in a 2d plane.
But this definition is too generic...
* this definition can be further generalized or specified.
2. Historical example (2013): word2vec.
A map from words to vectors learned to give similar vectors to words that often have the same context (same neighbour words) in a text corpus.
Similar examples: GloVe, fastText. Typical number of dimensions: 50..300.
A vector is just a sequence of floating-point numbers:
king -> [1.203125,-1.6953125,-0.859375,-1.0546875,0.31835938,...,0.875]
Classic: king + woman − man ≈ queen
This is also not what we are interested in.
3. Token embeddings in a transformer model.
Then every token (and also every position) is mapped to a vector.
Then these vectors are fed to the model layers.
'Every' -> [0.55859375,-1.921875,0.74609375,1.640625, ...,-0.64453125]
' L' -> [-0.625,-0.14160156,-1.2265625,-0.33398438, ...,0.7890625]
'LM' -> [-1.171875,0.453125,1.25,1.59375, ...,-1.4375]
' starts' -> [-0.98046875,1.0625,-0.7421875,1.21875, ...,-0.8125]
' with' -> [-1.6015625,-0.46679688,2.671875,0.58203125,...,-0.46484375]
' token' -> [-0.546875,-0.1484375,0.25390625,-0.63671875,...,0.24511719]
'izing' -> [0.79296875,-0.34570312,-1.0078125,0.875, ...,0.625]
' the' -> [-0.00592041,0.203125,0.23535156,-0.34960938,...,-0.8984375]
' text' -> [-0.765625,0.11767578,1.9140625,-0.625, ...,0.14550781]
'.' -> [0.31445312,1.1484375,0.74609375,0.60546875, ...,0.12695312]
This is also not what we need!
4. Model activations as embeddings.
Run a transformer over the text and take a hidden state:
— the last token's state, or the mean over all tokens, from layer N.
This works - the vector does carry meaning.
This is not the best for direct usage as an embedding,
but the best embedding models (e.g., Qwen3-Embedding-8B)
are created by fine-tuning on top of the corresponding generative LLMs.
This is also not what we need!
5. Models trained for the job.
Contrastive training: pull similar pairs together, push everything else apart.
The training decides what "similar" means, so there are several families:
— similarity: two sentences that mean the same thing;
— retrieval: a question must be close to a passage that answers it
— which is asymmetric, hence prefixes:
'search_query: ' vs 'search_document: ';
— multimodal retrieval: CLIP, SigLIP — images and their captions
land in one shared space, so you can search photos with words;
— and question answering, code search, instructions...
Bonus: Sparse embeddings (bag of words model, 1970)
A very large vector, where each dimension corresponds to one possible word from the dictionary (10k+ dimensions) and contains its frequency in the given phrase (with variations like TF-IDF, BM25).
— dimension = vocabulary size, but only tens of non-zeros per document;
— exact lexical matching, fully interpretable;
— modern versions are learned sparse: SPLADE expands the query
into related terms, keeping the inverted-index shape.
In this project I added one as a baseline: an 8192-dimensional hashed bag of words over Hacker News comments — built entirely in SQL.
All of them. And store them in ClickHouse!
What models are available?
| model | year | params | dims | licence |
|---|---|---|---|---|
| Images — 99.2M photos, 17.8M screenshots | ||||
| siglip2-so400m-patch16-512 | 2025 | 1.1B | 1152 | Apache-2.0 |
| nomic-embed-vision-v1.5 | 2024 | 93M | 768 | Apache-2.0 |
| CLIP ViT-L/14 | 2021 | 428M | 768 | MIT |
| Text — 37.8M comments, 19.0M pages | ||||
| Qwen3-Embedding-8B | 2025 | 7.6B | 4096 | Apache-2.0 |
| embeddinggemma-300m | 2025 | 303M | 768 | Gemma |
| nomic-embed-text-v1.5 | 2024 | 137M | 768 | Apache-2.0 |
| KaLM-embedding-mini-instruct-v1.5 | 2024 | 494M | 896 | MIT |
| jina-embeddings-v3 | 2024 | 572M | 1024 | CC-BY-NC |
| jina-embeddings-v2-small-en | 2023 | 33M | 512 | Apache-2.0 |
| granite-embedding-30m-english | 2024 | 30M | 384 | Apache-2.0 |
| snowflake-arctic-embed-xs | 2024 | 23M | 384 | Apache-2.0 |
| snowflake-arctic-embed-m | 2024 | 109M | 768 | Apache-2.0 |
| e5-small-v2 | 2023 | 33M | 384 | MIT |
| e5-base-v2 | 2023 | 109M | 768 | MIT |
| e5-large-v2 | 2023 | 335M | 1024 | MIT |
| gte-small | 2023 | 33M | 384 | MIT |
| gte-base | 2023 | 109M | 768 | MIT |
| bge-small-en-v1.5 | 2023 | 33M | 384 | MIT |
| bge-base-en-v1.5 | 2023 | 109M | 768 | MIT |
| bge-large-en-v1.5 | 2023 | 335M | 1024 | MIT |
| all-MiniLM-L6-v2 | 2021 | 23M | 384 | Apache-2.0 |
| bag of words (hashed, TF) | 1970 | — | 8192 | — |
All 22 are open-weight — the closed APIs (Gemini Embedding, Nemotron-VL over OpenRouter) were trialled and dropped: per-token pricing does not survive 100 million rows.
For the 99 million photos:
| model | dims | table |
|---|---|---|
| google/siglip2-so400m-patch16-512 | 1152 | emb_siglip2 |
| open_clip ViT-L-14 (openai) | 768 | emb_clip |
| nomic-ai/nomic-embed-vision-v1.5 | 768 | emb_nomic |
One g6.12xlarge (4× NVIDIA L4), spot, in the same region as the bucket.
All three models loaded on every GPU, so the S3 read and the JPEG decode
are amortized across them.
Wall time: about a day. Cost: $50–200 for 300 million embeddings.
For the Hacker News comments, from smallest to largest:
384-d: all-MiniLM-L6-v2, snowflake-arctic-embed-xs, bge-small-en-v1.5, gte-small, e5-small-v2, granite-embedding-30m
512–768-d: jina-embeddings-v2-small, arctic-embed-m, bge-base-en-v1.5, gte-base, e5-base-v2, nomic-embed-text-v1.5, embeddinggemma-300m
896–1024-d: KaLM-embedding-multilingual-mini, bge-large-en-v1.5, e5-large-v2, jina-embeddings-v3
4096-d: Qwen3-Embedding-8B
8192-d sparse: the bag-of-words baseline
The 30M-parameter models take hours. The 8B ones take days
— and the 4096-dimensional table alone is 1.1 TiB.
Do it from inside ClickHouse, since 26.6:
# /etc/clickhouse-server/config.d/openrouter.yaml
named_collections:
openrouter:
provider: openai # OpenRouter speaks the OpenAI API
endpoint: https://openrouter.ai/api/v1/embeddings
api_key: sk-or-v1-...
SELECT aiEmbed(review_text, 'google/gemini-embedding-2',
map('credentials', 'openrouter')) AS embedding
FROM reviews LIMIT 10;
You do not always need a model. A hashed bag of words, a TF-IDF vector,
a vector of aggregated features - these are embeddings too,
and ClickHouse can build them with SQL.
The bag of words on this map was built by one INSERT SELECT:
INSERT INTO hackernews_embeddings_bow
WITH 8192 AS vec_size,
tokens(lowerUTF8(decodeHTMLComponent(extractTextFromHTML(text)))) AS toks,
arrayMap(x -> cityHash64(x) % vec_size, toks) AS tokens_idx,
arrayMap(j -> toFloat32(countEqual(tokens_idx, j)), range(vec_size)) AS vec,
(vec / if(L2Norm(vec) > 0, L2Norm(vec), 1))::Array(BFloat16) AS embedding
SELECT id, embedding FROM hackernews_history
WHERE id >= {lo:UInt32} AND id < {hi:UInt32}
Search. Nearest neighbours by distance — text, images, or text → images.
Classification. The vector is a feature vector; a linear model on top of it
is usually enough, and needs only a few hundred labels.
Mapping to other spaces. Project to 2-D to look at it, reduce dimensions
to make it cheap, or map one model's space into another's.
Also: clustering, deduplication, recommendations, anomaly detection,
and RAG (which is just search with extra steps).
Demo: https://embeddings.info/
Embeddings are trained with respect to a similarity function,
which can be the L2 distance, cosine distance, or dot product.
Almost all modern embedding models produce unit-normalized vectors,
which means their L2 norm equals 1 - the vectors are divided by their length before output.
As all vectors have length 1, they are located on an n-dimensional sphere.
For normalized vectors, all similarity functions are in a monotonic relation with each other, so they can be used interchangeably.
Dot product aka scalar product of two vectors is the sum of products of their i-th coordinates. L2 distance aka Euclidean distance is the L2 norm of the difference between vectors. L2 norm of a vector is the sqrt of the dot product of vec with itself - a vector length. Cosine similarity is the normalized dot product: the dot product divided by the norms of both vectors. It is the cosine of the angle between two vectors, ranging from -1 to 1. Don't confuse cosine similarity and cosine distance which is 1 - cosineSimilarity(v, u) so it is non-negative and has the right direction.
High dimensional spaces break the intuition:
— the volume of a unit sphere (ball) converges to zero;
— most of the volume of a cube or a ball is near its border;
— the distance between random points of a unit cube goes to infinity;
— random vectors are almost orthogonal;
— random vectors on a sphere have L2 distance close to sqrt(2);
— most of vector coordinates on a unit sphere are close to zero;
— coordinates of a random vector on a unit sphere have a Gaussian distribution N(0, 1/sqrt(ndims));
For raw data, use the 16-bit data type at most.
Do not use Float64 or Float32. ClickHouse provides BFloat16.
16-bit floating point data type does not lose any precision on embeddings.
And most modern models already use no more than 16-bit for inference.
Example: Qwen3-8B has 4096 dimensions, and on 37.8M comments,
— the whole table without embeddings - 12 GB
— embeddings in Float64 - 1.24 TB
— embeddings in Float32 - 620 GB
— embeddings in BFloat16 - 310 GB.
In most cases you will store further quantized data!
Quantization:
— representing vectors with a lower number of bits per coordinate.
Practical options can be as low as 1..4 bit per dimension.
Dimensionality reduction:
— representing vectors with a lower number of dimensions.
Good results on search and retrieval even with 384 dimensions
out of initial 4096.
Example: 2 bit with 384 dimensions takes only 3.6 GB.
Check if embeddings are "good" - centered and unit-normalized.
Most of modern models produce normalized embeddings.
-- should return something close to 1.
SELECT avg(L2Norm(embedding)) FROM hackernews_embeddings;
-- should return values close to 0 for each coordinate.
SELECT avgForEach(embedding) FROM hackernews_embeddings;
-- should return values close to 1 for each coordinate, ok if less on a dataset.
SELECT stddevForEach(embedding) * sqrt(any(length(embedding)))
FROM hackernews_embeddings;
Note: most models output unit-normalized embeddings, so that the L2Norm ≈ 1 and stddev of a coordinate ≈ 1 / sqrt(ndims),
but some models output normalized embeddings, so that L2Norm ≈ sqrt(ndims) and stddev of a coordinate ≈ 1.
The difference is only in scaling.
You may need to normalize embeddings for further steps.
If we rotate the whole space randomly,
the distances and angles don't change.
But the information is better spread across coordinates, even if it was concentrated.
If applied before quantization, rotation improves the search quality on quantized data.
There is a family of orthogonal matrices allowing fast multiplication,
and ClickHouse has a function for that!
randomHadamardTransform(vec)
Nomic embeddings of Flickr photos, without rotation, orthogonal projection on 2D.
Nomic embeddings of Flickr photos, with rotation, orthogonal projection on 2D.
SELECT randomHadamardTransform([1., 2., 3., 4.]::Array(Float32));
-- [0, 2, 1, -5] an orthogonal, norm-preserving rotation
SELECT randomHadamardTransform(v, seed); -- a different rotation
SELECT randomHadamardTransform(v, seed, 256); -- rotate, then truncate
— deterministic: the same seed always gives the same rotation,
so it can be a MATERIALIZED column and a query can reproduce it;
— norm-preserving, so unit vectors stay unit vectors;
— the truncated form is a Johnson–Lindenstrauss random projection;
Half the storage compared to BFloat16.
Map every dimension to a number −128..127 so we can reconstruct the approximate original values, while minimizing the distance calculation error.
The algorithm - use a lookup table, assuming that the value has Gaussian distribution N(0, 1).
ClickHouse has a function for that!
-- pack Array(BFloat16) into Array(Int8)
SELECT quantizeBFloat16ToInt8(embedding * sqrt(length(embedding)))
FROM hackernews_embeddings;
-- unpack Array(Int8) into reconstructed original values of Array(BFloat16)
SELECT dequantizeInt8ToBFloat16(quantized) / sqrt(length(quantized))
FROM hackernews_embeddings;
A data type for vector embeddings,
that allows tuning the search precision at runtime.
CREATE TABLE vectors (
id UInt64, name String, ...
vector QBit(BFloat16, 1536)
) ORDER BY ();
SELECT id, name FROM vectors
ORDER BY L2DistanceTransposed(vector, target, 10)
LIMIT 10;
It uses a bit-sliced data layout:
every number is sliced by bits,
e.g., for 1536-dim vector of BFloat16,
we store 16 subcolumns with Nth (1..16th) bits from all dimensions.
At the query time, we specify, how many (most significant) bits to take.
For example, we can ask to read
only 10 out of 16 bits.
QBit supports Int8, which allows anything from binary (1 bit) to full 8 bit quantization, chosen at runtime per query.
It can store any Int8 codes, and we also provide quantizeBFloat16ToInt8 and dequantizeInt8ToBFloat16 functions for optimal conversion from Float to Int8, minimizing the error on gaussian-distributed vector coordinates.
CREATE TABLE images (id UInt64, emb QBit(Int8, 2048)) …;
INSERT INTO images
SELECT id, quantizeBFloat16ToInt8(embedding * sqrt(2048)) FROM src;
-- scale unit-normalized embeddings by sqrt(dim) and convert to Int8
SELECT id FROM images
ORDER BY cosineDistanceTransposedQuantized(emb, target, 4) LIMIT 10;
-- reads only the top 4 bit-planes — half the I/O of 8-bit
The optional stride parameter slices dimensions into groups — each bit-plane of each group is its own stream on disk: QBit(Int8, 1024, 256):
| dims 1–256 | 257–512 | 513–768 | 769–1024 | |
| bit 1 (sign) | ||||
| bit 2 | ||||
| bit 3 | ||||
| bit 4 | ||||
| bit 5 … 8 |
… ORDER BY cosineDistanceTransposedQuantized(emb, target, 4, 256) …
-- 4 bits × first 256 dims: reads the 4 green streams of 32 — 3% of a Float32 column
You can request reading only first N dimensions - good for:
- rotated embeddings with randomHadamardTransform;
- MRL (Matryoshka Representation Learning) embeddings to tune between the speed and recall.
Tune the precision and speed of a brute-force search at query time:
For search, retrieval, and analytics:
— Use first N dimensions for distance calculation on rotated embeddings, e.g., 384 from 4096 with the QBit data type.
For data exploration and visualization:
— Use UMAP (see also: t-SNE) - learned dimensionality reduction to a low-dimensional space, 2D/3D.
99 million photos, SigLIP2, Hadamard-rotated, projected to 2-D by summing slices. A sphere, as promised.
The same 99 million photos. Hue is a fourth UMAP component; brightness is density.
ClickHouse also supports vector indices:
— HNSW for fast in-memory similarity search
(often you don't need it, as QBit solves the problem perfectly)
And ready-made quantization codecs:
— e.g., RaBitQ, Turboquant (based on 8-dimensional sphere packing).
ClickHouse is the best DBMS for analytic applications:
— Fast, scalable, and resource efficient;
— Easy to use and pleasant to work with;
— Robust and reliable;
— Batteries included.
Friends tell friends to use ClickHouse!