Request the source
Historical technical snapshot · v2.0

A Transparent, Multi-Signal Ranking Model for Real-Estate Search

The algorithm behind ChatHome vibe search — query understanding into a query knowledge graph, a bounded clarification loop, semantic retrieval, twelve scoring functions, the relevance/quality partition, and accountability by design.

Version 2.0 Published 3 July 2026 (v1.0: 12 June 2026) Authors ChatHome Engineering, Luxembourg Code Open to review on request (hello@chathome.lu) Primary scoring source at publication apps/web/src/lib/search/ranking.ts
Historical snapshot — not the current implementation reference. This paper records the v2.0 scoring model published on 3 July 2026. Current production listing and vibe retrieval uses gemini-embedding-001 at 1,536 dimensions in embeddings.description_vector_v2, with COSINE vector retrieval inside a hybrid pipeline and an active semantic reranking stage. Later production changes are listed in the Ranking Policy changelog.

Abstract

ChatHome lets people describe what they want in natural language — “a calm two-bedroom near Kirchberg under €2,200 with good light” — and ranks Luxembourg listings by how well they satisfy that intent. This paper documents the v2.0 scoring model as published on 3 July 2026; it is not an end-to-end description of the current production retrieval and ordering pipeline. At publication, a query was parsed into a structured search plan, candidates were retrieved by semantic similarity over 768-dimensional multilingual embeddings and gated by deterministic hard filters, then twelve interpretable component functions mapped each candidate to the unit interval [0,1]. Components were partitioned into relevance and quality signals, weights were segment-specific and boosted by explicit intent, and missing signals were dropped through signal-aware renormalisation. The current production embedding and retrieval facts are stated in the historical-status notice above; later changes are maintained in the Ranking Policy changelog. Critically, no term in the model described here encodes payment, advertising spend, or commercial relationship: ranking cannot be bought.


1. Design principles

The model is built around six commitments that constrain every design decision:

  1. Interpretability over opacity. Each signal is a named, bounded function of observable listing and query attributes. There is no learned end-to-end black box whose output cannot be decomposed.
  2. Determinism. Given the same query, listing corpus, and measurement inputs, the ranking is reproducible. Identical inputs yield identical orderings. Even the clarification dialogue is deterministic: which questions are asked, and how answers modify the plan, is fixed by the query knowledge graph, not by a model's mood.
  3. Graceful degradation. Missing data (no energy certificate, no measured commute, no benchmark) removes a signal rather than scoring it as zero. Absence is not penalised.
  4. Ask, don't guess. When the query omits a must-have dimension (location, budget), the system asks the user — a bounded number of times, always skippable — instead of silently inventing a constraint on their behalf.
  5. Intent alignment. Weights adapt to who is searching (renter, family, student, buyer, investor) and to the constraints they made explicit. The displayed match score reflects only what the user asked for; listing “niceness” can never impersonate query fit.
  6. Incorruptibility. No feature represents money. Position is earned by relevance, never by payment.

2. Notation

Let q denote a user query and L = {1, …, n} the candidate listings. For a listing i we write a set of component scores vk(i) ∈ [0,1], indexed by the twelve components kK = {sem, loc, com, bud, spc, vibe, egy, tr, fr, mkt, life, pers}. Each component additionally emits a boolean signal flag hk(i) ∈ {0,1} indicating whether it had enough data to produce a meaningful value. The clamp operator is

clamp01(x) = max(0, min(1, x)) (1)

K is partitioned (§8) into relevance components KR = {sem, loc, com, bud, spc, vibe} and quality components KQ = {tr, fr, mkt, life, pers}; egy joins KR only when the user voiced an energy preference, otherwise KQ. A weight vector w = (wk)kK assigns a non-negative importance to each component, with the base vectors normalised so that ∑k wk = 1.

3. Pipeline overview

Natural-language query LLM query understanding Search plan → query knowledge graph

Completeness gate Clarification questions (≤2 rounds, skippable) Completed plan

Semantic retrieval at publication (768-d cosine) Hard filters Candidate set L

12 component scores Segment weights + intent boost Relevance/quality split + renormalisation

Relevance score σ + capped quality tie-breaker Diversity guardrail Ranked results + two-tier explanations
Figure 1. The v2.0 pipeline at publication. Later retrieval, clarification, calibration, reranking, and guardrail changes are documented on the Ranking Policy page.

4. Query understanding & the query knowledge graph

4.1 The search plan

A language model parses q into a structured search plan P with three parts:

