Cách Mình Phát Triển Image Agent

Từ quản lý ảnh thủ công đến AI-powered image library với semantic search và generation

Quản lý ảnh cho SEO ở quy mô agency là bài toán khó. Ảnh nằm rải rác trên Google Drive, máy cá nhân, folder dự án. Nhân sự team Content dùng nhiều dụng cụ khác nhau: Canva, Gemini. Quy trình tạo ảnh lâu và nhiều bước. Image Agent gộp tất cả vào 1 tool duy nhất -- kho ảnh tập trung với search thông minh, và giao diện generate ảnh bằng AI.


1. Tổng Quan

Image Agent làm 2 việc:

  1. Image Library: Kho ảnh tập trung với hybrid search -- kết hợp semantic search (tìm theo ý nghĩa) và full-text search (tìm theo từ khóa). Upload ảnh lên, gắn metadata, rồi tìm lại bằng ngôn ngữ tự nhiên.
  2. Image Generation: Giao diện generate và chỉnh sử dụng API call đến nano banana pro, hỗ trợ multi-turn conversation -- iterate trên kết quả, dùng ảnh có sẵn làm reference, rồi lưu thẳng vào library.

2 phần này kết nối với nhau: ảnh trong library có thể dùng làm reference cho generation, ảnh generate xong có thể lưu vào library để tìm lại sau.

Layer Công nghệ
Frontend Next.js 16, React 19, Zustand, react-virtuoso
Backend FastAPI (Python), SQLAlchemy async
Database Supabase PostgreSQL + pgvector
CDN Cloudflare Images
AI Google Gemini (generation), Voyage AI (embeddings)

2. Hybrid Search: Semantic + Full-Text

Khi tool đã có 1 kho database gồm các ảnh. 1 phương pháp search ảnh hiệu quả là cần thiết để người dùng dễ truy hồi và tìm kiếm ảnh mong muốn.

1 phương pháp search không đủ. Semantic search hiểu nghĩa nhưng bỏ sót exact match. Full-text search tìm đúng từ nhưng không hiểu ngữ cảnh. Image Agent dùng cả 2 rồi merge kết quả.

Mỗi ảnh khi upload được tạo embedding vector từ Voyage AI (model voyage-4-large, 1024 dimensions). Input cho embedding là chuỗi {name} | {description} -- ghép tên và mô tả ảnh lại.

Khi người dùng search, query cũng được embed thành vector, rồi so sánh cosine distance với tất cả vectors trong database (pgvector). Chỉ trả về kết quả có score từ 0.6 trở lên -- dưới ngưỡng này thì kết quả không đủ liên quan.

Tại sao ghép name + description thay vì chỉ name? Name thường ngắn và generic ("banner website"), description chứa context quan trọng ("banner trang chủ cho dự án bất động sản, tông màu xanh navy"). Ghép cả 2 cho embedding giàu thông tin hơn.

PostgreSQL tsvector với xử lý đặc biệt cho tiếng Việt: dùng immutable_unaccent để loại bỏ dấu khi index và khi query. Nghĩa là search "bat dong san" vẫn tìm được ảnh có description "bất động sản".

Tại sao immutable unaccent thay vì unaccent thường? PostgreSQL yêu cầu function phải IMMUTABLE để dùng trong index expression. Function unaccent mặc định là STABLE (vì phụ thuộc vào dictionary config), nên phải wrap lại thành IMMUTABLE để tạo được index trên cột đã unaccent.

Search vector được tạo từ name, description, và tags -- 3 nguồn metadata chính.

2.3 Reciprocal Rank Fusion (RRF)

2 phương pháp search trả về 2 danh sách kết quả khác nhau. Vấn đề: merge chúng thế nào?

Cách đơn giản nhất là weighted average -- lấy score semantic nhân weight + score FTS nhân weight. Nhưng score từ 2 hệ thống có scale khác nhau (cosine similarity 0-1 vs FTS relevance score không cố định), nên weighted average cho kết quả thiên lệch.

RRF giải quyết bằng cách chỉ dùng rank (thứ hạng), không dùng score:

RRF_score = 1 / (k + rank_semantic) + 1 / (k + rank_fts)

Với k = 60, ảnh xếp hạng cao ở cả 2 phương pháp sẽ có RRF score cao nhất. Ảnh chỉ xuất hiện ở 1 phương pháp vẫn được tính nhưng với score thấp hơn. Cách này scale-agnostic -- không quan tâm score gốc là bao nhiêu.

2.4 Smart Suggestions

Khi search không có kết quả, agent không chỉ trả về "không tìm thấy". Thay vào đó, agent nhìn vào các ảnh gần ngưỡng (score 0.45-0.6) và trích xuất keywords từ metadata của chúng bằng pyvi -- thư viện NLP tiếng Việt với POS tagging.

