Skip to content

Полный процесс поиска и ответа (RAG Pipeline) ​

Запрос на вопрос-ответ по знаниям попадает в событийно-ориентированный конвейер RAG: после распознавания намерения, переформулировки вопроса, поиска, переранжирования и сборки контекста генерируется ответ. Ответ возвращается потоком SSE, ссылки разворачиваются при выводе; менеджер потоков отвечает за восстановление событий после обрыва соединения.

Общая архитектура ​

Цепочка вопрос-ответ платформы — это событийно-ориентированный плагинный конвейер (Event-Driven Plugin Pipeline): каждая стадия представляет собой плагин, реализующий интерфейс Plugin и регистрируемый в EventManager; оркестратор (KnowledgeQAByEvent) по динамически собранному списку EventType поочерёдно запускает события, а плагины связываются в цепочку ответственности (next()). Результат генерации не пишется напрямую в HTTP-ответ, а публикуется через отдельный для каждого запроса EventBus; AgentStreamHandler складывает события в общий StreamManager (память или Redis), а HTTP-слой опросом каждые 100ms отдаёт события SSE-клиенту — такая схема естественным образом поддерживает возобновление потока после обрыва соединения и распределённое развёртывание с несколькими репликами.

Событийно-ориентированный плагинный фреймворк ​

Интерфейс Plugin и цепочка ответственности ​

internal/application/service/chat_pipeline/chat_pipeline.go определяет базовые абстракции:

go
type Plugin interface {
    OnEvent(ctx context.Context, eventType types.EventType,
        chatManage *types.ChatManage, next func() *PluginError) *PluginError
    ActivationEvents() []types.EventType
}

EventManager хранит отображение eventType → []Plugin. При Register плагины добавляются в порядке регистрации, а buildHandler строит вложенную замыкающую цепочку ответственности с конца к началу: на одном событии раньше зарегистрированный плагин оказывается во внешнем слое цепочки, а позже зарегистрированный — во внутреннем; внешний плагин входит во внутренний, только вызвав next() внутри OnEvent. Плагин может выполнить предобработку до next() (большинство плагинов) либо сначала вызвать next(), а затем выполнить постобработку (например, PluginWikiBoost применяет буст после переранжирования).

Ошибки передаются через *PluginError; среди предопределённых ошибок — ErrSearchNothing (поиск не дал результатов, что запускает резервный ответ, а не сбой), ErrRerank, ErrGetChatModel, ErrModelCall и др. (chat_pipeline.go).

Порядок регистрации (container.go) ​

Все плагины конструируются и саморегистрируются в DI-контейнере через container.Invoke (internal/container/container.go); порядок регистрации и есть порядок выполнения в цепочке одного события:

go
must(container.Provide(chatpipeline.NewEventManager))
must(container.Invoke(chatpipeline.NewPluginSearch))               // CHUNK_SEARCH
must(container.Invoke(chatpipeline.NewPluginRerank))               // CHUNK_RERANK (внешний слой цепочки)
must(container.Invoke(chatpipeline.NewPluginWebFetch))             // WEB_FETCH
must(container.Invoke(chatpipeline.NewPluginMerge))                // CHUNK_MERGE
must(container.Invoke(chatpipeline.NewPluginDataAnalysis))         // DATA_ANALYSIS
must(container.Invoke(chatpipeline.NewPluginIntoChatMessage))      // INTO_CHAT_MESSAGE
must(container.Invoke(chatpipeline.NewPluginChatCompletion))       // CHAT_COMPLETION
must(container.Invoke(chatpipeline.NewPluginChatCompletionStream)) // CHAT_COMPLETION_STREAM
must(container.Invoke(chatpipeline.NewPluginFilterTopK))           // FILTER_TOP_K
must(container.Invoke(chatpipeline.NewPluginQueryUnderstand))      // QUERY_UNDERSTAND (внешний слой цепочки)
must(container.Invoke(chatpipeline.NewPluginLoadHistory))          // LOAD_HISTORY
must(container.Invoke(chatpipeline.NewPluginMemoryRecall))         // MEMORY_RECALL
must(container.Invoke(chatpipeline.NewPluginExtractEntity))        // QUERY_UNDERSTAND (внутренний слой цепочки)
must(container.Invoke(chatpipeline.NewPluginSearchEntity))         // ENTITY_SEARCH
must(container.Invoke(chatpipeline.NewPluginSearchParallel))       // CHUNK_SEARCH_PARALLEL
must(container.Invoke(chatpipeline.NewPluginWikiBoost))            // CHUNK_RERANK (средний слой цепочки)
must(container.Invoke(chatpipeline.NewPluginMemoryAffinity))       // CHUNK_RERANK (самый внутренний слой цепочки)

Полное отображение событий на плагины (с порядком в цепочке одного события):

EventTypeПлагины (в порядке цепочки)Исходный файл
load_historyPluginLoadHistoryload_history.go
memory_recallPluginMemoryRecallmemory_recall.go
query_understandPluginQueryUnderstand → PluginExtractEntityquery_understand.go, extract_entity.go
chunk_searchPluginSearchsearch.go, query_expansion.go
chunk_search_parallelPluginSearchParallel (внутри комбинирует PluginSearch + PluginSearchEntity)search_parallel.go
entity_searchPluginSearchEntitysearch_entity.go
chunk_rerankPluginRerank → PluginWikiBoost → PluginMemoryAffinityrerank.go, wiki_boost.go, memory_affinity.go
web_fetchPluginWebFetchweb_fetch.go
chunk_mergePluginMergemerge.go, merge_overlap.go, merge_expand.go, merge_faq.go, merge_history.go
data_analysisPluginDataAnalysisdata_analysis.go
into_chat_messagePluginIntoChatMessageinto_chat_message.go
chat_completionPluginChatCompletionchat_completion.go
chat_completion_streamPluginChatCompletionStreamchat_completion_stream.go
filter_top_kPluginFilterTopKfilter_top_k.go

ChatManage: объект состояния, проходящий через весь конвейер ​

ChatManage в internal/types/chat_manage.go состоит из трёх встроенных частей:

  • PipelineRequest (неизменяемая конфигурация запроса): Query, KnowledgeBaseIDs/KnowledgeIDs/SearchTargets, VectorThreshold/KeywordThreshold/EmbeddingTopK, RerankModelID/RerankTopK/RerankThreshold, ChatModelID/SummaryConfig, FallbackStrategy, CitationEnabled, EnableRewrite/EnableQueryExpansion, стратегия FAQ (FAQPriorityEnabled/FAQDirectAnswerThreshold/FAQScoreBoost), DataAnalysisEnabled, мультимодальность (Images/VLMModelID/ChatModelSupportsVision), веб-поиск (WebSearchEnabled/WebFetchEnabled/WebFetchTopN) и т. д.
  • PipelineState (промежуточное состояние, читаемое и записываемое между плагинами): RewriteQuery, Intent, History, трёхуровневые результаты SearchResult → RerankResult → MergeResult, Entity/EntityKBIDs/GraphResult, UserContent, RenderedContexts, SystemPromptOverride и т. д.
  • PipelineContext (дескрипторы времени выполнения): EventBus, MessageID (ID сообщения assistant), UserMessageID.

ChatManage.Clone() обеспечивает глубокое копирование (при параллельном поиске это предотвращает конкурентные чтение/запись разделяемых slice), но не копирует PipelineContext.