The plan also carries a plan-strength estimate and a list of ambiguities — fields the extractor resolved but is not certain about (a bare “Luxembourg” may mean the city or the central region). The query understanding layer is itself grounded: figures, localities, and POIs are resolved against the corpus rather than free text, so the ranker measures real geography instead of matching strings.

4.2 The query knowledge graph

Every plan field maps to a node in a fixed query knowledge graph, and every node carries a requirement tier. The graph is the single source of truth for “is this query complete enough to search well?” — the clarification gate (§4.3), the UI's ranking hints, and the explanation layer all read the same structure, so they can never contradict each other.

Table 0. The query knowledge graph: field tiers and what satisfies them.
FieldTierSatisfied by (any of)
Transaction intentmust-haverent/buy — always present (locked by the UI toggle).
Locationmust-havecommunes, region, cantons, locality references, a proximity target, or POI references.
Budgetmust-havean explicit price ceiling or budget ceiling.
Property typemust-havedwelling type, property-type list, or a commercial/mixed use-class.
Cross-border scopemust-have*always satisfied — the default (Luxembourg-only) is a valid state, registered as must-have so the platform's choice is always disclosed, never hidden.
Bedroomsgood-to-havea minimum bedroom count.
Sizegood-to-havea surface floor or ceiling (m²).
Pets / furnishedextraan explicit boolean either way.
Vibe tagsextrasoft tags or vibe keywords.
Energyextraa voiced energy preference.

Evaluating the graph over a plan yields, per field, {filled, waived} and the set of missing must-haves. A field the user explicitly declined to answer (“no limit”) counts as waived and never re-asks. Good-to-have and extra fields never block a search; they surface as optional depth questions or UI hints.

4.3 The clarification loop

When must-have fields are missing, the system asks rather than guesses — but under strict, verifiable bounds:

The clarification gate is client-declared: it fires only for interfaces that state they can render questions, so programmatic callers (APIs, agents, map view) receive direct results, and interfaces where the user just edited a specific field re-run with clarification suppressed — the system never re-asks for something the user deliberately removed.

4.4 From graph to hard filters

Once the graph is complete (or explicitly waived), the plan's hard-filter fields are applied as retrieval-time predicates (§5) and its expressed dimensions drive the weight boost (§7.1) and the relevance partition (§8). The same graph also powers the “add for more precise results” hint chips on weak plans, guaranteed consistent with what the gate would ask.

5. Candidate generation & hard filters (v2.0 snapshot)

At publication, the candidate set was produced by approximate nearest-neighbour retrieval over multilingual sentence embeddings. The query and every listing were embedded into the same D = 768-dimensional space with text-multilingual-embedding-002 (covering EN/FR/DE/LU/PT). For unit-normalised vectors the index returned the cosine distance

di = 1 − cos(eq, ei) = 1 − eq · eieq‖ ‖ei (2)

Retrieval scope defaults to Luxembourg; listings from the French Grande Région enter the candidate pool only when the user opts in to cross-border scope — a first-class, always-disclosed toggle (§4.2), never a silent expansion.

Before scoring, several classes of listing are removed outright (a hard mask, not a soft penalty):

One deliberate asymmetry: a stated minimum surface is enforced fail-closed in the classic (filter-based) search path, but fail-open in vibe search — vibe is recall-oriented, and listings without a recorded area are down-weighted by the space-fit signal (§6.5) rather than hidden. Survivors form L. Everything downstream is a continuous re-ranking of L; the hard mask only ever removes, never re-orders.

6. The twelve scoring functions

Each function maps a listing to [0,1] and reports whether it had a signal. We give the exact form of each. A recurring theme in v2.0 is de-saturation: v1.0's flat plateaus (any in-budget price → 1.0, anywhere inside a locality → 1.0, any under-budget commute → 1.0) tied entire result pages at identical scores; each plateau is now a gentle, continuous, monotone gradient that restores discrimination without punishing good matches.

6.1 Semantic affinity (sem)

Directly converts the retrieval distance (2) into a similarity:

vsem = clamp01(1 − di) (3)

Signal semantics matter here. When the vector channel ran and a listing was not among its results, that is a measured weak match: the listing scores vsem = 0 with the signal flag set, so it can never gain rank by having its semantic component silently dropped from the renormalisation. Only when the vector channel itself failed (no distances for anyone) is the component dropped, and the route degrades disclosed-and-fail-closed rather than pretending semantic relevance was assessed.

6.2 Location fit (loc)

