Введение
При первой попытке агент из первой части ответил почти правильно: получил вопрос о том, что предписывает плейбук при SSH-переборе против web-server-01, нашел в индексе нужный документ, назвал правила и владельца и пропустил блокировку на 12 часов. Из пяти найденных фрагментов к плейбуку относился только один, раздел Scope; шаг сдерживания лежал тремя разделами ниже, и до модели он не дошел.
Чтобы такой ответ стал невозможным, нужны две вещи. Документы должны попадать в индекс целиком и с метаданными, по которым найденный фрагмент можно привязать к своему файлу; агент должен уметь по этим метаданным дочитать файл до конца. В индекс soc-knowledge внутри Wazuh Indexer для этого попали сами плейбуки, руководство пользователя Wazuh 4.14 с описанием правил и active response, MITRE ATT&CK в том виде, в каком его отдает менеджер Wazuh, и закрепленный набор OSINT-событий MISP для вопросов про индикаторы. Всего 7118 документов, и у каждого есть вектор из 1024 чисел, который Indexer получает сам через коннектор ML Commons к Amazon Titan Text Embeddings V2. Загрузчик отправляет только текст. Дочитывать документ агент будет вторым инструментом, SearchIndexTool, а искать, как и раньше, - инструментом VectorDBTool.

Схема установки, на которой сделаны измерения (это диаграмма, а не снимок экрана): индекс знаний, коннектор векторной модели, чат-агент с инструментами поиска и чтения, MCP-сайдкар и прямой путь запроса
Серия состоит из четырех частей.
- Часть 1 - ML Commons, коннектор Bedrock Claude и чат-агент в Dashboard
- Часть 2 -
opensearch-mcp-server-pyкак сайдкар и Claude Desktop поверх MCP - Часть 3 (эта статья) - Titan Embeddings V2, lucene k-NN индекс по четырем источникам и гибридный поиск
- Часть 4 - те же четыре источника в Amazon S3 Vectors через Bedrock Knowledge Bases и сравнение с индексом внутри Indexer (скоро)
Результаты собраны за три дня.
- 2026-09-02 - падение узла на движке
faissи первый гибридный запрос по индикатору на предыдущей сборке индекса, пересобранного на следующий день. - 2026-09-03 - сборка индекса, две загрузки подряд.
- 2026-09-04 - проверка агента, примеры запросов, два негативных контроля, расчет стоимости и повторный гибридный запрос с другим хешем.
Полной независимой воспроизводимости статья не обещает. При повторной сборке должны совпасть количества документов MITRE, документации и плейбуков, потому что их источники закреплены. Оценки поиска совпадать не обязаны: они зависят от векторов, посчитанных в конкретный день. Может измениться и число фрагментов MISP: для этого источника закреплен только список UUID, поэтому отредактированное или отозванное событие сдвинет итог. Падение на faiss я воспроизводил только 2026-09-02 в одноразовом индексе, а не на рабочем: воспроизведение перезапускает узел, а к моменту остальных измерений индекс и привязка чата уже были в работе. Для каждого результата в соответствующем разделе указано, как он получен.
Окружение
- Стек первой части: Docker и Docker Compose v2,
wazuh-dockerв варианте single-node с уже сгенерированными сертификатами Indexer, не меньше 8 ГБ памяти под Docker. - Версии: первая часть написана на Wazuh 4.14.3, измерения для третьей части сделаны на 4.14.7 с OpenSearch 2.19.5 и ML-плагинами 2.19.5.0.
- Плагины Indexer:
opensearch-skillsиopensearch-flow-framework2.19.5.0 из Maven;opensearch-knn,opensearch-mlиopensearch-neural-searchтой же версии уже есть в образе. - Плагины Dashboard: три плагина из дистрибутива OpenSearch Dashboards 2.19.5.
- AWS: доступ к моделям
amazon.titan-embed-text-v2:0иus.anthropic.claude-sonnet-4-5-20250929-v1:0в вашем регионе и правоbedrock:InvokeModelна обе; регион в статье вездеus-east-1. Ключи из именованного профиля AWS CLI читает командойaws configure export-credentialsтолько процесс регистрации коннектора; после его завершения файла с ключами не остается. - Коннектор и модель Claude из первой части регистрируются без изменений; их проверка с параметром
promptпроходит.
В командах первой части меняются только номера версий.
mkdir -p config/wazuh_indexer/plugins
cd config/wazuh_indexer/plugins
curl -L -O https://repo1.maven.org/maven2/org/opensearch/plugin/opensearch-flow-framework/2.19.5.0/opensearch-flow-framework-2.19.5.0.zip
curl -L -O https://repo1.maven.org/maven2/org/opensearch/plugin/opensearch-skills/2.19.5.0/opensearch-skills-2.19.5.0.zip
mkdir -p opensearch-flow-framework opensearch-skills
unzip opensearch-flow-framework-2.19.5.0.zip -d opensearch-flow-framework/
unzip opensearch-skills-2.19.5.0.zip -d opensearch-skills/
rm -f *.zip
cd ../../..
curl https://artifacts.opensearch.org/releases/bundle/opensearch-dashboards/2.19.5/opensearch-dashboards-2.19.5-linux-x64.tar.gz -o opensearch-dashboards.tar.gz
tar -xzf opensearch-dashboards.tar.gz
mkdir -p config/wazuh_dashboard/plugins
cp -r opensearch-dashboards-2.19.5/plugins/observabilityDashboards config/wazuh_dashboard/plugins/
cp -r opensearch-dashboards-2.19.5/plugins/mlCommonsDashboards config/wazuh_dashboard/plugins/
cp -r opensearch-dashboards-2.19.5/plugins/assistantDashboards config/wazuh_dashboard/plugins/
rm -rf opensearch-dashboards-2.19.5* opensearch-dashboards.tar.gz
Что именно увидела модель в первом результате
Блокировка на 12 часов, которой не хватило в первом ответе, есть только в одном месте, в плейбуке, написанном для этой статьи, и в этом вся ценность плейбуков как проверки: правильный ответ по MITRE или по руководству Wazuh модель могла дать по памяти, эти тексты опубликованы. Вместе с блокировкой в ответе должны сойтись условие эскалации по успешному входу после серии отказов и два номера правил, к которым относится плейбук именно этого хоста.
Вот сам документ; в индексе он разбит на шесть фрагментов, каждый с заголовком своего раздела.
# Playbook: SSH brute force against web-server-01
Scope: agent web-server-01 (Ubuntu 24.04, internet-facing). Applies to Wazuh rule 5712 (SSH brute force against a non-existent user) and rule 5763 (SSH brute force with failed authentication). Owner: SOC tier 1. Escalate to tier 2 when containment fails or when a successful login follows the burst.
## Detection
Read the alert fields data.srcip, data.dstuser and rule.frequency. Eight matches from one source within 120 seconds trigger rule 5712 when the failures target a non-existent user, or rule 5763 for failed authentication. If a successful login from the same address follows either burst within two minutes, preserve the authentication evidence, contain the source and escalate to tier 2. Never close the sequence as benign based on the success alone.
## Triage
Check whether data.srcip belongs to the corporate VPN range 10.20.0.0/16 or to a known scanner. Query the alert store for the same source across all agents for the last 24 hours. If the address hits more than one host, open a campaign ticket instead of a single-host ticket.
## Containment
Block the source address on the host firewall of web-server-01 with the active response firewall-drop for 12 hours. Confirm the action in /var/ossec/logs/active-responses.log and verify the firewall state on the endpoint. A zero count of later alerts is supporting evidence only, because the source may have stopped sending traffic. Do not disable the sshd service.
## Escalation
Escalate to tier 2 if any of the following hold: a successful login from the blocked address before the block, more than three hosts targeted by the same address, or a user account that is not in the expected administrator list.
## Closure
Record the source address, the number of failures, the block time and the ticket id in the case. Close within one business day if no escalation criterion was met.
Ближайшим к вопросу фрагментом оказался раздел Scope, фрагмент 0, с оценкой 0.866 2026-09-04. В нем есть оба правила и владелец, но нет ни слова о блокировке: она описана в разделе Containment, фрагменте 3 того же файла. Пока агент получал только результат поиска, все, что лежит ниже раздела Scope, включая Containment, для него не существовало.
Срок блокировки в 12 часов и критерии эскалации я задал сам; в рабочем SOC их определяет собственная политика. Номера правил и шаг сдерживания, напротив, не выдуманы: 5712 и 5763 срабатывают на восемь совпадений за 120 секунд по определениям SSH-правил Wazuh 4.14.7, а блокировка через firewall-drop повторяет сценарий active response для SSH-перебора, где в примере она длится 180 секунд. Остальные три плейбука построены по той же схеме из пяти разделов и посвящены:
- любому алерту уровня 12 и выше - этот документ ссылается на остальные по имени;
- запуску оболочки процессом веб-сервера (правило 100450);
- изменению
sshd_config(правила 550 и 553).
Откуда 7118 документов
Число в заголовке складывается из четырех источников, загруженных 2026-09-03: 1031 документ MITRE, 3854 фрагмента документации из 316 файлов, 24 фрагмента плейбуков из 4 файлов и 2209 фрагментов MISP из 200 событий. У каждого источника есть правило отбора, по которому его можно загрузить заново.
- MITRE ATT&CK. 750 техник, 267 мер противодействия и 14 тактик при
mitre_version2.0, взятые через API менеджера Wazuh: так документы совпадают с тем набором, по которому менеджер размечает алерты. Из техники получается документ с именем, идентификатором, описанием, названиями мер, тактиками и рекомендациями по обнаружению. - Документация Wazuh. 316 файлов
.rstизsource/user-manual/ветки4.14; и список файлов, и их содержимое взяты на одном закрепленном коммитеe3296dd. Файл режется на секции по подчеркиваниям заголовков RST, секции - на фрагменты. - Плейбуки. Четыре файла Markdown, разбитые по заголовкам H1 и H2; их темы перечислены в разделе о плейбуке.
- MISP. 200 UUID событий из манифеста публичного фида CIRCL OSINT, закрепленные 2026-09-02, когда манифест перечислял 1680 событий. События скачиваются заново по этим UUID в день запуска: хеши, домены, адреса и URL идут в поле
indicators, а строкаinfoс атрибутами comment и text - в текст.
API менеджера потребовал одной поправки в идентификаторах. К doc_id мер противодействия и тактик загрузчик добавляет префикс типа, mitigation:T1081 и tactic:TA0003, а для техник оставляет T-номер без префикса. Без этого мера и техника получили бы один идентификатор документа, потому что на этой установке 224 из 267 мер приходят с external_id в форме T-номера техники, а поздняя запись молча затерла бы раннюю.
Две особенности текста фрагментов объясняются одной ошибкой, о которой речь пойдет в разделе про агента: каждый фрагмент документации и плейбука начинается с заголовка своей секции, а фрагмент, который и с заголовком разбирается как JSON, получает префикс Excerpt:.
Загрузку я запустил два раза подряд и после каждого прохода снял список идентификаторов через Scroll API. Два файла по 7118 строк совпали побайтно, повторов _id и повторов тройки (source, doc_id, chunk) не нашлось.
Индекс, пайплайны и размер фрагмента
Проба токенизации сделана на странице руководства объемом 150 КБ, source/user-manual/reference/internal-options.rst: страница отправлялась в bedrock-runtime кусками нарастающей длины, а из каждого ответа читался inputTextTokenCount.
| Префикс, символов | Токенов | Символов на токен |
|---|---|---|
| 2000 | 401 | 4,99 |
| 4000 | 612 | 6,54 |
| 8000 | 994 | 8,05 |
| 12000 | 1342 | 8,94 |
| 16000 | 1714 | 9,33 |
| 24000 | 2389 | 10,05 |
| 32000 | 3121 | 10,25 |
| 40000 | 3844 | 10,41 |
| 44000 | 4218 | 10,43 |
| 48000 | 4582 | 10,48 |
Из таблицы и выбран размер фрагмента: 3000 символов при перекрытии в 200 символов, около 600 токенов по самому плотному соотношению, 4,99 символа на токен. Ни один префикс до 48000 символов отклонен не был, и перед созданием индекса загрузчик проверяет, что четыре таких фрагмента умещаются в самый длинный принятый префикс. Средним значением здесь пользоваться нельзя. Руководство Amazon Bedrock ограничивает Titan V2 8192 токенами или 50000 символами и называет среднее 4,7 символа на токен для английской прозы, а внутри одной страницы reStructuredText с директивами, таблицами и блоками кода соотношение уходит до 10,48: расчет по среднему соотношению всей страницы на ее плотных участках занизил бы число токенов вдвое.
Индекс один на четыре источника, и его маппинг объявляет векторное поле, признак источника и метаданные, по которым результат можно процитировать.
PUT /soc-knowledge
{
"settings": {
"index": {
"knn": true,
"number_of_shards": 1,
"number_of_replicas": 0,
"search.default_pipeline": "soc-hybrid"
}
},
"mappings": {
"properties": {
"source": { "type": "keyword" },
"doc_id": { "type": "keyword" },
"chunk": { "type": "integer" },
"title": { "type": "text" },
"text": { "type": "text" },
"text_embedding": {
"type": "knn_vector",
"dimension": 1024,
"method": {
"name": "hnsw",
"engine": "lucene",
"space_type": "cosinesimil",
"parameters": { "ef_construction": 128, "m": 16 }
}
},
"indicators": { "type": "keyword" },
"url": { "type": "keyword" },
"version": { "type": "keyword" },
"ingested_at": { "type": "date" }
}
}
}
Два поля keyword дают то, чего векторный поиск не умеет: точный отбор и фильтр по корпусу. Запрос term ищет точное совпадение; поэтому indicators хранит у плейбука номера правил Wazuh вида rule-5712, у записи MITRE - ее T-номер, у события MISP - хеши, адреса и домены, и именно по этому полю позже работает гибридный запрос. Поле source разводит корпуса: вопрос по одному источнику получает фильтр term, вопрос без привязки идет по всему индексу.
Пайплайн soc-hybrid привязан к индексу как index.search.default_pipeline: запрос hybrid обходится без параметра search_pipeline, а Dashboard, curl и сайдкар получают одни и те же веса. Внутри него один процессор нормализации: оба списка оценок приводятся к диапазону от 0 до 1 через min_max и складываются как взвешенное среднее с весами 0.7 у точной части и 0.3 у векторной; документация требует, чтобы весов было столько же, сколько подзапросов, и чтобы они давали в сумме 1,0.
PUT /_search/pipeline/soc-hybrid
{
"description": "Hybrid: exact indicators plus semantic text",
"phase_results_processors": [
{
"normalization-processor": {
"normalization": { "technique": "min_max" },
"combination": {
"technique": "arithmetic_mean",
"parameters": { "weights": [0.7, 0.3] }
}
}
}
]
}
Ingest-пайплайн короче: один процессор text_embedding, который кладет вектор поля text в text_embedding; пробный документ, пропущенный через него, получил вектор из 1024 чисел.
PUT /_ingest/pipeline/soc-knowledge-embed
{
"description": "Embed the text field with Titan Text Embeddings V2",
"processors": [
{
"text_embedding": {
"model_id": "<titan_model_id>",
"field_map": { "text": "text_embedding" }
}
}
]
}
Прежде чем процессор сможет сослаться на модель, нужно зарегистрировать коннектор Titan, а затем саму модель. Тело коннектора взято из схемы в репозитории ML Commons, откуда пришли и две функции bedrock.embedding, переводящие формат text-embedding в запрос Titan и обратно.
POST /_plugins/_ml/connectors/_create
{
"name": "soc-titan-embed-v2",
"description": "Amazon Titan Text Embeddings V2 for the SOC knowledge index",
"version": 1,
"protocol": "aws_sigv4",
"credential": {
"access_key": "<AWS_ACCESS_KEY_ID>",
"secret_key": "<AWS_SECRET_ACCESS_KEY>"
},
"parameters": {
"region": "us-east-1",
"service_name": "bedrock",
"model": "amazon.titan-embed-text-v2:0",
"dimensions": 1024,
"normalize": true,
"embeddingTypes": ["float"]
},
"actions": [
{
"action_type": "predict",
"method": "POST",
"url": "https://bedrock-runtime.${parameters.region}.amazonaws.com/model/${parameters.model}/invoke",
"headers": {
"content-type": "application/json",
"x-amz-content-sha256": "required"
},
"request_body": "{ \"inputText\": \"${parameters.inputText}\", \"dimensions\": ${parameters.dimensions}, \"normalize\": ${parameters.normalize}, \"embeddingTypes\": ${parameters.embeddingTypes} }",
"pre_process_function": "connector.pre_process.bedrock.embedding",
"post_process_function": "connector.post_process.bedrock.embedding"
}
]
}
POST /_plugins/_ml/models/_register?deploy=true
{
"name": "soc-titan-embed-v2",
"function_name": "remote",
"description": "Titan Text Embeddings V2 for the SOC knowledge index",
"connector_id": "<titan_connector_id>"
}
model_id из ответа на регистрацию понадобится в трех местах: в каждом запросе neural, в инструменте агента и в ingest-пайплайне. Проверять модель удобнее вызовом predict для text-embedding: по тому же формату потом работают пайплайн и запрос neural.
POST /_plugins/_ml/_predict/text_embedding/<titan_model_id>
{
"text_docs": ["What does our runbook say for an SSH brute force against web-server-01?"],
"return_number": true,
"target_response": ["sentence_embedding"]
}
В ответе один объект sentence_embedding с массивом data из 1024 элементов, столько же, сколько указано в dimensions. Настройки кластера ML Commons на этой сборке я прочитал и оставил как есть: agent_framework_enabled, memory_feature_enabled и rag_pipeline_feature_enabled включены по умолчанию, в trusted_connector_endpoints_regex по умолчанию 11 шаблонов, среди них bedrock-runtime, а plugins.ml_commons.only_run_on_ml_node остался в значении true, и удаленная модель развернулась с ним без единого изменения. Первая часть меняла и список шаблонов, и эту настройку; на 2.19.5.0 ни то ни другое не требуется.

