Image embeddings are one of those things that sound more complicated than they feel once you build the first version.
The basic idea is simple: you give a model an image, and the model turns that image into a list of numbers.
That list of numbers is the embedding.
It may look like this:
[0.012, -0.443, 0.087, 0.991, …]
Cute? Not really.
Useful? Very.
Because once images become vectors, you can compare them, search them, cluster them, recommend similar items, detect duplicates, build visual search, organize media libraries, or connect images to text prompts.
In this guide, we’ll build image embeddings with Python using CLIP and OpenCLIP, then use those embeddings for similarity search with FAISS. We’ll also talk about storage, batching, evaluation, and where LLMAPI fits when image embeddings become part of a larger AI workflow.
Why we can write this guide
We’ve spent around 6 years working with AI APIs, image workflows, embeddings, search systems, and developer automation. We also researched current image embedding tools, CLIP-style models, vector search libraries, and newer multimodal embedding research for this article.
The practical lesson is pretty simple: generating image embeddings is easy. Building a good image search or recommendation workflow around those embeddings takes more planning.
You need the right model, consistent preprocessing, vector storage, metadata, similarity search, and a way to test whether the results actually make sense for your users.
What are image embeddings used for?
Before we write code, let’s make the use case clear.
Image embeddings help when you need to compare images by meaning, not by filename or pixels.
For example:
| Use case | What embeddings help you do |
| Visual search | Find images similar to a query image |
| Text-to-image search | Search images using a text prompt |
| Duplicate detection | Find near-identical or similar images |
| Product recommendations | Recommend visually similar products |
| Content moderation | Group risky or similar visual content |
| Asset management | Organize large image libraries |
| E-commerce | Search by style, shape, color, or product type |
| RAG with images | Retrieve relevant visuals for a multimodal app |
| AI workflow routing | Decide which model or tool should handle an image |
The magic is that embeddings place similar images close together in vector space.
So a photo of a red sneaker should be closer to another red sneaker than to a picture of a soup bowl. Very fair, honestly.
Which Python approach should you use?
There are a few ways to generate image embeddings.
| Approach | Best for |
| CLIP | Simple image and text embeddings |
| OpenCLIP | More model choices and open-source flexibility |
| Sentence Transformers CLIP models | Friendly embedding workflow |
| Hosted embedding APIs | Apps that do not want to manage models |
| Custom vision model | Domain-specific image similarity |
| Vector database + embeddings | Production search and retrieval |
For most developers, the easiest first choice is CLIP or OpenCLIP.
CLIP is important because it learns image and text representations in the same space. OpenAI’s CLIP article explains that CLIP was trained to match images with natural language descriptions, which is why it can support zero-shot classification and image-text similarity. That fits image embeddings perfectly because we often want to compare an image to another image or to a text query. The original CLIP work also showed how natural language supervision can make visual models more flexible across tasks.
Path 1: Generate image embeddings with CLIP
Let’s start with the classic CLIP setup.
Install the package:
pip install torch torchvision pillow
pip install git+https://github.com/openai/CLIP.git
Now create a Python file:
import torch
import clip
from PIL import Image
device = "cuda" if torch.cuda.is_available() else "cpu"
model, preprocess = clip.load("ViT-B/32", device=device)
image = preprocess(Image.open("image.jpg")).unsqueeze(0).to(device)
with torch.no_grad():
image_embedding = model.encode_image(image)
print(image_embedding.shape)
print(image_embedding[0][:10])
You should see something like:
torch.Size([1, 512])
tensor([ 0.0342, -0.1181, 0.2773, …])
That means the image became a 512-dimensional vector.
The official OpenAI CLIP GitHub repo shows this same basic pattern: load the model, preprocess the image, run model.encode_image(), and use the output as the image representation.
Normalize the embedding before search
For similarity search, it is common to normalize embeddings.
image_embedding = image_embedding / image_embedding.norm(dim=-1, keepdim=True)
Why? Because normalized vectors work cleanly with cosine similarity. When vectors are normalized, inner product search behaves like cosine similarity.
That matters when you want to compare one image to another.
def get_image_embedding(image_path):
image = preprocess(Image.open(image_path)).unsqueeze(0).to(device)
with torch.no_grad():
embedding = model.encode_image(image)
embedding = embedding / embedding.norm(dim=-1, keepdim=True)
return embedding.cpu().numpy()[0]
Now you have a reusable function.
Path 2: Generate embeddings with OpenCLIP
OpenCLIP is a strong choice if you want more model options.
The OpenCLIP GitHub repo describes it as an open-source implementation of CLIP with many pretrained models. That makes it useful if you want to compare different model sizes, datasets, and checkpoints.
Install it:
pip install open_clip_torch pillow torch
Generate an embedding:
import torch
import open_clip
from PIL import Image
device = "cuda" if torch.cuda.is_available() else "cpu"
model, _, preprocess = open_clip.create_model_and_transforms(
"ViT-B-32",
pretrained="laion2b_s34b_b79k"
)
model = model.to(device)
model.eval()
image = preprocess(Image.open("image.jpg")).unsqueeze(0).to(device)
with torch.no_grad():
image_embedding = model.encode_image(image)
image_embedding = image_embedding / image_embedding.norm(dim=-1, keepdim=True)
print(image_embedding.shape)
OpenCLIP is especially useful when you want to test stronger or more recent CLIP-style checkpoints.
A newer research direction also explains why CLIP-style embeddings keep evolving. The 2025 paper Contrastive Localized Language-Image Pre-Training argues that image-level CLIP training can be weaker for tasks that need fine-grained region understanding, so it adds region-text contrastive learning. This matters for developers because basic image embeddings may work well for “find similar image,” while region-level or localized tasks may need more specialized models.
Path 3: Generate both image and text embeddings
One of the best parts of CLIP-style models is that image and text live in the same embedding space.
That means you can search images with text.
Example:
red running shoes
And compare that text embedding against image embeddings.
text = clip.tokenize(["red running shoes"]).to(device)
with torch.no_grad():
text_embedding = model.encode_text(text)
text_embedding = text_embedding / text_embedding.norm(dim=-1, keepdim=True)
print(text_embedding.shape)
Now compare image and text:
similarity = (image_embedding @ text_embedding.T).item()
print(similarity)
Higher score means the image and text are more similar.
This is the whole reason CLIP-style models are so useful for visual search. You can search images by image, or search images by text.
The original CLIP paper and project showed how this image-text alignment supports zero-shot classification: instead of training a new classifier for every category, you compare an image embedding with text label embeddings.
Build a small image search engine
Now let’s make this useful.
Imagine you have a folder of images:
images/
red-shoe.jpg
blue-shirt.jpg
black-bag.jpg
white-sneaker.jpg
We’ll generate embeddings for all images.
from pathlib import Path
import numpy as np
def build_image_index(image_folder):
image_folder = Path(image_folder)
image_paths = list(image_folder.glob("*.jpg")) + list(image_folder.glob("*.png"))
embeddings = []
metadata = []
for image_path in image_paths:
embedding = get_image_embedding(str(image_path))
embeddings.append(embedding)
metadata.append({
"path": str(image_path),
"filename": image_path.name
})
print(f"Embedded {image_path.name}")
return np.array(embeddings).astype("float32"), metadata
Use it:
embeddings, metadata = build_image_index("images")
print(embeddings.shape)
print(metadata[:2])
Now you have:
- A matrix of vectors.
- Metadata that tells you which vector belongs to which image.
Search images with FAISS
For small experiments, you can compare every vector manually.
For real search, use a vector index.
FAISS is a library for efficient similarity search and clustering of dense vectors. Its docs explain that FAISS is written in C++ and has Python wrappers, which makes it a common choice for local vector search in Python. The FAISS getting-started guide also shows that vectors are usually represented as NumPy arrays in Python. (faiss.ai)
Install:
pip install faiss-cpu
Create an index:
import faiss
dimension = embeddings.shape[1]
index = faiss.IndexFlatIP(dimension)
index.add(embeddings)
print(index.ntotal)
Because we normalized the embeddings, IndexFlatIP works well for cosine-style similarity search.
Now search with another image:
query_embedding = get_image_embedding("query.jpg").astype("float32")
query_embedding = np.expand_dims(query_embedding, axis=0)
scores, indices = index.search(query_embedding, k=5)
for score, idx in zip(scores[0], indices[0]):
print(score, metadata[idx])
This returns the 5 most similar images.
Search images with a text query
Now let’s search images using text.
def get_text_embedding(text):
tokens = clip.tokenize([text]).to(device)
with torch.no_grad():
embedding = model.encode_text(tokens)
embedding = embedding / embedding.norm(dim=-1, keepdim=True)
return embedding.cpu().numpy()[0]
Use it:
query_embedding = get_text_embedding("a black leather handbag").astype("float32")
query_embedding = np.expand_dims(query_embedding, axis=0)
scores, indices = index.search(query_embedding, k=5)
for score, idx in zip(scores[0], indices[0]):
print(score, metadata[idx])
This is the base of text-to-image search.
For example, an e-commerce app can let users type:
minimal white sneakers
And return product images that visually match.
Save embeddings so you do not recompute them every time
Image embedding can be slow. Do not regenerate embeddings every time your app starts.
Save them.
import pickle
def save_embeddings(embeddings, metadata, path="image_embeddings.pkl"):
with open(path, "wb") as file:
pickle.dump({
"embeddings": embeddings,
"metadata": metadata
}, file)
def load_embeddings(path="image_embeddings.pkl"):
with open(path, "rb") as file:
data = pickle.load(file)
return data["embeddings"], data["metadata"]
Use it:
save_embeddings(embeddings, metadata)
embeddings, metadata = load_embeddings()
For production, you may store embeddings in:
| Storage option | Best for |
| Pickle or NumPy files | Local prototypes |
| SQLite/Postgres | Small apps with metadata |
| FAISS index files | Local vector search |
| Pinecone, Qdrant, Weaviate, Milvus | Production vector search |
| Object storage + database | Large image libraries |
If you use a vector database, store metadata too: file ID, image URL, user ID, product ID, category, timestamp, and permissions.
Batch embeddings for better performance
If you have many images, process them in batches.
def get_image_embeddings_batch(image_paths, batch_size=32):
all_embeddings = []
for i in range(0, len(image_paths), batch_size):
batch_paths = image_paths[i:i + batch_size]
images = [
preprocess(Image.open(path)).unsqueeze(0)
for path in batch_paths
]
batch = torch.cat(images).to(device)
with torch.no_grad():
embeddings = model.encode_image(batch)
embeddings = embeddings / embeddings.norm(dim=-1, keepdim=True)
all_embeddings.append(embeddings.cpu().numpy())
print(f"Processed batch {i // batch_size + 1}")
return np.vstack(all_embeddings).astype("float32")
Batching is much faster on GPU because the model processes multiple images at once.
Just be careful with memory. If your GPU runs out of memory, reduce batch_size.
Add metadata filters
Vector search finds visually similar images. Your app may also need filters.
Example:
Find similar shoes, but only from the women’s category and only in stock.
That means your search needs embeddings plus metadata.
Example metadata:
metadata = [
{
"path": "images/red-shoe.jpg",
"category": "shoes",
"gender": "women",
"in_stock": True,
"product_id": "sku_1001"
}
]
For a small local prototype, filter after search:
def search_with_filter(query_embedding, index, metadata, k=20, category=None):
query_embedding = np.expand_dims(query_embedding.astype("float32"), axis=0)
scores, indices = index.search(query_embedding, k=k)
results = []
for score, idx in zip(scores[0], indices[0]):
item = metadata[idx]
if category and item.get("category") != category:
continue
results.append({
"score": float(score),
"metadata": item
})
return results
For production, use a vector database that supports metadata filters directly.
What makes image embeddings good or bad?
An image embedding is useful if similar images land near each other for your use case.
That last part matters.
A fashion app may care about color, cut, fabric, and style. A medical app may care about tiny visual differences. A real estate app may care about room type, layout, lighting, and condition. A moderation app may care about unsafe content.
The same embedding model may behave differently across those tasks.
Test with examples like:
| Test | Why it matters |
| Same object, different angle | Checks viewpoint robustness |
| Same category, different style | Checks semantic grouping |
| Different objects, similar colors | Checks whether color dominates |
| Similar layout, different meaning | Checks false matches |
| Text query search | Checks image-text alignment |
| Duplicate images | Checks near-duplicate detection |
| Domain-specific images | Checks whether CLIP understands your niche |
CLIP-style embeddings are strong, but they have limits. The 2024 paper Contrastive Localized Language-Image Pre-Training explains that image-level CLIP training can be insufficient when downstream tasks need fine-grained region understanding. This fits real apps because searching “red dress” may work well, while searching “small logo on the left sleeve” may need more localized or specialized models.
How do you evaluate image search?
Please do not judge it by vibes only.
Make a small test set.
Example:
| Query | Expected result type |
| “white running shoes” | White sneaker/running shoe images |
| “black leather handbag” | Black bag images |
| “blue office chair” | Blue chair images |
| “invoice screenshot” | Invoice-like screenshots |
| Query image: red sneaker | Similar red sneakers |
Track:
| Metric | What it tells you |
| Top-1 accuracy | Is the best result correct? |
| Top-5 accuracy | Is a good result near the top? |
| Mean reciprocal rank | How high the first good result appears |
| Precision@k | How many top results are relevant |
| False matches | Which bad images look too similar |
| Latency | How fast search feels |
| Cost per image | Whether embedding generation scales |
For product search, review results with a human. Similarity can be subjective. A user may care more about style than category, or more about category than color.
Use image embeddings for duplicate detection
Duplicate detection is one of the easiest wins.
Generate embeddings for every image, then search each image against the index.
def find_duplicates(index, embeddings, metadata, threshold=0.98):
duplicates = []
scores, indices = index.search(embeddings, k=2)
for i in range(len(embeddings)):
# First result is usually the image itself
neighbor_score = scores[i][1]
neighbor_idx = indices[i][1]
if neighbor_score >= threshold:
duplicates.append({
"image": metadata[i],
"duplicate_candidate": metadata[neighbor_idx],
"score": float(neighbor_score)
})
return duplicates
Use it:
duplicates = find_duplicates(index, embeddings, metadata)
for item in duplicates:
print(item)
A high threshold like 0.98 catches near-identical images. Lower thresholds catch visually similar images, which may include false positives.
Use image embeddings for clustering
If you have a large image folder and want to organize it, clustering helps.
You can cluster embeddings with scikit-learn.
pip install scikit-learn
from sklearn.cluster import KMeans
num_clusters = 5
kmeans = KMeans(n_clusters=num_clusters, random_state=42)
labels = kmeans.fit_predict(embeddings)
for item, label in zip(metadata, labels):
item["cluster"] = int(label)
print(metadata[:5])
This can help group images by visual similarity.
Useful for:
| Use case | How clustering helps |
| Media libraries | Group similar assets |
| Product catalogs | Find style clusters |
| Moderation queues | Group similar content |
| Dataset cleanup | Find repeated or near-duplicate images |
| Creative tools | Organize moodboard images |
Clustering is not perfect, but it is a great way to explore a dataset.
Where LLMAPI fits after image embeddings
LLMAPI fits when image embeddings become part of a larger AI workflow.
For example:
- Python generates image embeddings.
- FAISS or a vector database retrieves similar images.
- Your app sends image metadata, captions, or retrieved results to an LLM.
- LLMAPI routes the task to the best model.
- The model summarizes, labels, compares, classifies, or explains the result.
Useful follow-up tasks:
| Task | Example |
| Image search explanation | “Why are these images similar?” |
| Product tagging | Generate product tags from retrieved matches |
| Content moderation | Route suspicious clusters for review |
| RAG over images | Retrieve visuals and answer user questions |
| Caption enrichment | Generate captions for unlabeled images |
| Recommendation text | Explain why a product is recommended |
| Workflow routing | Send easy tasks to cheaper models and hard tasks to stronger ones |
Image embeddings are the retrieval layer. LLMAPI can help with the reasoning, text generation, routing, and fallback layer around that retrieval.
Common mistakes when generating image embeddings
| Mistake | Better approach |
| Recomputing embeddings every time | Save embeddings and metadata |
| Mixing models in one index | Use one embedding model per index |
| Forgetting normalization | Normalize vectors for cosine similarity |
| Ignoring preprocessing | Use the model’s official preprocess function |
| Testing only 5 images | Build a real test set |
| No metadata | Store image path, ID, category, and permissions |
| No filters | Add category/user/security filters |
| Using image embeddings for tiny details | Test localized or domain-specific models |
| No human review | Review search quality manually |
| No evaluation metrics | Track top-k accuracy and false matches |
The “same model per index” point is important. Embeddings from different models usually do not live in the same vector space. If you switch models, rebuild the index.
Full starter script
Here is a complete starter version using CLIP and FAISS.
from pathlib import Path
import clip
import faiss
import numpy as np
import torch
from PIL import Image
device = "cuda" if torch.cuda.is_available() else "cpu"
model, preprocess = clip.load("ViT-B/32", device=device)
model.eval()
def get_image_embedding(image_path):
image = preprocess(Image.open(image_path).convert("RGB")).unsqueeze(0).to(device)
with torch.no_grad():
embedding = model.encode_image(image)
embedding = embedding / embedding.norm(dim=-1, keepdim=True)
return embedding.cpu().numpy()[0].astype("float32")
def get_text_embedding(text):
tokens = clip.tokenize([text]).to(device)
with torch.no_grad():
embedding = model.encode_text(tokens)
embedding = embedding / embedding.norm(dim=-1, keepdim=True)
return embedding.cpu().numpy()[0].astype("float32")
def build_index(image_folder):
image_folder = Path(image_folder)
image_paths = (
list(image_folder.glob("*.jpg"))
+ list(image_folder.glob("*.jpeg"))
+ list(image_folder.glob("*.png"))
)
embeddings = []
metadata = []
for image_path in image_paths:
embedding = get_image_embedding(str(image_path))
embeddings.append(embedding)
metadata.append({
"path": str(image_path),
"filename": image_path.name
})
print(f"Embedded {image_path.name}")
embeddings = np.vstack(embeddings).astype("float32")
index = faiss.IndexFlatIP(embeddings.shape[1])
index.add(embeddings)
return index, embeddings, metadata
def search_by_text(query, index, metadata, k=5):
query_embedding = get_text_embedding(query)
query_embedding = np.expand_dims(query_embedding, axis=0)
scores, indices = index.search(query_embedding, k)
results = []
for score, idx in zip(scores[0], indices[0]):
results.append({
"score": float(score),
"image": metadata[idx]
})
return results
def search_by_image(image_path, index, metadata, k=5):
query_embedding = get_image_embedding(image_path)
query_embedding = np.expand_dims(query_embedding, axis=0)
scores, indices = index.search(query_embedding, k)
results = []
for score, idx in zip(scores[0], indices[0]):
results.append({
"score": float(score),
"image": metadata[idx]
})
return results
if __name__ == "__main__":
index, embeddings, metadata = build_index("images")
print("\nSearch by text:")
text_results = search_by_text("a red sneaker", index, metadata)
for result in text_results:
print(result)
print("\nSearch by image:")
image_results = search_by_image("query.jpg", index, metadata)
for result in image_results:
print(result)
This gives you:
- Image embeddings.
- A FAISS similarity index.
- Text-to-image search.
- Image-to-image search.
That is a solid base for visual search.
Before you ship it
Here is the practical checklist.
- Pick one embedding model and stick to it for the index.
- Use the official preprocessing function.
- Normalize embeddings.
- Store image IDs and metadata.
- Add metadata filters for users, categories, and permissions.
- Save embeddings so you do not recompute them.
- Test text search and image search separately.
- Review false matches manually.
- Track top-k accuracy.
- Rebuild the index if you change models.
If you’re just learning, CLIP plus FAISS is enough.
If you’re building a real app, add metadata, filters, vector storage, evaluation, and a review loop.
And if image embeddings are only one step in a larger workflow, connect the retrieval results to LLMAPI so your app can route follow-up tasks like tagging, summarization, moderation, explanation, and recommendation copy across different models.