- BrainTools - https://www.braintools.ru -
Привет, по специальности своей я маркетолог, большую часть карьеры работаю с digital‑продвижением и продуктами в сфере недвижимости. Но в последний год начал активно погружаться в сторону AI: созданием цифровых продуктов с помощью ИИ, автоматизацией процессов и тем, как бренды вообще становятся видимыми в ответах ИИ. И как раз таки так получилось, что на последнем направлении я остановился чуть подробнее.
Вокруг GEO‑продвижения слишком много разговоров о том, как попасть в ответы нейросетей, какие сайты любят ИИ и что нужно сделать для того, чтобы бренд чаще рекомендовали в выдаче. Но чем больше я погружался в тему, тем сильнее меня смущало то, что большую часть таких выводов делают по нескольким ручным запросам. Спросили нейросеть 5–10 раз, увидели несколько знакомых брендов и уже делают выводы о видимости компании.
Мне хотелось понять, можно ли подойти к этому чуть строже. Не просто опросить ИИ, какие сервисы недвижимости они знают, а построить небольшой эксперимент: использовать одинаковые пользовательские сценарии, несколько систем, повторять [1] одни и те же запросы, сохранять исходные ответы и отдельно разбирать рекомендации, их порядок и источники. На первый взгляд задача казалась довольно прямой: взять несколько ИИ‑систем, задать им одинаковые вопросы, сохранить ответы и посчитать, какие платформы они рекомендуют чаще. На практике именно с этого момента всё стало значительно интереснее, и проблемы начались сразу после получения первых тестов:
Что считать рекомендацией, если бренд упомянут в примере, но не советуется пользователю?
Как отличить платформу «M2» от фразы «цена за м2»?
Что делать, если три сервиса перечислены в одном пункте без внутреннего порядка?
Можно ли считать ссылку источником рекомендации, если провайдер вообще не сообщил, к какому фрагменту ответа она относится?
В итоге из небольшого эксперимента вырос отдельный исследовательский пайплайн на Python.
Сначала я заранее зафиксировал протокол сбора данных: исследовательский вопрос, набор AI‑систем, пользовательские запросы, количество повторов и основные метрики. После тестового запуска я собрал 100 API‑ответов и сохранил как структурированные данные в SQLite, так и оригинальные JSON‑ответы. После этого пришлось отдельно находить названия платформ в текстах, объединять разные варианты написания одного бренда, отличать реальные рекомендации от простых упоминаний и вручную проверять неоднозначные случаи. Финальная версия данных была зафиксирована по SHA-256 и проверялась повторной сборкой байт в байт. Отдельно считалась устойчивость рекомендаций между повторами и обрабатывались 775 ссылок на источники. Чтобы все эти правила не ломались при изменениях кода, к моменту подготовки этой статьи test suite вырос до 1197 проходящих pytest‑тестов в 49 файлах.
В этой статье я не разбираю сами результаты исследования AI‑видимости российских сервисов недвижимости, их я опубликовал отдельно. Здесь речь именно о технической части: какие инженерные и методологические проблемы возникали по пути и какие решения помогли не превратить анализ стохастических AI‑ответов в красивую, но нереплицируемую таблицу.
Сам пайплайн я разрабатывал в AI‑assisted режиме: использовал Claude Code для реализации и итераций, а архитектуру исследования, методологические правила, проверки и финальные решения контролировал отдельно.
В эксперименте участвовали четыре конкретные API‑системы:
anthropic/claude-sonnet-5
openai/gpt-5.6-terra
perplexity/sonar-pro-search
x-ai/grok-4.5
Все запросы шли через OpenRouter. Я сознательно не смешивал это с потребительскими интерфейсами ChatGPT, Claude, Perplexity или Grok: веб‑приложение и конкретная API‑конфигурация — разные объекты наблюдения.
Было 10 пользовательских сценариев: общий поиск квартиры по России, надёжность сервисов, первая покупка, новостройки Москвы, вторичный рынок Санкт‑Петербурга, Екатеринбург, дистанционный переезд, риск пошенничества, поиск по карте и фильтрам и сценарий «какими 2–3 платформами пользоваться одновременно».
5 запросов запускались по три раза для каждой системы, ещё 5 по два:4 AI-системы × (5 запросов × 3 повтора + 5 запросов × 2 повтора) = 100 research-наблюдений.
Все 100 исследовательских запросов завершились без ошибок и ретраев. Стоимость именно research‑сбора составила $5.1841.
На уровне архитектуры пайплайн выглядел так: Research protocol → Preflight → Canary → Collection → Raw SQLite + JSON → Observation validation → Mention extraction → Entity resolution → Occurrence / morphology audit → Recommendation proposal engine → Calibration + holdout → Full adjudication → Order resolution → Frozen coding dataset → Baseline metrics → Stability metrics → Citation/source coding → Frozen source dataset → Source metrics → Integrated outputs / figures.
Важный принцип: сетевой этап заканчивался после collection. Всё дальнейшее (разметка, валидация, метрики, воспроизведение и графики) выполнялось офлайн по сохранённым данным.
Для каждого наблюдения мне нужно было знать, какая именно модель и какой endpoint дали ответ. Поэтому provider pinning включался явно, fallback отключался, а OpenRouter‑уровневый массив models вообще не отправлялся.
Фрагмент реального build_chat_payload:
drop = set(slot.unsupported_parameters)
payload: dict[str, Any] = {
"model": slot.model_slug,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query_text},
],
"stream": False,
# Ask OpenRouter to report generation cost inline so the budget guard uses actual spend.
"usage": {"include": True},
}
if "max_tokens" not in drop:
payload[slot.max_output_tokens_param] = effective_max_output_tokens(protocol, slot)
provider: dict[str, Any] = {
"allow_fallbacks": slot.allow_fallbacks and protocol.allow_model_fallbacks,
"require_parameters": slot.require_parameters and protocol.require_endpoint_parameters,
}
if slot.provider_only:
provider["only"] = list(slot.provider_only)
if protocol.provider_zdr:
provider["zdr"] = True
payload["provider"] = provider
plan = build_web_search_plan(protocol, slot)
tool_entry = plan.tool_entry()
if tool_entry is not None:
payload["tools"] = [tool_entry]
payload.update(plan.request_fields)
# NOTE: no `models` key is ever set. That array is OpenRouter's model-level fallback
# mechanism, and this study must attribute every answer to exactly one requested model.
return payload
Для систем, поддерживавших server‑side web search через OpenRouter, использовался openrouter:web_search с engine=native, приблизительной локацией RU / Europe/Moscow, tool_choice="required" и max_tool_calls=3. Perplexity Sonar Pro Search работал со встроенным поиском, поэтому его поисковый контур отличался: количество внутренних поисковых обращений нельзя было независимо наблюдать тем же способом. Поэтому исследование сравнивает четыре AI‑системы в зафиксированных конфигурациях, а не четыре чистых LLM с идентичным retrieval backend.
Я не задавал temperature, top_p и seed генерации. В запросах использовались provider defaults, а конфигурация падала, если кто‑то пытался добавить sampling‑параметры. Это не делает модель детерминированной, наоборот, повторяемость как раз была отдельным объектом измерения. При этом sampling‑настройки между системами не стандартизировались: temperature, top_p и seed генерации не передавались, поэтому использовались значения по умолчанию конкретного провайдера. Это означает, что сравнение повторяемости между системами относится к AI‑системе в целом в данной конфигурации, модели, provider defaults и поискового контура, а не измеряет чистую стохастичность underlying LLM при одинаковых sampling‑параметрах.
Retry policy тоже пришлось формализовать. Если сервер уже сгенерировал завершённый платный ответ, повторять запрос на всякий случай будет плохой идеей: вы получите второе наблюдение вместо технического ретрая и ещё раз заплатите. Поэтому ретраи разрешены только на транспортные ошибки [2], таймауты и явно перечисленные retryable status codes:
def _attempt(
self,
method: str,
path: str,
payload: dict[str, Any] | None,
counter: list[int],
) -> httpx.Response:
counter[0] += 1
try:
response = self._client.request(method, path, json=payload, headers=self._headers())
except httpx.TimeoutException as exc:
raise _RetryableTransportError(errors.TIMEOUT, f"request timed out: {exc}") from exc
except httpx.TransportError as exc:
raise _RetryableTransportError(errors.NETWORK, f"transport error: {exc}") from exc
if response.status_code in errors.RETRYABLE_STATUSES:
raise _RetryableStatusError(response)
# Any other completed response is final. Never retried.
return response
tenacity был настроен максимум на четыре попытки с exponential jitter. Но в финальных 100 research‑наблюдениях ретраи не понадобились.
Основная база данных проекта — SQLite, на момент исследования схема дошла до 13-й версии и включала 19 таблиц. В ней хранились не только сами API‑наблюдения, но и промежуточные результаты обработки: найденные в ответах упоминания брендов, предварительная автоматическая классификация этих упоминаний, финальные решения после проверки, история исправлений и данные, позволяющие восстановить происхождение каждого результата.
Но одной записью в базе я не ограничивался. Для каждого запуска дополнительно сохранялся отдельный JSON‑файл с полным запросом и ответом API, служебной информацией об использовании модели, ссылках, попытках запроса и причине завершения генерации. Файл создавался через режим open("x"): если файл с таким именем уже существовал, программа падала с ошибкой вместо того, чтобы молча перезаписать исходные данные.
API‑ключ перед сохранением заменялся на ***REDACTED***. Это оказалось полезно позже: если автоматическую разметку приходилось перепроверять, я мог вернуться не к уже обработанному тексту, а к оригинальному ответу API.
Каждый из десяти пользовательских запросов получил собственный ID — от RU_001 до RU_010. Например, RU_004 соответствовал сценарию поиска квартиры в новостройке в Москве. Для каждого запуска формировался детерминированный run_id: первые 32 символа SHA-256 от комбинации manifest_id | model_slot | query_id | repetition. Благодаря этому конкретный повтор конкретного запроса для конкретной модели имел стабильную идентичность независимо от порядка выполнения.
Сам порядок запросов при сборе данных при этом специально перемешивался алгоритмом Fisher‑Yates с фиксированным seed 20260101, а фактическая позиция сохранялась в поле random_order.
Наивный подход выглядит так:
if "ЦИАН" in response:
...
Он перестаёт работать почти сразу. Во‑первых, есть разные написания одного бренда: имя, домен, регистр, падежи. Во‑вторых, короткие названия конфликтуют с обычным языком. Пример:
M2 / m2.ru — платформа
"Метр квадратный" — связанное название, которое создаёт неоднозначность
metrtv.ru / "Квадратный метр" — вообще другой сервис
"цена за квадратный метр" — не бренд
А ещё есть «Этажи», которые могут быть названием компании или обычным множественным числом «этаж».
Просто найти название бренда в тексте оказалось недостаточно. Сначала система искала возможные упоминания по заранее подготовленному словарю вариантов написания: например, название бренда, домен и отдельные словоформы. Более длинные варианты проверялись раньше коротких, чтобы Яндекс Недвижимость не превращался просто в Яндекс. Ссылки, email‑адреса и Markdown‑разметка при этом исключались из поиска, чтобы не считать технические фрагменты текста упоминаниями бренда.
От более умного нечёткого поиска отказался. В русском языке это легко даёт ложные совпадения: короткое название бренда может оказаться обычным словом или частью другого слова. Поэтому вместо автоматического поиска по похожим формам я заранее добавлял в словарь только те падежные формы, которые действительно нужно было распознавать, и отдельно проверял их на ошибочные совпадения.
Для неоднозначных названий понадобилась дополнительная проверка контекста. Если найденное слово могло быть и брендом, и обычным выражением, система отдельно проверяла, что именно имеется в виду в конкретном фрагменте ответа. Например, М2 считался потенциально неоднозначным: если рядом были признаки площади вроде «цена за м²» и не было явных указаний на сервис или домен, такое совпадение не принималось за упоминание бренда.
# A canonical name that two brands share.
if surface in {k.casefold() for k in BRAND_NAME_COLLISIONS}:
owners = next(v for k, v in BRAND_NAME_COLLISIONS.items() if k.casefold() == surface)
other = next((o for o in owners if o != brand), owners[1])
other_domains = BRAND_DOMAIN_EVIDENCE.get(other, ())
if other_domains and _contains(block, other_domains):
occurrence.audit_verdict = VERDICT_AMBIGUOUS_BRAND
occurrence.audit_reason = (
f"'{occurrence.raw_brand_text}' is a name both {owners[0]} and {owners[1]} use, "
f"and the block names {other}'s domain; which platform this is cannot be settled "
"by the dictionary"
)
return
if surface not in COLLISION_PRONE:
return
# A collision-prone token. Something has to say "platform".
storeys = _contains(local, STOREY_CONTEXT) if surface.startswith("этаж") else []
area = _contains(local, AREA_CONTEXT) if surface in {"м2", "m2"} else []
generic = storeys or area
if has_domain or is_item_head:
return
if generic and not has_strong_platform_word:
occurrence.audit_verdict = VERDICT_GENERIC
occurrence.audit_reason = (
f"'{occurrence.raw_brand_text}' here is the ordinary word, not the platform "
f"({', '.join(generic[:3])} in the block, and nothing names a service)"
)
return
Смысл здесь важнее конкретного кода: словарь не имеет права самостоятельно принимать финальное решение там, где поверхностная форма неоднозначна.
На последнем проходе экстрактор нашёл 738 кандидатов на упоминания брендов. Но напрямую сравнивать эту цифру с финальными 476 решениями нельзя: дальше менялась сама единица данных. Кандидаты проходили нормализацию брендов и контекстную проверку; отдельные найденные фрагменты объединялись в канонические единицы ответ × бренд; затем уже эти единицы классифицировались по роли бренда в ответе.
Поэтому это не воронка вида 738 найдено, 262 удалено, 476 осталось. На разных стадиях считались разные сущности. В качестве контрольных точек пайплайна были 738 кандидатов экстрактора, 1369 проверяемых occurrence‑спанов, 483 аудированных канонических единицы, 480 предварительных предложений шестой версии rule engine и 476 финальных решений после последующей проверки и исключений.
Финальные 476 — это уже уникальные пары конкретный API‑ответ × конкретный нормализованный бренд. Если ЦИАН несколько раз встречался в одном ответе, в финальном датасете это всё равно одна такая пара.
Даже после того как бренд найден правильно, остаётся более важный вопрос: что именно AI‑система говорит о нём? Само появление названия в тексте ещё не означает, что сервис рекомендуют пользователю. Например, это три совершенно разные ситуации:
Рекомендую ЦИАН и Домклик…
В отличие от ЦИАН, этот сервис…
Среди известных площадок можно назвать…
Поэтому каждую пару ответ × бренд я относил к одной из пяти категорий:
Явная рекомендация (explicit_recommendation) — система прямо советует использовать платформу.
Входит в рекомендованный список (included_in_recommended_list) — бренд включён в перечень подходящих сервисов, даже если рядом нет отдельной фразы «я рекомендую».
Нейтральное упоминание (neutral_mention) — бренд назван в тексте, но система не рекомендует и не отговаривает от него.
Негативное упоминание (negative_or_discouraged) — система явно не советует платформу или предупреждает против её использования.
Неопределённый случай (uncertain) — по тексту нельзя было уверенно понять роль бренда.
Для расчёта частоты рекомендаций использовались только первые две категории.
После финальной проверки распределение получилось таким:
|
Явная рекомендация |
118 |
|
В рекомендованном списке |
334 |
|
Нейтральное упоминание |
24 |
|
Негативное упоминание |
0 |
|
Неопределённый случай |
0 |
|
Всего |
476 |
Таким образом, 452 из 476 пар ответ × бренд были рекомендациями, а ещё 24 случая сохранились как нейтральные упоминания и в показатели Recommendation Share не попадали.
Чтобы не размечать сотни упоминаний полностью вручную с нуля, я сделал набор правил, который пытался определить роль бренда в ответе: это явная рекомендация, элемент рекомендованного списка или просто нейтральное упоминание. Правила постепенно дорабатывались и в итоге прошли шесть версий.
Но здесь было важно не проверять систему на тех же примерах, по которым она настраивалась. Поэтому часть размеченных данных использовалась для настройки правил, а отдельные 60 случаев я заранее отложил для контрольной проверки.
Из 60 отложенных случаев 59 можно было оценить по имеющимся данным. Для одного случая информации в экспортированном фрагменте оказалось недостаточно, поэтому он не вошёл в знаменатель этой проверки и потребовал отдельного обращения к исходному ответу. В результате 26 из 59 оцениваемых случаев, примерно 44%, потребовали существенного изменения или исключения предварительного решения. Это не означает, что точность алгоритма составила 56%. Такая интерпретация была бы некорректной: контрольная выборка использовалась прежде всего для ответа на практический вопрос можно ли уже доверить этому набору правил финальную разметку без ручной проверки? Ответ оказался отрицательным.
После этого я не стал дальше усложнять rule engine. Его результаты остались только предварительными предложениями для разметки.
Дальнейшая проверка тоже проходила с помощью AI‑ассистента: оставшиеся единицы были разобраны отдельными партиями, а затем сведены в единый пакет решений. После этого пакет прошёл техническую проверку на противоречия, порядок рекомендаций и соответствие исходным ответам, а финальная версия была утверждена мной как автором исследования. Только после этого решения записывались в финальный датасет и разрешался расчёт метрик.
На этапе ручной проверки возникла ещё одна проблема: что делать, если уже сохранённое решение позже оказалось неправильным? Самый простой вариант — выполнить обычный UPDATE в базе. Но тогда старая версия исчезнет. Через неделю уже невозможно будет понять, почему раньше метрика считалась иначе и какое именно решение было исправлено.
Поэтому финальная разметка хранилась по принципу append‑only: существующую запись нельзя изменить или удалить. Если решение нужно исправить, создаётся новая строка, которая явно указывает, какую предыдущую версию она заменяет.
Это ограничение было реализовано не только на уровне договорённости в коде, а непосредственно в SQLite через триггеры:
DROP TRIGGER IF EXISTS coding_decisions_block_update;
CREATE TRIGGER coding_decisions_block_update
BEFORE UPDATE ON coding_decisions
BEGIN
SELECT RAISE(ABORT, 'coding_decisions is append-only: a corrected decision is a new row '
|| 'with supersedes_decision_id set, never an edit of the original');
END;
DROP TRIGGER IF EXISTS coding_decisions_block_delete;
CREATE TRIGGER coding_decisions_block_delete
BEFORE DELETE ON coding_decisions
BEGIN
SELECT RAISE(ABORT, 'coding_decisions is append-only: decisions cannot be deleted');
END;
Поле supersedes_decision_id связывает новую запись с той, которую она исправляет. В результате история решений сохраняется целиком: можно восстановить не только текущее состояние разметки, но и последовательность её изменений.
Тот же принцип использовался и для других частей исследования. Например, если первоначальная оценка API‑ответа оказывалась неверной, старая запись не переписывалась — поверх неё добавлялась новая версия оценки.
Не каждый список в ответе AI‑системы можно честно превратить в места № 1, № 2 и № 3. Например:
Для проверки объявлений подойдут ЦИАН, Домклик и Яндекс Недвижимость.
Из этой фразы понятно, что система рекомендует все три платформы. Но нельзя уверенно утверждать, что ЦИАН здесь действительно поставлен на первое место, Домклик — на второе, а Яндекс Недвижимость — на третье. Это может быть просто перечисление равноправных вариантов. Поэтому для таких случаев я решил не придумывать порядок там, где его нельзя доказать. Если структура ответа не позволяла однозначно определить место бренда, рекомендация сохранялась без ранга.
Это правило было зафиксировано и в коде: запись либо имеет подтверждённый порядковый номер, либо явно помечается как неупорядоченная. Состояния вроде «порядок неизвестен, но число всё равно записано» не допускаются.
def __post_init__(self) -> None:
if self.status not in RESOLUTION_STATUSES:
raise ValueError(f"unknown resolution status {self.status!r}")
if self.is_resolved and self.order is None:
raise ValueError(f"{self.run_id}/{self.brand}: resolved but carries no order")
if not self.is_resolved and self.order is not None:
raise ValueError(
f"{self.run_id}/{self.brand}: {self.status} must not assert an order ({self.order})"
)
if not self.anchor:
raise ValueError(f"{self.run_id}/{self.brand}: a resolution needs an anchor")
После финальной проверки точный порядок удалось определить для 446 из 452 рекомендаций. Ещё шесть рекомендаций остались без ранга. Они учитывались при подсчёте общей частоты рекомендаций, но не участвовали в метриках, где требовалась конкретная позиция.
После завершения разметки нужно было определить конкретную версию данных, на которой считаются все опубликованные результаты. Просто написать, что это финальный CSV недостаточно. Файл можно случайно изменить, пересохранить или пересобрать другой версией кода, и внешне он останется файлом с тем же названием. Поэтому для финального CSV я вычислил SHA-256 — криптографический отпечаток файла. SHA-256 здесь работает как цифровой отпечаток: если в файле изменится хотя бы один байт, хэш станет другим.
В сам CSV я специально не добавлял изменяемые комментарии или служебную информацию: всё, что не относится непосредственно к данным, хранилось отдельно. Иначе изменение такого комментария тоже меняло бы SHA-256 файла.
Для финальной версии данных я хотел фиксировать не только SHA-256 самого CSV, но и версию кода, которой этот файл был создан. Для этого при экспорте сохранялся текущий Git commit. И здесь обнаружилась неприятная проблема.
В момент одного из финальных запусков часть изменений уже находилась в рабочей директории, но ещё не была закоммичена. Команда git rev-parse HEAD корректно возвращала последний commit, однако фактически расчёт выполнялся более новой версией кода. Получалась опасная ситуация: служебный файл уверенно утверждает, что датасет построен определённым commit, хотя часть исполнившегося кода в этот commit ещё не входила.
Сам финальный датасет после этого я не переписывал. Вместо изменения истории отдельно зафиксировал расхождение: какой commit был записан первоначально, какая версия кода фактически использовалась и какой SHA-256 имел уже опубликованный набор данных. Но главное изменение было другим: я решил, что одной записи с номером commit недостаточно. Воспроизводимость нужно проверять исполнением.
Для этого финальный CSV заново собирался из сохранённых решений во временную директорию, после чего новый файл сравнивался с зафиксированной версией байт в байт:
decisions = storage.coding_decisions(result.coding_batch_id)
runs = {str(r["run_id"]): r for r in result.runs}
with tempfile.TemporaryDirectory() as scratch:
rebuilt = write_final_coding_csv(
snapshot_rows(decisions, runs),
Path(scratch) / "rebuilt.csv",
)
reproduction = Reproduction(
attempted=True,
reproduced=rebuilt.read_bytes() == frozen.read_bytes(),
expected_sha256=sha256_file(frozen),
rebuilt_sha256=sha256_file(rebuilt),
decisions_read=len(decisions),
)
Проверялось не просто количество строк или совпадение рассчитанных метрик. Повторная сборка должна была создать точно тот же файл. Если менялся хотя бы один байт, проверка считалась проваленной. Дополнительно SHA-256 финального CSV был зафиксирован непосредственно в regression‑тесте. Поэтому случайное изменение файла не могло незаметно стать новой правильной версией: тест просто падал.
После фиксации финальной разметки можно было перейти к другой части исследования — повторяемости ответов. Один и тот же запрос к генеративной системе не обязан возвращать абсолютно одинаковый список платформ. Поэтому мне хотелось измерить не только частоту рекомендаций, но и насколько похожи ответы одной системы при повторении одного и того же вопроса.
Пять пользовательских сценариев запускались по три раза для каждой AI‑системы. Если есть три ответа, их можно сравнить тремя способами:
ответ 1 ↔ ответ 2
ответ 1 ↔ ответ 3
ответ 2 ↔ ответ 3
Ещё пять сценариев запускались по два раза — там получалась одна пара. Всего:
5 запросов × 4 системы × 3 сравнения = 60
5 запросов × 4 системы × 1 сравнение = 20Итого: 80 попарных сравнений
Для сравнения полного состава рекомендаций я использовал коэффициент Jaccard. Предположим, первый ответ содержит:
ЦИАН
Домклик
Авито
Яндекс Недвижимость
А второй:
ЦИАН
Домклик
Яндекс Недвижимость
Этажи
Совпадают три платформы. Всего в двух списках пять уникальных платформ. Значит:
Формально: Jaccard = размер пересечения / размер объединения
Если оба набора полностью одинаковые, значение равно 1. Если общих платформ вообще нет — 0. Реализация получилась почти тривиальной:
def jaccard(a: frozenset[str], b: frozenset[str]) -> float:
union = a | b
if not union:
return 1.0
return round(len(a & b) / len(union), SHARE_DP)
Отдельно пришлось заранее решить редкий случай, когда оба ответа не содержат ни одной рекомендации. С точки зрения [3] состава это два одинаковых пустых множества, поэтому для Jaccard я считаю их совпадение равным 1.0. Если один список пустой, а другой нет, результат естественным образом равен 0.
У Jaccard есть особенность: он вообще не учитывает порядок. Например:
1. ЦИАН 2. Домклик 3. Авито
и
1. Авито 2. ЦИАН 3. Домклик
для Jaccard полностью идентичны — набор брендов один и тот же. Но для пользователя верх ответа может иметь отдельное значение. Поэтому дополнительно я считал совпадение первой тройки рекомендаций.
Если в двух ответах в Top-3 находятся те же три бренда, показатель равен 1, даже если они переставлены местами. Если совпали два из трёх — примерно 0,67, один — 0,33, ни одного — 0. То есть две метрики отвечают на разные вопросы:
Jaccard — насколько сохраняется весь набор рекомендуемых платформ;
совпадение Top-3 — насколько сохраняется состав верхней части ответа.
Здесь есть важный крайний случай. Если оба ответа не содержат рекомендаций, их наборы совпадают полностью и Jaccard равен 1. Но Top-3 overlap для такой пары равен 0, потому что никакой верхней тройки в обоих ответах нет. В выборке было три таких сравнения. Эти две метрики намеренно отвечают на разные вопросы.
Здесь появилась ещё одна небольшая статистическая ловушка. У запросов с тремя повторами есть по три попарных сравнения, а у запросов с двумя повторами — только одно. Если просто усреднить все 80 значений, пять запросов с тремя повторами получат втрое больший вес только из‑за схемы сбора данных. Поэтому я считал результат в два этапа.
Сначала все сравнения усреднялись внутри конкретной комбинации: AI‑система × пользовательский запрос. Таких комбинаций было 4 системы × 10 запросов = 40. И уже после этого рассчитывалось среднее по 40 полученным значениям.
Так каждый пользовательский сценарий для каждой системы влияет на итог одинаково — независимо от того, запускался он два или три раза. Именно это среднее дальше использовалось как основной показатель повторяемости.
При этом сами 80 попарных сравнений я не трактовал как 80 независимых статистических наблюдений: несколько сравнений могут использовать один и тот же исходный ответ. Они служат промежуточными элементами описательной метрики повторяемости, а основной единицей усреднения является комбинация AI‑система × пользовательский запрос. Проверка статистических гипотез и доверительные интервалы для этих 80 пар не рассчитывались.
После разбора рекомендаций я перешёл к источникам, которыми AI‑системы сопровождали ответы. Сначала казалось, что здесь всё просто: либо модель дала ссылки, либо нет. На практике разные провайдеры возвращали информацию об источниках по‑разному, а само количество ссылок оказалось не самой полезной единицей измерения.
В 100 собранных ответах было 775 отдельных ссылок на источники. После приведения адресов к единому виду они соответствовали 191 уникальному домену. Хотя бы одна ссылка присутствовала в 93 из 100 ответов.
Но здесь возникает проблема. Допустим, в одном ответе AI‑система сослалась на cian.ru [4] одиннадцать раз, а в другом — всего один. Если просто считать все ссылки, первый ответ даст этому домену вес в одиннадцать раз больше. Для анализа структуры источников это не всегда имеет смысл: меня интересовало в том числе само присутствие конкретного сайта в конкретном ответе. Поэтому данные сохранялись на двух уровнях. Первый — все 775 ссылок в том виде, в котором они были получены. Второй — уникальные сочетания ответ × домен, где повторные ссылки на один сайт внутри одного ответа объединялись.
После такого объединения получилось 628 уникальных пар ответ × домен:
775 — отдельных ссылок
191 — уникальный домен
628 — уникальных сочетаний ответ × домен
93 из 100 ответов содержали хотя бы одну ссылку
Например, если один ответ одиннадцать раз ссылается на cian.ru [4] и один раз на rbc.ru [5], на уровне отдельных ссылок соотношение будет 11 к 1. Но на уровне присутствия доменов в ответе это два сайта: cian.ru [4] и rbc.ru [5]. Для показателей, где нужно было анализировать именно структуру источников, использовался второй вариант. Исходные ссылки при этом никуда не удалялись и оставались доступны для проверки.
Это правило отдельно защищалось тестом:
def test_owned_source_share_uses_pairs_not_citation_counts() -> None:
units = [unit("r1", "ЦИАН")]
observations = [observation("r1")]
occurrences = [
occurrence("r1", i, "cian.ru", placement=True)
for i in range(11)
] + [
occurrence("r1", 11, "rbc.ru", placement=True)
]
response_domains = [
response_domain("r1", "cian.ru", 11),
response_domain("r1", "rbc.ru", 1),
]
brands, _ = metrics_of(
units,
observations,
occurrences,
response_domains,
[],
)
brand = one(brands, "ЦИАН")
assert brand.owned_response_domain_pairs == 1
assert brand.all_response_domain_pairs == 2
assert brand.owned_source_share == 0.5
То есть один ответ, который одиннадцать раз сослался на один и тот же сайт, не мог искусственно получить вес одиннадцати независимых ответов.
Следующая проблема возникла уже из‑за различий между API. В идеальном случае вместе со ссылкой провайдер сообщает, к какому конкретному фрагменту текста она относится. Для этого могут передаваться две координаты: где связанный со ссылкой фрагмент начинается и где заканчивается. Условно: Для поиска новостроек можно использовать ЦИАН [1].
Если API сообщает позицию [1], можно технически связать источник именно с этим фрагментом ответа. Но в собранных данных провайдеры вели себя по‑разному. У Anthropic и Perplexity координаты всех ссылок приходили как: start = 0 end = 0.
Формально два числа присутствуют. Но диапазон от нулевого символа до нулевого символа не содержит текста, поэтому интерпретировать его как реальную позицию ссылки нельзя. Я зафиксировал это непосредственно в коде:
def placement_available(self) -> bool:
"""A `(0, 0)` span is an absence of placement, not a position at the start of the text."""
if self.start_index is None or self.end_index is None:
return False
return self.end_index > self.start_index
После такой проверки различия между провайдерами стали довольно заметными:
Anthropic 0 из 156 ссылок с известной позицией
OpenAI 76 из 76
Perplexity 0 из 284
Grok 162 из 259
Всего: 238 из 775
Таким образом, только для 238 из 775 ссылок API передал достаточно информации, чтобы определить её точную позицию в тексте. Для остальных 537 ссылок можно было надёжно установить только одно: этот источник присутствовал в данном ответе.
Отсюда возникает важное ограничение. Если в ответе одновременно упоминается ЦИАН и присутствует ссылка на какой‑то сайт, этого ещё недостаточно, чтобы утверждать, что именно эта ссылка относится именно к рекомендации ЦИАН.
Технически можно было бы придумать эвристику: найти ближайшее к ссылке название бренда, посмотреть соседний абзац или попытаться восстановить соответствие по структуре текста. Но в таком случае исследовательский пайплайн начал бы создавать информацию, которой API на самом деле не предоставил. Поэтому правило было консервативным: если точная привязка ссылки к фрагменту текста отсутствовала, источник анализировался только на уровне всего ответа.
Это особенно важно при сравнении систем. Anthropic и Perplexity нельзя было присвоить нулевой показатель локальной связи источников только потому, что их API не предоставил необходимые координаты. Ноль означал бы, что связь проверили и не обнаружили. Здесь же её в большинстве случаев невозможно было проверить. Поэтому я разделял два разных утверждения:
Можно сказать: источник присутствовал в ответе, в котором рекомендовался бренд.
Нельзя автоматически сказать: эта конкретная ссылка относится именно к этой конкретной рекомендации.
Тем более по этим данным нельзя утверждать, что найденный источник стал причиной, по которой AI‑система решила рекомендовать бренд. Для такого вывода понадобился бы уже другой эксперимент.
Самой полезной частью разработки оказались не только решения, которые сразу заработали, но и ошибки, обнаруженные по пути. Некоторые из них выглядели совершенно безобидно: код выполнялся, файлы создавались, цифры казались правдоподобными.
Для ручной проверки я выгружал данные в CSV. Одно из полей содержало фрагмент оригинального ответа AI‑системы, поэтому внутри ячейки вполне мог находиться Markdown вроде:
### 2. **Домклик**
При этом в служебных CSV‑файлах строки, начинавшиеся с #, использовались для комментариев с технической информацией. В первой реализации такие комментарии отфильтровывались до того, как файл разбирался CSV‑парсером. Строка находившаяся внутри многострочной ячейки в кавычках, ошибочно воспринималась как служебный комментарий и удалялась. Если на той же строке находилась закрывающая кавычка, структура CSV ломалась ещё сильнее, соседние записи могли фактически склеиться. Проблему заметил простой контроль количества строк: в проверочной выборке должно было быть 65 записей, а парсер возвращал 64.
Исправление оказалось концептуально простым: сначала файл должен полностью разбираться стандартным csv.reader, и только после этого можно проверять, является ли первое поле уже разобранной строки служебным комментарием.
При анализе ссылок у каждого домена было две независимые характеристики. Первая: что это за сайт (портал недвижимости, СМИ, рейтинг, государственный ресурс и так далее). Вторая: кому он принадлежит относительно рекомендуемого бренда (собственный домен, связанный ресурс или сторонний источник).
В первой реализации классификации эти две характеристики случайно оказались связаны. Если система уже знала владельца домена, но не знала его тип, код по умолчанию относил сайт к категории property_portal_or_marketplace.
Результат выглядел правдоподобно, пока я не проверил распределение целиком: все 28 доменов с известным владельцем внезапно превратились в порталы недвижимости, включая региональные новостные сайты. Исправлять это набором исключений было бы неправильным. Вместо этого я изменил само правило: если тип сайта не доказан, он остаётся неопределённым и отправляется на ручную проверку, даже если его владелец уже известен.
После этого категория источника и его принадлежность бренду стали действительно независимыми характеристиками.
Похожая смысловая ошибка обнаружилась при расчёте доли собственных источников.
Для отдельного бренда данные правильно учитывались на уровне бренд × ответ × домен. То есть если в одном ответе рекомендовались несколько брендов, источник рассматривался отдельно в контексте каждого из них. Но при расчёте агрегированного показателя по AI‑системе первая реализация объединяла домены сразу поверх всех брендов. В результате метрика фактически отвечала уже на другой вопрос:
Принадлежит ли этот источник хотя бы одному из брендов, рекомендованных в ответе?
Это не то же самое, что первоначально определённая доля собственных источников для конкретных брендов. Получившееся число выглядело совершенно реалистично. Ошибку нельзя было заметить по одному только результату, пришлось проверять смысл знаменателя и единицу подсчёта. После исправления агрегированное значение стало рассчитываться из тех же пар бренд × ответ × домен, что и исходная метрика. Дополнительно появился тест, который проверяет, что агрегирование не меняет смысл показателя.
К концу исследования в проекте накопилось 1197 автоматических тестов в 49 файлах. Для проекта на 100 API‑ответов это может выглядеть чрезмерно, но большая часть тестов появилась не ради архитектурной красоты и не потому, что исследовательский скрипт внезапно понадобилось превращать в enterprise‑систему. Тестами я в первую очередь фиксировал правила, от которых зависит смысл результатов.
Например, отдельные проверки гарантировали, что:
М2 не превращается в другой сервис с похожим названием
обычная фраза «цена за квадратный метр» не распознаётся как бренд
рекомендация без доказанного порядка не получает выдуманный ранг
нейтральное упоминание бренда не попадает в статистику рекомендаций
тип источника не определяется автоматически только по тому, кому принадлежит домен
частичное совпадение строки не делает источник собственным доменом бренда
зафиксированный финальный CSV нельзя незаметно изменить
повторная сборка финального датасета создаёт тот же файл байт в байт
несколько ссылок на один домен внутри одного ответа не искажают показатели структуры источников
устаревшие предварительные решения автоматической разметки не используются как финальная истина
Кроме pytest, проект проверялся через ruff для анализа и форматирования Python‑кода, mypy для проверки типов и встроенную проверку целостности SQLite. Отдельные тесты проверяли и ограничения самой базы — например, что финальные решения действительно нельзя изменить обычным UPDATE.
Пайплайн сформировался постепенно. Часть решений появилась не потому, что я предусмотрел их в самом начале, а после конкретных ошибок. Если переносить этот подход на следующую например, международную, выборку, несколько вещей я бы заложил в архитектуру сразу.
В текущем проекте постепенно сформировались три разных уровня:
Исходное упоминание — конкретный фрагмент текста, который автоматический поиск посчитал потенциальным названием бренда.
Нормализованный бренд — понимание того, к какой реальной платформе относится найденное написание.
Финальное решение — является ли этот бренд рекомендацией, нейтральным упоминанием или другим типом записи.
В начале проекта эти уровни были разделены менее строго, поэтому позднее пришлось добавлять дополнительные проверки и промежуточные слои. В следующей версии я бы зафиксировал эту границу с первого дня: найти текст, определить сущность, интерпретировать её роль в ответе.
История с dirty working tree показала, что просто записать Git commit рядом с результатом недостаточно. Поэтому перед созданием любой финальной версии данных я бы сделал автоматическую проверку: если в рабочей директории есть незакоммиченные изменения, экспорт просто не запускается. То есть правило Перед финальным расчётом убедись, что Git чистый должно быть не пунктом в инструкции для человека, а условием, которое проверяет сама программа.
В результате исходный эксперимент задать нескольким AI‑системам одинаковые вопросы и посмотреть на рекомендации превратился в достаточно длинную цепочку обработки данных.
Финальная российская выборка содержит 100 валидных API‑ответов. После поиска, объединения вариантов написания и проверки упоминаний из них получилось 476 уникальных пар ответ × бренд, из которых 452 были рекомендациями. Для исследования повторяемости было выполнено 80 попарных сравнений ответов. Отдельно обработаны 775 ссылок, сведённые к 191 уникальному домену и 628 парам ответ × домен.
К концу работы для практически любого значения в итоговой таблице можно было пройти цепочку назад: от опубликованной метрики к финальному решению, от решения к найденному упоминанию, а затем к исходному JSON‑ответу API. И наоборот, для каждого финального артефакта можно было проверить:
из какой версии данных он был построен
какой SHA-256 имеет эта версия
каким кодом она была создана
воспроизводится ли файл повторной сборкой
какие правила определяют включение данных в метрику
какие пограничные случаи отдельно защищены тестами
Получить 100 ответов от AI‑систем технически несложно. Даже построить по ним график можно довольно быстро. Намного сложнее доказать, что каждая цифра на графике действительно измеряет именно то, что написано в его заголовке.
В AI Search часто ответы стохастичны, web search частично непрозрачен, провайдеры возвращают разные метаданные о ссылках, а названия продуктов могут совпадать с обычными словами или другими брендами. Между API вернул текст и исследование показало результат возникает довольно большой слой решений, каждое из которых потенциально способно изменить вывод.
Исследовательский пайплайн стоит проектировать не только так, чтобы он умел посчитать результат, но и так, чтобы этот результат потом можно было проверить, воспроизвести и оспорить.
Исследовательский пайплайн написан на Python 3.12. Для управления зависимостями использовался uv, основное хранилище — SQLite. Сетевой слой построен на httpx, конфигурация и валидация данных — на pydantic и pydantic-settings, CLI — на Typer, повторные попытки сетевых запросов — на tenacity. Для конфигурационных файлов использовался PyYAML.
Расчётный слой намеренно оставался достаточно простым: большая часть обработки строилась на стандартных csv, sqlite3 и decimal. pandas и numpy для расчёта исследовательских метрик не использовались. Проверка проекта включала pytest, ruff и mypy, а также встроенные проверки целостности SQLite.
На момент завершения российской части исследования проект выглядел так:
100 / 100 API-наблюдений валидны
100 ответов с HTTP 200
0 ошибок при основном сборе
0 повторных попыток при основном сборе
$5.1841 — стоимость основной выборки
$5.4263 — весь проект с тестовыми запусками и диагностикой
476 уникальных пар ответ × бренд
452 рекомендации
446 рекомендаций с определённым порядком
6 рекомендаций без доказанного ранга
24 нейтральных упоминания
775 ссылок на источники
191 уникальный домен
628 пар ответ × домен
93 из 100 ответов содержали ссылки
1197 автоматических тестов
49 файлов с тестами
13-я версия схемы SQLite
К концу проекта большая часть методологических правил, от определения рекомендации до обработки повторных ссылок, была зафиксирована не только в тексте методики, но и в исполняемых проверках.
В статье описан технический процесс конкретного эксперимента с четырьмя API‑системами в зафиксированной конфигурации и одном временном срезе. Результаты не оценивают объективное качество платформ недвижимости и не переносятся автоматически на пользовательские версии ChatGPT, Claude, Perplexity и Grok. Поисковые контуры систем также не были идентичным общим retrieval backend.
Автор: berdinskikh
Источник [6]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/34377
URLs in this post:
[1] повторять: http://www.braintools.ru/article/4012
[2] ошибки: http://www.braintools.ru/article/4192
[3] зрения: http://www.braintools.ru/article/6238
[4] cian.ru: http://cian.ru
[5] rbc.ru: http://rbc.ru
[6] Источник: https://habr.com/ru/articles/1070202/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1070202
Нажмите здесь для печати.