Кто хоть раз просил языковую модель написать пост или статью, видел одну и ту же картину. Вводный абзац с общим тезисом, несколько подзаголовков, список преимуществ, вывод, который пересказывает начало. Если ИИ для создания контента пользуются многие авторы одной ниши, лента быстро заполняется текстами, которые различаются темой, но не устройством. Читатель это замечает раньше, чем успевает сформулировать, что именно не так.

Ниже разберу, откуда берётся эта схожесть, и покажу, как её измерить кодом: на уровне слов, на уровне корпуса и на уровне структуры. Измерение полезно не ради отчёта. Пока похожесть нельзя посчитать, спор о том, «шаблонный» ли текст, остаётся спором вкусов, а с цифрой на руках его можно сделать проверкой перед публикацией.

Почему ответы модели сходятся к одному шаблону

Первая причина лежит в декодировании. Модель на каждом шаге выбирает следующее слово из распределения вероятностей, и при обычных настройках температуры и top‑p выигрывают самые вероятные продолжения. Для похожих запросов самые вероятные продолжения тоже похожи, поэтому два автора, попросившие «статью про продвижение в соцсетях», получают тексты одного покроя, даже если формулировки запросов слегка различались.

Вторая причина появляется на этапе дообучения. Модели, которые доводят до диалогового режима с помощью обучения на человеческих предпочтениях, становятся удобнее и послушнее, но платят за это разнообразием ответов. Этот эффект отдельно измеряли в работе «Understanding the Effects of RLHF on LLM Generalisation and Diversity» (Kirk и соавторы, 2023): обобщение у таких моделей растёт, а разнообразие выходов заметно падает по сравнению с моделями после обычного дообучения на примерах.

Третья причина — сами пользователи. В эксперименте «Does Writing with Language Models Reduce Content Diversity?» (Padmakumar и He, 2023) люди писали эссе с помощью двух моделей. Тексты, написанные вместе с моделью, дообученной на предпочтениях, получились статистически значимо менее разнообразными между авторами, а с базовой моделью такого эффекта не нашли. Иначе говоря, однообразие возникает не внутри одного текста, а между текстами разных людей, которые пользуются одним инструментом.

Разные люди, один инструмент - и оттиски выходят одинаковыми
Разные люди, один инструмент — и оттиски выходят одинаковыми

Как измерить похожесть двух текстов

Самый простой и честный способ — шинглы. Текст разбивается на перекрывающиеся цепочки из нескольких слов, а похожесть двух текстов считается как доля общих цепочек среди всех, то есть коэффициент Жаккара. Цепочки из пяти слов хорошо ловят повторяющиеся обороты и при этом не срабатывают на случайно совпавшие пары слов.

import re
def shingles(text: str, n: int = 5) -> set[tuple[str, ...]]:
    words = re.findall(r"[а-яёa-z0-9]+", text.lower())
    return {tuple(words[i:i + n]) for i in range(len(words) - n + 1)}
def jaccard(a: str, b: str, n: int = 5) -> float:
    sa, sb = shingles(a, n), shingles(b, n)
    if not sa or not sb:
        return 0.0
    return len(sa & sb) / len(sa | sb)

Порог, после которого тексты считаются слишком похожими, лучше не брать из чужих статей, а откалибровать на своём корпусе. Возьмите пары текстов, которые вы сами считаете разными, и пары, которые считаете переписанными друг из друга, посмотрите на распределение значений и проведите границу между ними. На коротких текстах коэффициент шумит сильнее, поэтому сравнивать имеет смысл тексты сопоставимой длины.

Похожесть на корпусе: MinHash вместо попарного сравнения

Попарное сравнение быстро перестаёт работать: число пар растёт квадратично, и проверка свежей партии против большого архива превращается в долгую задачу. Здесь помогает MinHash: каждый текст сворачивается в короткую подпись фиксированной длины, по которой коэффициент Жаккара оценивается приближённо, а индекс LSH сразу отдаёт кандидатов в близнецы без перебора всех пар.