Динамическая сборка конвейера (PipelineBuilder) ​

KnowledgeQA из session_knowledge_qa.go динамически собирает список событий по характеристикам запроса:

go
// Чистый чат (без KB и без включённого веб-поиска)
pipeline = types.NewPipelineBuilder().
    AddIf(hasHistory, types.LOAD_HISTORY).
    Add(types.MEMORY_RECALL).
    Add(types.CHAT_COMPLETION_STREAM).Build()

// RAG
pipeline = types.NewPipelineBuilder().
    AddIf(hasHistory, types.LOAD_HISTORY).
    Add(types.MEMORY_RECALL).
    Add(types.QUERY_UNDERSTAND).
    Add(types.CHUNK_SEARCH_PARALLEL).
    Add(types.CHUNK_RERANK).
    AddIf(webSearchEnabled, types.WEB_FETCH).
    Add(types.CHUNK_MERGE).
    Add(types.FILTER_TOP_K).
    AddIf(chatManage.DataAnalysisEnabled, types.DATA_ANALYSIS).
    Add(types.INTO_CHAT_MESSAGE).
    Add(types.CHAT_COMPLETION_STREAM).Build()

В map types.Pipeline также сохранены статические пресеты chat / chat_stream / chat_history_stream / rag / rag_stream и т. д. — для вызывающих сторон, которым не нужна динамическая сборка.

Оркестратор KnowledgeQAByEvent ​

KnowledgeQAByEvent (session_knowledge_qa.go) поочерёдно вызывает eventManager.Trigger и выполняет большой объём сопутствующей работы:

  • Каждая стадия оборачивается в Langfuse span (pipeline.<event_type>); исключение — CHAT_COMPLETION_STREAM (его OnEvent возвращается немедленно, и span завершился бы раньше окончания потока).
  • События прогресса: progress.go объединяет CHUNK_SEARCH_PARALLEL → CHUNK_RERANK → CHUNK_MERGE → FILTER_TOP_K (включая условные WEB_FETCH/DATA_ANALYSIS) в одно видимое фронтенду окно прогресса tool_call knowledge_search; QUERY_UNDERSTAND получает отдельное окно query_understand. Пути ошибки/короткого замыкания также закрывают окно, чтобы у фронтенда не крутился бесконечно индикатор «идёт поиск по базе знаний».
  • Ссылки заранее: до запуска CHAT_COMPLETION_STREAM вызывается emitKnowledgeReferencesEvent, который отправляет MergeResult в виде события references — так клиент гарантированно получает список ссылок до закрытия SSE-соединения.
  • Приоритет отмены: по окончании каждой стадии сначала проверяется ctx.Err() (остановка пользователем отменяет контекст), и это должно происходить раньше проверки ErrSearchNothing, иначе остановка была бы ошибочно принята за «поиск не дал результатов» и привела бы к записи резервного ответа.
  • Резерв: ErrSearchNothing → handleFallbackResponse: FallbackStrategyFixed сразу отправляет фиксированный текст FallbackResponse; FallbackStrategyModel использует FallbackPrompt, позволяя модели ответить свободно.

Подробно о плагинах по стадиям ​

LOAD_HISTORY — загрузка истории сессии ​

load_history.go. MaxRounds <= 0 означает, что агент явно отключил многоходовость (MultiTurnEnabled=false), — стадия просто пропускается, без отката к глобальному значению по умолчанию. В противном случае вызывается loadAndProcessHistory (common.go):

  1. messageService.GetRecentMessagesBySession берёт последние maxRounds*2+10 сообщений;
  2. по RequestID сообщения user/assistant попарно объединяются в types.History (на стороне user добавляются Caption изображений и промпт вложений; на стороне assistant регулярным выражением regThinkTags срезаются теги размышлений, а также переносятся KnowledgeReferences);
  3. выбираются maxRounds раундов в обратном хронологическом порядке, затем разворачиваются в прямой хронологический и записываются в chatManage.History.

Обратите внимание: для исторических сообщений user воспроизводится исходный Content, а не RenderedContent (чтобы конверт контекста старой версии не попал в текущий протокол); исторические ссылки внедряются отдельно через merge_history.go.

MEMORY_RECALL — выборка долговременной памяти ​

memory_recall.go. Если в пространстве включена долговременная память, по текущему вопросу выбирается память вызывающего, записывается в chatManage.MemoryPrompt и отправляется событие memory_recalled, чтобы фронтенд мог показать, какая память использована в этом раунде. Стадия не вызывает модель, и её сбой не прерывает вопрос-ответ. О включении и управлении памятью см. Межсессионная долговременная память.

QUERY_UNDERSTAND — переформулировка запроса + распознавание намерения (+ извлечение сущностей) ​

На одном событии последовательно связаны два плагина:

PluginQueryUnderstand (query_understand.go) отвечает за переформулировку и классификацию намерения:

  • Вход бывает трёх видов: только текст (chat model), текст + изображения, только изображения (предпочтительно chat model с поддержкой зрения, иначе VLMModelID).
  • Промпт берётся из config/prompt_templates/rewrite.yaml (пара system + user) и может быть перекрыт на уровне агента через RewritePromptSystem/RewritePromptUser; плейсхолдеры {conversation} / {query} / {language} рендерятся через types.RenderPromptPlaceholders.
  • Модель должна выдать JSON: {"rewrite_query":"...","intent":"kb_search","image_description":"..."}; разбор устойчив к помехам (обёртка markdown, псевдонимы полей, слияние полей OCR); когда ответ обрезан лимитом вывода, по полям спасаются уже записанные rewrite_query, intent и неполный image_description; если разобрать не удаётся вовсе, сохраняется исходный запрос, а намерение остаётся пустым (обрабатывается как требующее поиска). Для раундов с изображениями лимит вывода — 2048 token (промпт требует поместить полный текст OCR в image_description).
  • Перечисление намерений (types.QueryIntent): kb_search, web_search, greeting, chitchat, follow_up, image_only, doc_only, summarize, clarification. Намерение от модели сначала нормализуется через types.NormalizeQueryIntent (регистр и -/пробелы игнорируются, например KB-Search → kb_search), а неизвестные метки трактуются как пустое значение. NeedsKBRetrieval() возвращает true только для kb_search/clarification/summarize/пустого значения; ChatManage.NeedsRetrieval() для web_search дополнительно проверяет WebSearchEnabled (если выключено, идёт промпт намерения, сообщающий пользователю, что сейчас поиск в интернете недоступен). Все последующие поисковые плагины используют NeedsRetrieval() как условие пропуска.
  • При непоисковом намерении applyIntentPromptOverride применяет config/prompt_templates/intent_prompts.yaml (id шаблона взаимно однозначно соответствует значению намерения, например greeting) либо перекрытие настроек агента SystemPromptOverride.
  • Описание изображения асинхронно записывается обратно в Images[0].Caption сообщения user (для использования в истории следующего раунда).
  • Можно задать QueryUnderstandModelID, чтобы выделить для этой стадии отдельную небольшую модель; при неудаче происходит откат к ChatModelID.

PluginExtractEntity (extract_entity.go) выполняется во внутреннем слое цепочки: только при NEO4J_ENABLE=true, когда намерение текущего раунда требует поиска по базе знаний (при приветствиях, светской беседе и т. п. не вызывается) и в области поиска есть база знаний с ExtractConfig.Enabled, он предпочтительно использует переформулированный запрос и с шаблоном config.ExtractManager.ExtractEntity (graph_extraction.yaml) вызывает LLM для извлечения сущностей запроса, записывая их в chatManage.Entity / EntityKBIDs / EntityKnowledge для использования в ENTITY_SEARCH.

