Загрузка данных
Теперь данных достаточно. Идём **только от твоей реальной таблицы**.
Я бы в твоём случае сделал **не `search_text`, не Elasticsearch и не попытку угадывать структуру российского адреса**, а отдельное поисковое поле на основе `full_address` + `pg_trgm`. При 90k ПВЗ этого более чем достаточно.
Ниже готовая реализация.
## 1. Устанавливаем расширения
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent;
```
## 2. Добавляем нормализованный адрес
`full_address` оставляем как есть. Добавляем отдельную колонку:
```sql
ALTER TABLE delivery_points
ADD COLUMN address_normalized text;
```
Заполняем:
```sql
UPDATE delivery_points
SET address_normalized =
trim(
regexp_replace(
lower(
unaccent(
coalesce(full_address, '')
)
),
'[^a-zа-я0-9]+',
' ',
'g'
)
);
```
Например:
```text
Россия, Ленинградская Область, Выборгский Район, Выборг,
Железнодорожная улица, 15
```
превратится примерно в:
```text
rossia leningradskaya oblast vyborgskii raion vyborg zheleznodorozhnaya ulitsa 15
```
---
# 3. Индекс
Вот это принципиально важно.
Текущий:
```sql
"delivery_points_search_idx" btree (search_text)
```
для поиска по произвольной подстроке **не нужен**.
Создаём trigram GIN:
```sql
CREATE INDEX delivery_points_address_trgm_idx
ON delivery_points
USING gin (address_normalized gin_trgm_ops)
WHERE is_active = true;
```
И всё.
Для 90k записей этого будет более чем достаточно.
---
# 4. Функция поиска
Делаем одну функцию, которую потом вызывает API:
```sql
CREATE OR REPLACE FUNCTION search_delivery_points(
p_query text,
p_limit integer DEFAULT 20
)
RETURNS TABLE (
delivery_point_id bigint,
name text,
delivery_point_number text,
type text,
full_address text,
latitude double precision,
longitude double precision,
is_active boolean,
is_bulky boolean,
storage_period_days integer,
fitting_rooms_count integer,
restrictions jsonb,
shipment_method_ids jsonb,
score real
)
LANGUAGE sql
STABLE
AS $$
WITH q AS (
SELECT
trim(
regexp_replace(
lower(
unaccent(
coalesce(p_query, '')
)
),
'[^a-zа-я0-9]+',
' ',
'g'
)
) AS query
)
SELECT
dp.delivery_point_id,
dp.name,
dp.delivery_point_number,
dp.type,
dp.full_address,
dp.latitude,
dp.longitude,
dp.is_active,
dp.is_bulky,
dp.storage_period_days,
dp.fitting_rooms_count,
dp.restrictions,
dp.shipment_method_ids,
similarity(
dp.address_normalized,
q.query
) AS score
FROM delivery_points dp
CROSS JOIN q
WHERE dp.is_active = true
AND q.query <> ''
AND dp.address_normalized % q.query
ORDER BY
similarity(dp.address_normalized, q.query) DESC,
dp.delivery_point_id
LIMIT LEAST(GREATEST(p_limit, 1), 100);
$$;
```
Теперь поиск:
```sql
SELECT *
FROM search_delivery_points('Выборг Железнодорожная 15');
```
или:
```sql
SELECT *
FROM search_delivery_points('выборг железнодорожная', 10);
```
---
# 5. Но есть важная проблема
Если оставить только этот вариант, запрос:
```text
Выборг Железнодорожная 15
```
сравнивается целиком с:
```text
Россия Ленинградская Область Выборгский Район Выборг Железнодорожная улица 15
```
То есть длинный адрес начинает влиять на `similarity`.
Поэтому для **реально хорошего поиска** я бы добавил второй индекс/механизм: поиск по отдельным словам.
Для этого PostgreSQL уже умеет полнотекстовый поиск.
Добавляем:
```sql
ALTER TABLE delivery_points
ADD COLUMN address_tsv tsvector
GENERATED ALWAYS AS (
to_tsvector(
'russian',
coalesce(full_address, '')
)
) STORED;
```
И индекс:
```sql
CREATE INDEX delivery_points_address_tsv_idx
ON delivery_points
USING gin (address_tsv)
WHERE is_active = true;
```
---
# 6. Теперь функция делает два поиска
Вот эту функцию ставим **вместо предыдущей**:
```sql
CREATE OR REPLACE FUNCTION search_delivery_points(
p_query text,
p_limit integer DEFAULT 20
)
RETURNS TABLE (
delivery_point_id bigint,
name text,
delivery_point_number text,
type text,
full_address text,
latitude double precision,
longitude double precision,
is_active boolean,
is_bulky boolean,
storage_period_days integer,
fitting_rooms_count integer,
restrictions jsonb,
shipment_method_ids jsonb,
score double precision
)
LANGUAGE sql
STABLE
AS $$
WITH normalized AS (
SELECT
trim(
regexp_replace(
lower(
unaccent(
coalesce(p_query, '')
)
),
'[^a-zа-я0-9]+',
' ',
'g'
)
) AS query
),
candidates AS (
SELECT
dp.*,
similarity(
dp.address_normalized,
n.query
)::double precision AS trigram_score,
ts_rank_cd(
dp.address_tsv,
websearch_to_tsquery(
'russian',
n.query
)
)::double precision AS text_score
FROM delivery_points dp
CROSS JOIN normalized n
WHERE dp.is_active = true
AND n.query <> ''
AND (
dp.address_normalized % n.query
OR dp.address_tsv @@ websearch_to_tsquery(
'russian',
n.query
)
)
)
SELECT
delivery_point_id,
name,
delivery_point_number,
type,
full_address,
latitude,
longitude,
is_active,
is_bulky,
storage_period_days,
fitting_rooms_count,
restrictions,
shipment_method_ids,
(
trigram_score * 0.55 +
text_score * 0.45
) AS score
FROM candidates
ORDER BY score DESC, delivery_point_id
LIMIT LEAST(GREATEST(p_limit, 1), 100);
$$;
```
Теперь у тебя есть:
```text
pg_trgm
+
PostgreSQL Full Text Search
+
ranking
```
---
# 7. Проверяем
Например:
```sql
SELECT
delivery_point_id,
full_address,
score
FROM search_delivery_points(
'Выборг Железнодорожная 15',
10
);
```
И:
```sql
SELECT
delivery_point_id,
full_address,
score
FROM search_delivery_points(
'выборг железнодорожная',
10
);
```
И даже:
```sql
SELECT
delivery_point_id,
full_address,
score
FROM search_delivery_points(
'железнодорожная 15 выборг',
10
);
```
Порядок слов уже не должен быть критичным для FTS-компонента.
---
# 8. Что делать с опечатками
`pg_trgm` как раз для этого.
Например:
```text
выборг железнодорожная
```
и:
```text
выбор железнодорожная
```
будут иметь достаточно высокую похожесть.
Но есть важный нюанс: **не надо ставить слишком низкий threshold**, иначе на коротких запросах появится мусор.
Можно посмотреть текущий:
```sql
SELECT show_limit();
```
И поставить, например:
```sql
SELECT set_limit(0.25);
```
Но я бы **не фиксировал значение вслепую**. На реальных запросах лучше посмотреть результаты.
---
# 9. Что делать с короткими запросами
Например пользователь вводит:
```text
москва
```
или:
```text
15
```
Trigram для очень коротких строк — не лучший инструмент.
Поэтому в API я бы сделал минимальную длину:
```text
1 символ → ничего
2 символа → FTS/точные варианты
3+ символов → полноценный fuzzy search
```
А для:
```text
москва
```
получится нормальный полнотекстовый поиск.
---
# 10. API
На backend тебе теперь вообще не нужно знать детали SQL.
Например:
```http
GET /api/delivery-points/search?q=Выборг%20Железнодорожная%2015
```
Backend:
```sql
SELECT *
FROM search_delivery_points($1, 20);
```
где `$1`:
```text
Выборг Железнодорожная 15
```
Возвращаешь JSON:
```json
[
{
"delivery_point_id": 5627,
"name": "Пункт Ozon",
"full_address": "Россия, Ленинградская Область, Выборгский Район, Выборг, Железнодорожная улица, 15",
"latitude": 60.713,
"longitude": 28.732,
"score": 0.91
}
]
```
---
# Что в итоге менять в твоей БД
Текущую таблицу:
```text
delivery_points
```
**не надо переделывать.**
Добавляем только:
```text
address_normalized
address_tsv
```
и два индекса:
```text
delivery_points_address_trgm_idx
delivery_points_address_tsv_idx
```
То есть:
```text
delivery_points
│
├── delivery_point_id
├── name
├── delivery_point_number
├── type
├── full_address ← исходный адрес, НЕ трогаем
├── latitude
├── longitude
├── ...
│
├── address_normalized ← новое
└── address_tsv ← новое
```
Поиск:
```text
"Выборг ЖД 15"
│
▼
normalization
│
┌──────────┴──────────┐
▼ ▼
pg_trgm FTS
│ │
└──────────┬──────────┘
▼
ranking
│
▼
top 20 ПВЗ
```
**И всё это остаётся внутри PostgreSQL.**
При твоих ~90 000 строк я бы сначала именно так и запустил. Если после этого окажется, что проблема уже не в скорости, а в качестве результатов для запросов типа `мск ленинградка 15`, тогда следующим шагом будет уже **нормальный разбор адреса на город/улицу/дом**. Но сейчас для этого нет необходимости гадать по данным, которых в таблице нет.