<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Camily Albres</title>
    <description>The latest articles on DEV Community by Camily Albres (@albres).</description>
    <link>https://dev.to/albres</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F999230%2Ffffa7e90-03e9-4208-a0f7-89ce0096c6f4.jpg</url>
      <title>DEV Community: Camily Albres</title>
      <link>https://dev.to/albres</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/albres"/>
    <language>en</language>
    <item>
      <title>What I learned from building a Image Search System</title>
      <dc:creator>Camily Albres</dc:creator>
      <pubDate>Tue, 06 Oct 2026 19:56:31 +0000</pubDate>
      <link>https://dev.to/albres/what-i-learned-from-building-a-image-search-system-7np</link>
      <guid>https://dev.to/albres/what-i-learned-from-building-a-image-search-system-7np</guid>
      <description>

&lt;p&gt;Repository: &lt;a href="https://github.com/albrescamily/semantic-image-search" rel="noopener noreferrer"&gt;https://github.com/albrescamily/semantic-image-search&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;Some people say we learn more from our mistakes… well, I can say I've learned a LOT by doing this project.&lt;/p&gt;

&lt;p&gt;The goal was to create a system that receives an image or a text query and searches for matches in a database, similar to how Google Photos works. It seemed simple at first, but as I got into the mechanics, I kept running into problems that looked trivial on the surface and turned out to be much harder than I expected.&lt;/p&gt;

&lt;p&gt;I will start by explaining the architecture, the design decisions I made, and why I made them. Also, I will analyze from a new perspective what I could have done better.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup and Infrastructure
&lt;/h3&gt;

&lt;p&gt;I used LocalStack, which emulates AWS services locally. In this project, I use it mainly to simulate S3 object storage. I also run Qdrant as the vector database. Both LocalStack and Qdrant run in Docker.&lt;/p&gt;

&lt;p&gt;For this prototype, everything runs on the same machine, so I access the services locally through their network APIs. I use Boto3 to communicate with LocalStack through the S3-compatible API, and I use the Qdrant Python client to communicate with Qdrant through its API. &lt;/p&gt;

&lt;p&gt;I didn’t focus much on reproducing a production network topology. Even though I wanted to simulate a production-like system at a small scale, I kept the infrastructure local instead of deploying the services across multiple machines to simulate real systems.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhv73tt8ttb6z7c79gcth.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhv73tt8ttb6z7c79gcth.png" alt="Architecture Diagram" width="800" height="298"&gt;&lt;/a&gt;&lt;br&gt;
(Fig 1: Architecture Diagram)&lt;/p&gt;
&lt;h3&gt;
  
  
  Input Path
&lt;/h3&gt;

&lt;p&gt;For the storage API, I created several routes to manage objects: upload, update, delete, and get/list operations. The upload endpoint is the main entry point for the ingestion pipeline. When a user uploads an image, the API layer orchestrates the storage, embedding, and indexing steps as one unit.&lt;/p&gt;

&lt;p&gt;The upload endpoint calls the embedding service and then the indexing service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/upload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UploadFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;(...)):&lt;/span&gt;
    &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;upload_to_s3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;embed_image_from_s3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;index_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Uploaded successfully!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem here is that the upload process is linear, and the upload route acts like an orchestrator. If a fault happens during one of these steps, it can cause inconsistencies between S3 and Qdrant. There is no guarantee that the whole upload flow will run without problems or that every service will always be available.&lt;/p&gt;

&lt;p&gt;It also creates a scalability problem because the request depends on multiple operations being completed before returning a response. If one of these operations becomes slow or gets stuck, the request stays open for longer, consumes resources, and can increase the load on the system as more requests arrive.&lt;/p&gt;

&lt;p&gt;This is also fundamentally an &lt;strong&gt;idempotency problem&lt;/strong&gt;. The system has no defined behavior for retries, duplicate uploads, duplicate IDs, or a step that hangs indefinitely. If a client retries after a timeout, does it create a duplicate object? Does it overwrite the original? Right now, the answer is undefined.&lt;/p&gt;

&lt;p&gt;There's another issue that's very important to address: deletions are not propagated safely.&lt;/p&gt;

&lt;p&gt;If I want to delete an image, how will this operation be replicated so the corresponding vector is also removed from Qdrant? There is no guarantee.&lt;/p&gt;