CHUNK_SEARCH_PARALLEL — параллельный поиск (chunk + сущности графа) ​

search_parallel.go. Если NeedsRetrieval() ложно, стадия сразу пропускается. Иначе chatManage копируется дважды через Clone() и обе копии выполняются конкурентно через RunParallel:

  • chunk_search: внутренний (не зарегистрированный) PluginSearch.OnEvent(CHUNK_SEARCH, ...);
  • entity_search: при наличии сущностей выполняется PluginSearchEntity.OnEvent(ENTITY_SEARCH, ...), в Neo4j параллельно по NameSpace{KnowledgeBase, Knowledge} вызывается SearchNode, найденные узлы/связи графа преобразуются в SearchResult и собирается GraphResult. Блоки и документы запрашиваются по ID (без ограничения текущим рабочим пространством — попадания графа в общие для организации базы знаний тоже разрешаются), сохраняются только блоки, принадлежащие базам знаний из области поиска текущего раунда, всё ещё включённые и с существующим документом, максимум 30 штук; у попаданий графа нет поискового балла (Score=0), при переранжировании он заменяется баллом модели.

После объединения результатов двух веток removeDuplicateResults выполняет дедупликацию (по ID chunk + сигнатуре содержимого searchutil.BuildContentSignature). Если обе ветки пусты, возвращается ErrSearchNothing.

PluginSearch (search.go) внутри также распараллеливается на две ветки:

  1. Поиск по KB searchByTargets:
  • SearchTargets группируются по «идентичности embedding-модели (model.Name + BaseURL, может быть общей для разных тенантов)» (ResolveEmbeddingModelKeys), и в каждой группе вектор запроса вычисляется один раз (GetQueryEmbedding);
  • цели «вся база» без ограничений по тегам/документам в группе объединяются в один вызов HybridSearch (params.KnowledgeBaseIDs несёт несколько баз), цели с ограничениями обрабатываются по одной через searchSingleTarget (с KnowledgeIDs/TagIDs/ScopeTagIDs, причём для целей с явно очерченной областью можно отключить порог выборки через DisableRecallThresholds);
  1. Веб-поиск searchWebIfEnabled: при WebSearchEnabled через разрешённый для тенанта/агента WebSearchProviderID вызывается webSearchService.Search, а результаты преобразуются в SearchResult через searchutil.ConvertWebSearchResults (URL как ID, KnowledgeSource="web_search").

Расширение запроса (query_expansion.go): срабатывает при EnableQueryExpansion и когда число первичных попаданий меньше EmbeddingTopK. Без вызова LLM локально генерируются варианты запроса (удаление стоп-слов, перестановка порядка слов, извлечение ключевых фраз и т. п.). Китайская сегментация идёт через types.Jieba.CutForSearch: непрерывные фрагменты иероглифов целиком передаются jieba для разбиения на слова, а смешанные китайско-английские/цифровые участки делятся по переключению письменностей, больше не деградируя до «один иероглиф — один token»; фильтрация по стоп-словам и длине ведётся подсчётом rune, чтобы многобайтовые символы не принимались за один символ, и для каждой комбинации (вариант × SearchTarget) конкурентно (семафор не более 16) выполняется HybridSearch только по ключевым словам (варианты увеличивают выборку лишь на стороне ключевых слов, векторная сторона почти совпадает с исходным запросом и больше не переэмбеддится для каждого варианта), порог ключевых слов ослабляется до 0.8 от исходного, а TopK увеличивается до max(EmbeddingTopK, RerankTopK) * 2; при отключённом поиске по ключевым словам расширение не выполняется.

CHUNK_RERANK — переранжирование, составное ранжирование, MMR, Wiki-буст ​

PluginRerank (rerank.go, 720 строк):

  1. Очистка passage cleanPassageForRerank: переранжирующая модель работает с семантическим сходством, а синтаксис структуры Markdown для неё — шум. Из блоков кода и формул $$...$$ снимается только ограждение, а внутренний текст сохраняется (в ранней реализации блок удалялся целиком, и кандидаты из чистого кода или формул очищались в пустую строку, теряя балл); HTML-теги, ссылки на изображения, разметка ссылок (с сохранением текста), «голые» URL, строки-разделители таблиц (строки данных склеиваются через запятую), разметка заголовков/цитат/полужирного/списков поочерёдно снимаются, а в конце сжимаются лишние пустые строки.
  2. Обогащение passage getEnrichedPassage: подклеивается текст Caption/OCR из ImageInfo и вопросы, сгенерированные в ChunkMetadata (GeneratedQuestions); идентичный основному тексту текст изображения (сами блоки OCR / описаний) повторно не подклеивается. Если для модели задан лимит длины одного фрагмента или одного запроса, длинный passage обрезается с конца до лимита; кандидаты по поисковому баллу берутся не более 200 штук и отправляются на оценку.
  3. Вызов rerankModel.Rerank(ctx, RewriteQuery, passages) с фильтрацией по RerankThreshold:
  • все ниже порога и top1 ≥ rerankFallbackMinScore (по умолчанию 0.15; при явно очерченной пользователем области тегов/документов — 0, сохраняя лучшего кандидата авторитетной области) → сохраняется top1 как резерв;
  • нет результатов и порог > 0.3 → понижение порога с одной повторной попыткой (threshold * 0.7, нижняя граница 0.3);
  • сбой загрузки переранжирующей модели (удалена, неверная конфигурация) или сбой API (включая таймаут 60 секунд по умолчанию) → откат к исходным результатам поиска и продолжение конвейера.
  • Попадания изображений (image_vector, поиск по вектору самого изображения, см. Поиск изображений): текстовая переранжирующая модель видит только описание и текст OCR и может отбросить попадание, которого «нет в описании». Обработка двух видов (internal/reranking/image.go):
  • переранжирующая модель умеет работать с изображениями (возможность модели input содержит image, в каталоге — jina-reranker-m0, nvidia/llama-nemotron-rerank-vl-1b-v2): после чтения изображения (KnowledgeBaseService.ReadChunkImage, приведение формата и сжатия по ограничениям провайдера) отправляется само изображение, оценка идёт в той же шкале и по тому же порогу, что и для текста; при сбое чтения или оценки — откат к оценке по тексту.
  • текстовая переранжирующая модель: у изображений с OCR ≥ 200 символов (скриншоты, таблицы, фотографии документов) текст по сути и есть содержимое, и они проходят порог по тексту как обычно; когда изображение «картинного» типа с OCR < 200 символов отклоняется, а исходный векторный балл (VectorScore, зафиксированный до слияния поиска) ≥ max(порог вектора, 0.25), по векторному баллу сверху вниз сохраняется максимум 2 изображения — они дописываются в конец отсортированного результата с пометкой Metadata["kept_by"]="image_vector". Такие изображения возвращаются и тогда, когда ни один текст не прошёл порог.
  • в диагностику записываются images_scored / images_kept.
  1. Составное ранжирование compositeScore: 0.6*балл модели + 0.3*базовый балл поиска + 0.1*вес источника (вес источника web_search — 0.95, остальные — 1.0), с приведением к [0,1]. У блоков, найденных как сущности графа, нет поискового балла, поэтому базовым баллом служит балл модели. Базовый балл / балл модели / составной балл записываются в Metadata["base_score"] / ["model_score"] / ["composite_score"]. В ранних версиях также умножался позиционный приор «чем ближе к началу документа, тем выше» (±0.05), но поскольку он был связан с меняющимся после ручного редактирования блоков смещением и давал неясную выгоду, он был удалён.
  2. Буст FAQ: при FAQPriorityEnabled и FAQScoreBoost > 1.0 балл блока FAQ умножается на boost и записывается Metadata["faq_boosted"]. Результат не ограничивается сверху значением 1.0 — ограничение свело бы все высокобалльные FAQ к 1.0, и их взаимный порядок выродился бы в tie-breaker.
  3. Выбор разнообразия MMR reranking.SelectMMR (λ=0.7, k=RerankTopK): mmr = 0.7*relevance - 0.3*max_jaccard_redundancy. Обогащённый текст блока токенизируется один раз через searchutil.TokenizeSimple, после чего строится обратный индекс «token → индекс кандидата». На каждом шаге накапливается только пересечение кандидатов, разделяющих token с уже выбранным блоком, Jaccard считается как пересечение / (сумма чисел token с обеих сторон − пересечение), а кэш максимальной избыточности обновляется; кандидаты без общих token сравнивать не нужно. Индекс хранится непрерывными массивами, дополнительная память пропорциональна сумме token у кандидатов, а результат выбора и порядок при равных баллах сохраняются. При k=1 выбирается сразу по релевантности, без очистки passage, токенизации и построения индекса. Реализация общая для чата, инструмента поиска агента и API поиска.