Không thể split theo space vì tiếng Việt có từ ghép ("bất động sản" là 1 từ, không phải 3 từ). pyvi dùng POS tagging để nhận diện:

  • N (danh từ), Np (danh từ riêng), A (tính từ) → giữ lại
  • V (động từ), E (giới từ), C (liên từ) → bỏ

Rồi rank các token theo số lượng FTS match -- token nào match nhiều ảnh nhất thì gợi ý đầu tiên.

Kết quả: từ description "banner quảng cáo cho dự án bất động sản cao cấp", agent trích xuất ["banner", "quảng cáo", "dự án", "bất động sản", "cao cấp"] thay vì ["banner", "quảng", "cáo", "cho", "dự", "án", "bất", "động", "sản", "cao", "cấp"]. Người dùng thấy "Không tìm thấy, thử tìm: bất động sản, banner, website" thay vì trang trắng.


3. Lưu Trữ Ảnh

SEO Image Database

3.1 Tách Metadata và File

Image Agent tách hoàn toàn việc lưu metadata và lưu file ảnh:

Thành phần Lưu ở đâu Lưu gì
Metadata Supabase PostgreSQL name, description, tags, dimensions, embedding vector, cloudflare_image_id, project_id
File ảnh Cloudflare Images File gốc + các variant (kích thước, chất lượng khác nhau)

Tại sao không lưu file vào database? PostgreSQL có thể lưu binary (bytea) nhưng không nên. Database sẽ phình to, backup chậm, query chậm. Quan trọng hơn: database không phải CDN -- không có edge caching, không tối ưu bandwidth, không tự convert format.

Tại sao không lưu vào Supabase Storage? Có thể, nhưng Cloudflare Images giải quyết thêm 1 bài toán: variant system. Upload 1 file gốc, Cloudflare tự tạo nhiều phiên bản (kích thước, chất lượng khác nhau) mà không cần xử lý ở backend. Supabase Storage thì phải tự resize, tự cache.

3.2 Cloudflare Images

Mỗi ảnh upload lên Cloudflare Images nhận 1 ID duy nhất. URL delivery theo format:

https://imagedelivery.net/{delivery_hash}/{cloudflare_image_id}/{variant}

Variant hq (high quality) là default cho hiển thị. Cloudflare tự xử lý:

  • Edge caching: ảnh được cache ở CDN node gần người dùng nhất
  • Format optimization: tự convert sang WebP/AVIF nếu browser hỗ trợ
  • Bandwidth: không tốn bandwidth server, Cloudflare chịu

Backend chỉ cần lưu cloudflare_image_iddelivery_hash -- không cần quản lý URL hay file path.

3.3 Upload Flow và Rollback

Pipeline upload 1 ảnh đi qua 4 bước tuần tự:

File → Cloudflare Images (upload, nhận ID + URL)
     → PIL (extract width/height)
     → Voyage AI (embed name + description → vector)
     → Supabase (save metadata + vector)

Vấn đề: nếu bước cuối (save Supabase) fail sau khi đã upload lên Cloudflare thì sao? Ảnh nằm trên CDN nhưng không có metadata trong database -- orphan file, không ai tìm được, không ai xóa được.

Giải pháp: rollback. Khi save metadata fail, backend gọi Cloudflare API để xóa ảnh vừa upload. Đảm bảo 2 hệ thống luôn đồng bộ -- hoặc cả 2 có, hoặc cả 2 không có.

3.4 Soft Deletes

Ảnh không bị xóa thật khi người dùng delete. Thay vào đó, cột deleted_at được set timestamp. Tất cả query ở repository layer tự động filter WHERE deleted_at IS NULL.

Tại sao không hard delete?

  • Recovery: người dùng xóa nhầm thì restore được
  • Referential integrity: ảnh có thể đang được reference ở conversation history, generation links. Hard delete sẽ break các references đó
  • Audit trail: biết ảnh nào đã bị xóa, khi nào, để debug khi cần

Cloudflare file vẫn tồn tại sau soft delete -- chỉ bị xóa khỏi CDN khi hard delete (nếu implement sau).


4. Image Generation với Gemini

4.1 Generation vs Editing Mode

Agent tự phát hiện mode dựa trên input:

Điều kiện Mode Hành vi
Chỉ có text prompt, không có ảnh reference Generation Text-to-image, Gemini tạo ảnh từ mô tả
Có ảnh reference (subject/scene/style) Editing Image manipulation, Gemini dùng ảnh đầu vào để tạo/chỉnh sửa