&lt;p&gt;The frontend should not be responsible for coordinating a delete operation across S3 and Qdrant. Ideally, it should only send something like &lt;code&gt;DELETE /images/{id}&lt;/code&gt;, and the backend should handle how this change is propagated across the system.&lt;/p&gt;

&lt;p&gt;Since S3 and Qdrant are two independent systems, there is no shared transaction between these operations. If the image is deleted from S3 but the Qdrant operation fails, the vector can remain in the database even though the original object no longer exists.&lt;/p&gt;

&lt;p&gt;This is called the &lt;strong&gt;dual-write problem&lt;/strong&gt;. As the DDIA book discusses, writing related data to heterogeneous systems becomes difficult because we need some way to keep those changes consistent.&lt;/p&gt;

&lt;p&gt;One possible direction would be to change the architecture and make the propagation of these changes event-driven.&lt;/p&gt;

&lt;p&gt;There are a few approaches that could be considered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Two-Phase Commit (2PC):&lt;/strong&gt; The issue with using two-phase commit here is that neither S3 nor Qdrant exposes the kind of distributed transaction mechanism we would need to coordinate both writes, so this approach is not really practical for this system.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SAGA pattern:&lt;/strong&gt; A Saga could model the workflow as a sequence of operations and use compensating actions when something fails. This can reduce some coupling between services, but it also introduces eventual consistency and does not automatically solve reliable event publishing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Transactional Outbox pattern:&lt;/strong&gt; Probably the approach I like the most. Instead of directly trying to update every external system inside the same request, we can write the state change and an event into the same PostgreSQL transaction. Then, another process can publish or consume those events and update S3, Qdrant, or other services.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I really like the idea of the Transactional Outbox pattern, so I would probably choose this approach to address the consistency problem.&lt;/p&gt;

&lt;p&gt;The idea of change data capture is also very interesting. PostgreSQL could act as the source of truth, while an outbox table records events that still need to be propagated. A worker or CDC mechanism could then read those changes and update the other systems.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query Path
&lt;/h3&gt;

&lt;p&gt;When the query enters the system, it is sent to the embedding service inside the &lt;code&gt;query_text&lt;/code&gt; route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/query_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;input_query_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;query_embed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;embed_text_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ann_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_embed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;images&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s3_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;ExpiresIn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;images&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use the same image ID as the identifier for both the S3 object and the Qdrant vector. This gives me a direct mapping between the vector search result and the original image.&lt;/p&gt;

&lt;p&gt;Using the same ID is useful, but it does not guarantee that both systems will stay consistent.&lt;/p&gt;

&lt;p&gt;If the two stores get out of sync because some operation was not propagated correctly, a query can return a vector that has no corresponding object in S3. The endpoint also has no way to detect or recover from this situation.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;generate_presigned_url&lt;/code&gt; call does not check if the object actually exists. It just creates a valid URL for that object key. So the frontend can receive the URL, try to load the image, and fail because the object is not there.&lt;/p&gt;

&lt;p&gt;Well, there are a lot of issues with my current project. I wanted to point out the most relevant ones I discovered, so there may be other problems I still don't know about.&lt;/p&gt;

&lt;p&gt;It was really fun building this project and interesting investigating the issues it has. Next time, I may refactor this project to build as event-driven to address those pain points&lt;/p&gt;

&lt;h3&gt;
  
  
  Bonus
&lt;/h3&gt;

&lt;p&gt;Besides all the issues I addressed above, I also started thinking about the security side and how to keep the system secure.&lt;/p&gt;

&lt;p&gt;One of the things that came to my mind was user image metadata. Sometimes, images have sensitive information in their metadata, for example geolocation, that can put users at risk and should not be visible to other users. So, when an image enters the system, we need a process that removes this sensitive metadata from the version of the image that will be displayed. &lt;/p&gt;

&lt;p&gt;At the same time, I may still want to preserve the original image and its metadata in case the owner wants to download it again. The original version could stay private and accessible only to the owner, while the processed version would have sensitive metadata removed and would be used for search results and display.&lt;/p&gt;

&lt;p&gt;Thanks for reading these lessons learned.&lt;/p&gt;

&lt;p&gt;-- Cami&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>database</category>
      <category>infrastructure</category>
    </item>
  </channel>
</rss>
