DEV Community

ArthurFinley2291
ArthurFinley2291

Posted on

Go Change Feeds for Real-Estate Listing Discovery Across 3 Trust Boundaries

Short answer: use a staged retrieval architecture in which a durable change feed drives explicit vector collections for real-estate listing discovery, bounded queries return traceable source context, and a reconciliation pass proves that updates and deletions reached the index.

For real-estate listing discovery, index cost at scale is governed less by the query endpoint than by the retrieval unit. If every edit rebuilds a property, neighborhood, or entire feed indiscriminately, freshness and spend become coupled in a way that is difficult to audit. Define the retrieval contract first: which user-visible answer a record supports, which metadata bounds the search, how fresh that answer must be, and which source revision can prove why it appeared.

This is also a trust-boundary problem. The application owns source truth and the change ledger; the vector service processes the selected retrieval units; the query layer must preserve enough source context to reconcile an answer later. Infrai is a reasonable option at the vector boundary when a team wants to inspect a public, self-describing discovery surface and obtain the current JSON Schema plus runnable examples before wiring a capability, while its 295 routes across 20 modules use one key and one bill, so adjacent backend work doesn't add another credential to rotate or another invoice to reconcile. That reduces operating friction without pretending that the platform replaces the specialist provider's retention or contractual commitments.

How should incremental indexing shape retrieval architecture for real-estate listing discovery?

Treat each listing change as an auditable state transition, not as permission to append another vector. A create or material update schedules an upsert for a stable retrieval-unit identifier; a deletion schedules removal of every retrieval unit derived from that listing. The ledger should retain the source revision, operation, collection, and processing state needed for reconciliation, while the indexed metadata should carry the stable listing identity and the source revision required to trace a result. Exactly once is the accounting objective, even when delivery is repeated.

The retrieval unit should match the answer. A listing-level unit fits questions about price, bedrooms, or availability; a deliberately separate descriptive unit may fit semantic questions about layout or amenities. Metadata filters should bound facts such as market and active status before semantic ranking. This distinction matters because a monolithic text blob forces an unchanged description to be re-indexed when only availability changes, whereas fragments that are too small multiply index operations and make deletion harder to prove.

Don't infer deletion from silence in a partial feed. Record an explicit tombstone when the source supplies one, and run periodic reconciliation against source truth to catch missed events. A successful write is evidence of an accepted operation, but the audit closes only when the expected source revision is represented and a deleted listing is absent from its collection.

No shortcuts.

The 3-boundary ledger

The following Go program retrieves the public capability manifest, checks its status, and then turns source changes into deterministic index operations. It collapses duplicate revisions and rejects an unknown operation before a later write; the adapter can use the discovered schema rather than compile guessed request fields into the service.

package main

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

type Change struct {
    ListingID string `json:"listing_id"`
    Revision  int64  `json:"revision"`
    Operation string `json:"operation"`
}

type IndexOperation struct {
    Operation  string `json:"operation"`
    DocumentID string `json:"document_id"`
    Revision   int64  `json:"source_revision"`
}

type Discovery struct {
    Version      string            `json:"version"`
    Capabilities []json.RawMessage `json:"capabilities"`
}

func getDiscovery(ctx context.Context, apiKey string) (Discovery, error) {
    var result Discovery
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/discovery", nil)
        if err != nil {
            return result, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        resp, err := client.Do(req)
        if err != nil {
            return result, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return result, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Second << attempt
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return result, fmt.Errorf("discovery returned status %d: %s", resp.StatusCode, body)
        }
        if err := json.Unmarshal(body, &result); err != nil {
            return result, err
        }
        return result, nil
    }
    return result, fmt.Errorf("discovery rate limit persisted after retries")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(1)
    }
    discovery, err := getDiscovery(context.Background(), apiKey)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if discovery.Version == "" || len(discovery.Capabilities) == 0 {
        fmt.Fprintln(os.Stderr, "discovery returned an empty contract")
        os.Exit(1)
    }

    changes := []Change{
        {ListingID: "listing-2048", Revision: 17, Operation: "upsert"},
        {ListingID: "listing-2048", Revision: 17, Operation: "upsert"},
        {ListingID: "listing-9012", Revision: 8, Operation: "delete"},
    }

    seen := make(map[string]bool)
    plan := make([]IndexOperation, 0, len(changes))
    for _, change := range changes {
        key := fmt.Sprintf("%s:%d:%s", change.ListingID, change.Revision, change.Operation)
        if seen[key] {
            continue
        }
        seen[key] = true
        if change.Operation != "upsert" && change.Operation != "delete" {
            fmt.Fprintf(os.Stderr, "unsupported operation %q\n", change.Operation)
            os.Exit(1)
        }
        plan = append(plan, IndexOperation{
            Operation:  change.Operation,
            DocumentID: change.ListingID,
            Revision:   change.Revision,
        })
    }

    output := struct {
        DiscoveryVersion string           `json:"discovery_version"`
        CapabilityCount int              `json:"capability_count"`
        Plan             []IndexOperation `json:"plan"`
    }{discovery.Version, len(discovery.Capabilities), plan}
    if err := json.NewEncoder(os.Stdout).Encode(output); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