Обе удаленные модели зарегистрированы и отвечают, снимок 2026-09-02; идентификаторы моделей на снимке заменены заглушками
Где ломался векторный движок, запись и обратное чтение
Первым сломался движок. В логе контейнера Indexer после первой же записи документа в поле knn_vector с engine: faiss стояло вот что:
java.lang.UnsatisfiedLinkError: no opensearchknn_faiss in java.library.path: /usr/java/packages/lib:/usr/lib64:/lib64:/lib:/usr/lib
at java.base/java.lang.ClassLoader.loadLibrary(ClassLoader.java:2458)
at java.base/java.lang.Runtime.loadLibrary0(Runtime.java:916)
--
fatal error in thread [opensearch[wazuh.indexer][write][T#13]], exiting
В первый раз узел успел упасть дважды, пока я удалял индекс: при старте восстановление translog повторяет ту же запись, и процесс падает на ней снова. Воспроизведение 2026-09-02 я сделал в одноразовом индексе: через 15 секунд после записи узел снова отвечал, я удалил индекс, а счетчик перезапусков контейнера к этому моменту показывал 3 вместо 2. Сам маппинг такого поля Indexer принимает с HTTP 200, а на запросе записи curl показывает код HTTP 000, потому что соединение обрывается посреди запроса.
Нативных библиотек в образе wazuh/wazuh-indexer:4.14.7 нет ни для linux/amd64, ни для linux/arm64: в каталоге плагина лежит opensearch-knn-2.19.5.0.jar со вспомогательными jar и ни одного файла libopensearchknn* (содержимое каталога я вывел 2026-09-04 из одноразового контейнера с подмененной точкой входа). Во всех индексах серии с тех пор стоит lucene с теми параметрами метода, что видны в маппинге выше (ef_construction 128 против документированных 100 по умолчанию, m 16 по умолчанию). Страница о движках k-NN для 2.19 перечисляет библиотеки libopensearchknn_faiss*.so, которые OpenSearch поставляет для Faiss, и там же сказано, что движок lucene выполняет векторный поиск средствами самой библиотеки Lucene и нативного кода не требует; в этом образе выбора между движками нет.

Список плагинов Indexer 2026-09-02: три поисковых плагина идут в образе, skills и flow-framework добавлены еще в первой части
Вторая поломка не роняла ничего и потому опаснее. В журнале загрузки 2026-09-03 такие строки встречаются 174 раза в первом запуске и 175 во втором:
bulk: 13 items throttled (429), retry 1/6 after 2s
Каждая из них - ответ _bulk с HTTP 200 и errors: true, в котором часть элементов пришла со статусом 429: так Bedrock сообщает об ограничении частоты запросов. Загрузчик в этом случае повторяет только элементы с 429 или 5xx, с паузами 2, 4, 8, 16, 32 и 64 секунды, а останавливается в трех случаях: после шести неудачных попыток, при любой другой ошибке элемента, при errors: true без указания отказавшего элемента. Все 349 повторных запросов в двух запусках завершились успешно после первой двухсекундной паузы. Остановись загрузчик на коде ответа, счетчик документов по источнику после загрузки показал бы меньше, чем отправлено, и это был бы первый признак беды. По журналам первая загрузка заняла около 15 минут, вторая около 25; паузы дают примерно шесть минут на каждую.
Третья поломка случилась один раз. Повторный запуск 2026-09-03 сначала остановился на _mget с ответом 400 parsing_exception: флаг _source=false стоял в теле запроса рядом с ids, а Indexer принимает его только как параметр запроса; после исправления загрузчик был запущен заново, и сравнивались два завершившихся запуска того же дня. Этот _mget - обратное чтение, которым загрузчик перед любым удалением проверяет каждый записанный идентификатор, пачками по 500. Для оператора это означает, что повторный запуск безопасен в любой момент: он перезаписывает те же документы, а удаляет только после обратного чтения, только для полного снимка источника и только разницу между тем, что индекс хранит для этого источника, и тем, что запуск только что отправил. Нужное поколение документов находится запросом term по полю version.
Как агенту дали дочитать плейбук
SocPlaybookRead и есть ответ на пропущенную блокировку из введения: после его вызова модель видит уже не один фрагмент, а весь плейбук, все шесть фрагментов по порядку номеров chunk, отобранные по source, doc_id и version найденного попадания. Это SearchIndexTool; doc_id и version модель берет из ответа поискового инструмента SocKnowledgeSearch (VectorDBTool), который отдает их вместе с текстом каждого попадания. Четыре инструмента для алертов из первой части остались как были.
Описание инструмента чтения содержит литерал, в котором модель меняет только DOC_ID и VERSION, и каждый элемент этого литерала появился после ошибки на реальном вызове. track_scores: true стоит там потому, что сортировка по полю без подсчета оценок закончилась отказом с сообщением Gson: NaN is not a valid double value as per JSON specification. Вложенный ключ query внутри значения query появился после того, как фильтр bool, положенный прямо под верхний query, ушел в OpenSearch как все тело запроса и вернул Unknown key for a START_OBJECT in [bool]; документированная форма - снаружи index и query, внутри query - целое тело запроса. У поискового инструмента правило одно, и оно тоже родилось из ошибки: когда агент передал ему объект Query DSL вместо вопроса, Titan ответил HTTP 400 Invalid payload, и в ошибке был виден тот же JSON внутри inputText, потому что ML Commons подставляет входной текст в request_body без изменений. При загрузке текст каждого фрагмента проходит тем же путем, и фрагмент, который целиком разбирается как JSON, ушел бы в тело запроса Titan как объект. Отсюда и требование одной строки обычного текста в описании, и заголовок секции в начале каждого фрагмента, и префикс Excerpt: из раздела об источниках.
[
{
"type": "VectorDBTool",
"name": "SocKnowledgeSearch",
"description": "Semantic search over the SOC knowledge base: MITRE ATT&CK techniques and mitigations, Wazuh 4.14 documentation, the team's playbooks for an SSH brute force against web-server-01, an unexpected change to sshd_config, a web server process that spawned a shell and alerts at level 12 or higher, and recent MISP threat intelligence events. INPUT RULE: the input is the question or topic as one plain-text sentence, for example: SSH brute force playbook for web-server-01. NEVER pass JSON, a Query DSL object, an index name or field names as the input: this tool embeds the input text as it is, and a JSON input is refused by the embedding model. Every hit carries source, doc_id, chunk, title, text, url and version. A playbook hit is one section of a document; pass its doc_id and version to SocPlaybookRead to read the whole document.",
"parameters": {
"model_id": "<titan_model_id>",
"index": "soc-knowledge",
"embedding_field": "text_embedding",
"source_field": [
"source",
"doc_id",
"chunk",
"title",
"text",
"url",
"version"
],
"doc_size": 5,
"k": 10,
"input": "${parameters.question}"
}
},
{
"type": "SearchIndexTool",
"name": "SocPlaybookRead",
"description": "Reads one playbook document of the SOC knowledge base through, in section order. INPUT RULE: the input is one JSON object with exactly two top-level keys, index and query. index is always the string soc-knowledge. The value of query is the COMPLETE search request body, and that body has its own key named query holding the bool filter, beside sort, size and _source. Never put bool directly under the top-level query, and never put sort, size or _source at the top level. Copy this literal input and replace only DOC_ID and VERSION with the doc_id and version of the SocKnowledgeSearch hit: {\"index\": \"soc-knowledge\", \"query\": {\"query\": {\"bool\": {\"filter\": [{\"term\": {\"source\": \"playbook\"}}, {\"term\": {\"doc_id\": \"DOC_ID\"}}, {\"term\": {\"version\": \"VERSION\"}}]}}, \"sort\": [{\"chunk\": \"asc\"}], \"track_scores\": true, \"size\": 20, \"_source\": [\"source\", \"doc_id\", \"chunk\", \"title\", \"text\", \"version\"]}}. track_scores must be true: this tool serialises the score of every hit, and a sort without computed scores makes it fail. Use the doc_id and version of the hit, never a guessed name. Every returned hit is one section of that one document; read them all before answering.",
"parameters": {
"index": "soc-knowledge"
}
}
]
Ответ агент собирает из пяти помеченных частей: Rules covered, Owner, Containment actions, Escalation conditions и Sources quoted, а на русский вопрос - Правила, Владелец, Действия по сдерживанию, Условия эскалации и Источники. Владелец берется одной строкой из документа; если строки владельца в контексте нет, агент пишет Owner: not found. Эти требования лежат в llm.parameters под ключом prompt.prefix, и этот ключ продиктован коннектором. Тело запроса коннектора Claude из первой части отправляет модели ровно один параметр:
"content": [{"type": "text", "text": "${parameters.prompt}"}]
Кроме prompt в это тело не подставляется ничего, так что ключ system_instruction, под которым инструкция лежала в прежнем определении агента в этой серии, до модели по этому пути дойти не мог, и она не доходила. Значение prompt собирает чат-агент ML Commons 2.19 из prompt, prompt.prefix и prompt.suffix (MLChatAgentRunner и AgentUtils.addPrefixSuffixToPrompt); после переноса инструкции в prompt.prefix в трассировке впервые оказались все пять помеченных частей, и в русском ответе тоже.
Проверка ответа устроена так. Для каждого языка выполняется один вызов _execute с verbose: true, и по трассировке я исключаю четыре отказа:
- поиск выполнен - в трассировке шаг чтения стоит после шага поиска;
- прочитан нужный документ - запрос чтения содержит индекс,
doc_idиversion, которые вернул поиск; - прочитан целиком - в ответе чтения ровно шесть фрагментов, с 0 по 5, с теми же заголовками разделов, что в файле при загрузке;
- ничего не выдумано - у каждого утверждения ответа есть фрагмент в выдаче инструментов, где оно написано.
Агент привязывается к чату Dashboard после того, как оба языка проходят эту проверку. Запись документа os_chat в .plugins-ml-config защищена от параллельного изменения. Если между чтением и записью документ изменил кто-то другой, Indexer вернет на запрос обновления 409, потому что запрос содержит _seq_no и _primary_term, прочитанные перед ним. Если документ не изменился, запрос выполняется с администраторским сертификатом Indexer, как в первой части, а результат затем читается обратно. Оба языка прошли проверку 2026-09-04. Русский ответ из журнала, без части о правилах:
[...]
**Владелец:** SOC tier 1
**Действия по сдерживанию:** Заблокировать адрес источника на хостовом брандмауэре web-server-01 с помощью активного ответа firewall-drop на 12 часов. Подтвердить действие в /var/ossec/logs/active-responses.log и проверить состояние брандмауэра на конечной точке. Нулевое количество последующих алертов является только подтверждающим свидетельством, потому что источник мог прекратить отправку трафика. Не отключать службу sshd.
**Условия эскалации:** Эскалация на tier 2 происходит, если выполняется любое из следующих условий: успешный вход с заблокированного адреса до блокировки, более трех хостов атакованных одним и тем же адресом, или учетная запись пользователя, которая не входит в ожидаемый список администраторов. Также эскалация необходима, когда сдерживание не удается или когда за всплеском следует успешный вход.
**Источники:** ssh-brute-force-web-server-01.md версии playbook-2026-09, разделы: Playbook: SSH brute force against web-server-01, Detection, Triage, Containment, Escalation.

Чат Dashboard с агентом чтения плейбука, тот же вопрос задан 2026-09-04; это свежий ответ привязанного агента, а не ответ из журнала выше
Запросы к индексу и негативные контроли
Тот же индекс без агента опрашивается обычными запросами, и первый из них - neural с фильтром по source.
POST /soc-knowledge/_search
{
"size": 5,
"_source": ["source", "title", "doc_id", "url"],
"query": {
"neural": {
"text_embedding": {
"query_text": "Which technique covers password guessing against SSH and what mitigates it?",
"model_id": "<titan_model_id>",
"k": 5,
"filter": { "term": { "source": "mitre" } }
}
}
}
}
Таблица показывает, что возвращает каждый корпус на вопрос по его теме. Все четыре запроса выполнены 2026-09-04, по одному на источник, со своим вопросом и фильтром.
| Фильтр | Вопрос | Первое попадание | Оценка |
|---|---|---|---|
mitre | Which technique covers password guessing against SSH and what mitigates it? | T1110.001 Password Guessing, следом мера противодействия T1184 SSH Hijacking с 0.7979 | 0.8128 |
wazuh-docs | What is the manager configuration block for vulnerability detection called? | configuring-scans.rst, раздел Configuration | 0.7858 |
playbook | How long do we block a brute force source on web-server-01? | раздел Containment плейбука SSH-перебора, за ним его заголовочный раздел с 0.7298 и Detection с 0.6776 | 0.7598 |
misp | Recent Emotet infrastructure | пять ежедневных дайджестов Maltrail IOC с оценками от 0.5998 до 0.5874 | 0.5998 |
Чего ждать от источника почти без связной прозы, видно по строке MISP: пять дайджестов, чьи оценки расходятся лишь во втором знаке, без явного лидера. По трем остальным корпусам поиск возвращает нужный раздел нужного источника.

Отдельное выполнение запроса neural по одним плейбукам в Dev Tools, 2026-09-04; идентификатор модели в запросе заменен заглушкой
Для индикатора к векторной части добавляется точный запрос term по полю indicators. Как оценки двух частей складываются, видно из арифметики пайплайна на примере одного sha256, взятого из загруженных событий MISP в день запуска:
единственное попадание term: 1.0 x 0.7 + 0 x 0.3 = 0.7
лучший сосед по вектору: 0 x 0.7 + 1.0 x 0.3 = 0.3
POST /soc-knowledge/_search
{
"size": 5,
"_source": ["source", "title", "doc_id", "indicators"],
"query": {
"hybrid": {
"queries": [
{ "term": { "indicators": "0d2211b7e92fcc6a9f7c94d4adf8e47f6f97e31dacd3b2ffb6cce3c485fcef26" } },
{
"neural": {
"text_embedding": {
"query_text": "Is this file hash known malware and which campaign does it belong to?",
"model_id": "<titan_model_id>",
"k": 10
}
}
}
]
}
}
}
Ровно эти 0.7 и 0.3 и пришли 2026-09-04: первым из 11 попаданий за 574 мс событие MISP с этим хешем, Malicious File Creates Network Socket and Contacts fdh32fsdfhs.shop, вторым раздел руководства об обнаружении вредоносных файлов по хешам через CDB-список. Вес 0.7 у точной части нужен ради этого порядка. В оба дня, с разными хешами, исход один: векторная часть без term 2026-09-04 (тот же вопрос с дописанным хешем, фильтр source: misp, k 5) вернула пять дайджестов Maltrail IOC с оценками от 0.6772 до 0.6610 и событие с хешем не вернула вовсе, а 2026-09-02, на предыдущей сборке индекса, с хешем из события CERT-FR о Sandworm поставила это событие лишь третьим с 0.6645, тогда как гибридный пайплайн в тот день дал ему 0.7000 и первое место.

Отдельное выполнение гибридного запроса в Dev Tools, 2026-09-04: событие с точным хешем на первом месте; идентификатор модели в запросе заменен заглушкой
Читателю второй части важнее другое: что меняется для Claude Desktop. Ничего. Сайдкар из второй части пересылает тело запроса как есть, за вектором в Bedrock ходит сам Indexer, веса берутся из пайплайна индекса по умолчанию. Знать нужно только имя аргумента, в котором SearchIndexTool сайдкара принимает тело: по ответу tools/list этой сборки его схема состоит из format, index, query_dsl и size, и гибридный запрос выше кладется в query_dsl без изменений.
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "SearchIndexTool",
"arguments": { "index": "soc-knowledge", "query_dsl": { ...гибридный запрос выше... } } } }
Против прямого запроса двумя абзацами выше разницы почти нет: тот же ответ isError: false от сайдкара 2026-09-04, то же событие первым с оценкой 0.7 и хешем в массиве indicators, те же 11 попаданий, только на 71 мс быстрее, за 503 мс.
Почти правильный ответ из введения появился на верных документах. Что делает поиск, когда верных документов нет или они устарели, показывают два контроля, каждый в своем одноразовом индексе с тем же маппингом и пайплайном.
Устаревшие страницы. Вопрос один: как называется XML-блок менеджера для vulnerability detection. Индексов два, soc-knowledge-stale и soc-knowledge-fresh, и разница между ними только в возрасте одних и тех же трех страниц руководства: ветка 4.7 на коммите 288e2ee против ветки 4.14 на коммите e3296dd. От каждого контрольного ответа ниже сохраняются первые 300 символов, отсюда обрыв на полуслове в последнем из них. Ответ модели по устаревшему индексу (10 фрагментов; из пяти найденных старое имя есть в первом и в пятом):
Based on the documentation excerpt, the XML block for vulnerability detection is called: **vulnerability-detector**
Ответ по свежему индексу (13 фрагментов; текущее имя есть во втором и в пятом из пяти):
Based on the documentation, the XML block for vulnerability detection is called: **vulnerability-detection** This is shown in the exact tag format: `<vulnerability-detection>`
На верных документах модель назвала верное имя, на устаревших - старое, и оба ответа одинаково уверены: по ответу нельзя определить, насколько свежи данные. Порог по оценке тоже не поможет. В свежем индексе фрагменты с нужным именем стоят на 0.7931 и 0.7414, а фрагменты без него - на 0.7946, 0.7591 и 0.7528. В устаревшем индексе старое имя стоит на 0.7852 и 0.7454, а фрагменты без него - на 0.7739, 0.7596 и 0.7539. Любой порог, который пропускает нужные фрагменты, пропускает и фрагменты без нужного имени.
Молчаливый промах. k-NN отдает k ближайших векторов независимо от того, есть ли среди них нужный, и этот контроль показывает, как такой промах выглядит. Сначала неожиданность: запрос query_string по имени тега нашел в индексе soc-knowledge-miss 12 фрагментов, хотя ни одного файла с этим тегом там быть не должно. Стандартный анализатор разрезал имя по дефисам, после чего запрос совпал с теми же двумя словами в прозе. Отсутствие тега пришлось подтверждать проходом по сырому тексту всех 1099 фрагментов, и он нашел ноль. Сам индекс собран из 48 страниц каталогов vulnerability detection и ossec-conf версии 4.14 на том же коммите с исключением каждого файла, который совпадает с vulnerability-detect(or|ion): ушли 4 файла, остались 1099 фрагментов. Поиск вернул пять ранжированных фрагментов с оценками от 0.7946 до 0.7547 и здесь, как при наличии ответа, но на этот раз модель отказалась отвечать:
Based on the documentation provided, the XML tag name for vulnerability detection is not explicitly shown in these excerpts. The documentation discusses the Vulnerability Detection module and its functionality, but does not include a section showing the XML configuration block name for vulnerability
Два вопроса после контролей остаются без ответа, и о них стоит помнить, читая любой ответ агента. Какой из пяти фрагментов модель прочла? По журналу не видно: в нем ранжированный список и текст ответа, а не путь модели по ним. Откажется ли она отвечать и завтра? Не обязательно: отказ в контроле с промахом - поведение модели в тот день, и на тех же пяти фрагментах в другой день или с другим промптом модель может уверенно назвать неверное имя. В soc-knowledge контроли ничего не пишут, там по-прежнему 7118 документов.
Что получилось на этой установке и чего результат не обещает
По прайс-листу полная загрузка стоит от $0.021 до $0.044. Тариф взят из AWS Pricing API 2026-09-04: Titan Text Embeddings V2 в us-east-1 по on-demand стоит $0.00002 за 1000 входных токенов, тип использования USE1-TitanEmbeddingV2-Text-input-tokens, дата вступления в силу 2026-08-01. Объем текста посчитан проходом по индексу через Scroll API в тот же день: 10885546 символов в 7118 документах индекса, из них 6271908 от MISP, 2929684 от документации, 1678191 от MITRE и 5763 от плейбуков. Два коэффициента из пробы токенизации дают границы в токенах, 10885546 / 10,48 = 1,04 млн и 10885546 / 4,99 = 2,18 млн, и отсюда 1,04 млн x 0.00002 / 1000 = $0.021 и 2,18 млн x 0.00002 / 1000 = $0.044. Для корпуса такого размера пакетный режим Titan заводить незачем, хотя он есть: Titan V2 поддерживает Bedrock Batch, AWS назначает пакетной обработке для поддерживаемых моделей цену на 50% ниже on-demand, но это отдельное задание через S3, у пайплайна из этой статьи такого параметра нет, а экономить пришлось бы на центах. Один запрос требует одного вектора; после векторизации все равно вызывается Claude, поэтому стоимость вектора на его фоне незаметна. Счет AWS я не читал. Оценка выше - по тарифу, и точнее ее сделать нечем: функция постобработки коннектора оставляет только вектор и отбрасывает inputTextTokenCount.
Чаще всего индекс придется загружать заново из-за правки плейбука; остальные поводы - свежий снимок MISP, новый коммит документации, новый набор MITRE после обновления менеджера, и расписание здесь ни при чем. Идентификатор документа - sha1(source, doc_id, chunk), поэтому повторный запуск на тех же данных ложится на те же _id; что при этом перезаписывается и что удаляется на изменившемся источнике, описано в разделе про обратное чтение.
Неверный ответ разбирается по трем слоям, через которые он прошел: фрагменты, индекс, генерация.
- Фрагменты. В Dev Tools повторите запрос
neuralиз раздела о запросах с тем же вопросом: пять полейtextв ответе - это ровно то, что видела модель. Если нужного факта в них нет, до модели дело еще не дошло. - Индекс. Там же выполните
termпоdoc_idили поversionдокумента, который должен был найтись; пустойhitsозначает, что документ не загружен. Векторный поиск на этот вопрос не ответит: контроль с молчаливым промахом выше вернул пять соседних фрагментов, но нужного документа среди них не было. - Генерация. Если факт в пяти полях
textесть, а в ответе модели его нет или он искажен, виноват промпт или модель, и только тогда есть смысл править инструкцию агента.
Чтобы снять оставшиеся оговорки, понадобились бы измерения, которых в этой статье нет:
- другой образ Indexer или другая версия, чтобы вывод о движке перестал держаться на одном образе;
- проба токенизации на руководстве на другом языке или на фиде с длинными JSON-атрибутами, чтобы размер фрагмента не опирался на одну страницу одного корпуса;
- сравнение сохраненного текста и векторов между двумя запусками загрузки, а не только идентификаторов, и замер того, куда ушли лишние минуты второй загрузки сверх пауз;
- второй вопрос по регламенту с другой структурой, чтобы увидеть, что шаг чтения возвращает не только для этого плейбука;
- коннектор с отдельным системным полем, чтобы проверить, держится ли вывод о
prompt.prefixза пределами этого шаблона запроса.
Когда отдельное хранилище оправданнее индекса Wazuh
Вопрос, который стоит задать до выбора, скорее эксплуатационный, чем технический: кто будет владеть вторым сервисом. Отдельное векторное хранилище приносит собственную аутентификацию, собственные резервные копии и вторую копию данных, а индекс внутри Indexer живет за тем же контролем доступа и по тому же адресу, что и алерты, и за вектором для него в Bedrock ходит сама база. При корпусе такого размера и читателях, которые и так ходят в OpenSearch, второй сервис не добавляет к поиску ничего.
Три условия меняют ответ, и первое из них оператор Wazuh встретит раньше остальных.
- Процессор и память Indexer уже заняты приемом алертов: каждый вызов векторизации проходит через тот же узел, каждый k-NN поиск выполняется на нем, и база знаний начнет конкурировать с основной задачей SIEM.
- Корпус перерастает один узел.
- Важен сам движок: в этом образе работает только
lucene, и все, что привязано к faiss или nmslib, здесь недоступно.
Заключение
Ответ из введения теперь полный: агент дочитывает плейбук до конца и называет блокировку на 12 часов вместе с правилами, владельцем и условиями эскалации, потому что получает документ целиком. Тот же индекс из 7118 документов внутри Wazuh Indexer curl и MCP-сайдкар опрашивают одним гибридным запросом. В оба измеренных дня этот запрос поставил событие с нужным хешем на первое место, а полная загрузка стоит несколько центов. Чему не верить, показали контроли: уверенности ответа, когда корпус устарел, и списку из пяти соседей, когда нужного документа нет.
Навигация по серии:
- Часть 1: ML Commons + Bedrock Connector
- Часть 2: OpenSearch MCP Server + Claude Desktop
- Часть 3: RAG с Titan Embeddings и k-NN (вы здесь)
- Часть 4: S3 Vectors и Bedrock Knowledge Bases (скоро)