PluginMemoryAffinity (memory_affinity.go) зарегистрирован в самом внутреннем слое цепочки и также сначала вызывает next(), а затем выполняет постобработку: для документов, на которые этот вызывающий в прошлых ответах ссылался минимум 2 раза, применяется вес, логарифмически растущий от числа использований, максимум ×1.15, — только чтобы разрешать ничьи между близкими кандидатами. И лишь затем наступает очередь пост-буста WikiBoost.

PluginWikiBoost (wiki_boost.go) зарегистрирован в среднем слое цепочки того же события; OnEvent сначала вызывает next() (дожидаясь завершения переранжирования), а затем выполняет постобработку: если в RerankResult есть блок типа wiki_page и среди целей поиска действительно есть KB с включённым Wiki, балл умножается на wikiBoostFactor = 1.3 и выполняется устойчивая пересортировка — Wiki-страницы представляют собой предварительно синтезированные LLM знания и приоритетнее исходных блоков.

WEB_FETCH — извлечение полного текста веб-страниц ​

web_fetch.go. Только при WebFetchEnabled && WebSearchEnabled. Из RerankResult берутся первые WebFetchTopN (по умолчанию 3) веб-результата, и параллельно web_fetch.FetchURLContent(ctx, url) извлекает основной текст, заменяя краткий snippet (обрезается до 8000 байт). Стадия стоит после переранжирования и перед объединением — затраты на извлечение оплачиваются только для высокобалльных веб-страниц, попадающих в контекст.

CHUNK_MERGE — восьмишаговое слияние и объединение ​

Комментарий к OnEvent в merge.go и есть описание процесса:

  1. Выбор входа: приоритетно RerankResult, при пустом — откат к SearchResult (сортировка по баллу и обрезка до RerankTopK, чтобы не делать обратный запрос в БД и расширение для всех результатов выборки);
  2. Дедупликация: ID + сигнатура содержимого;
  3. Внедрение исторических ссылок (merge_history.go): из последнего раунда истории со ссылками берутся KnowledgeReferences, фильтруются по тому, какую долю слов запроса покрывает ссылка (коэффициент пересечения |q∩c|/min(|q|,|c|) ≥ 0.3; Jaccard делится на объединение, поэтому запрос из ~8 слов против блока в сотни слов никогда не достигает порога), балл умножается на 0.6, внедряется не более 3 штук, помечаются MatchTypeHistory;
  4. Разбор родительских/дочерних блоков resolveParentChunks: и текстовые дочерние блоки, и дочерние блоки image_ocr/image_caption/image_vector добирают контекст из текущего содержимого parent_text; сужение Markdown изображений опирается на стабильный URL изображения (PruneMarkdownImagesByImageInfo), а не на координаты парсера; ImageInfo строго ограничивается попавшим текстовым дочерним блоком, чтобы насыщенные изображениями родительские блоки не заливали в контекст весь OCR соседних страниц. Цепочка image → text → parent_text делает дополнительный запрос к блоку-деду только когда действительно есть попадание в результат-изображение;
  5. Слияние с сохранением порядка и группировкой groupAndMergeCurrentContent: группировка по KnowledgeID + ChunkType, внутри группы сортировка по ChunkIndex и затем mergeSequentialChunks — при последовательных номерах или когда содержимое одного включает содержимое другого выполняется склейка через searchutil.JoinChunkContent, сохраняется наибольший балл, в SubChunkID записываются объединённые блоки, а mergeImageInfo объединяет информацию об изображениях с дедупликацией по URL;
  6. Заполнение ответов FAQ (merge_faq.go): блоки типа FAQ пакетно читаются из БД по FAQMetadata, содержимое переписывается в Q: стандартный вопрос + Answer: список ответов;
  7. Расширение соседями при коротком контексте (merge_expand.go): когда содержимое текстового блока короче 350 символов, пакетно берутся соседи PreChunkID/NextChunkID и приклеиваются до примерно 850 символов. Сам попавший блок сохраняется целиком, а оставшаяся длина распределяется между концом непосредственно предшествующего и началом последующего текста; соседи должны принадлежать тому же документу, а блоки общих для организации баз знаний тоже подлежат расширению;
  8. Новые дубликаты, привнесённые расширением, объединяются ещё раз, затем финальная дедупликация + removePartialOverlaps (удаление кросс-базовых почти-дубликатов по нормализованному включению / доле пересечения token ≥ 0.85, удаляется тот, у кого балл ниже; доля пересечения сравнивается только когда число token у обоих отличается не более чем в 3 раза, чтобы длинные веб-страницы или родительские блоки не «поглощали» короткие блоки; каждый текст токенизируется один раз). У одного и того же изображения попадание image_vector и попадание по описанию дают одинаковый текст после дополнения контекстом, и при дедупликации может остаться любое из них; если отброшено именно попадание изображения, у сохранённой записи ставится Metadata["image_vector_match"]="true", и изображение всё равно можно передать визуальной модели.

Результат записывается в chatManage.MergeResult.

Почему больше не используются символьные смещения

После того как блоки стали поддерживать ручное редактирование, координаты парсера StartAt / EndAt перестали надёжно обозначать «положение текущего содержимого в исходном тексте» — одно редактирование может рассогласовать длину интервала и длину текста. Поэтому стадия объединения полностью перешла на определение смежности и включения через текущий текст + порядковый номер ChunkIndex (JoinChunkContent / ContainsChunkContent выполняют дедупликацию и склейку на уровне текста), а исходные координаты сохраняются только для локализации ссылок. Ключ разрешения ничьих у FILTER_TOP_K также сменился с StartAt/EndAt на ChunkIndex.

FILTER_TOP_K — детерминированная сортировка и усечение ​

