Как попасть в ответы Perplexity AI и почему эта ИИ-модель берёт не весь текст с цитируемых страниц

Я прогнал 20 промптов через Perplexity API по три повтора, собрал 60 ответов и у каждой отсылки в тексте связал предложение ответа со страницей источника, на которую оно указывает. Дальше самое трудоёмкое: для каждой такой пары — предложение ответа и страница источника — я искал в HTML кусок текста, максимально похожий на утверждение из ответа. Из 1355 отсылок 948 привели на страницы, которые ещё открываются, и на 448 из них нашёлся пассаж — абзац, пункт списка или ячейка таблицы, — покрывающий хотя бы треть слов утверждения.
Главный результат для того, кто пишет страницы: на сработавшей странице задействовано медианно два кусочка текста из 85, длиной около 49 слов каждый. По числу кусков это 2%, по словам больше — медиана 6,4% текста страницы при среднем 11,6%: сработавший кусок текста длиннее типичного. Планировать имеет смысл не прочтение страницы целиком, а то, что из неё вытащат один самодостаточный абзац.
Всё началось с того, что в логах моего замера видно, какие URL Perplexity ставит в источники, но не видно, что именно со страницы поехало в ответ — весь текст, абзац или одна строка списка.
Perplexity показывает, какие страницы взял в источники, но не показывает, какой текст с них
У каждого источника в ответе Search API возвращается пять полей: title, url, date, last_updated и snippet — «короткая выдержка из статьи, относящаяся к запросу». Поля с полным текстом страницы, который ушёл в модель, в ответе нет вообще. Отдельная оговорка про имена: sources_cited, которое встречается дальше по тексту, — это поле моего лога, куда я нормализую источники ответа; в самом API Perplexity они приходят как search_results и citations. Путать удобное имя из своего кода со схемой чужого ответа — хороший способ потом полчаса искать несуществующее поле в документации.
Сопоставить два факта — что источник процитирован и что именно из него взято — руками нереально: 60 ответов дают больше тысячи номерных пометок.
Датасет: 60 ответов Perplexity (20 промптов по 3 повтора), модель sonar-pro, поиск включён у всех. В них 583 строки в sources_cited, за которыми стоит 220 уникальных URL, и 1407 номерных отсылок [N] в текстах ответов. После отсева коротких обрубков вроде «см. также» без содержательного предложения рядом в разбор ушло 1355 отсылок.
В API Perplexity есть лимит текста с одной страницы, но как её режут, не описано
Работа с фрагментом страницы, а не с её полным текстом, заложена в интерфейс Perplexity Search API впрямую: у клиента есть ручка на то, сколько токенов брать с одной найденной страницы. Параметр search_context_size со значениями low / medium / high управляет глубиной извлечения. У max_tokens_per_page формулировка в документации прямая — максимум токенов содержимого, извлекаемых с каждой результирующей страницы, — а рядом стоит max_tokens, тот же лимит общим счётом на все результаты сразу.
Как эти ручки соотносятся с тем, что реально долетает до ответа, документация не раскрывает: там не описано ни то, как выбирается кусок, ни то, на отрезки какого размера режется страница, ни правило самой нарезки. Всё остальное я увидел снаружи, сопоставляя текст ответа с текстом источника напрямую.
Как я делил страницу на абзацы и сравнивал их с ответом: код на Python
Страницу источника я режу не по переводам строк, а по закрывающим тегам </p>, </li>, </h1>–</h6>, </td>, </blockquote>. Перенос строки в вёрстке не значит ничего, а закрытый тег абзаца, пункта списка или ячейки — это граница единицы смысла. Правило целиком лежит в одной константе, и от неё напрямую зависит, сколько пассажей окажется на странице:
# Границы пассажа — только теги, за которыми стоит единица смысла: абзац, пункт
# списка, заголовок, ячейка, цитата. `</div>` сюда не входит намеренно: вёрстка
# оборачивает в div и обёртку, и её содержимое, поэтому один и тот же текст
# попадает в выборку дважды, а счётчик пассажей раздувается.
TAG_SPLIT = re.compile(r"</(?:p|li|h[1-6]|td|blockquote)>", re.I)
WORD = re.compile(r"[а-яёa-z0-9]+", re.I)
def passages(html):
"""Страница → список видимых кусков: абзац, пункт списка, заголовок, ячейка."""
body = re.sub(r"<(script|style|noscript|svg)\b.*?</\1>", " ", html, flags=re.S | re.I)
head = re.search(r"<body\b.*", body, flags=re.S | re.I)
body = head.group(0) if head else body
out = []
for chunk in TAG_SPLIT.split(body):
txt = htmllib.unescape(re.sub(r"<[^>]+>", " ", chunk))
txt = re.sub(r"\s+", " ", txt).strip()
if len(txt.split()) >= 5:
out.append(txt)
return outКуски короче пяти слов на этом шаге и короче восьми на шаге сравнения выброшены — это меню, хлебные крошки и одиночные кнопки, чистый шум. Слова я привожу к основе грубой обрезкой: отбрасываю до двух последних букв, но не короче четырёх символов, стоп-слова выкидываю.
def stems(text):
s = set()
for w in WORD.findall(text.lower().replace("ё", "е")):
if w in STOP or len(w) < 4:
continue
s.add(w[:max(4, len(w) - 2)])
return s
def similarity(a, b):
"""Доля слов утверждения, которые нашлись в пассаже.
Нормируем на длину предложения ответа, а не на длину пассажа: иначе выигрывают
короткие куски вроде пунктов меню — три общих слова из трёх дают единицу.
"""
if not a or not b:
return 0.0
return len(a & b) / len(a)Для каждого предложения ответа с отсылкой [N] я перебираю все кусочки текста страницы N и беру тот, у которого совпадение по основам слов максимальное. Порог вынесен во флаг запуска: разбор имеет смысл смотреть кривой по нескольким порогам, а не одним числом, — что и видно по следующей таблице.
Perplexity пересказывает, а не копирует: почти дословно совпали 12 фраз из 948
Из 948 отсылок, ведущих на открывшиеся страницы, половину слов утверждения и больше набирают 158 — 16,7%:
Покрытие утверждения одним пассажем | Отсылок | Доля |
|---|---|---|
≥ 0,2 | 706 | 74,5% |
≥ 0,3 | 448 | 47,3% |
≥ 0,4 | 253 | 26,7% |
≥ 0,5 | 158 | 16,7% |
≥ 0,6 | 70 | 7,4% |
≥ 0,7 | 27 | 2,8% |
≥ 0,8 | 12 | 1,3% |
Медиана покрытия по всем 948 парам — 0,286, среднее — 0,309. Типичное утверждение ответа собрано примерно из четверти своих слов, найденных в одном абзаце источника; остальное — формулировка модели или второй источник рядом. Дословного переноса почти нет: совпадение на 80% слов и выше дают 12 отсылок из 948, чуть больше процента. Нейросеть Perplexity пересказывает, а не копирует.
В ответ уходит абзац около 50 слов, и лежать он может в любой части страницы
Кусок текста, который реально совпал с утверждением, — это абзац, а не строчка навигации. В подвыборке от 0,3 (448 отсылок) его медианная длина 49 слов, квартили 29 и 85. При более строгом пороге 0,5 (158 отсылок) медиана 52 слова, квартили 34 и 146 — верхний квартиль уезжает вверх за счёт длинных табличных ячеек.
Показатель | Порог 0,3 (n=448) | Порог 0,5 (n=158) |
|---|---|---|
Длина сработавшего пассажа, слов (25% / медиана / 75%) | 29 / 49 / 85 | 34 / 52 / 146 |
Пассажей на самой странице: медиана (среднее) | 85 (101,3) | 76,5 (90,7) |
Задействовано пассажей на странице, медиана | 2 | 1 |
Страниц, где нашёлся хоть один такой пассаж | 109 из 146 | 66 из 146 |
Отсюда и число из заголовка: у страниц, где матч нашёлся, медиана нарезки — 85 пассажей, в дело идут два. По всем 146 открывшимся страницам медиана чуть ниже, 80. Знаменатель здесь — 146 страниц, которые открылись из 194 участвующих в разборе; все 220 уникальных URL из sources_cited в эту статистику не входят, потому что на часть из них ни одна номерная отсылка не указывает.
Медиана позиции — 0,402 от начала документа при пороге 0,3 и 0,421 при 0,5. В первой трети страницы лежит 41,7% совпадений (порог 0,3) и 40,5% (порог 0,5). Перевес начала есть, но он в пределах двух раз, а не «читается только первый экран»: рабочие куски находятся по всей длине документа, включая последнюю треть.
Причём часть этого перевеса — не про поведение модели, а про то, чем набит хвост типовой страницы. В последних децилях лежат футер, блок перелинковки, форма подписки и контакты. Совпасть с содержательным утверждением ответа они не могут в принципе, поэтому провал последнего дециля (11 совпадений против 55 в первом) я бы не считал чистой мерой того, как глубоко Perplexity читает документ.

