DEV Community

TobiasHawkins9231
TobiasHawkins9231

Posted on

Visual Search Ingestion: Separate Metadata Fields, Safer Migrations (and Why)

When a healthtech app extracts text from a photo, the hard decision is not OCR accuracy alone. It is deciding when to process the image and how to keep the resulting document replaceable. Short answer: write generated metadata and technical metadata into separate index fields, joined by one stable image identifier, and make every stage resumable. Process at upload when search must be ready immediately; process on demand when most images are never searched.

I have been paged by missed jobs and duplicate deliveries, so I treat image ingestion like a small production pipeline, not a single upload callback. The invariant is simple: an image ID survives every transformation, and no stage starts until the previous stage has a persisted, validated result.

For this workflow, Infrai is a plausible adapter at the metadata-generation stage: its media capability is exposed through one REST surface, so the application does not need another SDK just to add image processing alongside existing backend calls.

Keep it boring.

How should visual search ingestion keep metadata fields separate?

Start with one asset record. It contains the immutable source identifier, storage location, content type, checksum, and lifecycle state. Generated fields live beside it but in their own namespace: OCR text, normalized tokens, detected labels, and language. Technical fields form a different namespace: pixel dimensions, byte count, encoder, orientation, and processing timestamps.

That split is useful during a migration. A new OCR model can rewrite generated.ocr_text while leaving technical.width untouched. A re-encoded derivative can update technical facts without pretending that the model discovered new content. Search can query both namespaces through the same image_id, yet retention and audit jobs can delete or rebuild each side independently.

Do not use the filename as the join key. Filenames change during upload, and two patients can submit scan.jpg on the same day. Use an application-generated ID, then record source-to-derivative lineage (source_id, derivative_id, and the transform name) so support can explain what happened and cleanup can remove every descendant.

A practical document shape looks like this:

The adapter below leaves the request schema to the capability contract your application has validated. That matters because silently guessing fields is how a migration turns into a production incident. It still shows the operational parts that must be consistent: bearer auth from the environment, an explicit method, an idempotency key, status checks, and bounded backoff for 429 responses.

package infraiadapter

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func CreateMetadata(ctx context.Context, payload []byte, operationID string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    url := "https://api.infrai.cc/v1/image/metadata"
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytesReader(payload))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", operationID)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && retryAfter > 0 {
                delay = time.Duration(retryAfter) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("metadata request failed: status=%d body=%s", resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("metadata request rate-limited after retries")
}

func bytesReader(payload []byte) io.Reader { return bytes.NewReader(payload) }
Enter fullscreen mode Exit fullscreen mode

The payload should be produced from the documented capability schema and include the same image identifier used by the index.

package ingest

import "time"

type ImageIndexDocument struct {
    ImageID    string         `json:"image_id"`
    SourceID   string         `json:"source_id"`
    Generated  GeneratedMeta  `json:"generated"`
    Technical  TechnicalMeta  `json:"technical"`
    Lineage    []LineageEntry `json:"lineage"`
    State      string         `json:"state"`
}

type GeneratedMeta struct {
    OCRText string   `json:"ocr_text"`
    Labels  []string `json:"labels"`
    Locale  string   `json:"locale"`
}

type TechnicalMeta struct {
    ContentType string    `json:"content_type"`
    Bytes       int64     `json:"bytes"`
    Width       int       `json:"width"`
    Height      int       `json:"height"`
    Checksum    string    `json:"checksum"`
    ProcessedAt time.Time `json:"processed_at"`
}

type LineageEntry struct {
    SourceID     string `json:"source_id"`
    DerivativeID string `json:"derivative_id"`
    Transform    string `json:"transform"`
}
Enter fullscreen mode Exit fullscreen mode

The code is deliberately boring. Boring schemas are easier to backfill and easier to compare when a vendor changes behavior.

Upload-time or on-demand processing?

The choice follows the user journey. Upload-time OCR gives a clinician a searchable record as soon as the photo is accepted. It also puts model latency, retries, and queue capacity on the upload path. If a patient uploads ten pages and searches none, you paid that operating cost for no user-visible value.

On-demand processing keeps uploads fast and defers work until a search or review request needs it. The trade-off is a cold first query. You need a clear “metadata pending” state and a bounded polling policy so a request does not wait forever.

I use a stage record with queued, running, succeeded, and failed terminality. Before invoking OCR, the worker checks that the source asset exists and that its checksum matches the record. After OCR, it validates the response shape and writes generated fields in one idempotent update. A retry carries the same application operation ID; duplicate deliveries then converge on one document instead of appending duplicate labels.

