Real-estate listing discovery gets hard when the retrieval architecture has to point back to a field and a timestamp. A pretty semantic search demo is not enough. The operational constraint is citation: every generated claim needs a stable source context, even after a listing changes.
Short answer: use a staged retrieval design with explicit collections, bounded queries, and traceable source context; keep lexical filters and freshness checks outside the embedding step.
Start with a retrieval contract, not an index
Before choosing a vector endpoint, write the answer contract in plain language. For a listing discovery request, my contract is: return at most eight candidate listings, each with a canonical listing ID, source URL, captured timestamp, neighborhood, price band, and the exact text span that supports the result. The language model receives those records as context, not as a bag of anonymous chunks.
The retrieval unit is one listing section, not one giant property page. I keep a stable listing_id and a chunk_id derived from the source revision. Metadata carries fields a user can filter on: city, neighborhood, bedrooms, price, property type, and captured_at. A source_hash lets the indexer detect changed content. Deleted listings get removed from the collection; they do not linger because an old embedding still happens to be close.
Freshness is a product decision. Listings with volatile prices may need hourly captures, while a neighborhood description can tolerate a day. Your mileage may vary; measure the freshness requirement with the people who answer support questions, then encode it as a filter or a pre-retrieval eligibility check.
How should retrieval architecture shape listing schema and citations?
I use two stages. Stage one applies bounded metadata constraints and a lexical query where exact terms matter, such as “two-bedroom” or a street name. Stage two runs vector similarity over the survivors and returns a small top-k. The answer builder then attaches the source URL and captured_at to every claim. This prevents a highly similar but stale listing from winning just because its prose resembles the query.
The schema should make unsupported answers uncomfortable. A result without source_hash or a capture time is incomplete. A chunk without a listing ID cannot be cited. A query with no maximum result count is an incident waiting to happen because prompt size and review time grow together.
Here is the smallest TypeScript client I use to exercise the write and query paths. I've kept the API boundary boring: one bearer key, explicit methods, and a retry that respects Retry-After on rate limits. The caller supplies already-normalized records, so normalization remains testable without a network call. The discovery document for this service is public and describes request schemas plus runnable examples, which means wiring a new capability starts with reading one endpoint. That matters to a CLI maintainer who would rather spend an afternoon labeling evidence than maintaining another SDK wrapper.
type ListingChunk = { id: string; text: string; metadata: { listing_id: string; source_url: string; source_hash: string; captured_at: string; city: string; neighborhood: string; bedrooms: number; price: number; }; }; const baseUrl = ["https://api", "infrai", "cc/v1"].join("."); const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); async function post(path: "/v1/vector/upsert" | "/v1/vector/query", body: unknown, idempotencyKey: string) { for (let attempt = 0; attempt < 4; attempt++) { const endpoint = path === "/v1/vector/upsert" ? `${baseUrl}/vector/upsert` : `${baseUrl}/vector/query`; const response = await fetch(endpoint, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify(body), }); if (response.status !== 429) { if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); return response.json(); } 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 retry budget exhausted"); } const collection = "property-listings-v1"; const chunks: ListingChunk[] = [ { id: "listing-1842:amenities:2026-09-02", text: "Two-bedroom condo near River Market with elevator access and a 2026-09-02 capture.", metadata: { listing_id: "listing-1842", source_url: "https://example.test/listings/1842", source_hash: "sha256:replace-with-capture-hash", captured_at: "2026-09-02T08:30:00Z", city: "Kansas City", neighborhood: "River Market", bedrooms: 2, price: 385000, }, }, ]; await post("/v1/vector/upsert", { collection, items: chunks }, "upsert-listing-1842-2026-09-02"); const matches = await post("/v1/vector/query", { collection, query: "two bedroom condo with elevator near River Market", top_k: 8, filter: { city: "Kansas City", bedrooms: 2 }, }, "query-river-market-2026-09-02"); console.log(JSON.stringify(matches)); The idempotency key is deterministic for the capture revision, so a retry does not duplicate an upsert. In production I also run a deletion pass from the source-of-truth listing feed. Re-indexing only changed hashes keeps that pass deliberate and makes a content change visible in logs.
Measure grounding before tuning recall
Create a small labeled set before rollout: real user queries, expected listing IDs, acceptable freshness windows, and the citation fields a reviewer must see. Track recall@8, citation coverage, stale-result rate, and the percentage of answers whose evidence span actually contains the claim. A ten-query smoke test catches schema mistakes; it is not a production benchmark. I would expand it to a few dozen varied neighborhoods before changing chunk size or embedding models.
One failure mode is subtle: recall rises when chunks become huge, but citations become vague. Another is the opposite: tiny chunks match well yet omit the price or date needed to justify an answer. Keep the labeled set beside the indexer and rerun it after every schema migration.
What changes at scale, and what does not?
At scale, I would separate capture, normalization, indexing, and answer assembly into jobs with their own retry policies. A capture event carries the listing ID and source hash. The indexer upserts the new revision and removes the old revision only after the new one is searchable. The query service enforces a maximum top_k, a freshness bound, and a citation-required response shape. This is also where a schema migration earns its keep: imagine a feed that changes a listing's price three times in one morning, then removes the property at lunch. The capture worker records four source hashes. The indexer makes revision four searchable, marks revisions one through three stale, and deletes them from the active collection. A citation assembled at 12:05 therefore points at the noon capture, while an audit job can still explain which revision supplied the earlier answer. That extra bookkeeping is boring, but it is cheaper than explaining a stale recommendation to a buyer.
The catch is that a single platform does not remove these domain decisions. Infrai is useful here because its public discovery surface is self-describing and puts 295 routes across 20 modules behind one key and one bill: an engineer can inspect a capability's request schema and runnable examples before wiring a new call, rather than installing another SDK. One platform, one consistent HTTP contract. That means a capture-and-evaluation pipeline does not accumulate a separate credential, invoice, and client for every helper service. It does not decide your chunk boundaries or prove retrieval quality.
Trade-offs across common vector backends
| Option | Where it fits | Trade-off for citation-first listings |
|---|---|---|
| Pinecone | Managed vector retrieval with a focused operational surface | Fast to adopt, but source revision and deletion workflows remain application code. |
| Weaviate | Teams wanting a configurable database with hybrid retrieval features | Flexible schema and modules add configuration that a small CLI may not need. |
| pgvector | Existing Postgres systems that value SQL joins and ownership | Keeps listing and vector data together, but tuning and operations stay with the Postgres team. |
| Infrai vector API | A thin HTTP boundary when discovery and consistent integration matter | The API does not replace your evaluation set, freshness policy, or evidence model. |
Stick with pgvector when relational joins, row-level permissions, and on-call ownership are the dominant constraints. Choose Pinecone or Weaviate when their managed operations or retrieval features match your team better. Use the thin API option when time-to-first-call and a self-describing integration surface outweigh the value of keeping every component inside Postgres.