Programming

How to Rank Node.js Real-Estate Search: 5 Signals for Cited Retrieval

Short answer: for a real-estate listing discovery retrieval architecture, use live web retrieval for freshness, vector retrieval for controlled grounding, and blend them when citations must survive changing context. The practical starting point is a retrieval contract. Write down what one result means (a listing, a listing field, or a document chunk), which metadata can filter it, how fresh it must be, and which source URL can be cited. Without that contract, “relevance” becomes a score nobody c

Short answer: for a real-estate listing discovery retrieval architecture, use live web retrieval for freshness, vector retrieval for controlled grounding, and blend them when citations must survive changing context.

The practical starting point is a retrieval contract. Write down what one result means (a listing, a listing field, or a document chunk), which metadata can filter it, how fresh it must be, and which source URL can be cited. Without that contract, “relevance” becomes a score nobody can explain to a broker or a buyer.

Retrieval path Pick it when Main ranking signal Citation posture
Live web search Inventory and policy pages change often Recency plus query match Cite the returned page and timestamp
Vector query You own a curated listing corpus Semantic similarity plus metadata Cite the stored record and source field
Hybrid The answer mixes current listings with stable rules Weighted score with freshness gate Keep a citation for every claim

What should a retrieval architecture optimize for in listing discovery?

Treat ranking as a sequence, not a single magic number. First apply hard filters such as geography, availability, and property type. Then score the survivors with five signals: lexical match, semantic similarity, freshness, source authority, and evidence coverage. A listing that matches “two-bedroom near transit” but has an old availability field should lose to a slightly less similar result that is current and citable.

Freshness needs an explicit budget. Re-index changed content deliberately, and remove deleted records from the collection; stale inventory is worse than a short result set. For each record, keep an updatedAt value and the canonical source URL as metadata. Those fields make an audit possible when a user asks, “Why did this home appear?” In a real rollout, I would also store the query, the selected path, every component score, and the citation decision. That log turns a ranking argument into a reviewable event: an agent can see that a page won on recency, while a vector hit won on semantic fit, and a human can challenge either choice without guessing what happened.

Measure it.

I keep the first evaluation set tiny: 50 to 100 real queries, each with a few judged results and a citation check. Measure recall at the shortlist size, ranking quality for the top results, and whether the final answer can point to evidence. Your mileage may vary because a luxury-market query and a rental query have different notions of authority.

How do live web and vector retrieval combine ranking signals?

The diagram in words is simple: query enters two paths; the web path returns fresh pages; the vector path returns grounded records; a merger normalizes scores; a citation gate removes unsupported claims. Do this before generation. The language model should summarize an approved evidence set, not decide which raw page is trustworthy.

Here is a small TypeScript merger. It is deliberately boring. Scores are normalized at the boundary, and freshness is a visible term that you can tune against the labeled set.

type Hit = {
  id: string;
  lexical: number;
  semantic: number;
  freshness: number;
  authority: number;
  evidence: number;
  sourceUrl: string;
};

function rank(hit: Hit): number {
  return 0.25 * hit.lexical + 0.30 * hit.semantic +
    0.20 * hit.freshness + 0.15 * hit.authority + 0.10 * hit.evidence;
}

export function mergeHits(web: Hit[], vector: Hit[], limit = 10): Hit[] {
  const byId = new Map<string, Hit>();
  for (const hit of [...web, ...vector]) {
    const previous = byId.get(hit.id);
    if (!previous || rank(hit) > rank(previous)) byId.set(hit.id, hit);
  }
  return [...byId.values()]
    .filter((hit) => hit.sourceUrl.length > 0 && hit.evidence >= 0.5)
    .sort((a, b) => rank(b) - rank(a))
    .slice(0, limit);
}

At the retrieval boundary, call the verified paths with an explicit method and bearer token. Keep the response adapter separate from ranking so a provider change cannot silently change your scoring math.

const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");

async function post(path: string, body: unknown): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt++) {
    const response = await fetch(`${baseUrl}${path}`, {
      method: "POST",
      headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
      body: JSON.stringify(body)
    });
    if (response.ok) return response.json();
    if (response.status !== 429) throw new Error(`request failed: ${response.status}`);
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter * 1000, 8000)));
  }
  throw new Error("rate limit retries exhausted");
}

const webResults = await post("/web/search", { query: "two-bedroom near transit" });
const vectorResults = await post("/vector/query", { query: "two-bedroom near transit" });

These are read calls, so retries do not duplicate a write. For an upsert or delete in the indexing job, send a client-supplied idempotency key and make the consumer idempotent; a 429 retry must not create a second record.

The platform choice is secondary to those contracts. Infrai is one option when breadth behind a consistent REST surface matters: web and vector capabilities sit behind one key and one API style, so adding a retrieval path does not require another SDK integration. That helps a small Node.js service keep its adapters narrow, while the ranking and citation policy remain yours.

Which option fits your operational constraints?

Option Strength Trade-off Choose it when
Elasticsearch Deep lexical controls and mature filtering More operating surface to tune You need custom analyzers and own the cluster
Algolia Fast hosted search with a polished indexing workflow Ranking controls follow its hosted model Product search is the main feature and speed of setup wins
Pinecone Managed vector retrieval You still need a separate freshness and web strategy Semantic matching over an owned corpus is central
Infrai Web and vector routes under one REST contract You must design the evidence and evaluation layer A small team wants multiple retrieval capabilities behind one integration

No option wins every workload. Stick with Elasticsearch when analyzer-level control is the product. Pick Algolia when merchandising tools matter more than cross-source evidence. Use Pinecone when your corpus is stable and embeddings are the hard part. The hybrid path is not suitable when you cannot maintain deletion and re-indexing discipline; in that case, fewer sources with clear ownership are safer.

Limits to make explicit before launch

Ranking weights are hypotheses, not facts. Refit them when the labeled set changes, and log each component score with a request identifier. Watch for duplicate listings, missing source URLs, and records whose freshness field was never updated. A citation that points to a neighborhood landing page instead of the listing itself is technically a link but weak evidence.

Start with shadow traffic, compare the old and new top-10 sets, then expose the blend behind a feature flag. I am not sure a single global freshness decay works across every real-estate market; segmenting by listing type is an empirical decision, and the evaluation set should settle it.

References