DEV Community

EmersonPrice3718
EmersonPrice3718

Posted on

Real-Estate Listing Retrieval with Python: Ranking Signals for Fresh, Cited Answers

Short answer: use a hybrid retrieval contract for listing discovery: a live web path for volatile facts, a vector path for durable descriptions, and an explicit ranking score that makes freshness and citation coverage visible. The right choice is driven by the answer you must defend, not by which search box has the most features.

The retrieval contract comes before the endpoint

A property manager asking “which two-bedroom units changed this week?” needs a different system from one asking for a neighborhood summary. Before choosing an endpoint, write down the retrieval unit (whole listing, field-level change, or chunk), the metadata filters (market, availability, timestamp), and the freshness bound. Those three decisions are the contract.

I use three invariants. Every returned item has a stable listing identifier. Every score can be explained with signals available at query time. Deletions are propagated: when a feed removes a listing, the corresponding record is removed from the collection rather than left to age out.

The failure boundary is deliberately boring. If the live source cannot be cited, the answer is marked incomplete; if the vector index is stale, it can still support background context but cannot override a newer listing fact. That distinction prevents a fluent summary from quietly becoming a false availability claim.

How should real-estate listing discovery rank fresh, cited results?

Use a small, inspectable score instead of a single opaque relevance number. The live call below deliberately accepts a request body generated from the public discovery schema; that keeps the example runnable without inventing undocumented search fields, while still showing the transport rules that matter in production.

import json
import os
import sys
import time
import urllib.error
import urllib.request


API_URL = os.environ["INFRAI_BASE_URL"].rstrip("/") + "/web/search"


def web_search(payload: dict) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    body = json.dumps(payload).encode("utf-8")
    for attempt in range(4):
        request = urllib.request.Request(
            API_URL,
            data=body,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                raw = response.read().decode("utf-8")
                return json.loads(raw) if raw else {}
        except urllib.error.HTTPError as error:
            error_body = error.read().decode("utf-8")
            if error.code != 429 or attempt == 3:
                raise RuntimeError(
                    f"web search rejected with HTTP {error.code}: {error_body}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)

    raise RuntimeError("retry budget exhausted")


if __name__ == "__main__":
    if len(sys.argv) != 2:
        raise SystemExit("usage: python web_search.py request.json")
    with open(sys.argv[1], "r", encoding="utf-8") as request_file:
        print(json.dumps(web_search(json.load(request_file)), indent=2))
Enter fullscreen mode Exit fullscreen mode

The request file must be generated from the endpoint's declared schema, so this sample does not pretend that a field name is universal. The same adapter can call the verified POST /v1/vector/query path after its request is generated from discovery. Once both responses are normalized, the ranking policy remains local:

from dataclasses import dataclass
from datetime import datetime


@dataclass
class Candidate:
    listing_id: str
    lexical: float
    semantic: float
    updated_at: datetime
    has_source: bool


def rank(candidate: Candidate, now: datetime) -> float:
    age_hours = max((now - candidate.updated_at).total_seconds() / 3600, 0)
    freshness = 1.0 if age_hours <= 24 else max(0.0, 1.0 - age_hours / 168)
    citation_gate = 1.0 if candidate.has_source else 0.0
    return (
        0.35 * candidate.lexical
        + 0.35 * candidate.semantic
        + 0.20 * freshness
        + 0.10 * citation_gate
    )


def choose(candidates: list[Candidate], now: datetime) -> list[Candidate]:
    scored = [(rank(item, now), item) for item in candidates]
    return [item for _, item in sorted(scored, reverse=True)]


Enter fullscreen mode Exit fullscreen mode

The numbers are policy, not universal truth. Tune them against a small labeled set containing changed, deleted, and unchanged listings before production rollout. I would also log the winning signals and the rejected citation gate; without that trace, debugging a ranking complaint becomes guesswork.

For a live check, the verified web search path is POST /v1/web/search. For durable semantic context, the corresponding vector path is POST /v1/vector/query. The application can call both, normalize their results into Candidate, and then apply one ranking contract. Re-index changed content deliberately, and remove deleted records from the vector collection.

What do the practical retrieval options trade off?

No option wins every workload. The useful comparison is the boundary each one makes visible.

Option Best fit in listing discovery Main control you gain Cost or risk to carry
PostgreSQL with pgvector Teams already keeping listing state and filters in Postgres One transactional data model and SQL-side filtering Vector tuning and index operations become your responsibility
Elasticsearch Large catalogs needing lexical, faceting, and operational search controls Detailed analyzers, filters, and ranking pipelines More moving parts to operate and keep synchronized with the source feed
Algolia A managed, low-ops search experience for interactive browsing Hosted relevance controls and fast product iteration Less freedom around storage semantics and vendor-specific ranking behavior
Infrai retrieval paths A mixed workflow that needs live web context plus vector retrieval One REST API and one key/bill across backend capabilities, with the same contract around the two paths You still own the retrieval contract, deletion policy, and evaluation set

Infrai's useful distinction here is operational: one REST surface can cover both POST /v1/web/search and POST /v1/vector/query, so a Python service does not need a separate SDK convention for each capability. That does not remove the hard work. It only keeps the integration boundary consistent while you decide what “fresh enough” means.

The trade-off is control depth. Stick with PostgreSQL or Elasticsearch when specialist index tuning or a single transactional read model matters more than a shared REST boundary.

Where the hybrid design should stop

The live-plus-vector pattern is not suitable when every answer must be generated from a single transactionally consistent snapshot. In that case, keep the source database and its read model authoritative, and use a database-native search path. It is also a poor fit for a tiny catalog where a filtered SQL query is easier to reason about than a second index.

Infrai is not suitable when a team needs specialist index controls or a single transactionally consistent read model; choose PostgreSQL with pgvector or Elasticsearch for those requirements and keep the extra operating boundary explicit.

Conversely, a vector-only design is a bad choice for “listed this morning” or deletion-sensitive questions. Embeddings are useful for descriptions and neighborhood language; they are not evidence that a unit is still available. A web-only design has the opposite weakness: it can answer current pages but loses durable context and makes repeated semantic queries expensive to stabilize.

I initially treated freshness as just another score. That was too permissive. A deleted listing with a high semantic match is still a deleted listing, so deletion and citation checks belong in the contract, not as a cosmetic reranking feature.

Build a compact labeled set before rollout: a few “new listing,” “changed rent,” “removed listing,” and “explain this neighborhood” queries, each with expected identifiers and acceptable source citations. Measure recall for the expected listing, freshness compliance, and citation coverage separately. One aggregate score hides the exact failure that matters to a property manager.

No magic.

Run the set whenever weights, chunking, metadata filters, or re-indexing rules change. For a concrete fixture, keep one query that asks for a listing changed yesterday, one that names a removed listing, and one that asks for a neighborhood description without a date. Record the expected listing IDs, the maximum acceptable age for each answer, the source URL or identifier that makes the claim citable, and the reason a candidate was rejected. Then replay the exact fixture after a feed update, a chunking change, and a weight change; compare the source-level verdicts, not just the final text. Your mileage may vary across markets because feed latency and listing language differ; I am not sure a single global weight can survive those differences without per-market calibration. Keep the labels and the scoring policy versioned together.

The decision rule is straightforward: combine the two paths when the feature needs durable and live context, prefer one authoritative path when consistency dominates, and reject any result that cannot meet the citation contract. That is a retrieval architecture, not a vendor slogan.

Keep it boring.

That is the boundary.

References

Top comments (0)