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.
- Tool: image-agent.seongonai.com
- Codebase: github.com/seongon-agency/image-agent
1. Tổng Quan
Image Agent làm 2 việc:
- 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.
- 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ả.
2.1 Semantic Search
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.
2.2 Full-Text Search
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
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_id và delivery_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ó.