Как работать с Perplexity, чтобы он цитировал страницу: каждый абзац понятен без соседних
Единица, которой оперирует Perplexity в моём разборе, — абзац на 30–50 слов, понятный без соседей. Не страница, не раздел и не полезный контент вообще. Абзац, который попадёт в ответ, должен держаться сам: без местоимений, отсылающих наверх по тексту, без «как я писал выше» и без указательных оборотов на месте названия. Факт, цифра и условие, при котором цифра верна, — в одном куске текста между двумя </p>.
Объём работы тут меньше, чем кажется, когда речь заходит о переписывании страниц. На сработавшей странице задействовано медианно два пассажа из 85 — цитируют не весь текст, а те два-три абзаца, где лежит фактический ответ на вопрос. Начинать имеет смысл с них, а не с равномерной вычитки сайта: найти на странице абзац, ради которого её вообще могут процитировать, и сделать его самодостаточным.
Позиция помогает, но не решает. В первой трети документа лежит 41,7% совпадений, в последней — 21,9% (в последних трёх децилях — 19%), и часть этого разрыва объясняется тем, что в хвосте страницы стоит футер, а не тем, что туда не доходят. Обещать удвоение шанса за один перенос абзаца вверх я бы не стал: 11 совпадений из 448 нашлись в самом последнем дециле, 30 — в предпоследнем. Хоронить конец страницы не нужно, достаточно не прятать туда главное.
Две ошибки в моём коде меняли результат сильнее, чем сами данные
Первая — в знаменателе метрики. Первая версия функции сравнения отличалась от рабочей ровно одной строкой:
def similarity(a, b):
"""a — основы слов утверждения ответа, b — основы слов пассажа страницы."""
if not a or not b:
return 0.0
return len(a & b) / len(b) # рабочая версия делит на len(a)Доля слов пассажа, подтвердившихся в утверждении ответа. Я перезапустил эту версию на тех же логах и тех же страницах, чтобы числа не остались на память: при пороге 0,5 старая формула находит матч для 139 отсылок из 948 — 14,7%, ровно тот диапазон, который я и ждал увидеть. Правдоподобно. Но медианная длина найденного пассажа в этой выборке — 9 слов, а 124 матча из 139 короче 12 слов. Абзац в девять слов на живой странице — это подпись, пункт меню или заголовок раздела, из него нельзя взять целое утверждение.
Дальше я вытащил такие строки руками, и паттерн оказался один. Заголовок H2 на странице rookee.ru про выбор брендов нейросетями — «Может ли AI всегда рекомендовать один и тот же бренд?», десять слов — старая метрика засчитала лучшим источником для четырёх разных предложений из разных ответов, один раз со сходством 1,0. Ни одно из четырёх не пересказывало вопрос из заголовка: они говорили про упаковку бренда как сущности, про внешние сигналы доверия, про сценарии использования. Заголовок просто содержал общие слова темы — бренд, рекомендовать, AI, — и этого хватало, чтобы обыграть по счёту настоящие абзацы. Всего таких идеальных единиц набралось 12, и среди них прайс-строки вроде «02 GEO в нейросетях NEW от 80 000 ₽/мес».
Причина арифметическая. len(a & b) / len(b) делит число общих основ на длину пассажа, и чем пассаж короче, тем легче ему набрать высокую долю: у заголовка из десяти основ два-три совпадения дают 0,3 и выше. У настоящего абзаца из полусотни слов те же два-три совпадения размываются длинным знаменателем. Метрика искала не пассаж, который отвечает на утверждение, а самый короткий пассаж, где нашлось хоть что-то общее. После правки знаменателя на длину предложения ответа медиана длины сработавшего пассажа при том же пороге 0,5 выросла с 9 слов до 52, а число матчей — со 139 до 158. Воспроизвести старый результат может кто угодно: поменять len(a) обратно на len(b) и прогнать заново.