filter_top_k.go. Над MergeResult (при отсутствии последовательно откатываясь к RerankResult/SearchResult) выполняется sortSearchResultsDeterministically — по убыванию балла, с устойчивым разрешением ничьих по KnowledgeID/ChunkType/ChunkIndex/ID (обход map на стадии merge перемешивает порядок, а здесь восстанавливается глобально воспроизводимая сортировка), затем результат усекается до RerankTopK. Результаты, сохранённые переранжированием за пределами TopK (kept_by, см. попадания изображений в CHUNK_RERANK), не занимают мест и дописываются после усечённого результата.

DATA_ANALYSIS — табличный анализ данных в DuckDB ​

data_analysis.go. По умолчанию выключено (DataAnalysisEnabled берётся из конфигурации агента). Если в MergeResult попал файл CSV/Excel: сначала отфильтровываются блоки типов table_column/table_summary, берётся первый файл с данными, через tools.NewDataAnalysisTool файл загружается в DuckDB и извлекается schema, затем LLM решает, нужен ли анализ данных, и генерирует SQL для DuckDB (структурированный вывод DataAnalysisInput); после выполнения результат добавляется в MergeResult как синтетический SearchResult с MatchTypeDataAnalysis и score=1.0.

INTO_CHAT_MESSAGE — сборка контекста ​

into_chat_message.go:

  • utils.ValidateInput проверяет безопасность запроса (защита от инъекций);
  • путь непоискового намерения: всё равно проходит рендеринг ContextTemplate (contexts пуст), чтобы внедрить current_time и другие метаданные времени выполнения;
  • Приоритетная стратегия FAQ: при FAQPriorityEnabled результаты FAQ и документов разделяются на две секции — source type="faq" priority="high" и source type="document" priority="supplementary"; у FAQ, чей балл до взвешивания (composite_score, а без переранжирования — поисковый балл) ≥ FAQDirectAnswerThreshold, его context помечается match="exact" (подсказывая модели, что этот ответ можно принять напрямую). Сравнение баллов именно до взвешивания нужно, чтобы бусты FAQ/Wiki/памяти не поднимали среднее совпадение выше порога;
  • обычный путь нумерует и оборачивает каждый обогащённый passage в порядке context id="N" (getEnrichedPassageForChat встраивает ImageInfo в содержимое как Markdown-изображение + описание);
  • заголовок buildDocumentHeader выводит дедуплицированные метаданные документов (title/description);
  • рендерится SummaryConfig.ContextTemplate (из config/prompt_templates/context_template.yaml) с плейсхолдерами {query} / {contexts} / {language}; добавляются описания изображений (для не-визуальных моделей), контекст цитат QuotedContext, промпт вложений;
  • Поиск изображений: когда модель поддерживает визуальный ввод, изображения из context'ов, попавших через вектор изображения (image_vector или с image_vector_match), читаются (дедупликация по изображению, максимум 3, приведение к PNG/JPEG/WebP/GIF и ≤ 5 МБ), сохраняются в ContextImages и при генерации отправляются вместе с изображениями, загруженными самим пользователем; подлинность исходного изображения сохраняется до развёртывания родительских блоков и дедупликации (matched_images), после слияния читаются фактически попавшие изображения; в финальном сообщении модели каждый источник помечается псевдонимом chunk'а из запроса и учитывается перед пользовательскими изображениями. Благодаря этому модель напрямую видит сведения, которых нет в описании, но есть на картинке. В режиме агента search_knowledge сохраняет отдельные кандидаты-изображения до переранжирования и, если модель поддерживает визуальный ввод, также включает изображения в результат инструмента, помечая источники псевдонимами (в истории сохраняется только подлинность исходного изображения, а не байты изображения);
  • собранный UserContent асинхронно записывается обратно в RenderedContent сообщения user (persistRenderedContent) — для аудита и отладки; в RenderedContexts сохраняется чистая строка contexts для подстановки ссылок.

CHAT_COMPLETION / CHAT_COMPLETION_STREAM — генерация ​

Оба плагина используют вспомогательные функции из common.go:

  • prepareChatModel: берёт chat model и собирает ChatOptions из SummaryConfig (Temperature/TopP/Seed/MaxTokens/Thinking и т. д.);
  • prepareMessagesWithHistory: system prompt = SystemPromptOverride (перекрытие намерения) или SummaryConfig.Prompt (system_prompt.yaml); после рендеринга плейсхолдеров, если в контексте поиска есть Markdown-изображения, добавляется абзац «требование к выводу найденных изображений» (appendRetrievedImageOutputRequirement); затем в хронологическом порядке добавляются пары вопрос/ответ из истории и, наконец, текущее сообщение user (визуальным моделям прилагаются Images).

prepareMessagesWithModelContext из references.go поверх этого выполняет подстановку псевдонимов ссылок (подробнее см. Механизм генерации ссылок (Citation)): контексты с позиционной нумерацией из RenderedContexts заменяются на изолированное по запросу представление псевдонимов блоков, генерируемое modelcontext.Registry, а в конец system prompt добавляется протокол ссылок.