Người dùng không cần chọn mode -- agent nhìn vào có ảnh reference hay không rồi quyết định. Giảm cognitive load cho người dùng.

4.2 Reference Types

Khi cung cấp ảnh reference, người dùng phân loại thành 3 loại:

Loại Mục đích Ví dụ
Subject Đối tượng chính cần giữ Ảnh sản phẩm, logo, nhân vật
Scene Bối cảnh/nền Ảnh văn phòng, cảnh thiên nhiên, background
Style Phong cách nghệ thuật Ảnh mẫu về tông màu, composition, art style

Phân loại này quan trọng vì nó thay đổi cách Gemini hiểu prompt. "Đặt sản phẩm này (subject) vào văn phòng (scene) theo phong cách minimalist (style)" cho kết quả tốt hơn nhiều so với "dùng 3 ảnh này để tạo ảnh mới".

4.3 Multi-Turn Conversations

Generation không phải one-shot. Người dùng thường iterate: "tạo banner" → "đổi màu nền sang xanh" → "thêm text ở góc phải". Agent giữ conversation history để Gemini hiểu context.

Nhưng không giữ toàn bộ history -- có giới hạn:

Giới hạn Giá trị Lý do
Messages 10 tin nhắn cuối (5 turns) Token cost tăng tuyến tính với history length
Images trong history 3 turns cuối Ảnh tốn nhiều tokens hơn text, diminishing returns sau 3 turns
Thought signature Giữ nguyên Cho phép model tiếp tục reasoning chain

thought_signature là 1 pattern đáng chú ý: Gemini trả về reasoning (suy nghĩ) tách biệt với response. Agent lưu cả 2, và gửi lại thought signature trong lượt tiếp theo để model "nhớ" chain of thought trước đó.

4.4 SSE cho Async Generation

Image generation mất thời gian -- vài giây đến vài chục giây. Thay vì polling (client hỏi server liên tục "xong chưa?"), agent dùng Server-Sent Events (SSE):

Client mở connection SSE → Server generate ảnh → Server push event khi xong → Client nhận và hiển thị

So với polling:

  • Ít request hơn: 1 connection thay vì hàng chục requests
  • Real-time: client nhận kết quả ngay khi có, không phải đợi interval tiếp theo
  • Đơn giản hơn WebSocket: SSE là one-way (server → client), đủ cho use case này vì client chỉ cần nhận kết quả

5. Performance Patterns

5.1 Virtualized Lists

Image library có thể chứa hàng nghìn ảnh. Render tất cả DOM nodes cùng lúc sẽ làm browser chậm. Agent dùng react-virtuoso -- chỉ render các items đang visible trong viewport.

3 nơi dùng virtualization:

  • Conversation grid (trang chủ): grouped by time period
  • Chat panel: vertical scroll with auto-follow
  • Image gallery: grid layout with infinite scroll

Khi load conversation history, agent cần download ảnh từ Cloudflare để reconstruct context cho Gemini. Thay vì download tuần tự (ảnh 1 xong mới download ảnh 2), agent dùng asyncio.gather để download song song.

Về phía client-side data fetching (cache, deduplication, infinite scroll), xem chi tiết cách tiếp cận trong bài Fetch vs TanStack Query.

5.2 Optimistic UI

Không đợi server response mới update UI:

Action UI ngay lập tức Server ở background
Generate Hiện message "đang xử lý", clear input Gọi Gemini API
Cancel Xóa message, restore prompt Gọi cancel API
Retry Flip status sang "đang xử lý" Gọi lại Gemini API

Người dùng cảm thấy app nhanh hơn thực tế vì feedback là instant.


6. Những Bài Học

Embedding input matters. Ban đầu chỉ embed name -- kết quả search kém vì name quá ngắn và generic. Ghép name | description cho vector giàu context hơn, search accuracy tăng rõ rệt.

Giới hạn history là cần thiết. Gửi toàn bộ conversation history cho Gemini tốn tokens và không cải thiện chất lượng. Sau 5 turns, context cũ ít ảnh hưởng đến output. Giới hạn 10 messages + 3 turns ảnh là trade-off hợp lý giữa context và cost.

Tách metadata và file từ đầu. Nếu lưu file trong database rồi migrate ra CDN sau thì phải rewrite toàn bộ upload/serve logic. Tách từ đầu thì mỗi hệ thống làm đúng việc của nó: database cho query, CDN cho delivery.

Rollback phải explicit. Khi pipeline có nhiều bước qua nhiều service (Cloudflare → Voyage → Supabase), fail ở bước sau phải cleanup bước trước. Không có rollback = orphan data tích tụ dần, debug càng ngày càng khó.