For each requested locality reference r with corpus centroid cr and radius ρr (default 1 km), let κr = haversine(gi, cr) be the great-circle distance from the listing's geocode gi. Inside the radius the score is a gentle gradient from 1 at the centre to λ = 0.85 at the edge; outside, the pre-existing linear decay (zero at three radii) is rescaled to join the gradient continuously, so a listing just outside can never outscore one at the edge inside. The component takes the best-matching locality:

sr = {
1 − (1 − λκrρr,κrρr
max(0, λ·(1 − κrρr2ρr)),κr > ρr
,   vloc = maxr sr (4)

When no locality was geocoded, a legacy string fallback assigns 1.0 on a district match and 0.8 on a whole-token city-name match (token matching, so a short commune name cannot spuriously match inside an unrelated city name); with no location intent at all the component is dropped. Cross-border listings score no-signal on this component — they do not resolve against the Luxembourg locality corpus and must not be zeroed unfairly on it.

6.3 Commute fit (com)

For each POI reference p with a measured door-to-door ETA ti,p (minutes, from Maps grounding or isochrone retrieval), the score is a banded, monotone function of the absolute ETA — so a genuinely close commute beats a far-but-under-budget one (v1.0's flat 1.0-under-budget tied Pétange with Luxembourg-Ville for “commute to Kirchberg”):

sp : ≤10 min→1.0,   ≤20→0.85,   ≤30→0.7,   ≤45→0.5,   ≤60→0.3,   else 0.1 (5)

When the user stated an explicit time budget Bp and the ETA exceeds it, the band score is additionally capped by a decaying penalty:

sp ← min( sp,  max(0, 0.5·(1 − ti,pBpBp)) ),    vcom = maxp sp (6)

The ranker scores only commutes it has actually measured; it never infers proximity from text. A legacy aggregate (the same bands over city-level ETA, or a distance-banded fallback over haversine distance to canonical targets such as a train station) is retained for queries predating per-listing ETA measurement.

6.4 Budget fit (bud)

Let the listing price be xi and the requested range [φ, γ] (floor, ceiling; either may be absent). In-range prices score a headroom gradient: full marks up to η = 0.8 of the ceiling, easing linearly to β = 0.9 at exactly the ceiling. Above the ceiling the original decay (zero at 2γ) is rescaled to start at β, keeping the curve continuous and monotone — €1 over the ceiling can never outscore exactly-at-ceiling. Below the floor the price scales toward it:

vbud = {
1,xiηγ (in range)
1 − (1 − βxi/γη1 − η,ηγ < xiγ
max(0, β·(1 − xiγγ)),xi > γ
max(0, xiφ),xi < φ
(7)

Floor-only queries keep the flat 1.0 in range — with no ceiling there is no headroom concept to grade against. With no explicit bounds, a soft budget tier (anchors: budget €1,500, mid €3,000, luxury €6,000) maps the price-to-anchor ratio xi/a to 0.75 (≤1.0), 0.55 (≤1.3) or 0.35 (otherwise). Absent both, the component is dropped. (Recall from §5 that under a stated budget, price-on-request listings never reach this function — they are excluded fail-closed upstream, with the count disclosed.)

6.5 Space fit (spc)

Two sub-scores are averaged over whichever are available. The bedroom sub-score rewards meeting the requested count β and tolerates a little over-provision:

b = { 1 if beds=β; 0.85 if β+1; 0.7 if ≥β+2; 0.45 if β−1; 0.1 otherwise } (8)

One anti-evasion rule: a listing with no recorded bedroom count but a surface of at most 32 m² is inferred to be a studio (0 bedrooms) and scored as such — otherwise a 16 m² studio would dodge a “2 bedroom” requirement by dropping the space dimension entirely and keep a misleadingly high match. Larger null-bedroom listings remain unknown (sub-score dropped without penalty). The surface sub-score α = 1 when the area lies within [Amin, Amax], 0.2 when below the minimum, 0.3 when above the maximum. With parts S ⊆ {b, α} present,

vspc = 1|S|zS z (9)

6.6 Vibe-tag overlap (vibe)

Given the query's tag set Q and the listing's tag set T, each query tag is first expanded through a curated multilingual alias table — “bright” matches the canonical bright_natural_light; “cosy” matches gemütlich, chaleureux, acolhedor, gemittlech; accents are folded — and counts as satisfied if any alias hits a listing tag. With mtag tags so satisfied, the primary score is coverage:

vvibe = mtag|Q|   (if mtag > 0) (10)

When structured tags are missing, the same alias expansion runs as a free-text fallback over the listing's title/description/locale fields, yielding vvibe = min(1, 0.2 + 0.8·m/|Q|), where m is the number of query tags with any alias found in the text. The component is dropped if the query carries no tags.

6.7 Energy efficiency (egy)

A direct ordinal mapping of the energy-performance class:

A→1.0,   B→0.85,   C→0.70,   D→0.55,   E→0.40,   F→0.25,   G→0.10;   off-scale class→0.30 (11)

Missing-certificate handling depends on whether the user asked about energy. When they did not, no certificate means no signal — the component drops, and energy acts only as a quality tie-breaker (§8). When the user did voice an energy preference, a missing class is a measured uncertainty, not “no data”: the listing is scored at the uncertainty value 0.3 so that an unknown-energy listing participates as a mild miss instead of being lifted by renormalisation above a known-but-mediocre class-G.

6.8 Listing trust & completeness (tr)

A weighted measure of how complete and verifiable a listing is — more photos, richer description, a certificate, and a stated price all raise it. With nimg images and a description of ξ characters:

vtr = 0.40·clamp01(nimg6) + 0.30·clamp01(ξ280) + 0.15·𝟙[CPE] + 0.15·𝟙[price] (12)

6.9 Freshness (fr)

A monotone-decreasing step function of listing age in days, with a faster decay for rentals than for purchases (rentals turn over more quickly). For rentals, ages of <3, <7, <14, <30, <60 days score 1.0, 0.9, 0.7, 0.5, 0.3 and 0.1 thereafter; for purchases the thresholds are <7, <30, <90, <180, <365 days for the same descending values. Dropped when no creation timestamp exists.

6.10 Market value (mkt)

Compares the listing's price-per-m² against the commune benchmark μc. With ppm = xi/Ai and relative delta δ = 100·(ppmμc)/μc:

vmkt = {
1.0,δ ≤ 0
0.7,0 < δ ≤ 10
0.5,10 < δ ≤ 20
0.3,δ > 20
(13)

Below-benchmark pricing scores highest. Dropped when price, area, or benchmark is unavailable.

6.11 Lifestyle fit (life)

Uses precomputed per-listing lifestyle indices on a 0–100 scale. Families read the family-friendly index; students, couples and cross-border workers read the urban-professional index; otherwise the two are averaged. All are divided to the unit interval. Dropped when no lifestyle indices exist — including the all-zero case, which the ingestion layer uses as “no data” and which must not be mistaken for “worst in class”.

6.12 Personalization (pers)

For signed-in users, a per-listing affinity in [0,1] derived upstream from a privacy-preserving user-taste profile (embedding-space similarity between the profile vector u and the listing embedding ei), passed into the ranker per listing:

vpers = clamp01(cos(u, ei)) (14)

Dropped entirely for anonymous sessions, so signed-out ranking is unaffected by any profile. As a quality-side component (§8), personalization can nudge ordering among near-equal results but can never enter the displayed match score.

7. Weighting & intent boosting

The twelve components are combined as a convex combination. The base weight vector is selected by the user's segment (intent × household type). Table 1 in Appendix A gives the full set; each column sums to 1. The profiles encode intuitive priorities — families weight location and space; students weight commute and budget; investors weight market value; renters spread weight toward semantic and personalization.

7.1 Explicit-intent weight boosting

When the user makes a constraint explicit — as recorded by the query knowledge graph's expressed dimensions — the corresponding weight is increased before normalisation, sharpening the ranking toward what they actually asked for. Writing 𝟙[·] for the indicator of an explicit constraint:

w′loc = wloc + 0.10·𝟙[explicit location]  ·  w′com = wcom + 0.12·𝟙[commute target]
w′bud = wbud + 0.05·𝟙[budget stated]  ·  w′vibe = wvibe + 0.05·𝟙[tags present]
w′spc = wspc + 0.05·𝟙[size/beds stated] (15)

The resulting vector w′ need not sum to 1; normalisation is deferred to §8, which also handles missing signals in the same pass.

8. The relevance/quality partition & renormalisation

This section is the heart of the model's honesty properties, and the largest change since v1.0. Two distinct failure modes are prevented here: missing data must not penalise a listing (signal-aware renormalisation, §8.2), and a nice listing must not impersonate a matching one (the relevance/quality partition, §8.1).

8.1 The partition

The components split along the query knowledge graph:

The rationale: in v1.0 all twelve components entered one weighted sum, so a fresh, photo-rich, below-benchmark listing could out-score a listing that better matched the actual query — and the displayed “% Match” conflated “matches what you asked” with “is generally a good listing”. The partition makes the displayed score answer exactly one question: how well does this satisfy what you asked for?

8.2 Live sets and effective weights

Within each side of the partition, signal-less components are dropped and the remaining weights renormalised — absence removes a signal, it never scores as zero:

𝓛R(i) = { kKR : hk(i) = 1 },    𝓛Q(i) = { kKQ : hk(i) = 1 } (16)
ŵk = w′k · 𝟙[k𝓛R] j𝓛R w′j   (and identically for 𝓛Q) (17)

By construction the effective weights sum to 1 whenever at least one component is live, so two listings are always compared on a common, fully-weighted basis even when they expose different subsets of attributes. Note the interaction with §6.1: a listing the vector channel measured as irrelevant carries sem = 0 with a signal — renormalisation can never rescue it by dropping the semantic axis.

8.3 Displayed score and rank score

The displayed 0–100 match score is the renormalised relevance combination alone:

σ(i) = 100 · clamp01( ∑k𝓛R ŵk · vk(i) ) (18)

Quality enters only the internal ordering key, capped at c = 2 points:

rank(i) = σ(i) + min( c,  c · ∑k𝓛Q ŵk · vk(i) ) (19)

Listings are sorted by rank(·) in descending order. Among near-equal-relevance results the better-quality listing ranks first, but quality can never cross relevance bands (the cap) and never appears in the displayed score. When a listing has no live relevance component at all, its score is suppressed rather than fabricated — the UI shows no match percentage instead of an empty claim.

Removed in v2.0: v1.0's “low-signal variance spreading” (a semantic-distance nudge applied when fewer than three components were live) is gone. The de-saturation gradients of §6.2–6.4 restore score separation at the source, and the relevance-only display makes sparse-query scores honest without a corrective term.

9. Diversity guardrail

Pure relevance sorting can fill the entire first page with one commune. A post-ranking guardrail enforces a minimum of m = 3 distinct communes within the top N = 10 results. If the top slice already spans m communes, it is untouched. Otherwise the algorithm scans the tail for the highest-scoring listings that introduce a new commune and promotes them, displacing the lowest-scoring entries of the top slice. Formally, the guardrail returns the smallest set of promotions π such that

| { commune() : ∈ topNπ } | ≥ m (20)

It only reorders the top N; deeper results are preserved. The guardrail's activation is recorded so it can be surfaced to the user.

10. Explainability

Because the score is a transparent convex combination, every result carries a decomposition — now delivered in two tiers:

10.1 Tier 1 — deterministic, numbers-first reasons

Zero LLM. Each measured dimension emits a signed, locale-templated reason carrying the actual numbers: “14 min to Kirchberg — 6 under your 20-min target” (positive), “€180 over your ceiling (+8%)” (negative), “12% below the commune's €/m² benchmark”, “one bedroom fewer than requested”. The polarity is baked into the reason key, so the panel renders honest two-sided rows — a listing is never described only by its good sides. Energy classes are bucketed by rank: A–C is presented as a strength, F–I as a weakness, D–E (and off-scale values such as “NC”) as neutral context — unknown is not a claim. Two consistency rules hold by construction: the score breakdown shows only live relevance components (matching the relevance-only headline score), while the reason list may additionally carry quality-side context (market delta, energy class) clearly as context — informative, but never score-inflating. Components with no signal are omitted rather than shown as 0%, so an explanation never contradicts the score.

10.2 Tier 2 — optional narrative

A single batched language-model call per search may rewrite the Tier-1 reason set for the top results into one fluent, locale-aware sentence each. It is strictly presentational: the narrative is generated from the deterministic reasons (never from the listing directly), cached, budget-capped, and fails soft — on any failure the numbers-first Tier-1 rows render unchanged. The panel never blocks on, and never depends on, the LLM.

Any user or agent may ask why a listing ranked where it did and receive the exact contributing terms.

11. Complexity

In the v2.0 snapshot, candidate generation was sub-linear in the corpus via an approximate-nearest-neighbour index over the 768-d space. Scoring was O(|L| · |K|) = O(12|L|) = O(|L|) with a small constant: each component was O(1) except location and commute, which were O(|R|) and O(|P|) in the number of requested localities and POIs. This complexity statement is historical; the current hybrid retrieval and reranking pipeline has additional stages.

12. What never influences ranking

The twelve components are inputs to the interpretable fit score described in this historical paper. Candidate retrieval, hard filters, calibration, semantic reranking, near-duplicate handling, and diversity guardrails can also affect which listings appear and their final order. None of those paths uses:

Material production ranking changes are published in the Ranking Policy changelog. This paper is a dated technical snapshot; the repository is the source of truth for the current implementation and is open to review on request (email hello@chathome.lu).

13. Limitations & future work recorded at publication


Appendix A — Weight reference table

Table 1. Base component weights by segment. Each column sums to 1.00. “Rent” is the default renter profile; family/student specialise it; buy and investor are the purchase profiles. Rows above the rule are relevance components (they form the displayed score); rows below it are quality components (capped tie-breaker only); energy switches sides per query (§8.1).
Component Rent Family (rent) Student (rent) Buy Investor (buy)
Semantic0.180.110.140.140.09
Location0.140.200.220.180.18
Commute0.140.090.180.090.05
Budget0.140.130.180.130.13
Space0.090.160.020.110.05
Vibe0.060.010.020.060.02
Energy (per-query side)0.040.070.020.070.09
Trust0.040.040.050.040.06
Freshness0.020.010.010.020.04
Market value0.020.020.020.020.18
Lifestyle0.030.050.040.040.02
Personalization0.100.110.100.100.09
Total1.001.001.001.001.00

Explicit-intent boosts of equation (15) are added to these base values, then the relevance and quality subsets are renormalised independently per equation (17), so the effective weights used at scoring time vary per query and per listing.

Appendix B — Symbol glossary

Table 2. Notation used throughout.
SymbolMeaning
i, LA candidate listing; the candidate set after hard filtering.
K, KR, KQThe twelve scoring components; their relevance and quality partitions.
PThe structured search plan; its fields populate the query knowledge graph.
vk, hkComponent score in [0,1]; its signal flag in {0,1}.
diCosine distance between query and listing embeddings (eq. 2).
w, w′, ŵBase, intent-boosted, and renormalised effective weights.
𝓛R, 𝓛QLive sets — relevance / quality components with a signal for a listing.
σ, rank(·)Displayed 0–100 relevance score (eq. 18); internal ordering key (eq. 19).
cQuality tie-breaker cap (2 points).
κr, ρr, λHaversine distance to locality centroid; locality radius; in-radius edge score (0.85).
ti,p, BpMeasured ETA to POI p; commute time budget.
φ, γ, η, βBudget floor and ceiling; headroom ratio (0.8); at-ceiling score (0.9).
μc, δCommune €/m² benchmark; relative price delta (%).
N, mDiversity window (10) and minimum distinct communes (3).

Appendix C — Revision history

Table 3. Revision history of this historical document. See the Ranking Policy page for the current production changelog.
VersionDateChange (plain language)
1.012 Jun 2026Initial publication: twelve components, one weighted sum, segment weights, signal-aware renormalisation, diversity guardrail.
2.03 Jul 2026Query knowledge graph & clarification (§4): plan fields now populate a tiered knowledge graph; missing must-haves trigger a bounded (≤2-round), skippable clarification dialogue with deterministic questions and deterministic answer application, instead of the system guessing. Relevance/quality partition (§8): the displayed match score is now computed from query-relative components only; listing-quality signals (trust, freshness, market value, lifestyle, personalization, and energy when not asked about) act solely as an invisible tie-breaker capped at 2 points — a nice listing can no longer impersonate a matching one. De-saturation (§6): the flat in-budget, in-locality, and under-budget-commute plateaus were replaced by gentle continuous gradients (budget headroom 1.0→0.9 at the ceiling; locality 1.0→0.85 at the radius edge; commute banded by absolute ETA), restoring discrimination on pages that previously tied at identical scores. Honest semantic zero (§6.1): listings the vector channel measured as non-matching now score semantic = 0 with a signal instead of dropping the axis. Anti-evasion (§6.5): small null-bedroom listings are inferred as studios so they cannot dodge bedroom requirements. Multilingual vibe matching (§6.6): query tags expand through en/fr/de/lu/pt alias tables. Hard-filter additions (§5): price-on-request listings are excluded fail-closed under a stated budget, with the excluded count disclosed; cross-border scope is an explicit, disclosed opt-in. Two-tier explanations (§10): deterministic, signed, numbers-first reasons (zero LLM) with an optional fail-soft narrative layer; energy classes bucketed honestly (A–C strength, D–E neutral, F–I weakness). Removed: v1.0's low-signal variance-spreading term, superseded by the above.