Потоковая версия (chat_completion_stream.go) требует наличия EventBus; после вызова chatModel.ChatStream запускается goroutine, потребляющая канал ответов:

  • ResponseTypeThinking → после прохождения через modelcontext.StreamDecoder (один и тот же декодер и восстанавливает псевдонимы ресурсов res://, и разворачивает теги ссылок ref) отправляется как EventAgentThought;
  • ResponseTypeAnswer → после такого же декодирования отправляется как EventAgentFinalAnswer. Терминальный ответ с Done передаётся только один раз: некоторые вендоры сначала отправляют завершение по finish_reason, а затем ещё раз по сторожевому значению конца потока, и повторная передача поставила бы событие ответа после события complete сессии;
  • Усечение: когда finish_reason равен length / max_tokens / max_output_tokens (без учёта регистра), событие ответа несёт truncated, и фронтенд показывает рядом с ответом предупреждение об усечении содержимого; если до усечения не было выведено ни одного символа, ответом становится фиксированная подсказка с советом сузить вопрос или увеличить max_completion_tokens. Резервный ответ модели при отсутствии результатов поиска (handleFallbackResponse) обрабатывается так же;
  • ResponseTypeError → EventError;
  • при закрытии канала или отмене ctx flushDecoders сбрасывает буферизованные хвостовые байты декодеров (чтобы не потерять псевдонимы, разорванные между chunk), после чего закрывается поток thinking.

Непоточная версия (chat_completion.go) вызывает Chat, затем modelContext.DecodeResponse один раз восстанавливает весь текст (дескрипторы ресурсов и теги ссылок), а результат пишется в chatManage.ChatResponse.

Полная схема процесса RAG ​

Управление сессиями и сообщениями ​

Session Service (session.go) ​

  • Полный набор CRUD: CreateSession / GetSession (тенант + общая область) / GetOwnedSession (строгая проверка владельца, для деструктивных операций вроде stop) / постраничный список / SetSessionPinned / UpdateSessionLastRequestState (состояние строки ввода памяти: выбор агента/модели/KB/веб-поиска/уровня размышлений и т. п., только для UI) / удаление одного, пакетное удаление, очистка.
  • Ветвление и откат: session_fork.go копирует историю в новую сессию (фиксируя parent_session_id / forked_from_message_id), session_rewind.go удаляет на месте сообщения после точки отката; оба опираются на git-контрольные точки рабочего пространства песочницы, записываемые по завершении каждого раунда (workspace_checkpointer.go), для восстановления /workspace; интерфейсы см. в API сессий и чата.
  • Генерация заголовка: GenerateTitleAsync асинхронно запускается на стадии сборки SSE (когда у сессии нет заголовка), по шаблону generate_session_title.yaml вызывает ту же модель, что и диалог, а результат уходит событием EventSessionTitle (SSE response_type=session_title); HTTP-слой после complete ждёт событие заголовка не более 3 секунд.

Message Service (message.go) ​

  • Сообщения user и assistant связываются в один раунд по одинаковому RequestID; сообщение user сразу при запросе получает IsCompleted=true, а сообщение assistant после завершения потока (или stop) дополняется содержимым и ссылками через completeAssistantMessage.
  • UpdateMessageRenderedContent / UpdateMessageImages асинхронно вызываются для обратной записи соответственно из INTO_CHAT_MESSAGE и QUERY_UNDERSTAND.
  • GetRecentMessagesBySession — источник данных для загрузки истории.
  • Дополнительные возможности: IndexMessageToKB (запись пар вопрос-ответ в «базу знаний истории чата» для межсессионного поиска), SearchMessages (поиск по сообщениям вектор + rerank).

Механизм потокового вывода ​

StreamManager: append-only поток событий ​

internal/stream/factory.go выбирает реализацию по переменной окружения STREAM_MANAGER_TYPE:

РеализацияХранилищеКлючевые моменты
memory (по умолчанию)внутрипроцессный map[sessionID]map[messageID]*events + RWMutexРазвёртывание на одной машине; GetEvents возвращает копию событий, избегая гонок
redisRedis List, key = {REDIS_PREFIX или stream:events}:{sessionID}:{messageID}AppendEvent = RPUSH + обновление TTL (фабрика передаёт 1 час); GetEvents = LRANGE offset..-1; разделяется между репликами, а событие stop тоже передаётся между узлами через него

У интерфейса всего два метода: AppendEvent(ctx, sessionID, messageID, StreamEvent) и GetEvents(ctx, sessionID, messageID, fromOffset) (events, nextOffset, error) — производитель только дописывает, потребитель забирает по offset, благодаря чему в любой момент и на любом узле можно воспроизвести поток с самого начала.

Поток событий: EventBus → AgentStreamHandler → StreamManager → SSE ​

  1. setupSSEStream (qa.go) создаёт для каждого запроса отдельный event.EventBus и отменяемый asyncCtx;
  2. AgentStreamHandler.Subscribe() (agent_stream_handler.go) подписывается на события thought / tool_call / tool_result / references / final_answer / reflection / error / session_title / agent.complete / одобрение инструментов / MCP OAuth и др., преобразует их в StreamEvent и дописывает в StreamManager. Он же накапливает в памяти answerSegments (сегментированные по ID события answer; «вступительное слово» незавершённого раунда помечается superseded при появлении последующего tool_call и не попадает в сохраняемый ответ) и knowledgeRefs, а по завершении потока собирает сообщение assistant и пишет его в БД;
  3. HTTP-слой handleAgentEventsForSSE (stream.go) с ticker 100ms опрашивает GetEvents, оборачивает каждое StreamEvent через buildStreamResponse в types.StreamResponse и отправляет через c.SSEvent("message", response); по получении события complete работа завершается (для новой сессии можно ещё до 3 с ждать событие заголовка).

Протокол SSE и типы событий response_type ​

Заголовки SSE задаются setSSEHeaders (text/event-stream, no-cache, keep-alive, X-Accel-Buffering: no). Каждое SSE-сообщение message — это JSON StreamResponse (internal/types/chat.go):

go
type StreamResponse struct {
    ID                  string       `json:"id"`            // request_id
    ResponseType        ResponseType `json:"response_type"`
    Content             string       `json:"content"`       // инкрементальный chunk, накопление — на стороне фронтенда
    Done                bool         `json:"done"`
    KnowledgeReferences References   `json:"knowledge_references,omitempty"`
    SessionID           string       `json:"session_id,omitempty"`
    AssistantMessageID  string       `json:"assistant_message_id,omitempty"`
    Data                map[string]interface{} `json:"data,omitempty"`
    ...
}

Полный список response_type (internal/types/chat.go; есть ещё stop, используемый на уровне handler; steer существует только в серверном подсписке очереди и в SSE не попадает):

response_typeЗначение
agent_queryЗапрос принят, несёт session_id / assistant_message_id (отсюда клиент получает message_id, необходимый для возобновления потока)
thinkingИнкремент процесса размышления (reasoning_content)
answerИнкремент текста ответа; при усечении по лимиту одного вывода модели несёт data.truncated: true
referencesСписок ссылок на знания (поле knowledge_references)
tool_call / tool_resultВызов и результат инструмента агента/прогресса (прогресс knowledge_search, query_understand конвейера RAG тоже идёт этими двумя типами); сбой выполнения инструмента тоже возвращается как tool_result с data.success=false
command_output / install_outputИнкрементальный вывод в процессе выполнения команды / установки навыка, обновляет только активную карточку инструмента
reflectionРефлексия агента
session_titleАсинхронно сгенерированный заголовок сессии
errorСбой всего раунда выполнения (Done=true означает терминальную ошибку); сбой отдельного инструмента этим типом не идёт
completeМаркер окончания потока (фронтенд завершает по нему, больше не полагаясь на пустой answer+done)
tool_approval_required / tool_approval_resolvedЗапрос/результат одобрения опасного MCP-инструмента
mcp_oauth_required / mcp_oauth_resolvedЗапрос/результат авторизации MCP OAuth
memory_recalledВнедрённая в этом раунде долговременная память
artifacts_pendingОтвет уже завершён, но артефакты песочницы ещё сохраняются; список файлов приходит с complete
user_message_injectedДобавленное во время выполнения сообщение доставлено агенту и записано в историю
context_compactedПроизошло сжатие контекста агента
install_promptПервое событие записи об установке навыка (инструкция установки)
stopУведомление об остановке пользователем (конструируется на уровне handler)

Возобновление потока после обрыва (continue-stream) и остановка ​

Возобновление: GET /sessions/continue-stream/:session_id?message_id=... (stream.go ContinueStream). После проверки сессии и сообщения с offset 0 выполняется GetEvents — воспроизведение всей истории событий (continue_stream_coalesce.go объединяет в один кадр идущие подряд незавершённые инкременты answer/thinking/reflection с одним ID события, чтобы длинный ответ не воспроизводился десятками тысяч кадров); если complete уже присутствует, работа сразу завершается, иначе продолжается опрос с отправкой новых событий каждые 100ms вплоть до complete — поскольку goroutine генерации и SSE-соединение полностью развязаны (события пишутся в StreamManager), обновление страницы или кратковременный обрыв сети не прерывают генерацию.

Остановка: POST /sessions/:id/stop (со строгой проверкой владельца) дописывает в StreamManager событие stop; его потребляют два пути: цикл опроса SSE, обнаружив его, отправляет в EventBus EventStop; независимый startStopWatcher (опрос каждые 300ms, не зависит от соединения клиента, со страховочным таймаутом 2 часа) гарантирует, что при уже отключённом клиенте stop всё равно отменит генерацию. Получив EventStop, setupStopEventHandler вызывает cancel() для asyncCtx и через context.WithoutCancel сохраняет уже отправленную часть содержимого.

Диаграмма последовательности потокового вопрос-ответа ​

Механизм генерации ссылок (Citation) ​

Псевдонимы источников уровня запроса и разворачивание ref (sources.go / citations.go) ​

internal/modelcontext/ (sources.go, citations.go, единообразно предоставляемые наружу через Registry из registry.go; прежний internal/llmreference/ вошёл в этот пакет). Цель: внутренние ID не попадают в контекст модели, а выводимые моделью ссылки можно безопасно разворачивать.

  • Registry (по одному экземпляру на каждый ответ, охватывает все раунды инструментов агента и никогда не персистится между запросами) назначает источникам низкоэнтропийные псевдонимы: cN = блок знания, wN = веб-страница, dN = документ, bN = база знаний.
  • ProtocolPrompt() добавляется в system prompt (включены ли ссылки, определяется при NewRegistry(citationsEnabled)): при включённых ссылках модель обязана давать встроенные ссылки в виде самозакрывающегося тега ref id="cN" (запрещено создавать собственные теги kb/web); при отключённых (при PipelineRequest.CitationEnabled=false, по умолчанию ссылки включены) любой вывод ссылок запрещён.
  • prepareMessagesWithModelContext из references.go регистрирует MergeResult через RegisterSearchResults в порядке приоритета FAQ и через ModelToolResult (внутри работает ModelOutput из model_output.go) рендерит результаты знаний/веб в компактное XML-представление, ориентированное на модель (display_type=search_results / web_search_results), и заменяет исходные RenderedContexts в сообщениях.
  • Теги ref в выводе модели разворачиваются через ExpandText / StreamDecoder (потоково, обрабатывая теги, разорванные между chunk) в публичные теги: блок → тег kb (с атрибутами chunk_id, knowledge_id и др.), веб-страница → тег web url title; неизвестные псевдонимы fail-closed просто удаляются. По ним фронтенд рендерит надстрочные индексы ссылок.
  • Независимо от встроенных ссылок, MergeResult всегда передаётся целиком событием SSE references (питая панель «результаты выборки»), даже когда встроенные ссылки отключены.

Псевдонимы дескрипторов хранимых ресурсов (resources.go) ​

internal/modelcontext/resources.go (прежний internal/llmresource/) решает другую задачу: высокоэнтропийные дескрипторы хранилищ вида resource://, minio://, cos://, а также slug wiki summary/<uuid> при попадании в контекст модели легко искажаются при пересказе URL моделью. Тот же Registry через EncodeMessages заменяет их низкоэнтропийными псевдонимами вида res://0001 (дескрипторы ресурсов кодируются раньше псевдонимов источников, порядок фиксирован внутри Registry, см. комментарий к типам в registry.go); потоковый вывод восстанавливается через StreamDecoder (Flush гарантирует, что псевдоним, разорванный между chunk, не потеряется), а аргументы вызовов инструментов дополняются реальными дескрипторами через DecodeToolCalls.

Параллельный кросс-базовый поиск и слияние (HybridSearch) ​

internal/application/service/knowledgebase_search.go — точка сбора всего поиска (общий код для конвейера чата, инструментов агента и поискового API):

  1. Авторизация и проверка: пакетная загрузка KB (включая общие для организации кросс-тенантные базы), по каждой базе authorizeKBAccess; validateSameEmbeddingModel отклоняет поиск по нескольким базам из разных embedding-пространств (для wiki/graph без векторной базы есть исключение).
  2. Нормализация входных параметров + перевыборка: MatchCount <= 0 (при отсутствии параметра от вызывающей стороны после десериализации JSON это 0) сначала нормализуется через normalizedMatchCount в types.DefaultRetrievalTopK (50), чтобы нижняя граница перевыборки, условие запуска итераций FAQ и финальное усечение читали одно и то же значение, — иначе усечение порезало бы результирующий набор до [:0], а отрицательное значение ещё и вызвало бы панику выхода за границы; затем matchCount = max(MatchCount*5, 50) * len(KBs) с верхней границей maxRetrievalPoolSize (500).
  3. Вектор запроса вычисляется один раз и передаётся через params.QueryEmbedding во все группы store.
  4. Группировка storeGroup (knowledgebase_search_storegroup.go): группировка по (VectorStoreID, тенант-владелец); в каждой группе через retriever.CreateRetrieveEngineForKB разрешается CompositeRetrieveEngine. buildRetrievalParams маршрутизирует по типу каждой KB в группе: база FAQ идёт по векторному индексу FAQ (KnowledgeType=faq, без индекса по ключевым словам), база документов — по стандартному векторному индексу + индексу по ключевым словам.
  5. Поиск изображений (knowledgebase_search_image.go): applyImageRecall разбирается с векторной моделью и проверяет, умеет ли она работать с изображениями, только когда хотя бы одна база знаний включила векторы изображений (ImageProcessingConfig.ImageVectorEnabled, по умолчанию выключено); если выполнено и то и другое, эти базы записываются в ImageKBIDs соответствующей группы, TopK документного векторного поиска группы увеличивается в 1.5 раза (всё ещё с потолком 500), а порог снижается до min(VectorThreshold, 0.1). Сходство текстового запроса с изображением по определению ниже, чем с текстом (разрыв между модальностями), и общий текстовый порог отсек бы почти все изображения. Результат каждой группы перед нормализацией проходит filterImageHits, где порог применяется по источникам: текстовые попадания по-прежнему идут по VectorThreshold, попадания изображений (ImageSourceType) — по min(VectorThreshold, 0.1); строки изображений в попаданиях по ключевым словам отбрасываются всегда (их Content — это описание изображения, у описания уже есть собственный индекс по ключевым словам); точно так же отбрасываются попадания векторов изображений из баз знаний, которых нет в ImageKBIDs (не включали, отключили после включения или документ переехал из включённой базы), а когда в группе нет ни одной включённой базы, TopK и порог остаются неизменными. Эти векторные блоки и строки индекса не удаляются: при удалении / повторном разборе документа и при удалении базы знаний они чистятся по идентификатору знания вместе с документом и восстанавливаются после повторного включения базы знаний. Движок не фильтрует по типу источника, поэтому отброшенные строки изображений исключаются уже после того, как движок усёк TopK, и когда они идут впереди текста, занимают место в пуле кандидатов; поэтому перед фильтрацией refillPastDroppedImages выполняет повторную выборку: если документный векторный или ключесловой пул заполнен и содержит отброшенные строки изображений, TopK удваивается и поиск повторяется, пока в пуле не окажется нужное число сохраняемых строк, не исчерпается индекс или не будет достигнут maxRetrievalPoolSize (500), после чего пул усекается до TopK сохраняемых строк (для пула в 50 строк — не более 4 дополнительных запросов). Цена — потенциально несколько дополнительных поисковых запросов в группах со строками изображений; если строк изображений впереди текста больше примерно 500, текстовые попадания всё равно будут пропущены. Повторная выборка работает best-effort: при ошибке или частичном ответе (RetrieveResult.Error) она останавливается и сохраняет полный пул предыдущего раунда, не подменяя его неполными данными.
  6. fan-out (knowledgebase_search_fanout.go): одна группа опрашивается напрямую без накладных расходов; несколько групп — конкурентно через errgroup (не более 4), таймаут каждой группы MULTI_STORE_RETRIEVE_TIMEOUT_SEC (по умолчанию 30s), стратегия отказа all-or-nothing; при разнотипных движках результаты нормализуются EngineAwareNormalizer, приводя векторные баллы к [0,1] (подробнее в документации по поисковым движкам).
  7. Слияние (knowledgebase_search_fusion.go):
  • только вектор или только ключевые слова → deduplicateByScore (по chunk сохраняется наибольший балл);
  • смешанный → взвешенный RRF: score = vectorWeight/(k+vectorRank) + keywordWeight/(k+keywordRank), затем деление на теоретический максимум (vectorWeight+keywordWeight)/(k+1) с нормализацией к [0,1]; k и веса берутся из RetrievalConfig тенанта (со значениями по умолчанию), rank вычисляется отдельно внутри каждого списка результатов поиска (1-indexed), у chunk берётся лучшая позиция;
  • все три пути дают значения в [0,1] (вектор — косинусное сходство), поэтому результаты разных поисковых вызовов можно сортировать вместе.
  1. Стратегия попаданий FAQ (knowledgebase_search_faq.go, только для KB типа FAQ):
  • итеративный поиск (когда основная KB — база FAQ): если после дедупликации не хватает до MatchCount и какой-то список векторных результатов уже заполнен доверху → начиная с удвоенной глубины первого раунда выполняется до 5 раундов удвоения TopK с повторным поиском, единообразно по всем группам store, с слиянием в каждом раунде тем же способом, что и в первом (одинаковый масштаб баллов); данные chunk кэшируются, чтобы избежать повторных запросов в БД; и начальное значение, и прирост каждого раунда ограничены maxRetrievalPoolSize, и если первый раунд уже достиг предела, итерации не выполняются и работа останавливается при достижении потолка;
  • фильтрация негативных вопросов: при точном совпадении запроса с NegativeQuestions FAQ (нижний регистр, без пробелов) эта запись отбрасывается — поддерживается операционная настройка «на этот вопрос не отвечай этой FAQ».
  1. После усечения до MatchCount processSearchResults дополняет метаданные chunk (в сценарии конвейера SkipContextEnrichment=true, а сборка контекста оставляется стадии merge).

Сопутствующие стратегии FAQ на стороне конвейера (конфигурация агента FAQPriorityEnabled / FAQScoreBoost / FAQDirectAnswerThreshold) см. в CHUNK_RERANK — переранжирование, составное ранжирование, MMR, Wiki-буст и INTO_CHAT_MESSAGE — сборка контекста.

Извлечение ключевых слов и searchutil ​

config/prompt_templates/keywords_extraction.yaml предоставляет пару шаблонов system+user «извлечь из вопроса не более 5 ключевых слов», загружаемую загрузчиком prompt_templates из internal/config/config.go и экспонируемую фронтенду для настройки через API шаблонов тенанта (internal/handler/tenant.go). Расширение запроса внутри конвейера (CHUNK_SEARCH_PARALLEL — параллельный поиск (chunk + сущности графа)) использует локальную эвристику без LLM для генерации вариантов ключевых слов.

internal/searchutil/ — библиотека чистых функций, общая для поиска и объединения:

ФайлКлючевые функцииНазначение
textutil.goBuildContentSignature / NormalizeContent / IsContentContained / ContentOverlapRatioДедупликация по сигнатуре содержимого, нормализация, определение включения/доли пересечения (дедупликация merge)
textutil.goTokenizeSimple / JaccardПростая токенизация (китайский — по символам, английский — по словам) и сходство Jaccard (MMR, фильтрация исторических ссылок)
chunkmerge.goAppendWithOverlap / MergeTextChunksСклейка перекрывающихся chunk по текстовому совпадению
imageinfo.go / imageinfo_match.goCollectImageInfoByChunkIDs, EnrichContentWithImageInfoForChat, FilterImageInfoByMatchRange, PruneMarkdownImagesOutsideRange, SliceContentByDocumentRangeСбор информации об изображениях, фильтрация по окну попадания, обогащение содержимого
conversion.goConvertWebSearchResultsПреобразование результатов веб-поиска в SearchResult
normalize.goNormalizeKeywordScoresУтилита нормализации баллов ключевых слов

Соответствие шаблонов Prompt и кода ​

Шаблоны загружаются loadPromptTemplates из internal/config/config.go из каталога config/prompt_templates/ в PromptTemplatesConfig; каждый yaml — это список шаблонов с id/i18n/default; такие параметры конфигурации, как system_prompt_id / context_template_id, разрешаются по id в текст шаблона по умолчанию.

Файл шаблонаПоле конфигурацииМесто использования
rewrite.yamlConversation.RewritePromptSystem/Userquery_understand.go переформулировка + классификация намерения (с плейсхолдерами {conversation}/{query}/{language})
intent_prompts.yamlConversation.IntentSystemPromptsПерекрытие system prompt для непоискового намерения (id шаблона = значение intent)
system_prompt.yamlConversation.Summary.PromptSystem prompt ответа RAG (common.go prepareMessagesWithHistory)
context_template.yamlConversation.Summary.ContextTemplateРендеринг контекста поиска (into_chat_message.go)
fallback.yamlConversation.FallbackPrompt/ResponseРезерв при отсутствии результатов поиска (handleFallbackResponse)
generate_session_title.yaml—Асинхронная генерация заголовка сессии (session.go GenerateTitle)
keywords_extraction.yamlPromptTemplates.KeywordsExtractionШаблон извлечения ключевых слов (экспонируется API шаблонов тенанта)
generate_questions.yaml / generate_summary.yaml—Обогащение при индексации (генерация вопросов/резюме, см. документацию по индексации документов)
generate_kb_description.yamlConversation.GenerateKBDescriptionPromptГенерация описания базы знаний по профилю документов
graph_extraction.yamlExtractManager.ExtractEntity/ExtractGraphИзвлечение сущностей запроса (extract_entity.go) и построение графа
agent_system_prompt.yaml—System prompt режима Agent (см. документацию по Agent)

Плейсхолдеры единообразно рендерятся через types.RenderPromptPlaceholders ({query}, {contexts}, {conversation}, {language} и т. д.). Протокол ссылок (Псевдонимы источников уровня запроса и разворачивание ref) добавляется на системном уровне и не находится ни в одном редактируемом пользователем шаблоне.

Справка по реализации ​

Соответствие этапов исходному коду:

ЭтапРасположение исходного кода
Вход HTTP / сборка SSEinternal/handler/session/qa.go, helpers.go, stream.go
Мост EventBus → события потокаinternal/handler/session/agent_stream_handler.go
Оркестрация Pipelineinternal/application/service/session_knowledge_qa.go
Плагинный фреймворк и все стадийные плагиныinternal/application/service/chat_pipeline/
Регистрация плагинов (DI-контейнер)internal/container/container.go
Типы событий/состоянийinternal/types/chat_manage.go, internal/types/chat.go, internal/event/event.go
Кросс-базовый гибридный поискinternal/application/service/knowledgebase_search*.go
Менеджер потоков (возобновление после обрыва)internal/stream/ (factory.go, memory_manager.go, redis_manager.go)
Управление сессиями / сообщениямиinternal/application/service/session.go, message.go
Псевдонимы и разворачивание ссылокinternal/modelcontext/ (sources.go, citations.go, resources.go, stream.go)
Текстовые утилитыinternal/searchutil/
Шаблоны Promptconfig/prompt_templates/, internal/config/config.go

Версия v0.8.3 · MIT License