-
Notifications
You must be signed in to change notification settings - Fork 1
Tech Stack
| Date | Version | Details |
|---|---|---|
| 2025/11/30 | v1.0 | Initial documentation |
| 2025/11/30 | v1.1 | Add experimental results |
This page documents the AI/ML and search stack used in MomenTag, and explains how the current production implementation evolved from the original PoC design.
The focus is on:
- How images and text are embedded
- How search & recommendation are computed
- How Qdrant and MariaDB work together
- What changed between the PoC and the production system
Code base: backend/
The current implementation is optimized for multi-user, scalable, and maintainable service operation.
- Vector DB: Qdrant
-
Collection:
IMAGE_COLLECTION_NAME - Vector dimension: 512
- Distance metric: Cosine similarity
-
Model:
clip-ViT-B-32(SentenceTransformers) -
Main code:
backend/gallery/gpu_tasks.py
Processing flow:
-
User uploads a photo from the Android client.
-
A Celery GPU task generates an image embedding.
-
The resulting vector is upserted into Qdrant’s
IMAGE_COLLECTION_NAME. -
Payload stored alongside the vector includes:
user_idfilenamephoto_path_idcreated_at-
lat,lng -
isTagged(whether any tags are attached)
Indexed payload fields in Qdrant (used for filters/sorting):
-
user_id,filename,photo_path_id,created_at,lat,lng
-
Text embedding model:
clip-ViT-B-32-multilingual-v1 -
Caption generation model: BLIP
Salesforce/blip-image-captioning-base
-
Caption storage:
Photo_Captiontable in MariaDB -
Key modules:
- Embedding:
backend/search/embedding_service.py - Captioning:
backend/gallery/gpu_tasks.py
- Embedding:
Caption pipeline:
-
For each uploaded image, BLIP generates 5 candidate captions via a Celery GPU task.
-
The captions are split into tokens using
phrase_to_words()(stop words removed). -
Each token is stored in
Photo_Captionwith:- token text
- frequency / weight
- image linkage
-
At query time, matching caption tokens are retrieved from the DB to give caption-based bonus in hybrid search.
BLIP generation parameters (production):
-
max_new_tokens: 20 -
num_return_sequences: 5 -
do_sample=True,top_k=50,top_p=0.95
-
Collection:
REPVEC_COLLECTION_NAME(Qdrant) - Purpose: Store representative vectors per tag for tag recommendation.
-
Main code:
backend/gallery/tasks.py→compute_and_store_rep_vectors
Representative vectors are computed as a Celery asynchronous task whenever the composition of a tag changes.
Trigger conditions:
- Tag has at least 10 photos attached.
Algorithm:
-
HDBSCAN clustering
-
min_cluster_size = 5 -
min_samples = 3 -
metric = "euclidean" -
Produces:
- One or more dense clusters
- Outliers labeled as
-1
-
-
Cluster centers
- For each non-outlier cluster, compute the mean vector as its center.
-
Final representative set
-
Union of:
- Cluster centers
- Outlier vectors
-
All of them are written into
REPVEC_COLLECTION_NAME.
-
-
Small tags (< 10 photos)
- For small tags, every image embedding is treated as a representative vector (no clustering).
Execution:
- Whenever photos are added or removed from a tag,
compute_and_store_rep_vectorsis enqueued. - This task runs on the
interactiveCelery queue (CPU only).
-
Collection:
TAG_PRESET_COLLECTION_NAME - Purpose: Provide generic, pre-defined tags for recommendation, such as “Travel”, “Food”, “Friends”, etc.
These preset vectors are not tied to any specific user and act as a fallback for users with limited personal data.
Main modules:
- Strategy pattern:
backend/search/search_strategies.py - Hybrid search orchestration:
backend/gallery/tasks.py→execute_hybrid_search
The search layer distinguishes between:
-
Tag-only queries:
{TagName} - Pure natural language queries
- Hybrid queries: tags + natural language
A strategy pattern is used to switch between three search strategies.
Condition: Query contains only tag syntax, e.g. {Travel}
Logic:
-
From MariaDB, retrieve all photos explicitly attached to the given tag(s).
- Each such photo starts with a base score of 1.0.
-
For each tag, call Qdrant
recommend():-
positive: all photo IDs belonging to that tag -
score_threshold: typically0.2 -
limit: 1000
-
-
Combine scores for multiple tags:
-
For each photo, gather scores from each tag.
-
Compute product of tag scores:
product_score = score₁ × score₂ × ... × scoreₙ -
To avoid collapsing scores due to repeated multiplication of values < 1, apply a scaling term:
final_score = product_score × 2^(n-1) -
If a tag score is missing, minimum value
0.1is used.
-
-
Sort by
final_scoredescending and apply pagination (offset,limit).
Condition: Query contains only natural language, no {} tags.
Logic:
-
Embed the text query using
create_query_embedding(). -
Call Qdrant
search()onIMAGE_COLLECTION_NAME:-
query_vector: embedded query -
score_threshold: typically0.2 -
limit: 1000
-
-
Sort by score and paginate results.
Condition: Query mixes tags and natural language, e.g. {Travel} beach sunset
Logic consists of three phases:
-
Same tag-based scoring as in TagOnlySearchStrategy:
-
Photos belonging to tags get base 1.0.
-
Qdrant
recommend()is used to expand candidates. -
Multi-tag scores are multiplied and scaled:
product_score = score₁ × score₂ × ... × scoreₙ final_tag_score = product_score × 2^(n-1) -
Minimum value is clamped at
0.1to avoid zeros.
-
- The natural language part of the query is embedded.
- Qdrant
search()returns semantic scores for candidate photos.
-
The NL query is tokenized via
phrase_to_words(). -
From the
Photo_Captiontable, photos whose caption tokens match query words are counted. -
For each matched word, a 0.1 bonus is added:
caption_bonus = num_matching_tokens × 0.1
final_score = (tag_weight × tag_score)
+ (semantic_weight × semantic_score)
+ caption_bonus
Default weights:
tag_weight = 1.0semantic_weight = 1.0
Sorted by final_score descending and paginated.
Endpoint:
GET /api/gallery/tag/<tag_id>/recommend/Goal: Suggest additional photos that fit well into a given tag album.
Main code: backend/gallery/tasks.py → recommend_photo_from_tag
Logic:
-
From DB, fetch all photo IDs currently attached to the tag.
-
Call Qdrant
recommend()onIMAGE_COLLECTION_NAME:-
positive: all photos of the tag (no RepVec used here) -
limit: 80 - Filter by
user_idto stay within the same user’s photos.
-
-
Filter out all photos already tagged with this tag.
-
Return the top 40 remaining candidates.
Key idea: Use the entire set of tag photos as positive examples, letting Qdrant infer similar images in the vector space.
Endpoint:
GET /api/gallery/tag-recommendations/<photo_id>/Goal: Recommend suitable tags (and caption-like phrases) for a single photo or a batch of photos.
Main code: backend/gallery/tasks.py
Steps:
-
Retrieve the image vector for the photo using Qdrant
retrieve()onIMAGE_COLLECTION_NAME. -
Preset tag search:
- Collection:
TAG_PRESET_COLLECTION_NAME - Query vector: image vector
limit = 10-
score_threshold = 0.0(wide net)
- Collection:
-
User tag search:
- Collection:
REPVEC_COLLECTION_NAME - Query vector: image vector
limit = 10score_threshold = 0.75- Filter by
user_idso only the user’s tags are considered.
- Collection:
-
Results are merged and deduplicated:
-
Preset tags:
{ "tag": "<name>", "tag_id": "", "is_preset": true } -
User tags:
{ "tag": "<name>", "tag_id": "<uuid>", "is_preset": false }
-
The frontend can present these as a mix of generic and user-specific tag suggestions.
Use case: Story/Moment generation or any situation that needs tag suggestions for many photos at once.
Optimizations:
-
Bulk vector fetch:
- A single Qdrant call retrieves vectors for all target photos.
-
Parallel recommendation:
- A
ThreadPoolExecutor(up to 10 workers) runs preset & user-tag searches in parallel for each photo.
- A
-
Bulk DB queries:
- Tag metadata is resolved from the DB in batch to reduce query overhead.
Recommendation sources:
-
TAG_PRESET_COLLECTION_NAME- Up to 2 preset tags per photo.
-
REPVEC_COLLECTION_NAME- Up to 10 user tags per photo, with
score_threshold = 0.75.
- Up to 10 user tags per photo, with
Result format:
-
A mapping from
photo_idto a list of suggested tags:{ "<photo_id>": [ { "tag": "...", "tag_id": "", "is_preset": true }, { "tag": "...", "tag_id": "<uuid>", "is_preset": false } ], ... }
Duplicate tags are removed per photo.
This section summarizes the evaluation of the search and recommendation system using a combined dataset of COCO test2017 and 600 personal photos. The experiments measure the effectiveness of tag-based recommendation, hybrid search, and natural-language attribute fusion.
-
COCO test2017: ~40,000 images
-
Personal dataset: 500 images (5 sets × 100 images each)
-
Test tags:
- Escape Room (
#escape) - Hoodie (
#hoodie) - Cooking
- Jeju Trip
- Screenshot
- Escape Room (
For each tag, 3 random reference images were manually assigned the tag. The system was then evaluated on how accurately it retrieved the remaining personal photos from the combined dataset.
Evaluation focuses on:
- Image recommendation from tagged examples
- Hybrid search (tag + natural language)
- Attribute fusion (color, count, etc.) within NL queries
This experiment measures how accurately the system recommends the correct personal images based on the 3 tagged reference images, using Qdrant’s recommend() API.
| Tag | Top 30 | Top 60 | Top 90 | Notes |
|---|---|---|---|---|
| Escape Room | 30/30 (100%) | 59/60 (98%) | 79/90 (87%) | Strong visual distinctiveness |
| Hoodie | 30/30 (100%) | 60/60 (100%) | 80/90 (88%) | Clothing without people reduces precision at Top 90 |
| Screenshot | 30/30 (100%) | 58/60 (96%) | 69/90 (76%) | — |
| Cooking | 27/30 (90%) | 52/59 (88%) | 75/90 (83%) | COCO contains many food images → noise |
| Jeju Trip | 20/30 (66%) | 30/60 (50%) | 40/90 (44%) | COCO contains many landscape/ocean images → noise |
- Tags with clear visual identity (Escape Room, Hoodie) achieve extremely high precision.
- Tags with high visual overlap with the COCO dataset (Cooking, Jeju Trip) suffer from more false positives.
Escape Room and Hoodie images are rare in COCO, making them strong candidates to test hybrid tag + NL performance.
The specificity of the verb in the natural language query significantly influenced retrieval quality.
Target condition: Among 100 hoodie-related images, 35 actually contain people wearing hoodies. These 35 images were treated as the “true target” subset.
-
Query A (General) —
people with #hoodie -
Query B (Specific) —
people wearing #hoodie
| Scroll Depth | Query A: with (Target / Hoodie / Else) |
Query B: wearing (Target / Hoodie / Else) |
|---|---|---|
| First 15 | 4 / 4 / 7 | 11 / 3 / 1 |
| Next 15 | 1 / 14 / 0 | 3 / 12 / 0 |
| Next 15 | 0 / 11 / 4 | 4 / 9 / 2 |
(Target : People wearing hoodie / Hoodie : Hoodie without people / Else : No hoodie)
- Ambiguous prepositions like “with” weaken semantic signals.
- Specific verbs like “wearing” dramatically improve retrieval of true target images.
- Natural language precision has a direct and substantial impact on hybrid search quality.
This experiment tests how well the system reflects attribute-level natural language (color, number of people) in combination with tags.
| Color | Total Photos | Found in First 15 | Found in First 30 | Notes |
|---|---|---|---|---|
| Gray | 26 | 12 | 19 | — |
| Black | 36 | 14 | 27 | — |
| Many | 7 | — | All 7 within 114 images | |
| Green | 4 | — | All 4 within 63 images |
True distribution in the dataset:
| People Count | Ratio |
|---|---|
| 2 | 32% |
| 3 | 40% |
| 4 | 20% |
| 5 | 8% |
However, the search results prioritized the requested count in the NL query more strongly than the natural dataset distribution.
| Query (N) | First 15 (Matched) | Next 15 | Next 15 |
|---|---|---|---|
| Two | 9 | 7 | 3 |
| Three | 13 | 5 | 6 |
| Four | 3 | 3 | 1 |
| Five | 2 | 3 | 0 |
- The system successfully integrates tag context + NL attributes.
- Attributes like color and count are accurately reflected even when dataset distribution suggests otherwise.
- Attribute fusion follows an implicit priority order: NL attribute > tag context > raw visual distribution
- Tag-based image recommendation already achieves very high accuracy, especially for visually distinct categories.
- Hybrid search performance is strongly influenced by the precision of natural language phrasing.
- The system accurately incorporates attribute-level semantics (color, number of people) on top of tag context.
- Categories with high overlap with public datasets (Cooking, Jeju Trip) produce more false positives, expected to improve as personal data volume increases.
| Feature | Qdrant API | Purpose |
|---|---|---|
| Tag-based expansion | recommend() |
Expand from a set of tagged images |
| Natural language search | search() |
Semantic similarity search with query vector |
| Hybrid search |
recommend() + search()
|
Tag + semantic fusion |
| Image recommendation | recommend() |
Recommend images for a tag album |
| Tag recommendation |
retrieve() + search()
|
Use image vector to search RepVec & preset tags |
| RepVec management |
retrieve(), delete(), upsert()
|
CRUD representative vectors |
The original PoC explored more experimental techniques, especially around personalized fine-tuning and graph-based recommendations. The production system later simplified and hardened these ideas.
-
Model:
openai/clip-vit-large-patch14 - Vector dimension: 768
- Fine-tuning: LoRA adapters per user (PEFT)
-
Storage: NumPy arrays (
embedding_vectors.npy), plus JSON metadata
LoRA fine-tuning:
-
Target modules:
["q_proj", "v_proj"] -
Hyperparameters:
r = 16lora_alpha = 32
-
Training data:
- User’s uploaded images
- BLIP captions
-
Objective:
- Contrastive learning (image–text matching)
-
Output:
- Per-user adapters under
/user_adapters/{userName}
- Per-user adapters under
Flow:
- Train user-specific LoRA adapter (~50 epochs).
- Use fine-tuned CLIP to generate embeddings.
- Store vectors in
.npyfiles.
- Text embedding: Same fine-tuned CLIP model (per user)
-
Captioning: BLIP
Salesforce/blip-image-captioning-base -
Storage:
.npyfiles
BLIP usage:
-
5 captions per image
-
Optimizations:
- FP16
torch.compile()- Batch size 8
Captions were primarily used as training data for LoRA, rather than direct search bonus.
-
Storage: Faiss
IndexFlatL2 - Purpose: Image recommendation
Algorithm:
-
Outlier removal: IsolationForest (
contamination=0.05) -
Clustering: KMeans (
K=10) -
Final representative set:
- Outlier vectors
- Cluster centers
These representative vectors were then indexed by Faiss to support nearest-neighbor queries.
- Not implemented.
- Tag recommendation relied solely on user-generated tags and RepVec.
-
Query is embedded with fine-tuned CLIP (user-specific LoRA).
-
Cosine similarity is computed between query vector and stored image vectors using:
sklearn.metrics.pairwise.cosine_similarity
-
Top-K images are returned (e.g., Top-20).
Characteristics:
- Only natural language search was supported.
- No tag syntax or hybrid search.
- No caption bonus; captions were used indirectly via fine-tuning.
Two approaches were explored:
-
Compute representative vectors for each tag (IsolationForest + KMeans, K=10).
-
Build a Faiss
IndexFlatL2over these vectors. -
For recommendation:
- Compare candidate image vectors against tag representative vectors.
- Use L2 distance or cosine similarity (with normalized vectors).
-
Graph structure:
-
Nodes:
- Image nodes (
bipartite=0) - Word nodes (
bipartite=1)
- Image nodes (
-
Edges:
- Image–word edges with weights (e.g., caption frequency)
-
-
Algorithms via NetworkX:
-
Weighted Rooted PageRank (RWR)
- Personalized PageRank seeded on input images.
-
Adamic/Adar index
- Measures similarity via shared neighbors (words).
-
-
Final score:
final_score = 0.6 × normalized_AdamicAdar + 0.4 × normalized_RWR
This graph-based approach was ultimately dropped for production due to complexity and scalability concerns.
- Representative vectors for each tag were computed using IsolationForest + KMeans (K=3).
- A Faiss index was built over these tag-level vectors.
- For a given photo, the closest representative vectors were retrieved; connected tags were recommended (excluding tags already attached to the image).
-
Model:
clip-ViT-B-32(SentenceTransformers) -
Approach: Window-based duplicate detection
- Maintain embeddings of the most recent 5 images.
- If cosine similarity ≥ 0.85, treat as a duplicate.
-
Optimizations:
- Compressed input images (JPEG quality ~25)
- Batch size 32
- FP16 inference
In production, this responsibility was shifted away from the backend and handled more simply on the client side (e.g., using photo_path_id).
| Aspect | PoC | Production |
|---|---|---|
| Image model | CLIP-ViT-Large (768-dim) | CLIP-ViT-B-32 (512-dim) |
| Fine-tuning | LoRA per user | Removed |
| Vector storage | NumPy files + JSON | Qdrant vector DB |
| Search engine | Faiss (in-memory) | Qdrant (distributed, persistent) |
| Multi-user support | Not designed | Built-in via user_id filters |
Why LoRA and Faiss were dropped:
-
LoRA:
- Complex training/infrastructure
- High GPU cost
- Pretrained models already perform well enough
-
Faiss:
- In-memory only
- Limited filtering capabilities
- No built-in persistence
| Aspect | PoC | Production |
|---|---|---|
| Algorithm | IsolationForest + KMeans (fixed K) | HDBSCAN (automatic cluster count + outlier detection) |
| Storage | Faiss index | Qdrant REPVEC_COLLECTION_NAME
|
| Main usage | Image recommendation | Tag recommendation |
| Aspect | PoC | Production |
|---|---|---|
| Query types | Natural language only | Tag-only, semantic-only, hybrid |
| Strategy pattern | No | Yes (3 strategies) |
| Hybrid scoring | N/A | Tag score + semantic score + caption bonus |
| Caption usage | Training data for LoRA | Direct bonus in hybrid search |
| Type | PoC | Production |
|---|---|---|
| Image recommendation | RepVec + Faiss / Graph-based PageRank+AA | Qdrant recommend() using all tag photos |
| Tag recommendation | RepVec + Faiss | Qdrant over RepVec + preset tags |
| Graph-based methods | NetworkX (RWR + Adamic/Adar) | Removed (complex & hard to scale) |
Carried forward from PoC:
- Representative vector concept (though algorithm & use changed)
- BLIP captioning (now used as search signal, not training data)
- Vector-based semantic search
Simplified or removed:
- User-specific LoRA fine-tuning
- Graph-based recommendation with NetworkX
- Backend duplicate detection with embeddings
-
torch.compile()tricks aimed at local optimization
New in production:
- Qdrant as a production-grade vector DB
- Full hybrid search (tags + natural language + caption bonus)
- Strategy pattern for search behavior
- Explicit
TAG_PRESET_COLLECTION_NAMEfor generic caption/tag suggestions - HDBSCAN for flexible clustering
- Thread-based parallelization for batch recommendations
The PoC phase allowed aggressive experimentation with fine-tuning, graph algorithms, and in-memory search. The production system then distilled these ideas into a simpler, scalable, and maintainable architecture:
- Qdrant replaces ad-hoc vector storage and Faiss-only setups.
- Search quality is driven by a combination of tag signals, semantic similarity, and caption-based bonuses rather than per-user fine-tuning.
- Representative vectors survive, but focus on tag recommendation, while image recommendation is handled directly via Qdrant’s
recommend()over full tag sets.
This evolution keeps the spirit of the PoC—semantic understanding of personal photos—while meeting real-world constraints of reliability, cost, and operational complexity.