Danh sách bài viết

Bài 15: Project 5 (optional) — Multi-modal AI Search Engine

Capstone project nâng cao: xây dựng image search engine cho phép search ảnh bằng text query hoặc ảnh tương tự, sử dụng CLIP embedding, Qdrant vector database, FastAPI và Streamlit. Phù hợp khi đã có ít nhất 3 project cơ bản trong portfolio.

27/05/2026
0 lượt xem
1

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
2

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-patch32 qua 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.
3

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.
4

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")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).
5

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.

6

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
7

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.

8

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.

9

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}'
10

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()
11

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
12

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
13

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.

14

Bonus — Filter metadata

Qdrant hỗ trợ filter payload khi search — tức là tìm "red dress" 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
    },
)
15

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 Google 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()VectorParams(size=...) tương ứng với dim của model mới.

16

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ả.
17

Common pitfalls

  • Không normalize embedding trước khi index. Nếu dùng Distance.COSINE trong 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/image cầ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.