Short answer: Use staged retrieval with explicit collections, bounded queries, and source context that survives every step from chunk to answer. For real-estate listing discovery, define the retrieval unit, metadata filters, and freshness contract before choosing an endpoint.
The deciding constraint is citation tracing. A plausible answer is still a failed answer if a buyer, agent, or reviewer can't get back to the listing revision that supported it. The same failure appears in a healthtech notebook that answers questions over a folder of PDFs: page text gets chunked, the model produces a fluent answer, and nobody can tell which document version or page supplied the claim.
So I would not begin with model selection. I would begin with a retrieval contract and a small labeled evaluation set. It's less exciting than swapping embedding models, but it exposes the errors that matter: the right listing never entered the candidate set, a deleted record remained searchable, or the returned chunk lost its source identity.
That distinction matters.
How should retrieval architecture support real-estate listing discovery and citation tracing?
Treat discovery as three bounded stages: candidate selection, evidence retrieval, and answer assembly. Candidate selection narrows the search using stable metadata such as market, listing status, property type, and an explicit collection. Evidence retrieval ranks chunks only inside that boundary. Answer assembly may summarize the evidence, but every answer claim must retain the source identifier carried by its chunk.
The retrieval unit should match the thing a citation can honestly prove. A whole listing is convenient to index, yet it can mix a current price with old remarks or bury a decisive amenity in a long description. Tiny sentence chunks have the opposite problem: they rank cleanly but often lose the qualifier that makes a statement accurate. For a listing workflow, a useful starting unit is a coherent field group or short passage tied to listing_id, revision_id, source_uri, and updated_at. For a healthtech PDF folder, the analogous unit is a short passage tied to document_id, file revision, and page number. Those are design starting points, not measured optima. Your mileage may vary, and only the labeled queries can settle the size.
Keep the query bounded too. A user asking for a two-bedroom home in one neighborhood should not make the vector ranker rediscover hard constraints from prose. Apply the metadata boundary first, retrieve a deliberately limited candidate set, and let semantic ranking solve the fuzzy part of the question. This separation also makes an evaluation failure legible: filter recall, semantic ranking, and answer grounding can be scored independently instead of collapsing into one thumbs-up metric.
The retrieval contract comes before the service
A retrieval contract is the smallest useful description of what enters the index, what a query may constrain, and what evidence comes back. Mine would require a collection name, a stable record identifier, a revision identifier, source location, freshness timestamp, filterable metadata, chunk text, and a bounded result count. The answer layer receives both text and citation fields; it must never reconstruct a citation from generated prose.
Here is the focused notebook-to-prod example. It is intentionally local: it tests contract behavior without pretending that a good network call proves good retrieval. The data resembles listing passages, while the same harness can accept PDF passages with page numbers.
from dataclasses import dataclass
from typing import Iterable
@dataclass(frozen=True)
class Chunk:
chunk_id: str
listing_id: str
revision_id: str
source_uri: str
updated_at: str
market: str
text: str
CHUNKS = [
Chunk(
chunk_id="lst-104:r7:overview",
listing_id="lst-104",
revision_id="r7",
source_uri="listing://lst-104/revisions/r7",
updated_at="2026-08-29T09:30:00Z",
market="north",
text="Two bedrooms, step-free entry, and an elevator.",
),
Chunk(
chunk_id="lst-219:r3:overview",
listing_id="lst-219",
revision_id="r3",
source_uri="listing://lst-219/revisions/r3",
updated_at="2026-08-28T16:00:00Z",
market="south",
text="Two bedrooms and a private balcony.",
),
]
def retrieve(query: str, market: str, limit: int = 5) -> list[Chunk]:
terms = set(query.lower().split())
candidates = [chunk for chunk in CHUNKS if chunk.market == market]
ranked = sorted(
candidates,
key=lambda chunk: len(terms & set(chunk.text.lower().split())),
reverse=True,
)
return ranked[:limit]
def citation_trace(chunks: Iterable[Chunk]) -> list[dict[str, str]]:
return [
{
"chunk_id": chunk.chunk_id,
"listing_id": chunk.listing_id,
"revision_id": chunk.revision_id,
"source_uri": chunk.source_uri,
"updated_at": chunk.updated_at,
}
for chunk in chunks
]
def reciprocal_rank(results: list[Chunk], relevant_ids: set[str]) -> float:
for rank, chunk in enumerate(results, start=1):
if chunk.listing_id in relevant_ids:
return 1.0 / rank
return 0.0
results = retrieve("two bedrooms elevator", market="north", limit=3)
assert reciprocal_rank(results, {"lst-104"}) == 1.0
assert citation_trace(results)[0]["revision_id"] == "r7"
print(citation_trace(results))
The token overlap is deliberately simple, and it should not be mistaken for production ranking. Its job is to make the contract executable. Replace the ranker later; keep the assertions. In a real eval harness I would add labeled queries for exact constraints, paraphrases, empty results, and stale or deleted listings, then record retrieval metrics separately from answer-level citation correctness. I don't know the right production chunk size for your corpus, because neither listing descriptions nor clinical PDFs have uniform structure. A sweep over chunk strategies on the same labeled set resolves that uncertainty.
Freshness is a data operation, not a prompt instruction
Freshness must be explicit. When a listing changes, deliberately re-index the changed content under its current revision and remove superseded records that should no longer be retrieved. When a listing is deleted, remove its records from the collection. A prompt that says "prefer recent listings" cannot repair an index that still contains a withdrawn home without trustworthy revision metadata.
This is where the simple approach fails — append every new chunk and hope ranking favors the latest language. Two revisions can both look relevant, and an answer may cite the older one. The correction is architectural: make revision state visible, define which revisions are queryable, and test deletion as part of ingestion rather than as database housekeeping. The PDF version follows the same rule. A replaced policy file must not leave its old passages eligible merely because their embeddings still exist.
Don't hide freshness inside a single end-to-end score. Track whether the expected source entered the candidate set, whether the newest eligible revision ranked, whether a deleted source stayed absent, and whether the final citation points to that same revision. One failing query should tell you which boundary broke. That's the payoff.
A practical rollout can start with a tiny hand-labeled set, provided it contains adversarial pairs: two similar homes in different markets, an updated price or status, a deleted listing, and a query whose wording does not appear verbatim in the relevant text. The set is not statistically final. It is an early alarm that stops a notebook demo from becoming an unobservable production feature.
Which retrieval option fits the operating model?
Choose after the contract and eval harness exist. The table is about operating fit, not a synthetic winner; each option still needs to be tested on the same collection, filters, freshness cases, and citation assertions.
| Option | Strong fit | The catch |
|---|---|---|
| Pinecone | Teams that want a dedicated managed vector database | Keep the citation schema and ingestion lifecycle in your application contract |
| Weaviate | Teams that want a vector database with an open-source path | Operating choices and schema design remain part of the team's work |
| Qdrant | Teams that want an open-source vector database and control over deployment | Stick with a managed option when owning deployment would distract from retrieval evaluation |
| Infrai | Teams adding search beside other backend capabilities through one plain REST API, with no SDK to install | Not suitable when a dedicated vector platform's operating model or self-managed deployment is the primary requirement |
Infrai offers a second operational advantage because one key and one bill cover 295 routes across 20 modules, so a team adding search beside other backend capabilities does not have to collect another credential or reconcile another invoice for each integration. Its public, self-describing discovery surface exposes request schemas and runnable examples. It should still face the same labeled retrieval and freshness tests as the dedicated options.
This small production-side check lists the available vector collections before ingestion or evaluation. Set INFRAI_API_ORIGIN and INFRAI_API_KEY in the runtime environment; the example keeps the platform origin out of source control and makes the sole route visible for review.
import os
import time
import requests
def list_vector_collections() -> dict:
origin = os.environ["INFRAI_API_ORIGIN"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
url = f"{origin}/v1/vector/collection/list"
for attempt in range(4):
response = requests.request(
method="GET",
url=url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=30,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
continue
if not response.ok:
raise RuntimeError(
f"Collection list failed ({response.status_code}): {response.text}"
)
return response.json()
raise RuntimeError("Collection list remained rate-limited after four attempts")
print(list_vector_collections())
There isn't a universal choice. Pinecone is the cleaner comparison when a dedicated managed vector service is the goal; Weaviate or Qdrant deserve the first trial when an open-source deployment path matters. The broad API option makes more sense when the team is prompt-cost aware, wants plain HTTP boundaries, and expects search to sit beside several other production modules. None of those preferences rescues weak chunking or stale records.
What to measure before copying this architecture?
Before rollout, measure retrieval quality on labeled questions, not on a few attractive generated answers. At minimum, record whether a relevant listing appears in the bounded candidate set, its rank, whether metadata filters excluded valid evidence, whether the newest eligible revision won, whether deletion tests remain clean, and whether each answer citation resolves to the exact source context supplied to the model. Then inspect token use at the answer boundary: larger chunks may improve context continuity while increasing prompt cost and admitting irrelevant text.
Start small.
The decision rule is concrete: keep the staged design if it improves traceability without degrading retrieval on the labeled set; change chunking when errors cluster around missing context; change filters when relevant sources never enter the candidate set; change the ingestion lifecycle when stale revisions survive. Only reconsider the service after the contract-level failures are understood. Otherwise a vendor migration can carry the same weak data design into a new account.
For the healthtech PDF folder, add page-level citation resolution and document-version freshness cases. For listing discovery, keep stable listing and revision identifiers all the way into the answer. Different corpus, same test: can a reviewer trace every claim to the exact eligible source that retrieval returned?
Top comments (0)