from datasketch import MinHash, MinHashLSH
def signature(text: str, num_perm: int = 128) -> MinHash:
    m = MinHash(num_perm=num_perm)
    for sh in shingles(text):
        m.update(" ".join(sh).encode("utf-8"))
    return m
lsh = MinHashLSH(threshold=0.3, num_perm=128)
for doc_id, text in archive.items():
    lsh.insert(doc_id, signature(text))
suspects = lsh.query(signature(new_text))

Порог в индексе здесь служит грубым фильтром: кандидатов, которых он вернул, стоит перепроверить точным коэффициентом из предыдущего раздела. Подписи архива имеет смысл считать один раз и хранить рядом с текстами, тогда проверка нового материала занимает доли секунды.

Шаблон виден не только в словах

Шинглы ловят совпадение формулировок, но пропускают совпадение устройства. Два текста могут не иметь ни одной общей цепочки из пяти слов и при этом читаться как близнецы: одинаковый ритм коротких абзацев, одни и те же первые слова у предложений, список на месте каждого рассуждения. Это тоже можно посчитать.

from collections import Counter
from statistics import pstdev
def structure(text: str) -> dict:
    paras = [p.strip() for p in text.split("\n\n") if p.strip() and not p.startswith(("#", "-", "*"))]
    sents = [s for p in paras for s in re.split(r"(?<=[.!?])\s+", p) if s]
    lengths = [len(s.split()) for s in sents]
    starts = Counter(s.split()[0].lower() for s in sents if s.split())
    return {
        "one_sentence_paragraphs": sum(len(re.split(r"(?<=[.!?])\s+", p)) == 1 for p in paras) / max(1, len(paras)),
        "sentence_length_spread": pstdev(lengths) if len(lengths) > 1 else 0.0,
        "repeated_openers": sum(n for w, n in starts.items() if n >= 3 and len(w) > 3),
    }

Разброс длины предложений у живого автора обычно заметно больше, чем у текста, который модель выдала одним куском: человек чередует длинные периоды и короткие реплики, а модель тяготеет к средней длине. Доля абзацев из одного предложения и повторяющиеся зачины выдают рубленый ритм, который читатели давно научились узнавать. Абсолютные значения здесь тоже лучше сравнивать с эталонами из своей ниши, а не с универсальными нормами.

Есть и третий уровень — совпадение мысли, пересказанной другими словами. Его не видят ни шинглы, ни метрики структуры, и для него нужны векторные представления текстов и косинусная близость. Смысл в том, чтобы находить статьи, где один и тот же довод повторяется в разных выражениях: именно так однообразие и проникает в ленту, когда формальная уникальность у каждого текста высокая.

Что меняет результат на входе

Измерение показывает проблему, но лечится она на входе. Сильнее всего на разнообразие влияет собственный материал автора: расшифровка разговора, заметки с реальной задачи, конкретные цифры и случаи. Когда модель получает такую фактуру, ей приходится опираться на неё, а не на самое вероятное продолжение общей темы.

Помогает и отказ от жёсткого шаблона запроса. Если каждая статья просится словами «напиши экспертную статью с подзаголовками и выводом», модель исправно воспроизводит именно эту форму. Лучше описывать задачу читателя и материал, а структуру оставлять открытой, проверяя её уже готовыми метриками. Последний слой — редактура человеком, которая убирает то, что метрики только подсвечивают.

Чего эти метрики не скажут

Высокая уникальность не делает текст хорошим, а низкий разброс длины предложений не доказывает, что текст написан моделью. Это сигналы для редактора, а не приговор, и использовать их стоит как проверку перед публикацией: текст, который заметно похож на архив или выбивается по структуре из эталонов ниши, отправляется на доработку, а решение принимает человек. Такой порядок даёт главное, чего не хватает при массовом использовании ИИ для создания контента, — возможность заметить однообразие до того, как его заметят читатели.