feat(sql): conservative trgm backfill objective_complex_mapping +23 (#307 OBJ-2) #1331
1 changed files with 259 additions and 0 deletions
259
data/sql/155_objective_mapping_trgm_backfill.sql
Normal file
259
data/sql/155_objective_mapping_trgm_backfill.sql
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
-- 155_objective_mapping_trgm_backfill.sql
|
||||
-- Issue #307 OBJ-2 — КОНСЕРВАТИВНЫЙ fuzzy (pg_trgm) backfill of
|
||||
-- objective_complex_mapping. Третий проход (после 116/150): добавляет ТОЛЬКО
|
||||
-- near-exact distinctive-name матчи, которые 150 (dev-corroboration-only gate)
|
||||
-- НЕ взял из-за Latin/Cyrillic dev-noise (TEN↔ТЭН, FORTIS↔Фортис) и юр.префиксов.
|
||||
--
|
||||
-- CONTEXT
|
||||
-- objective_complex_mapping связывает Objective-ЖК (objective_complex_name) →
|
||||
-- domrf_kn_objects.obj_id. До этой миграции — 263 строки (все objective_group=
|
||||
-- 'Екатеринбург'; match_method fuzzy/auto_fuzzy_v2/multi_feature_v1/fuzzy_v2/manual,
|
||||
-- все is_reviewed=false кроме 2 manual).
|
||||
--
|
||||
-- mv_layout_velocity JOIN-ит objective_corpus_room_month.project_name =
|
||||
-- objective_complex_mapping.objective_complex_name WHERE objective_group=
|
||||
-- 'Екатеринбург' AND domrf_obj_id IS NOT NULL (verified pg_get_viewdef
|
||||
-- 2026-06-14). То есть unique-ключ матчинга — project_name (НЕ project_id):
|
||||
-- новые строки с этим именем + group='Екатеринбург' немедленно расширяют MV.
|
||||
--
|
||||
-- ПОЧЕМУ ИМЕНА, А НЕ project_id
|
||||
-- objective_lots: 302 distinct objective_project_id ещё не в mapping, НО лишь
|
||||
-- 156 distinct project_name не в mapping под group='Екатеринбург' (многие
|
||||
-- project_id делят одно имя; многие имена уже сматчены под другим id). MV и
|
||||
-- UNIQUE-констрейнт работают по имени, 150 дедупил по имени — поэтому здесь
|
||||
-- тоже дедуп по project_name. Матчить 302 id создало бы дубль-имя конфликты,
|
||||
-- которые ON CONFLICT молча отбросит.
|
||||
--
|
||||
-- НОРМАЛИЗАЦИЯ (идентична 150; калибровка prod read-only 2026-06-14, snapshot
|
||||
-- 2026-06-10): strip ЖК-boilerplate ('Жилой комплекс/квартал/район/дом',
|
||||
-- 'Клубный дом', 'Микрорайон', 'Квартал', 'ЖК', 'Дом' …), кавычки,
|
||||
-- stage-суффиксы ('N этап/очередь') ДО similarity. Strip-токены по
|
||||
-- word-boundary (\m…\M) чтобы не резать середину ('Домино'→'ино').
|
||||
-- Dev нормализуется (strip юр.префиксов СЗ/ГК/ООО/Девелопмент/Development/Group…)
|
||||
-- и dsim = trigram ИЛИ substring-containment.
|
||||
--
|
||||
-- ПРАВИЛО ПРИЁМКИ — PRECISION OVER RECALL (P0: ложный mapping напрямую портит
|
||||
-- velocity/prices, поэтому держим precision≈1.0 на авто-наборе):
|
||||
-- nsim = similarity(norm(project_name), norm(comm_name))
|
||||
-- dsim = trigram(norm dev) OR substring-containment
|
||||
-- corroborated = (dsim >= 0.5) OR (district точно совпал)
|
||||
-- generic_guarded = norm(project_name) ∈ стоплист generic single-token имён,
|
||||
-- где межзастройщицкие коллизии РЕАЛЬНЫ (наблюдённые FP в band'е 1.0:
|
||||
-- «Солнечный», «Прекрасный», «Невский», «Стрелки», «Парковый» (over-strip
|
||||
-- «Жилой квартал Парковый»→'парковый' ≡ obj 'Парковый квартал' PRINZIP),
|
||||
-- «Тихий центр», плюс risk-слова центральный/самоцветы/южные/теплые/
|
||||
-- московский/сириус/нескучный сад).
|
||||
-- accept =
|
||||
-- (nsim >= 0.85 AND NOT generic_guarded) -- A: near-exact + distinctive
|
||||
-- OR (nsim >= 0.85 AND generic_guarded AND corroborated) -- A': generic, но dev/district подтверждён
|
||||
-- OR (nsim >= 0.62 AND nsim < 0.85 AND corroborated) -- B: good name + dev/district
|
||||
-- Калибровка по реальным band'ам (prod, BEGIN…ROLLBACK 2026-06-14):
|
||||
-- • Band A нетронутый дал 29 near-exact (все nsim=1.000). 6 — generic FP с
|
||||
-- чужим застройщиком → отсечены guard'ом. Остаток 23 — distinctive
|
||||
-- (RedRock/Геометрия/Башня Времени/…), межзастройщицкая коллизия
|
||||
-- практически невозможна → авто без dev. Precision на этих 23 ≈ 1.0.
|
||||
-- • Band B (0.62-0.85): 10 кандидатов, единственный corroborated — «Рио-3»
|
||||
-- (dsim=1.0 Стройтэк) — но его obj_id 31874 уже занят 150 ('Рио'); «Рио-3»
|
||||
-- это фаза того же ЖК → исключён obj_id-guard'ом. Прочие 9 uncorroborated
|
||||
-- (чужой dev / Kurgan-застройщик / уже-сматченный «Самоцветы Урала») →
|
||||
-- отброшены. ИТОГ band B = 0 авто (намеренно: ни одного безопасного).
|
||||
-- ИТОГ: 23 новых mapping (все nsim=1.000, decision=A_auto). 133 имени остаются
|
||||
-- на manual (Anton): 19 без кандидата в DOM.РФ (города-спутники / нет ПД),
|
||||
-- 6 generic-guarded near-exact, 108 низко-/средне-confident без подтверждения.
|
||||
--
|
||||
-- COLLISION HANDLING
|
||||
-- 1. Лучший domrf-obj на каждый objective project: DISTINCT ON (project_name)
|
||||
-- по nsim DESC, dsim DESC.
|
||||
-- 2. Один obj_id ≤ один objective project: ROW_NUMBER per obj_id
|
||||
-- ORDER BY corroborated DESC, nsim DESC.
|
||||
-- 3. obj_id, уже занятые существующими 263 mapping, исключаются (NOT IN).
|
||||
--
|
||||
-- ПОЧЕМУ ТРЕТИЙ ПРОХОД, А НЕ ПЕРЕДЕЛКА 150
|
||||
-- 150 уже применён на prod (_schema_migrations) — переписывать его нельзя
|
||||
-- (auto-apply once). 155 — аддитивный: только INSERT новых имён, НИКАКИХ
|
||||
-- DROP/UPDATE существующих 263 строк.
|
||||
--
|
||||
-- GIN-ИНДЕКС
|
||||
-- Существующий idx_domrf_kn_objects_comm_name_trgm — GIST partial WHERE is_ekb=true,
|
||||
-- а ВСЕ 1543 строки latest-snapshot region_cd=66 имеют is_ekb=false (verified) →
|
||||
-- индекс покрывает 0 строк, для backfill бесполезен. Добавляем НЕпартиальный
|
||||
-- GIN trgm индекс (IF NOT EXISTS) на comm_name: он используется %-оператором
|
||||
-- (verified EXPLAIN: Bitmap Index Scan) для будущих/runtime ad-hoc match-запросов.
|
||||
-- Честно: САМ backfill ниже использует similarity()>порог в CTE-join (не %),
|
||||
-- что НЕ триггерит trgm-индекс — но это one-shot 156×1543 Nested Loop (~4 s,
|
||||
-- измерено на 150), приемлемо. Индекс добавлен под требование задачи и runtime,
|
||||
-- не под эту миграцию. Таблица 9 MB / 10699 строк → обычный CREATE INDEX
|
||||
-- (не CONCURRENTLY — нельзя в транзакции) строится мгновенно, lock тривиален.
|
||||
--
|
||||
-- DEPENDENCIES: objective_lots, objective_complex_mapping, domrf_kn_objects,
|
||||
-- pg_trgm (1.6, установлен), mv_layout_velocity (UNIQUE pk mv_layout_velocity_pk
|
||||
-- на (obj_id, room_bucket) → CONCURRENTLY валиден).
|
||||
-- DEPLOY: auto-applied by deploy.yml через _schema_migrations (ровно один раз по NN).
|
||||
-- Применяется ДО compose up -d. ORDER: schema/data first → backend deploy second.
|
||||
-- IDEMPOTENT: ON CONFLICT (objective_complex_name, objective_group) DO NOTHING +
|
||||
-- CREATE INDEX IF NOT EXISTS. Re-apply добавит 0 строк (verified BEGIN/INSERT
|
||||
-- x2/ROLLBACK на prod: INSERT 0 23, затем INSERT 0 0).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Non-partial GIN trgm index for %-operator / runtime fuzzy match (см. шапку).
|
||||
CREATE INDEX IF NOT EXISTS idx_domrf_kn_objects_comm_name_gin_trgm
|
||||
ON domrf_kn_objects USING gin (comm_name gin_trgm_ops);
|
||||
|
||||
WITH
|
||||
-- 1. Latest DOM.РФ snapshot, регион 66 (Свердловская обл / ЕКБ-агломерация).
|
||||
latest AS (
|
||||
SELECT max(snapshot_date) AS sd FROM domrf_kn_objects
|
||||
),
|
||||
domrf AS (
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id,
|
||||
o.comm_name,
|
||||
o.dev_name,
|
||||
o.district_name,
|
||||
-- \m…\M = PG word boundaries → токены матчатся как ЦЕЛЫЕ слова, не подстроки.
|
||||
btrim(regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(lower(coalesce(o.comm_name, '')),
|
||||
'\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M',
|
||||
' ', 'g'),
|
||||
'\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'),
|
||||
'[«»"''.,()\-]+', ' ', 'g')) AS name_norm,
|
||||
btrim(regexp_replace(
|
||||
regexp_replace(lower(coalesce(o.dev_name, '')),
|
||||
'(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)',
|
||||
' ', 'g'),
|
||||
'[«»"''.,()\-]+', ' ', 'g')) AS dev_norm
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.snapshot_date = (SELECT sd FROM latest)
|
||||
AND o.region_cd = 66
|
||||
AND o.comm_name IS NOT NULL
|
||||
ORDER BY o.obj_id
|
||||
),
|
||||
-- 2. Distinct Objective projects (один ряд на project_name; dominant dev/district),
|
||||
-- исключая уже сматченные в group='Екатеринбург'.
|
||||
obj AS (
|
||||
SELECT
|
||||
l.project_name,
|
||||
mode() WITHIN GROUP (ORDER BY l.developer) AS developer,
|
||||
mode() WITHIN GROUP (ORDER BY l.district) AS district,
|
||||
max(l.objective_project_id) AS objective_project_id
|
||||
FROM objective_lots l
|
||||
WHERE l.project_name IS NOT NULL
|
||||
GROUP BY l.project_name
|
||||
),
|
||||
obj_unmapped AS (
|
||||
SELECT
|
||||
o.*,
|
||||
btrim(regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(lower(coalesce(o.project_name, '')),
|
||||
'\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M',
|
||||
' ', 'g'),
|
||||
'\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'),
|
||||
'[«»"''.,()\-]+', ' ', 'g')) AS name_norm,
|
||||
btrim(regexp_replace(
|
||||
regexp_replace(lower(coalesce(o.developer, '')),
|
||||
'(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)',
|
||||
' ', 'g'),
|
||||
'[«»"''.,()\-]+', ' ', 'g')) AS dev_norm
|
||||
FROM obj o
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM objective_complex_mapping m
|
||||
WHERE m.objective_complex_name = o.project_name
|
||||
AND m.objective_group = 'Екатеринбург'
|
||||
)
|
||||
),
|
||||
-- 3. Best DOM.РФ candidate per objective project (нормализованный trigram).
|
||||
pairs AS (
|
||||
SELECT
|
||||
u.project_name,
|
||||
u.developer AS obj_dev,
|
||||
u.district AS obj_district,
|
||||
u.objective_project_id,
|
||||
-- normalized objective name, whitespace-collapsed (для generic-guard сравнения)
|
||||
btrim(regexp_replace(u.name_norm, '\s+', ' ', 'g')) AS obj_nn,
|
||||
d.obj_id,
|
||||
d.comm_name,
|
||||
d.dev_name AS domrf_dev,
|
||||
d.district_name AS domrf_district,
|
||||
similarity(u.name_norm, d.name_norm) AS nsim,
|
||||
GREATEST(
|
||||
similarity(u.dev_norm, d.dev_norm),
|
||||
(char_length(btrim(u.dev_norm)) > 2
|
||||
AND char_length(btrim(d.dev_norm)) > 2
|
||||
AND (u.dev_norm ILIKE '%' || d.dev_norm || '%'
|
||||
OR d.dev_norm ILIKE '%' || u.dev_norm || '%'))::int::double precision
|
||||
) AS dsim,
|
||||
(u.district IS NOT NULL AND d.district_name IS NOT NULL
|
||||
AND lower(btrim(u.district)) = lower(btrim(d.district_name)))::int AS district_match
|
||||
FROM obj_unmapped u
|
||||
JOIN domrf d
|
||||
ON similarity(u.name_norm, d.name_norm) > 0.2 -- prefilter
|
||||
),
|
||||
best_per_proj AS (
|
||||
SELECT DISTINCT ON (project_name) *
|
||||
FROM pairs
|
||||
ORDER BY project_name, nsim DESC, dsim DESC, obj_id
|
||||
),
|
||||
classified AS (
|
||||
SELECT
|
||||
b.*,
|
||||
(b.dsim >= 0.5 OR b.district_match = 1) AS corroborated,
|
||||
(b.obj_nn = ANY (ARRAY[
|
||||
'солнечный','прекрасный','невский','стрелки','парковый','центральный',
|
||||
'тихий центр','самоцветы','южные','теплые','московский','сириус','нескучный сад'
|
||||
])) AS generic_guarded
|
||||
FROM best_per_proj b
|
||||
),
|
||||
accepted AS (
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
c.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY c.obj_id
|
||||
ORDER BY c.corroborated DESC, c.nsim DESC, c.project_name
|
||||
) AS rn_obj
|
||||
FROM classified c
|
||||
WHERE (
|
||||
(c.nsim >= 0.85 AND NOT c.generic_guarded)
|
||||
OR (c.nsim >= 0.85 AND c.generic_guarded AND c.corroborated)
|
||||
OR (c.nsim >= 0.62 AND c.nsim < 0.85 AND c.corroborated)
|
||||
)
|
||||
AND c.obj_id NOT IN (
|
||||
SELECT domrf_obj_id FROM objective_complex_mapping
|
||||
WHERE domrf_obj_id IS NOT NULL
|
||||
)
|
||||
) s
|
||||
WHERE s.rn_obj = 1
|
||||
)
|
||||
INSERT INTO objective_complex_mapping
|
||||
(objective_complex_name, objective_project_id, objective_group,
|
||||
domrf_obj_id, is_reviewed, match_method, match_score, note)
|
||||
SELECT
|
||||
a.project_name,
|
||||
a.objective_project_id,
|
||||
'Екатеринбург',
|
||||
a.obj_id,
|
||||
FALSE, -- ВСЕ авто-матчи на ручной review (Anton)
|
||||
'auto_trgm_v3',
|
||||
round(a.nsim::numeric, 3),
|
||||
'OBJ-2 conservative trgm backfill #307'
|
||||
|| ' | nsim=' || round(a.nsim::numeric, 3)
|
||||
|| ' dsim=' || round(a.dsim::numeric, 3)
|
||||
|| ' | domrf="' || coalesce(a.comm_name, '') || '"'
|
||||
|| ' dev_obj="' || coalesce(a.obj_dev, '') || '"'
|
||||
|| ' dev_domrf="' || coalesce(a.domrf_dev, '') || '"'
|
||||
FROM accepted a
|
||||
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- REFRESH ВНЕ транзакции (после COMMIT): CONCURRENTLY требует non-tx-блок и
|
||||
-- UNIQUE index (mv_layout_velocity_pk на (obj_id, room_bucket) существует) и не
|
||||
-- блокирует читателей. MV уже populated → CONCURRENTLY безопасен. deploy.yml
|
||||
-- прогоняет файл через `psql < file` без --single-transaction, поэтому COMMIT
|
||||
-- выше закрывает txn и этот стейтмент исполняется в собственной транзакции
|
||||
-- (тот же приём, что в 150_objective_mapping_backfill.sql).
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity;
|
||||
Loading…
Add table
Reference in a new issue