Вторая ошибка про другое. В первой версии константы TAG_SPLIT среди границ пассажа стоял </div>, и это неверно по смыслу: div не единица содержания, а контейнер вёрстки. Страница оборачивает в div и блок, и его содержимое, поэтому один и тот же текст попадал в выборку несколько раз, а счётчик пассажей рос. На 146 открывшихся страницах медиана числа пассажей падает с 94,5 до 80, как только div уходит из регулярки. Медианная страница меняется мало — отношение 1,05, — но на вёрстке с глубокой вложенностью разница в разы: у ai.pixeltools.ru 15 пассажей против 185 со старым правилом, у одной из страниц megagroup.ru — 14 против 107. Таких страниц, где старое правило раздувало счёт вдвое и сильнее, десять из 146.
Отсюда важная оговорка к 85-и кусочкам текстов на странице: эта цифра измеряет мою нарезку, а не устройство Perplexity. Сравнивать его с чужими разборами без сверки правила резки бессмысленно — сойдутся два разных числа об одном и том же тексте.
Через месяц почти треть ссылок из ответов Perplexity ведёт на мёртвые страницы
Из 194 URL, участвующих в разборе, сегодня открылись 146 — остальные 48 отдают 404, редирект в заглушку или таймаут. В пересчёте на отсылки это 407 из 1355: почти треть номерных пометок в ответах указывает на страницу, которой уже нет. Если брать все 220 уникальных URL из sources_cited, а не только те, на которые ссылаются отсылки, картина та же — открылось 167.
Между замером и проверкой прошёл ровно месяц: 17 августа я снял ответы и источники, 18 сентября выкачал HTML для разбора. Ссылка внутри ответа Perplexity не архивируется вместе с ответом, она живёт ровно столько, сколько живёт страница.
Практический вывод тут не для автора страниц, а для тех, кто сам гоняет такие замеры: HTML источников надо сохранять в момент прогона. Через месяц треть выборки просто не откроется, и восстановить её будет неоткуда.
Что нужно понимать. Эти данные — всего один прогон на русскоязычных коммерческих запросах. На других языках, в другой тематике и на другой вёрстке распределение может выглядеть иначе, второго прогона для сравнения у меня нет. Проверить на своей нише несложно: нужны ответы с номерными отсылками, HTML источников, снятый сразу, и тридцать строк кода из этой статьи — весь метод здесь и состоит из нарезки по тегам и одной дроби.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.