Полный процесс поиска и ответа (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 определяет базовые абстракции:
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); порядок регистрации и есть порядок выполнения в цепочке одного события:
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_history | PluginLoadHistory | load_history.go |
memory_recall | PluginMemoryRecall | memory_recall.go |
query_understand | PluginQueryUnderstand → PluginExtractEntity | query_understand.go, extract_entity.go |
chunk_search | PluginSearch | search.go, query_expansion.go |
chunk_search_parallel | PluginSearchParallel (внутри комбинирует PluginSearch + PluginSearchEntity) | search_parallel.go |
entity_search | PluginSearchEntity | search_entity.go |
chunk_rerank | PluginRerank → PluginWikiBoost → PluginMemoryAffinity | rerank.go, wiki_boost.go, memory_affinity.go |
web_fetch | PluginWebFetch | web_fetch.go |
chunk_merge | PluginMerge | merge.go, merge_overlap.go, merge_expand.go, merge_faq.go, merge_history.go |
data_analysis | PluginDataAnalysis | data_analysis.go |
into_chat_message | PluginIntoChatMessage | into_chat_message.go |
chat_completion | PluginChatCompletion | chat_completion.go |
chat_completion_stream | PluginChatCompletionStream | chat_completion_stream.go |
filter_top_k | PluginFilterTopK | filter_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 динамически собирает список событий по характеристикам запроса:
// Чистый чат (без 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_callknowledge_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):
messageService.GetRecentMessagesBySessionберёт последниеmaxRounds*2+10сообщений;- по
RequestIDсообщения user/assistant попарно объединяются вtypes.History(на стороне user добавляются Caption изображений и промпт вложений; на стороне assistant регулярным выражениемregThinkTagsсрезаются теги размышлений, а также переносятсяKnowledgeReferences); - выбираются
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) внутри также распараллеливается на две ветки:
- Поиск по KB
searchByTargets:
SearchTargetsгруппируются по «идентичности embedding-модели (model.Name + BaseURL, может быть общей для разных тенантов)» (ResolveEmbeddingModelKeys), и в каждой группе вектор запроса вычисляется один раз (GetQueryEmbedding);- цели «вся база» без ограничений по тегам/документам в группе объединяются в один вызов
HybridSearch(params.KnowledgeBaseIDsнесёт несколько баз), цели с ограничениями обрабатываются по одной черезsearchSingleTarget(сKnowledgeIDs/TagIDs/ScopeTagIDs, причём для целей с явно очерченной областью можно отключить порог выборки черезDisableRecallThresholds);
- Веб-поиск
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 строк):
- Очистка passage
cleanPassageForRerank: переранжирующая модель работает с семантическим сходством, а синтаксис структуры Markdown для неё — шум. Из блоков кода и формул$$...$$снимается только ограждение, а внутренний текст сохраняется (в ранней реализации блок удалялся целиком, и кандидаты из чистого кода или формул очищались в пустую строку, теряя балл); HTML-теги, ссылки на изображения, разметка ссылок (с сохранением текста), «голые» URL, строки-разделители таблиц (строки данных склеиваются через запятую), разметка заголовков/цитат/полужирного/списков поочерёдно снимаются, а в конце сжимаются лишние пустые строки. - Обогащение passage
getEnrichedPassage: подклеивается текст Caption/OCR изImageInfoи вопросы, сгенерированные вChunkMetadata(GeneratedQuestions); идентичный основному тексту текст изображения (сами блоки OCR / описаний) повторно не подклеивается. Если для модели задан лимит длины одного фрагмента или одного запроса, длинный passage обрезается с конца до лимита; кандидаты по поисковому баллу берутся не более 200 штук и отправляются на оценку. - Вызов
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.
- Составное ранжирование
compositeScore:0.6*балл модели + 0.3*базовый балл поиска + 0.1*вес источника(вес источника web_search — 0.95, остальные — 1.0), с приведением к [0,1]. У блоков, найденных как сущности графа, нет поискового балла, поэтому базовым баллом служит балл модели. Базовый балл / балл модели / составной балл записываются вMetadata["base_score"]/["model_score"]/["composite_score"]. В ранних версиях также умножался позиционный приор «чем ближе к началу документа, тем выше» (±0.05), но поскольку он был связан с меняющимся после ручного редактирования блоков смещением и давал неясную выгоду, он был удалён. - Буст FAQ: при
FAQPriorityEnabledиFAQScoreBoost > 1.0балл блока FAQ умножается на boost и записываетсяMetadata["faq_boosted"]. Результат не ограничивается сверху значением 1.0 — ограничение свело бы все высокобалльные FAQ к 1.0, и их взаимный порядок выродился бы в tie-breaker. - Выбор разнообразия 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 и есть описание процесса:
- Выбор входа: приоритетно
RerankResult, при пустом — откат кSearchResult(сортировка по баллу и обрезка доRerankTopK, чтобы не делать обратный запрос в БД и расширение для всех результатов выборки); - Дедупликация: ID + сигнатура содержимого;
- Внедрение исторических ссылок (
merge_history.go): из последнего раунда истории со ссылками берутсяKnowledgeReferences, фильтруются по тому, какую долю слов запроса покрывает ссылка (коэффициент пересечения|q∩c|/min(|q|,|c|)≥ 0.3; Jaccard делится на объединение, поэтому запрос из ~8 слов против блока в сотни слов никогда не достигает порога), балл умножается на 0.6, внедряется не более 3 штук, помечаютсяMatchTypeHistory; - Разбор родительских/дочерних блоков
resolveParentChunks: и текстовые дочерние блоки, и дочерние блоки image_ocr/image_caption/image_vector добирают контекст из текущего содержимого parent_text; сужение Markdown изображений опирается на стабильный URL изображения (PruneMarkdownImagesByImageInfo), а не на координаты парсера; ImageInfo строго ограничивается попавшим текстовым дочерним блоком, чтобы насыщенные изображениями родительские блоки не заливали в контекст весь OCR соседних страниц. Цепочка image → text → parent_text делает дополнительный запрос к блоку-деду только когда действительно есть попадание в результат-изображение; - Слияние с сохранением порядка и группировкой
groupAndMergeCurrentContent: группировка поKnowledgeID + ChunkType, внутри группы сортировка поChunkIndexи затемmergeSequentialChunks— при последовательных номерах или когда содержимое одного включает содержимое другого выполняется склейка черезsearchutil.JoinChunkContent, сохраняется наибольший балл, вSubChunkIDзаписываются объединённые блоки, аmergeImageInfoобъединяет информацию об изображениях с дедупликацией по URL; - Заполнение ответов FAQ (
merge_faq.go): блоки типа FAQ пакетно читаются из БД поFAQMetadata, содержимое переписывается вQ: стандартный вопрос + Answer: список ответов; - Расширение соседями при коротком контексте (
merge_expand.go): когда содержимое текстового блока короче 350 символов, пакетно берутся соседиPreChunkID/NextChunkIDи приклеиваются до примерно 850 символов. Сам попавший блок сохраняется целиком, а оставшаяся длина распределяется между концом непосредственно предшествующего и началом последующего текста; соседи должны принадлежать тому же документу, а блоки общих для организации баз знаний тоже подлежат расширению; - Новые дубликаты, привнесённые расширением, объединяются ещё раз, затем финальная дедупликация +
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(SSEresponse_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 возвращает копию событий, избегая гонок |
redis | Redis 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
setupSSEStream(qa.go) создаёт для каждого запроса отдельныйevent.EventBusи отменяемыйasyncCtx;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 и пишет его в БД;- 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):
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всегда передаётся целиком событием SSEreferences(питая панель «результаты выборки»), даже когда встроенные ссылки отключены.
Псевдонимы дескрипторов хранимых ресурсов (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):
- Авторизация и проверка: пакетная загрузка KB (включая общие для организации кросс-тенантные базы), по каждой базе
authorizeKBAccess;validateSameEmbeddingModelотклоняет поиск по нескольким базам из разных embedding-пространств (для wiki/graph без векторной базы есть исключение). - Нормализация входных параметров + перевыборка:
MatchCount <= 0(при отсутствии параметра от вызывающей стороны после десериализации JSON это 0) сначала нормализуется черезnormalizedMatchCountвtypes.DefaultRetrievalTopK(50), чтобы нижняя граница перевыборки, условие запуска итераций FAQ и финальное усечение читали одно и то же значение, — иначе усечение порезало бы результирующий набор до[:0], а отрицательное значение ещё и вызвало бы панику выхода за границы; затемmatchCount = max(MatchCount*5, 50) * len(KBs)с верхней границейmaxRetrievalPoolSize(500). - Вектор запроса вычисляется один раз и передаётся через
params.QueryEmbeddingво все группы store. - Группировка storeGroup (
knowledgebase_search_storegroup.go): группировка по(VectorStoreID, тенант-владелец); в каждой группе черезretriever.CreateRetrieveEngineForKBразрешаетсяCompositeRetrieveEngine.buildRetrievalParamsмаршрутизирует по типу каждой KB в группе: база FAQ идёт по векторному индексу FAQ (KnowledgeType=faq, без индекса по ключевым словам), база документов — по стандартному векторному индексу + индексу по ключевым словам. - Поиск изображений (
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) она останавливается и сохраняет полный пул предыдущего раунда, не подменяя его неполными данными. - fan-out (
knowledgebase_search_fanout.go): одна группа опрашивается напрямую без накладных расходов; несколько групп — конкурентно черезerrgroup(не более 4), таймаут каждой группыMULTI_STORE_RETRIEVE_TIMEOUT_SEC(по умолчанию 30s), стратегия отказа all-or-nothing; при разнотипных движках результаты нормализуютсяEngineAwareNormalizer, приводя векторные баллы к [0,1] (подробнее в документации по поисковым движкам). - Слияние (
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] (вектор — косинусное сходство), поэтому результаты разных поисковых вызовов можно сортировать вместе.
- Стратегия попаданий FAQ (
knowledgebase_search_faq.go, только для KB типа FAQ):
- итеративный поиск (когда основная KB — база FAQ): если после дедупликации не хватает до
MatchCountи какой-то список векторных результатов уже заполнен доверху → начиная с удвоенной глубины первого раунда выполняется до 5 раундов удвоения TopK с повторным поиском, единообразно по всем группам store, с слиянием в каждом раунде тем же способом, что и в первом (одинаковый масштаб баллов); данные chunk кэшируются, чтобы избежать повторных запросов в БД; и начальное значение, и прирост каждого раунда ограниченыmaxRetrievalPoolSize, и если первый раунд уже достиг предела, итерации не выполняются и работа останавливается при достижении потолка; - фильтрация негативных вопросов: при точном совпадении запроса с
NegativeQuestionsFAQ (нижний регистр, без пробелов) эта запись отбрасывается — поддерживается операционная настройка «на этот вопрос не отвечай этой FAQ».
- После усечения до
MatchCountprocessSearchResultsдополняет метаданные 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.go | BuildContentSignature / NormalizeContent / IsContentContained / ContentOverlapRatio | Дедупликация по сигнатуре содержимого, нормализация, определение включения/доли пересечения (дедупликация merge) |
textutil.go | TokenizeSimple / Jaccard | Простая токенизация (китайский — по символам, английский — по словам) и сходство Jaccard (MMR, фильтрация исторических ссылок) |
chunkmerge.go | AppendWithOverlap / MergeTextChunks | Склейка перекрывающихся chunk по текстовому совпадению |
imageinfo.go / imageinfo_match.go | CollectImageInfoByChunkIDs, EnrichContentWithImageInfoForChat, FilterImageInfoByMatchRange, PruneMarkdownImagesOutsideRange, SliceContentByDocumentRange | Сбор информации об изображениях, фильтрация по окну попадания, обогащение содержимого |
conversion.go | ConvertWebSearchResults | Преобразование результатов веб-поиска в SearchResult |
normalize.go | NormalizeKeywordScores | Утилита нормализации баллов ключевых слов |
Соответствие шаблонов Prompt и кода
Шаблоны загружаются loadPromptTemplates из internal/config/config.go из каталога config/prompt_templates/ в PromptTemplatesConfig; каждый yaml — это список шаблонов с id/i18n/default; такие параметры конфигурации, как system_prompt_id / context_template_id, разрешаются по id в текст шаблона по умолчанию.
| Файл шаблона | Поле конфигурации | Место использования |
|---|---|---|
rewrite.yaml | Conversation.RewritePromptSystem/User | query_understand.go переформулировка + классификация намерения (с плейсхолдерами {conversation}/{query}/{language}) |
intent_prompts.yaml | Conversation.IntentSystemPrompts | Перекрытие system prompt для непоискового намерения (id шаблона = значение intent) |
system_prompt.yaml | Conversation.Summary.Prompt | System prompt ответа RAG (common.go prepareMessagesWithHistory) |
context_template.yaml | Conversation.Summary.ContextTemplate | Рендеринг контекста поиска (into_chat_message.go) |
fallback.yaml | Conversation.FallbackPrompt/Response | Резерв при отсутствии результатов поиска (handleFallbackResponse) |
generate_session_title.yaml | — | Асинхронная генерация заголовка сессии (session.go GenerateTitle) |
keywords_extraction.yaml | PromptTemplates.KeywordsExtraction | Шаблон извлечения ключевых слов (экспонируется API шаблонов тенанта) |
generate_questions.yaml / generate_summary.yaml | — | Обогащение при индексации (генерация вопросов/резюме, см. документацию по индексации документов) |
generate_kb_description.yaml | Conversation.GenerateKBDescriptionPrompt | Генерация описания базы знаний по профилю документов |
graph_extraction.yaml | ExtractManager.ExtractEntity/ExtractGraph | Извлечение сущностей запроса (extract_entity.go) и построение графа |
agent_system_prompt.yaml | — | System prompt режима Agent (см. документацию по Agent) |
Плейсхолдеры единообразно рендерятся через types.RenderPromptPlaceholders ({query}, {contexts}, {conversation}, {language} и т. д.). Протокол ссылок (Псевдонимы источников уровня запроса и разворачивание ref) добавляется на системном уровне и не находится ни в одном редактируемом пользователем шаблоне.
Справка по реализации
Соответствие этапов исходному коду:
| Этап | Расположение исходного кода |
|---|---|
| Вход HTTP / сборка SSE | internal/handler/session/qa.go, helpers.go, stream.go |
| Мост EventBus → события потока | internal/handler/session/agent_stream_handler.go |
| Оркестрация Pipeline | internal/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/ |
| Шаблоны Prompt | config/prompt_templates/, internal/config/config.go |
