Загрузка данных


Теперь данных достаточно. Идём **только от твоей реальной таблицы**.

Я бы в твоём случае сделал **не `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`, тогда следующим шагом будет уже **нормальный разбор адреса на город/улицу/дом**. Но сейчас для этого нет необходимости гадать по данным, которых в таблице нет.