Mục lục
- Mục tiêu bài học
- Tổng quan project
- Vì sao project này optional và advanced
- CLIP — recap nhanh
- Chọn dataset
- Bước 1 — Cài đặt môi trường
- Bước 2 — Embedding ảnh với CLIP
- Bước 3 — Index vào Qdrant
- Bước 4 — FastAPI search endpoint
- Bước 5 — Streamlit UI
- Bước 6 — Evaluation
- Bước 7 — Dockerize
- Bước 8 — Deploy considerations
- Bonus — Filter metadata
- Bonus — Các model cross-modal khác
- Result target
- Common pitfalls
- Bài tiếp theo
Mục tiêu bài học
Sau bài này, bạn sẽ:
- ✅ Hiểu CLIP hoạt động như thế nào và tại sao nó phù hợp cho image search
- ✅ Biết cách embed ảnh và text về cùng 1 vector space với CLIP
- ✅ Index embedding vào Qdrant và thực hiện similarity search
- ✅ Xây dựng REST API với FastAPI cho 2 loại search: text → ảnh và ảnh → ảnh
- ✅ Wrap UI bằng Streamlit và Dockerize toàn bộ stack
- ✅ Biết các điểm cần chú ý về privacy, copyright và common pitfalls
Tổng quan project
Bài toán
User nhập text query hoặc upload ảnh → hệ thống trả về top-k ảnh tương tự nhất từ một index đã được build trước.
Hai mode:
- Text → Image: nhập
"red dress with floral pattern"→ trả ảnh sản phẩm phù hợp. - Image → Image: upload ảnh → trả ảnh tương tự (visual similarity search).
Use case ví dụ
- Product search (e-commerce): tìm sản phẩm bằng mô tả hoặc ảnh mẫu.
- Photo library: tìm ảnh trong gallery cá nhân không cần tag thủ công.
- Stock photo search: tìm ảnh creative theo concept.
Tech stack
- CLIP (
openai/clip-vit-base-patch32qua Hugging Face Transformers 4.40+): tạo embedding đa phương thức. - Qdrant 1.11+: vector database lưu và search embedding.
- FastAPI: REST API cho 2 endpoint search.
- Streamlit: UI demo.
- Docker Compose: orchestrate toàn bộ stack.
Timeline ước tính
3–4 tuần nếu làm song song với công việc. Breakdown:
- Tuần 1: dataset, embedding, index Qdrant.
- Tuần 2: FastAPI endpoints.
- Tuần 3: Streamlit UI, Docker Compose.
- Tuần 4: evaluation, README, deploy demo.
Vì sao project này optional và advanced
Project này được đánh dấu optional. Không cần làm nếu bạn chưa hoàn thành ít nhất 3 project cơ bản (project 1–3).
Độ phức tạp cao hơn project 1–4
- Multimodal phức tạp hơn unimodal — phải hiểu cả vision và language trong cùng 1 pipeline.
- Cần hiểu cơ chế cosine similarity và cách normalize embedding để search đúng.
- Stack nhiều service (model server + vector DB + API + UI) — dễ mắc lỗi integration.
Giá trị cho portfolio
- Ít candidate có project multimodal → tạo sự khác biệt với recruiter quan tâm đến vision-language.
- Showcase được CLIP — một vision-language model được nghiên cứu và deploy rộng rãi trong thực tế.
- Tích hợp vector database, một component quan trọng trong hệ thống AI hiện đại.
Khi nào nên làm
- Đã có 3 project cơ bản hoàn chỉnh trong portfolio.
- Muốn apply vào vị trí liên quan đến computer vision hoặc multimodal AI.
- Có thời gian 3–4 tuần dành riêng.
CLIP — recap nhanh
CLIP (Contrastive Language-Image Pre-training) là model của OpenAI, công bố năm 2021 (arXiv: 2103.00020). Ý tưởng cốt lõi:
- Train trên 400 triệu cặp (image, text caption) lấy từ internet.
- Dùng contrastive loss: đẩy embedding của ảnh và caption đúng gần nhau, đồng thời đẩy xa các cặp sai.
- Kết quả: 1 không gian vector chung cho cả ảnh và text — nghĩa là
embed("a cat")vàembed(photo_of_cat)sẽ gần nhau về cosine similarity.
Model variant dùng trong bài này
openai/clip-vit-base-patch32 — image encoder là ViT-B/32, text encoder là Transformer. Output: vector 512 chiều. Model size ~150MB, chạy được trên CPU.
Các variant khác (nếu muốn thử)
- SigLIP (Google, 2023): thay contrastive loss bằng sigmoid loss — chất lượng retrieval thường tốt hơn CLIP base, API tương tự.
- EVA-CLIP (BAAI): model lớn hơn, recall cao hơn, nhưng cần RAM nhiều hơn.
- open_clip: community re-implementation với nhiều checkpoint, dùng khi cần reproduced training.
- ImageBind (Meta, 2023): hỗ trợ 6 modality (text, image, audio, depth, thermal, IMU) — phức tạp hơn CLIP đáng kể.
Giới hạn của CLIP cần biết
- Training data chủ yếu là tiếng Anh và nội dung phương Tây → query tiếng Việt hoặc ảnh văn hoá địa phương có thể cho kết quả kém hơn.
- Không xử lý tốt text trong ảnh (OCR).
- Clip base-32 có thể bỏ sót chi tiết fine-grained (ví dụ phân biệt màu tương tự nhau).
Chọn dataset
Option A — Dataset public (khuyến nghị cho demo)
- Unsplash Lite: 25.000 ảnh, license open cho research, có metadata đi kèm. Phù hợp nhất để demo public.
- Flickr8k: 8.000 ảnh, mỗi ảnh có 5 caption — có thể dùng caption làm ground truth cho evaluation.
- COCO Captions (Lin et al., 2014): 118.000 ảnh train, có annotation phong phú. Hữu ích nếu muốn đánh giá Precision/Recall bài bản.
Option B — Dataset tự tạo
- Crawl product image: 5.000–10.000 ảnh từ shop online (Lazada, Shopee). Lưu ý: chỉ dùng nội bộ demo, không thương mại.
- Ảnh cá nhân: gallery trên máy. Tiện để thử nhưng tuyệt đối không host public — privacy.
Cảnh báo về license và privacy
- Nếu host demo public: chỉ dùng dataset có open license (Unsplash Lite, COCO).
- Ảnh crawl từ e-commerce: chỉ demo nội bộ, ghi rõ trong README.
- Ảnh cá nhân: tuyệt đối không upload lên server public hay HF Spaces.
Bài này dùng Unsplash Lite làm ví dụ xuyên suốt.
Bước 1 — Cài đặt môi trường
pip install transformers torch torchvision pillow
pip install qdrant-client fastapi uvicorn[standard]
pip install streamlit
Version tham chiếu: transformers>=4.40, qdrant-client>=1.11, torch>=2.2.
Cấu trúc thư mục
multimodal-search/
├── app/
│ ├── main.py # FastAPI app
│ └── clip_embed.py # CLIP helper functions
├── scripts/
│ └── index_images.py # Batch embed + upload to Qdrant
├── frontend.py # Streamlit UI
├── data/
│ └── raw/
│ └── photos/ # Ảnh gốc
├── docker-compose.yml
└── Dockerfile
Bước 2 — Embedding ảnh với CLIP
File app/clip_embed.py — tái sử dụng trong cả script index và API server:
# app/clip_embed.py
import torch
from PIL import Image
from transformers import CLIPProcessor, CLIPModel
def load_clip(model_name: str = "openai/clip-vit-base-patch32"):
device = (
"cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu"
)
model = CLIPModel.from_pretrained(model_name).to(device).eval()
processor = CLIPProcessor.from_pretrained(model_name)
return model, processor
def embed_image(model, processor, image: Image.Image) -> list[float]:
device = next(model.parameters()).device
inputs = processor(images=image, return_tensors="pt").to(device)
with torch.no_grad():
features = model.get_image_features(**inputs)
# Normalize về unit sphere để cosine similarity = dot product
features = features / features.norm(dim=-1, keepdim=True)
return features.squeeze().cpu().tolist()
def embed_text(model, processor, text: str) -> list[float]:
device = next(model.parameters()).device
inputs = processor(text=[text], return_tensors="pt", padding=True).to(device)
with torch.no_grad():
features = model.get_text_features(**inputs)
features = features / features.norm(dim=-1, keepdim=True)
return features.squeeze().cpu().tolist()
File scripts/index_images.py — batch embed toàn bộ ảnh:
# scripts/index_images.py
from pathlib import Path
from PIL import Image
from app.clip_embed import load_clip, embed_image
model, processor = load_clip()
image_dir = Path("data/raw/photos")
all_records = []
for img_path in image_dir.glob("*.jpg"):
img = Image.open(img_path).convert("RGB")
embedding = embed_image(model, processor, img)
all_records.append({
"id": img_path.stem,
"embedding": embedding,
"filename": img_path.name,
})
print(f"Indexed {len(all_records)} images")
Lưu ý quan trọng: phải normalize embedding (/ features.norm(...)) trước khi lưu vào Qdrant. Nếu bỏ bước này và dùng Distance.COSINE, Qdrant vẫn tự normalize khi search — nhưng normalize sẵn ở phía client tốt hơn để kiểm soát và debug.
Bước 3 — Index vào Qdrant
# scripts/index_images.py (tiếp theo)
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
client = QdrantClient(host="localhost", port=6333)
# Tạo collection — chạy lại sẽ drop collection cũ
client.recreate_collection(
collection_name="image_search",
vectors_config=VectorParams(size=512, distance=Distance.COSINE),
)
# Upsert theo batch để tránh timeout với dataset lớn
BATCH_SIZE = 100
for i in range(0, len(all_records), BATCH_SIZE):
batch = all_records[i : i + BATCH_SIZE]
points = [
PointStruct(
id=idx + i,
vector=rec["embedding"],
payload={"filename": rec["filename"]},
)
for idx, rec in enumerate(batch)
]
client.upsert(collection_name="image_search", points=points)
print("Upload done.")
Chạy Qdrant local trước khi index:
docker run -p 6333:6333 qdrant/qdrant:v1.11.0
python -m scripts.index_images
Kiểm tra collection đã có data:
curl http://localhost:6333/collections/image_search
Lưu ý: chỉ lưu filename vào payload, không lưu raw bytes ảnh — sẽ gây DB bloat. Ảnh được serve qua static file server hoặc folder mount.
Bước 4 — FastAPI search endpoint
# app/main.py
from fastapi import FastAPI, UploadFile, File, HTTPException
from pydantic import BaseModel
from PIL import Image
import io
from qdrant_client import QdrantClient
from contextlib import asynccontextmanager
from app.clip_embed import load_clip, embed_image, embed_text
state = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
# Load model một lần duy nhất khi startup
state["model"], state["processor"] = load_clip()
state["client"] = QdrantClient(host="localhost", port=6333)
yield
state.clear()
app = FastAPI(lifespan=lifespan)
class TextSearchRequest(BaseModel):
query: str
limit: int = 12
@app.post("/search/text")
def search_text(req: TextSearchRequest):
if not req.query.strip():
raise HTTPException(status_code=400, detail="query must not be empty")
embedding = embed_text(state["model"], state["processor"], req.query)
results = state["client"].search(
collection_name="image_search",
query_vector=embedding,
limit=req.limit,
)
return [
{"score": r.score, "filename": r.payload["filename"]}
for r in results
]
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
@app.post("/search/image")
async def search_image(file: UploadFile = File(...), limit: int = 12):
contents = await file.read()
if len(contents) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail="Image too large (max 5 MB)")
try:
img = Image.open(io.BytesIO(contents)).convert("RGB")
except Exception:
raise HTTPException(status_code=400, detail="Cannot decode image")
embedding = embed_image(state["model"], state["processor"], img)
results = state["client"].search(
collection_name="image_search",
query_vector=embedding,
limit=limit,
)
return [
{"score": r.score, "filename": r.payload["filename"]}
for r in results
]
Chạy API:
uvicorn app.main:app --reload --port 8000
Test nhanh với curl:
curl -X POST http://localhost:8000/search/text \
-H "Content-Type: application/json" \
-d '{"query": "sunset over the ocean", "limit": 5}'
Bước 5 — Streamlit UI
# frontend.py
import streamlit as st
import requests
from PIL import Image
import io
API = "http://localhost:8000"
st.title("Multimodal Image Search")
tab1, tab2 = st.tabs(["Text Search", "Image Search"])
with tab1:
query = st.text_input("Enter description...", "sunset over the ocean")
if st.button("Search by Text"):
with st.spinner("Searching..."):
resp = requests.post(
f"{API}/search/text",
json={"query": query, "limit": 12},
timeout=30,
)
results = resp.json() if resp.ok else []
if results:
cols = st.columns(4)
for i, r in enumerate(results):
cols[i % 4].image(
f"data/raw/photos/{r['filename']}",
caption=f"Score: {r['score']:.3f}",
)
else:
st.warning("No results.")
with tab2:
uploaded = st.file_uploader("Upload image to search similar", type=["jpg", "png", "webp"])
if uploaded and st.button("Search by Image"):
with st.spinner("Searching..."):
resp = requests.post(
f"{API}/search/image",
files={"file": (uploaded.name, uploaded.getvalue(), uploaded.type)},
timeout=30,
)
results = resp.json() if resp.ok else []
if results:
cols = st.columns(4)
for i, r in enumerate(results):
cols[i % 4].image(
f"data/raw/photos/{r['filename']}",
caption=f"Score: {r['score']:.3f}",
)
else:
st.warning("No results.")
Chạy Streamlit:
streamlit run frontend.py --server.port 8501
Lưu ý: nếu dùng Streamlit để load CLIP trực tiếp (không qua API), dùng @st.cache_resource để tránh model bị reload mỗi lần user tương tác:
@st.cache_resource
def get_model():
from app.clip_embed import load_clip
return load_clip()
Bước 6 — Evaluation
Qualitative evaluation (bắt buộc)
Chọn 20 text query đa dạng, xem top-10 kết quả và đánh giá thủ công theo 3 mức:
- Good: phần lớn kết quả trực quan liên quan.
- Partial: một vài kết quả đúng, một vài sai.
- Poor: kết quả không liên quan rõ ràng.
Lưu kết quả này vào README với ảnh minh hoạ — đây là phần recruiter nhìn vào đầu tiên.
Quantitative evaluation (nếu có ground truth)
Với Flickr8k hoặc COCO Captions, mỗi ảnh đã có caption — dùng caption làm query, ảnh tương ứng là relevant item.
- Precision@K: trong K kết quả trả về, bao nhiêu ảnh là relevant.
- Recall@K: trong tổng số relevant items, bao nhiêu xuất hiện trong top-K.
- MRR (Mean Reciprocal Rank): đo rank của relevant item đầu tiên.
def precision_at_k(retrieved: list, relevant: set, k: int) -> float:
retrieved_k = retrieved[:k]
hits = sum(1 for item in retrieved_k if item in relevant)
return hits / k
def recall_at_k(retrieved: list, relevant: set, k: int) -> float:
retrieved_k = retrieved[:k]
hits = sum(1 for item in retrieved_k if item in relevant)
return hits / len(relevant) if relevant else 0.0
def reciprocal_rank(retrieved: list, relevant: set) -> float:
for rank, item in enumerate(retrieved, start=1):
if item in relevant:
return 1.0 / rank
return 0.0
Bước 7 — Dockerize
# docker-compose.yml
services:
qdrant:
image: qdrant/qdrant:v1.11.0
ports:
- "6333:6333"
volumes:
- qdrant_data:/qdrant/storage
api:
build: .
ports:
- "8000:8000"
volumes:
- ./data:/app/data # mount ảnh để serve static
depends_on:
- qdrant
environment:
- QDRANT_HOST=qdrant
frontend:
build: ./frontend
ports:
- "8501:8501"
volumes:
- ./data:/app/data
depends_on:
- api
volumes:
qdrant_data:
Dockerfile cho API service:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Chạy toàn bộ stack:
docker compose up --build
Sau đó chạy index một lần:
docker compose exec api python -m scripts.index_images
Bước 8 — Deploy considerations
Resource requirements
- CLIP ViT-B/32: ~150MB RAM khi load, inference 1 ảnh/query khoảng 50ms trên CPU.
- GPU không bắt buộc cho serving — chỉ cần cho batch index lần đầu khi dataset lớn (>50k ảnh).
- Qdrant với 10k ảnh × 512 chiều float32 ≈ 20MB vector data — rất nhẹ.
Chiến lược index
Index ảnh một lần offline, lưu vào Qdrant persistent storage. Không re-compute embedding mỗi khi có search request. Chỉ chạy lại index khi thêm ảnh mới.
HF Spaces
Hugging Face Spaces hỗ trợ Streamlit và Gradio, cho phép deploy miễn phí trên CPU. Lưu ý:
- Space CPU free tier không có persistent storage giữa các session — cần pre-build Qdrant data vào image, hoặc dùng Qdrant Cloud (có free tier).
- Model CLIP sẽ download lúc startup Space (~150MB) — lần đầu chậm.
- Dùng
@st.cache_resourceđể model không reload mỗi request.
Render / Railway
Phù hợp hơn cho stack 3 service (Qdrant + API + Frontend). Render có free tier nhưng sleep sau 15 phút inactive — chấp nhận được cho demo portfolio.
Bonus — Filter metadata
Qdrant hỗ trợ filter payload khi search — tức là tìm "red dress" và chỉ trong category "clothing" với price < 50:
from qdrant_client.models import Filter, FieldCondition, Range, MatchValue
results = client.search(
collection_name="image_search",
query_vector=embedding,
query_filter=Filter(
must=[
FieldCondition(key="category", match=MatchValue(value="clothing")),
FieldCondition(key="price", range=Range(lt=50.0)),
]
),
limit=12,
)
Để dùng filter, khi index cần lưu metadata vào payload:
PointStruct(
id=i,
vector=embedding,
payload={
"filename": rec["filename"],
"category": rec["category"], # thêm vào
"price": rec["price"], # thêm vào
"color": rec["color"], # thêm vào
},
)
Bonus — Các model cross-modal khác
Nếu muốn so sánh hoặc nâng cấp model, đây là các lựa chọn:
| Model | Tổ chức | Dim | Đặc điểm |
|---|---|---|---|
clip-vit-base-patch32 |
OpenAI | 512 | Baseline, nhẹ, chạy tốt CPU |
clip-vit-large-patch14 |
OpenAI | 768 | Chất lượng tốt hơn, nặng hơn ~3× |
google/siglip-base-patch16-224 |
768 | Sigmoid loss, recall tốt hơn CLIP base | |
BAAI/EVA-CLIP-8B |
BAAI | 4096 | Lớn, recall cao, cần GPU cho inference |
BLIP-2 |
Salesforce | — | Captioning + VQA, phức tạp hơn CLIP |
Thay đổi model chỉ cần sửa model_name trong load_clip() và VectorParams(size=...) tương ứng với dim của model mới.
Result target
Mục tiêu tối thiểu để coi project là hoàn chỉnh:
- 5.000–10.000 ảnh indexed trong Qdrant.
- Query latency < 100ms cho cả text search và image search (đo end-to-end từ gửi request đến nhận kết quả, không tính render ảnh).
- Demo URL hoạt động (HF Spaces, Render, hoặc video demo quay màn hình).
- Top-10 kết quả trực quan relevant cho phần lớn query test trong qualitative evaluation.
- README có ví dụ query và screenshot kết quả.
Common pitfalls
-
Không normalize embedding trước khi index. Nếu dùng
Distance.COSINEtrong Qdrant nhưng embedding chưa normalize, kết quả không sai hoàn toàn (Qdrant tự normalize khi search) nhưng có thể gây nhầm lẫn khi debug. Chuẩn hoá ngay ở phía client. - Lưu raw image bytes vào Qdrant payload. Payload chỉ nên chứa metadata nhỏ (filename, category, ...). Ảnh gốc serve qua static file server hoặc object storage.
- Query không match distribution của dataset. CLIP train chủ yếu trên text tiếng Anh. Query tiếng Việt sẽ cho kết quả kém hơn — nên dùng tiếng Anh cho query trong demo.
- CLIP bias. Training data có bias về nội dung phương Tây và tiếng Anh. Kết quả search có thể không đại diện đồng đều mọi loại ảnh. Ghi rõ limitation này trong README.
-
Không có size limit cho image upload. Endpoint
/search/imagecần kiểm tra kích thước file. Bài này đặt max 5MB — điều chỉnh theo use case. -
Streamlit reload model mỗi lần click. Dùng
@st.cache_resourceđể cache model trong session. Không dùng@st.cache_data(chỉ dành cho data thuần, không phải object có state). -
Không upsert theo batch khi dataset lớn. Gọi
client.upsert()với 10.000 record một lần có thể timeout. Chia batch 100–500 record. - Privacy khi deploy. Nếu dataset có ảnh cá nhân hoặc crawl từ nguồn không có license rõ ràng, tuyệt đối không host public. Chỉ dùng Unsplash Lite hoặc COCO cho demo public.
Tài liệu tham khảo
- Radford et al., "Learning Transferable Visual Models From Natural Language Supervision" (CLIP), arXiv:2103.00020, 2021
- openai/clip-vit-base-patch32 — Hugging Face Model Hub
- Qdrant Documentation — v1.11
- Zhai et al., "Sigmoid Loss for Language Image Pre-Training" (SigLIP), arXiv:2303.15343, 2023
- Unsplash Dataset — Lite Edition (25k photos, open license)
- COCO Dataset — Common Objects in Context
- FastAPI Documentation
- Streamlit Documentation