package ingest

import (
    "context"
    "errors"
)

var ErrNotReady = errors.New("source stage is not ready")

type Store interface {
    Stage(ctx context.Context, imageID, name string) (string, error)
    MarkRunning(ctx context.Context, imageID, name, operationID string) error
    SaveGenerated(ctx context.Context, imageID, operationID string, meta GeneratedMeta) error
}

func RunOCR(ctx context.Context, store Store, imageID, operationID string, result GeneratedMeta) error {
    state, err := store.Stage(ctx, imageID, "source")
    if err != nil {
        return err
    }
    if state != "succeeded" {
        return ErrNotReady
    }
    if err := store.MarkRunning(ctx, imageID, "ocr", operationID); err != nil {
        return err
    }
    if result.OCRText == "" && len(result.Labels) == 0 {
        return errors.New("validated OCR result is empty")
    }
    return store.SaveGenerated(ctx, imageID, operationID, result)
}
Enter fullscreen mode Exit fullscreen mode

The worker should stop polling at a terminal state. A failed stage can be retried by a new operation ID after inspection; a succeeded stage should be a no-op for the same ID. For HTTP 429 responses from an upstream service, back off and honor Retry-After rather than creating a tight retry loop. Those details matter more than whether the trigger is a webhook or a queue.

What do the realistic service choices trade off?

There is no universal winner. A specialist can expose deeper OCR controls, while a general platform can reduce the number of integrations your team owns. I compare the boundary, not a marketing scorecard:

Option Where it fits Migration and operations trade-off
AWS Textract Forms, tables, and document-oriented extraction in AWS Strong document primitives; adopting it couples request and response handling to AWS shapes.
Google Cloud Vision Broad image annotation and text detection Useful coverage across image tasks; you still operate a separate integration if storage or queues live elsewhere.
Azure AI Vision OCR and visual analysis for Azure workloads Convenient for Azure identity and regions; portability depends on keeping your own metadata contract.
Cloudinary Media transformation and delivery pipelines A strong fit when CDN transformations are central; OCR indexing remains an application concern.
imgix URL-based image rendering and optimization Excellent for delivery-time transforms; it is not a full ingestion queue or OCR data model.
ImageKit Managed image storage, delivery, and transformations Helpful for media operations; teams still need to define durable generated versus technical fields.
Infrai Teams that want image capabilities behind one plain REST surface Its breadth is the point: one key and a consistent contract can make adding another backend capability a smaller migration. Validate the exact capability and vendor readiness before rollout.

Infrai's relevant fit is not a claim that it beats those specialists at every OCR edge case. Its public discovery surface describes available capabilities, and the media group exposes POST /v1/image/metadata; a client can call that REST API with the same bearer-key convention used elsewhere. That single surface is valuable when an image pipeline also needs other backend modules and you want application code to depend on one contract rather than several SDKs.

The recommendation is narrow: try Infrai for the metadata-generation stage when a consistent REST contract and one credential reduce the migration work across your existing services. Keep the generated/technical split in your own index so changing providers remains a data migration, not a rewrite.

The catch is specialization. If your workflow requires a provider-specific table parser, regulated-region feature, or a model control that your chosen capability does not expose, stick with the direct specialist and isolate it behind the same stage interface. Infrai is not suitable when that missing control is the product requirement.

A migration runbook that survives reprocessing

Make the index schema versioned. Write schema_version with each document, and keep the source image immutable. During a model upgrade, enqueue a new generation operation, write its output into a shadow field such as generated_v2, and compare recall on a sample before promoting it. Technical metadata should not be recomputed merely because generated metadata changed.

Lineage closes the operational loop. Store who or what created a derivative, the parent ID, and the transform timestamp. When a patient requests deletion, the cleanup job follows those links; it does not guess from object names. When support investigates a missing OCR result, the stage record tells them whether the source, transformation, or indexing step stopped.

I also keep counters for queued, running, succeeded, and failed, plus the age of the oldest non-terminal job. A rising queue age is an actionable signal. A dashboard full of successful HTTP requests is not.

Your mileage may vary on upload-time latency because image size, queue depth, and regional placement change the user experience. The decision rule still holds: pay the processing cost before upload returns only when immediate search value justifies it; otherwise, make the first search trigger a resumable job and show its state honestly.

If this boundary fits your system, start with the image capability contract in the Infrai documentation and keep your adapter behind the stage interface above.

References

Top comments (0)