-
Notifications
You must be signed in to change notification settings - Fork 1
Tech Stack
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.
| 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.