Skip to content
|By Muhammad Wilayat Shah|1 views • 1 unique IPs

How to Ground Real Estate AI Answers in Your Listing Database

Stop PropTech chatbots from inventing prices or availability. Retrieve listing facts from PostgreSQL, ground the LLM prompt, refuse when data is missing, and evaluate before you ship.

How to Ground Real Estate AI Answers in Your Listing Database

A PropTech chatbot that invents a price or marks a sold unit as available is not a minor UX bug—it is a trust and compliance problem. Large language models are fluent, but they do not know your listing table. The fix is not a longer system prompt. It is retrieval, structured context, and an explicit refusal path when the database has nothing safe to say.

This guide walks through a practical grounding pattern for React/Next.js front ends, Laravel APIs, PostgreSQL as the source of truth, and Redis for short-lived listing snapshots. The same ideas apply if you already run semantic property search with embeddings or an AI lead qualification assistant.

Diagram of retrieve listing facts, ground the LLM prompt, then answer or refuse

Why Listing Chatbots Hallucinate Prices

Models predict likely tokens. When a buyer asks "Is Unit 12B still available at $420,000?", the model may complete a plausible sales pitch from training patterns—even if your listings row says SOLD or a different asking price. System prompts like "be accurate" do not attach the model to your database.

Grounding means: every answer about price, availability, beds, baths, or address must be generated from rows you just fetched. If those rows are missing or incomplete, the assistant must refuse rather than improvise.

Architecture: Retrieve, Then Ask

Keep the LLM as the last step, not the first:

  1. Parse intent — extract listing IDs, project codes, or search filters from the user message (rules or a small structured-output call).

  2. Retrieve facts — query PostgreSQL (and optionally Redis) for the matching listing rows.

  3. Build grounded context — serialize only the fields you allow the model to cite.

  4. Answer or refuse — call the LLM with a strict system rule: use context only; if a field is absent, say you do not have it.

Laravel: fetch listing facts before the LLM

PHP
public function listingFacts(array $ids): array
{
    return Listing::query()
        ->whereIn('id', $ids)
        ->where('tenant_id', auth()->user()->tenant_id)
        ->get(['id', 'code', 'status', 'price_cents', 'currency', 'beds', 'baths', 'city', 'updated_at'])
        ->map(fn ($row) => [
            'id' => $row->id,
            'code' => $row->code,
            'status' => $row->status,
            'price' => [
                'amount' => $row->price_cents / 100,
                'currency' => $row->currency,
            ],
            'beds' => $row->beds,
            'baths' => $row->baths,
            'city' => $row->city,
            'as_of' => $row->updated_at->toIso8601String(),
        ])
        ->all();
}

Pass that array into the prompt as JSON. Do not dump free-text marketing blurbs as the only source of price—blurbs go stale; the row does not (if you treat it as source of truth).

Structured Context Beats Vague Paragraphs

Give the model a labeled block it cannot confuse with instructions:

Code
SYSTEM:
You are a listing assistant. Answer ONLY using LISTING_FACTS.
If a fact is missing, say you do not have that information.
Never invent prices, availability, or unit codes.

LISTING_FACTS:
[{"id":"12B","status":"AVAILABLE","price":{"amount":420000,"currency":"USD"},"beds":2,"city":"Austin","as_of":"2026-10-16T08:00:00Z"}]

USER:
Is Unit 12B still available at $420k?

Structured JSON reduces ambiguity. Prefer explicit field names (status, price.amount) over prose like "this lovely unit might still be on the market."

TypeScript: shape the grounded payload on the API edge

TypeScript
type ListingFact = {
  id: string;
  status: 'AVAILABLE' | 'RESERVED' | 'SOLD' | 'OFF_MARKET';
  price: { amount: number; currency: string } | null;
  beds: number | null;
  city: string | null;
  asOf: string;
};

function buildGroundedMessages(question: string, facts: ListingFact[]) {
  return [
    {
      role: 'system',
      content:
        'Answer only from LISTING_FACTS. If facts are empty or a field is null, refuse that part. Never invent prices or availability.',
    },
    {
      role: 'user',
      content: `LISTING_FACTS:\n${JSON.stringify(facts)}\n\nQUESTION:\n${question}`,
    },
  ];
}

Refusal When Data Is Missing

Empty retrieval must short-circuit. If the user names Unit 99Z and your query returns zero rows, do not call the LLM with an empty facts array and hope for the best—return a deterministic message:

  • "I could not find that listing in our database. Please check the unit code or ask about a different property."

  • Optionally offer a search path (filters, project name) instead of guessing.

Partial data needs the same discipline. If price is null because the listing is "price on request," the assistant should say so—not invent a round number that "sounds right" for the neighborhood.

PHP
if (count($facts) === 0) {
    return response()->json([
        'answer' => 'I could not find that listing in our database. Please verify the unit code.',
        'grounded' => false,
        'listing_ids' => [],
    ]);
}

Caching Listing Snapshots Without Stale Lies

Redis is useful for hot listing cards and chat latency, but availability changes fast. Cache the structured fact blob with a short TTL and invalidate on listing updates:

PHP
$key = "listing_facts:{$tenantId}:{$listingId}";
$facts = Cache::remember($key, now()->addSeconds(60), function () use ($listingId) {
    return $this->listingFacts([$listingId]);
});

On ListingUpdated events, delete the key. Prefer "slightly slower, correct" over "fast, wrong sold status." For more on when Redis helps versus hurts, see Redis caching: when you should and should not use it.

Evaluation: Prove It Does Not Invent Prices

Before production, treat grounding like any other LLM feature you would test before production. Build a small golden set:

  1. Known listing — question with correct price; expect the answer to match the DB amount and currency.

  2. Wrong price bait — user asserts a lower price; expect correction from facts, not agreement.

  3. Missing unit — expect refusal, not a fabricated listing.

  4. Sold unit — expect SOLD / unavailable language tied to status.

  5. Null price — expect "price on request" or equivalent, never a guessed number.

Score with simple checks: extract numbers from the answer and compare to price.amount; flag any answer that contains a dollar amount when facts had price: null. Log listing_ids, fact snapshot hash, and model output for every chat turn so you can audit failures.

Frontend Notes (Next.js / React)

Show provenance in the UI when you can: "Based on listing 12B as of 08:00 UTC" with a link to the public listing page. Disable "Buy now" style CTAs until status is confirmed from the API, not from model prose. Stream tokens for feel, but keep the server as the only place that attaches LISTING_FACTS—never trust the browser to inject prices into the system prompt.

Checklist Before You Ship

  • Every price/availability answer is preceded by a DB (or fresh cache) read.

  • Empty or partial facts trigger refuse / clarify, not free-form generation.

  • Prompt forbids inventing fields; evaluation catches dollar amounts without facts.

  • Redis TTL is short; updates invalidate keys.

  • Tenant scoping is applied on every listing query.

Grounding will not make the model omniscient. It makes the model honest about what your listing database actually contains—and that is the difference between a demo chatbot and a PropTech feature you can put in front of buyers and agents.