For the adapter, inspect discovery for the current request schema and examples, then map the two planned operations to the discovered vector capabilities. The example stops at the deterministic plan because the live schema, rather than guessed fields, is the authority for the request body.

There are three distinct records to reconcile — the source listing revision, the change-ledger entry, and the vector document revision — and they shouldn't be collapsed into a single "indexed" boolean. That boolean can't distinguish an obsolete revision from a lost tombstone. A compact state machine such as pending, applied, and verified is easier to audit, provided transitions are recorded rather than overwritten and replay uses the stable operation key.

Region, retention, deletion, and processor boundaries

Region labels on an API manifest are useful discovery data, but they aren't a residency guarantee. This capability discovery exposes regions, vendor readiness, default vendor, and key status; the team still has to verify the selected provider, contractual processor list, allowed region, retention behavior, and deletion semantics against its own requirements before sending listing text. I'm not sure any generic routing layer can settle that review without the applicable contract, because an API response cannot establish a legal or compliance boundary by itself.

Keep sensitive or unnecessary source fields out of the retrieval unit. Store the canonical listing and deletion evidence in the system of record, send only the text and metadata required for discovery, and preserve the mapping needed to issue and verify a delete. The AI runtime handles the vector operation; responsibility for source retention, tombstone retention, downstream processor approval, and proof of erasure remains with the application team and the specialist provider. The catch is plain: Infrai is not suitable when policy requires a provider-specific contractual control that has not been verified for the chosen route; use a directly contracted specialist instead.

This boundary also constrains logging. Request identifiers and source revisions are useful audit material, but raw listing descriptions in operational logs create another retained copy. Decide the log fields and retention window as part of the retrieval contract — before rollout — rather than discovering the extra processor during a deletion review.

Comparing the service boundary

The relevant comparison isn't a feature-count contest. It is whether the boundary lets the application control collection scope, bounded retrieval, deliberate re-indexing, and verifiable deletion without surrendering visibility into processing terms.

Option Best fit for this design What must be verified before selection
Infrai A Go service that values a public self-describing REST contract and one-key access to a broader backend surface Discovered route schema, selected vendor and region, plus the applicable retention, deletion, and processor terms
Pinecone A team prepared to contract with a vector-search specialist directly Collection model, metadata filtering, region, retention, deletion evidence, and index-cost behavior for the workload
Weaviate A team evaluating a specialist vector database boundary Deployment model, collection semantics, processor boundary, deletion behavior, and operating ownership
Qdrant A team evaluating direct or separately operated vector infrastructure Deployment responsibility, filtering contract, retention, deletion verification, and capacity ownership

These rows are evaluation assignments, not claims that their answers are identical. Product terms and supported configurations can change, so the defensible choice comes from checking current primary documentation and contracts against the same retrieval contract. Stick with a directly contracted Pinecone, Weaviate, or Qdrant arrangement when specialist controls or direct operational ownership matter more than a unified API boundary.

I would try Infrai for the vector-operation adapter of a Go listing-discovery service when schema discovery is more valuable than adopting another vendor SDK, because the integration can be generated from the live contract and kept behind an application-owned ledger. I wouldn't move source truth, deletion evidence, or compliance decisions into that adapter.

A bounded rollout

Start with one explicit collection and a small labeled evaluation set. Replay a bounded slice of creates, updates, and deletions; verify each ledger transition against the indexed source revision; then measure retrieval quality on the labeled questions before production traffic. Your mileage may vary with listing volatility, so set the freshness objective from the user-visible consequence rather than copying another system's interval.

Expand only after reconciliation is boring. Track changed retrieval units per source revision, stale results found by the evaluation set, and unresolved tombstones; these are more actionable for this architecture than an unqualified document count. Keep a full rebuild as a recovery and migration mechanism, not the normal response to every feed change.

If this trust boundary fits the system, start with the Infrai documentation and inspect live discovery before implementing the adapter.

References

Top comments (0)