From 8d5c1a888dcd9717ca27b064d9705e37a69e4eaf Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 27 Jun 2026 11:16:48 +0500 Subject: [PATCH] perf(estimate): trigram expression index for same-building anchor Tier A (~94x) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _fetch_anchor_comps Tier A runs on EVERY /estimate (flag estimate_same_building_anchor_enabled defaults True, not overridden in prod). Its address predicate wraps address in lower(translate(address,'ёЁ','ее')) for both the LIKE '%street%' and the ~house-number regex — non-sargable, and the existing listings_address_trgm_idx is on the RAW address, so the planner seq-scanned all ~66.7k listings (167ms, 15801 buffers) every estimate. Add a GIN trigram expression index on lower(translate(address,'ёЁ','ее')) matching the predicate exactly (gin_trgm_ops serves both LIKE and the regex). Prod EXPLAIN (BEGIN/ROLLBACK, "Хохрякова 48"): 167ms -> 1.77ms (~94x), buffers 15801 -> 97. Independently dry-run-verified: Bitmap Index Scan, 0.66ms. Result-identical (pure access-path; count/sum/min/max unchanged). No code change (expression matches the predicate). ~8MB, sub-second build. Other estimate-path queries confirmed already optimal (Tier W/H geom+rooms BitmapAnd, Tier S house_id_fk) — no other index warranted. tradein-mvp/backend/data/sql/137 (correct tradein migrations path). --- .../data/sql/137_listings_addr_norm_trgm.sql | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/137_listings_addr_norm_trgm.sql diff --git a/tradein-mvp/backend/data/sql/137_listings_addr_norm_trgm.sql b/tradein-mvp/backend/data/sql/137_listings_addr_norm_trgm.sql new file mode 100644 index 00000000..82700819 --- /dev/null +++ b/tradein-mvp/backend/data/sql/137_listings_addr_norm_trgm.sql @@ -0,0 +1,60 @@ +-- 137_listings_addr_norm_trgm.sql +-- Trigram GIN expression-индекс на lower(translate(address,'ёЁ','ее')) — +-- ускоряет same-building anchor Tier A эстиматора (горячий, на КАЖДОЙ оценке). +-- +-- WHY (perf-аудит /estimate, EXPLAIN-решение об индексе): +-- `_fetch_anchor_comps` Tier A (estimator.py, anchor-путь за флагом +-- estimate_same_building_anchor_enabled=True — дефолт ON, в проде не +-- переопределён) запускается на каждой оценке с распознаваемым номером дома. +-- Предикат: +-- lower(translate(address,'ёЁ','ее')) LIKE '%%' -- substring +-- lower(translate(address,'ёЁ','ее')) ~ '(^|[^0-9])...'-- regex +-- Оба условия оборачивают address в функцию lower(translate(...)) → +-- non-sargable. Существующий listings_address_trgm_idx = gin(address +-- gin_trgm_ops) построен на СЫРОМ address, поэтому планировщик НЕ может его +-- применить к нормализованному выражению → Seq Scan по всем ~66.7k строкам. +-- +-- Прод-EXPLAIN (2026-06-27, ЕКБ "Хохрякова 48", 18 строк-результат): +-- было — Seq Scan on listings: Rows Removed by Filter 66714, +-- read=15329 buffers → 167 мс. +-- стало — Bitmap Index Scan on listings_addr_norm_trgm_idx +-- (LIKE %s% И ~regex оба как Index Cond) → 97 buffers → 1.77 мс. +-- (~94× быстрее, ~98% меньше прочитанных буферов). Замерено dry-run'ом +-- (BEGIN; CREATE INDEX; EXPLAIN ANALYZE; ROLLBACK) на реальном проде. +-- Эквивалентность результата проверена: count/sum/min/max address с индексом и +-- без — байт-в-байт (18 строк, sum(price_per_m2)=8 536 769). Индекс — только +-- путь доступа, выборка не меняется. +-- +-- WHAT: +-- GIN trgm-индекс по выражению lower(translate(address,'ёЁ','ее')). Выражение +-- ТОЧНО совпадает с тем, как Tier A пишет предикат — без правок кода. gin_trgm_ops +-- обслуживает И LIKE '%...%', И regex (~) — оба условия Tier A покрыты. +-- ё→е нормализация в индексе симметрична нормализатору адресов (#677/#679). +-- +-- SAFETY: +-- - НЕ деструктивно: только добавление индекса, данные не трогаются. +-- - IF NOT EXISTS → идемпотентно при повторном прогоне (no-op). +-- - Размер ~8 МБ (меньше существующего listings_address_trgm_idx=16 МБ — +-- нормализация схлопывает часть триграмм). Сборка GIN по ~66.7k строк дёшева +-- (<1с), кратковременный ACCESS EXCLUSIVE lock на время сборки. CONCURRENTLY +-- не используем: файл обёрнут в BEGIN/COMMIT (CREATE INDEX CONCURRENTLY в +-- транзакции нельзя), на таком объёме обычная сборка дёшева. NB: раннер +-- деплоя (deploy.yml) гонит файл через psql -v ON_ERROR_STOP=on БЕЗ +-- --single-transaction, т.е. транзакцию открывает сам файл — как 134/133/104 +-- и house-rule (Schema_Migrations_vs_Alembic.md #4). +-- - listings_address_trgm_idx (gin(address gin_trgm_ops)) ОСТАВЛЯЕМ — он на +-- сыром address и нужен другим путям (ILIKE по необработанному адресу). +-- +-- DEPENDENCIES: +-- - pg_trgm extension (присутствует на проде — verified 2026-06-27). +-- - listings.address text -- 011_listings_alter.sql. +-- +-- Deploy order: after 136_backfill_listings_house_id_fk_source_identity.sql. + +BEGIN; + +CREATE INDEX IF NOT EXISTS listings_addr_norm_trgm_idx + ON listings USING gin (lower(translate(address, 'ёЁ', 'ее')) gin_trgm_ops) + WHERE address IS NOT NULL; + +COMMIT; -- 2.45.3