검색 기반 질의응답 전체 흐름(RAG Pipeline)
지식 질의응답 요청은 이벤트 기반 RAG 파이프라인으로 들어가 의도 인식, 질문 재작성, 검색, 리랭킹, 컨텍스트 구성을 거쳐 답변을 생성합니다. 답변은 SSE 스트림으로 반환되며, 출력 시 인용이 확장됩니다. 스트림 관리자는 연결이 끊긴 뒤 이벤트를 복구합니다.
전체 아키텍처
WeKnora의 질의응답 흐름은 **이벤트 기반 플러그인 파이프라인(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로 뒤에서부터 중첩 클로저 책임 체인을 만듭니다. 같은 이벤트에서 먼저 등록한 플러그인은 체인 바깥쪽에, 나중에 등록한 플러그인은 안쪽에 위치합니다. 바깥쪽 플러그인이 OnEvent 안에서 next()를 호출해야 안쪽으로 진입합니다. 플러그인은 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.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(체인 안쪽)이벤트와 플러그인의 전체 매핑(동일 이벤트의 체인 순서 포함):
| EventType | 플러그인(체인 순서) | 소스 파일 |
|---|---|---|
load_history | PluginLoadHistory | load_history.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 | rerank.go, wiki_boost.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: 전체 흐름을 관통하는 상태 객체
internal/types/chat_manage.go의 ChatManage는 다음 세 부분을 임베딩하여 구성합니다.
- 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의 3단계 결과,Entity/EntityKBIDs/GraphResult,UserContent,RenderedContexts,SystemPromptOverride등입니다. - PipelineContext(런타임 핸들):
EventBus,MessageID(assistant 메시지 ID),UserMessageID입니다.
ChatManage.Clone()은 깊은 복사(병렬 검색 시 공유 slice의 동시 읽기·쓰기 방지)를 제공하지만 PipelineContext는 복사하지 않습니다.
동적 파이프라인 구성(PipelineBuilder)
session_knowledge_qa.go의 KnowledgeQA는 요청 특성에 따라 이벤트 목록을 동적으로 구성합니다.
// 일반 채팅(KB가 없고 웹 검색도 비활성화)
pipeline = types.NewPipelineBuilder().
AddIf(hasHistory, types.LOAD_HISTORY).
Add(types.CHAT_COMPLETION_STREAM).Build()
// RAG
pipeline = types.NewPipelineBuilder().
AddIf(hasHistory, types.LOAD_HISTORY).
Add(types.QUERY_UNDERSTAND).
Add(types.CHUNK_SEARCH_PARALLEL).
Add(types.CHUNK_RERANK).
AddIf(req.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()types.Pipeline map에는 동적 구성이 필요 없는 호출자를 위해 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포함)를 프런트엔드에 표시되는 하나의knowledge_searchtool_call 진행 구간으로 묶습니다.QUERY_UNDERSTAND는 별도의query_understand구간을 사용합니다. 오류나 조기 종료 경로에서도 구간을 닫아 프런트엔드의 "지식 베이스를 검색하고 있어요" 표시가 계속 돌아가지 않도록 합니다. - 인용 선전송:
CHAT_COMPLETION_STREAM을 트리거하기 전에emitKnowledgeReferencesEvent를 호출해MergeResult를references이벤트로 보냅니다. SSE 연결이 닫히기 전에 클라이언트가 인용 목록을 받도록 보장합니다. - 취소 우선: 각 단계가 끝나면 먼저
ctx.Err()를 확인합니다(사용자 stop이 컨텍스트를 취소). 이 검사는 반드시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과 첨부 파일 prompt를 추가하고, assistant 측에서는 정규식regThinkTags로 사고 태그를 제거하며KnowledgeReferences를 함께 보관).- 시간 역순으로
maxRounds턴을 선택한 뒤 다시 시간순으로 뒤집어chatManage.History에 저장합니다.
주의: 과거 user 메시지는 RenderedContent가 아닌 원래 Content를 재생합니다(이전 버전의 컨텍스트 래퍼가 현재 프로토콜에 섞이는 것을 방지). 과거 인용은 merge_history.go에서 별도로 주입합니다.
QUERY_UNDERSTAND — 쿼리 재작성 + 의도 인식(+ 엔티티 추출)
동일 이벤트에 두 플러그인이 체인으로 연결됩니다.
PluginQueryUnderstand(query_understand.go)는 재작성과 의도 분류를 담당합니다.
- 입력 조합은 텍스트만(chat model), 텍스트+이미지, 이미지만의 세 가지입니다(이미지는 비전 지원 chat model을 우선 사용하고, 없으면
VLMModelID사용). - Prompt는
config/prompt_templates/rewrite.yaml(system + user 쌍)에서 가져오며 에이전트 수준의RewritePromptSystem/RewritePromptUser로 재정의할 수 있습니다.{conversation}/{query}/{language}자리표시자는types.RenderPromptPlaceholders로 렌더링합니다. - 모델은 JSON
{"rewrite_query":"...","intent":"kb_search","image_description":"..."}을 출력하도록 요청받습니다. 파싱 시 오류를 허용하며(markdown 래퍼, 필드 별칭, OCR 필드 병합), JSON 파싱에 완전히 실패하면 원문을 재작성 결과로 쓰고 기본 의도는kb_search로 설정합니다. - 의도 열거형(
types.QueryIntent):kb_search,web_search,greeting,chitchat,follow_up,image_only,doc_only,summarize,clarification.NeedsKBRetrieval()은kb_search/clarification/summarize/빈 값에만 true를 반환합니다.ChatManage.NeedsRetrieval()은web_search에 대해WebSearchEnabled도 확인합니다. 이후 모든 검색 플러그인은NeedsRetrieval()을 건너뛰기 판단 기준으로 사용합니다. - 검색이 필요 없는 의도라면
applyIntentPromptOverride가config/prompt_templates/intent_prompts.yaml(템플릿 id는greeting처럼 의도 값과 일대일 대응) 또는 에이전트 재정의를 사용해SystemPromptOverride를 설정합니다. - 이미지 설명은 user 메시지의
Images[0].Caption에 비동기로 다시 기록합니다(다음 턴의 기록에서 사용). QueryUnderstandModelID로 이 단계 전용 소형 모델을 지정할 수 있으며, 실패하면ChatModelID로 폴백합니다.
PluginExtractEntity(extract_entity.go)는 체인 안쪽에서 실행됩니다. NEO4J_ENABLE=true이고 검색 범위에 ExtractConfig.Enabled가 켜진 지식 베이스가 있을 때만 config.ExtractManager.ExtractEntity 템플릿(graph_extraction.yaml)으로 LLM을 호출해 쿼리 엔티티를 추출합니다. 결과는 ENTITY_SEARCH에서 사용할 chatManage.Entity / EntityKBIDs / EntityKnowledge에 저장합니다.
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를 구성합니다.
두 경로의 결과를 합친 뒤 removeDuplicateResults로 중복을 제거합니다(chunk ID + 내용 서명 searchutil.BuildContentSignature 기준). 두 경로가 모두 비어 있으면 ErrSearchNothing을 반환합니다.
PluginSearch(search.go) 내부도 두 경로를 동시에 실행합니다.
- KB 검색
searchByTargets:- "embedding 모델 식별 정보(
model.Name + BaseURL, 테넌트 간 공유 가능)"를 기준으로SearchTargets를 그룹화합니다(ResolveEmbeddingModelKeys). 각 그룹은 쿼리 벡터를 한 번만 계산합니다(GetQueryEmbedding). - 그룹 안에서 태그/문서 제약이 없는 전체 지식 베이스 대상은 한 번의
HybridSearch호출로 합칩니다(params.KnowledgeBaseIDs에 여러 지식 베이스 전달). 제약이 있는 대상은searchSingleTarget으로 각각 검색합니다(KnowledgeIDs/TagIDs/ScopeTagIDs전달. 범위를 명시적으로 지정한 대상은DisableRecallThresholds로 후보 검색 임계값을 끌 수 있음).
- "embedding 모델 식별 정보(
- 웹 검색
searchWebIfEnabled:WebSearchEnabled일 때 테넌트/에이전트에서 해석한WebSearchProviderID로webSearchService.Search를 호출합니다. 결과는searchutil.ConvertWebSearchResults로 SearchResult로 변환합니다(URL을 ID로 사용,KnowledgeSource="web_search").
쿼리 확장(query_expansion.go): EnableQueryExpansion이 켜져 있고 최초 후보 검색 수가 EmbeddingTopK보다 적으면 실행합니다. LLM을 호출하지 않고 로컬에서 쿼리 변형을 생성합니다(불용어 제거, 어순 조정, 핵심 구문 추출 등). 중국어 토큰화는 types.Jieba.CutForSearch를 사용합니다. 연속된 한자 구간 전체를 jieba에 넘겨 단어로 나누고, 중국어·영어·숫자가 섞여 있으면 문자 체계가 바뀌는 지점에서 구간을 나눠 처리하므로 더 이상 "한자 하나당 token 하나"로 퇴행하지 않습니다. 불용어와 길이 필터는 rune 수를 기준으로 하여 멀티바이트 문자를 단일 문자로 오판하지 않도록 합니다. 각 (변형 × SearchTarget) 조합에 대해 HybridSearch를 병렬 실행하며(세마포어 상한 16), 키워드 임계값은 원래 값의 0.8로 완화하고 TopK는 max(EmbeddingTopK, RerankTopK) * 2로 늘립니다.
CHUNK_RERANK — 리랭킹, 복합 점수, MMR, Wiki 가중치
PluginRerank(rerank.go, 720줄):
- Passage 정제
cleanPassageForRerank: 리랭킹 모델은 의미적 유사도를 평가하므로 Markdown 구조 문법은 잡음입니다. 코드 블록과$$...$$수식 블록은 구분자만 제거하고 내부 본문은 유지합니다(초기 구현은 블록 전체를 삭제하여 코드나 수식만 있는 후보가 빈 문자열이 되고 점수를 잃었음). HTML 태그, 이미지 참조, 링크 표기(텍스트 유지), 일반 URL, 표 구분 행(데이터 행은 쉼표로 연결), 제목/인용/굵게/목록 표기를 순서대로 제거하고 마지막으로 불필요한 빈 줄을 줄입니다. - Passage 보강
getEnrichedPassage:ImageInfo의 Caption/OCR 텍스트와ChunkMetadata의 생성 질문(GeneratedQuestions)을 추가합니다. rerankModel.Rerank(ctx, RewriteQuery, passages)를 호출하고RerankThreshold로 필터링합니다.- 모두 임계값보다 낮지만 top1 ≥
rerankFallbackMinScore이면 top1을 대체 후보로 유지합니다(기본값 0.15. 사용자가 태그/문서 범위를 명시한 경우 0으로 하여 지정된 신뢰 범위의 최선 후보 유지). - 결과가 없고 임계값 > 0.3이면 임계값을 낮춰 한 번 재시도합니다(
threshold * 0.7, 하한 0.3). - Rerank API 실패 시 원래 검색 결과로 폴백해 파이프라인을 계속 진행합니다.
- 모두 임계값보다 낮지만 top1 ≥
- 복합 점수
compositeScore:0.6*모델 점수 + 0.3*검색 기본 점수 + 0.1*출처 가중치(web_search 출처 가중치 0.95, 나머지 1.0)를 [0,1]로 제한합니다. 기본 점수/모델 점수는Metadata["base_score"]/["model_score"]에 기록합니다. 초기 버전은 "문서 앞부분일수록 높게" 주는 위치 사전 가중치(±0.05)도 곱했지만, 청크 편집 후 오프셋 변화와 결합되고 효과가 불명확해 제거했습니다. - FAQ 가중치:
FAQPriorityEnabled이고FAQScoreBoost > 1.0이면 FAQ chunk 점수에 boost를 곱하고(상한 1.0)Metadata["faq_boosted"]에 기록합니다. - MMR 다양성 선택
applyMMR(λ=0.7, k=RerankTopK):mmr = 0.7*relevance - 0.3*max_jaccard_redundancy.searchutil.TokenizeSimple+Jaccard로 token 집합을 병렬 사전 계산하고, 탐욕적 선택을 반복해RerankResult를 정합니다.
PluginWikiBoost(wiki_boost.go)는 동일 이벤트 체인의 안쪽에 등록되며, OnEvent에서 먼저 next()를 호출하고(리랭킹 완료 대기) 후처리를 수행합니다. RerankResult에 wiki_page 유형 chunk가 있고 검색 대상에 Wiki가 활성화된 KB가 실제로 있으면 점수에 wikiBoostFactor = 1.3을 곱한 뒤 안정적으로 재정렬합니다. Wiki 페이지는 LLM이 미리 종합한 지식이므로 원본 chunk보다 우선합니다.
WEB_FETCH — 웹페이지 전문 수집
web_fetch.go. WebFetchEnabled && WebSearchEnabled일 때만 실행합니다. RerankResult의 상위 WebFetchTopN개(기본 3) 웹 결과에 대해 web_fetch.FetchURLContent(ctx, url)을 병렬 호출하여 본문을 수집하고 요약 snippet을 교체합니다(8000바이트로 제한). 리랭킹 이후, 병합 이전에 배치하여 컨텍스트에 들어갈 고득점 웹페이지에만 수집 비용을 씁니다.
CHUNK_MERGE — 8단계 통합 병합
merge.go의 OnEvent 주석에 흐름이 설명되어 있습니다.
- 입력 선택:
RerankResult를 우선 사용하고, 비어 있으면SearchResult로 폴백합니다(점수순 정렬). - 중복 제거: ID + 내용 서명.
- 과거 인용 주입(
merge_history.go): 인용이 있는 가장 최근 턴의 기록에서KnowledgeReferences를 가져와 현재 쿼리와의 Jaccard 유사도로 필터링합니다(임계값 0.15). 점수에 0.6을 곱하고 최대 3개를 주입하며MatchTypeHistory로 표시합니다. - 부모·자식 청크 해석
resolveParentChunks: text 자식 청크와 image_ocr/image_caption 자식 청크 모두 현재 parent_text 내용으로 컨텍스트를 보충합니다. 이미지 Markdown 범위는 파서 좌표가 아닌 안정적인 이미지 URL(PruneMarkdownImagesByImageInfo)로 좁힙니다. ImageInfo는 검색된 text 자식 청크로 엄격히 한정하여 이미지가 많은 부모 청크에서 형제 페이지의 OCR까지 모두 컨텍스트에 들어오지 않도록 합니다. image → text → parent_text 체인에서는 이미지 결과가 실제로 검색된 경우에만 조부모 청크를 한 번 더 조회합니다. - 그룹별 순차 병합
groupAndMergeCurrentContent:KnowledgeID + ChunkType으로 그룹화하고 그룹 안에서ChunkIndex순으로 정렬한 뒤mergeSequentialChunks를 수행합니다. 번호가 연속되거나 한쪽 내용이 다른 쪽을 포함하면searchutil.JoinChunkContent로 이어 붙이고 최고 점수를 유지합니다.SubChunkID는 병합된 청크를 기록하며mergeImageInfo는 URL 기준으로 중복을 제거하면서 이미지 정보를 합칩니다. - FAQ 답변 채우기(
merge_faq.go): FAQ 유형 chunk의FAQMetadata를 원본 테이블에서 일괄 조회하고 Content를Q: 표준 질문 + Answer: 답변 목록으로 다시 씁니다. - 짧은 컨텍스트의 이웃 확장(
merge_expand.go): text 청크 내용이 350자 미만이면PreChunkID/NextChunkID이웃을 일괄 조회해 최대 850자까지 이어 붙입니다. - 확장으로 생긴 중복을 다시 한 번 병합하고, 마지막으로 중복 제거 +
removePartialOverlaps를 수행합니다(정규화된 포함 여부 판단 / token 중첩률 ≥ 0.85인 지식 베이스 간 유사 중복 제거. 낮은 점수의 결과 삭제).
결과는 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로 동점 순서를 안정적으로 결정합니다(merge 단계의 map 순회가 순서를 흐트러뜨리므로 여기서 전체 결과를 재현 가능한 순서로 복구). 이후 RerankTopK개로 제한합니다.
DATA_ANALYSIS — DuckDB 표 데이터 분석
data_analysis.go. 기본적으로 꺼져 있습니다(DataAnalysisEnabled는 에이전트 설정에서 가져옴). MergeResult에 CSV/Excel 파일이 검색되면 먼저 table_column/table_summary 유형 chunk를 제외하고 첫 번째 데이터 파일을 선택합니다. tools.NewDataAnalysisTool로 파일을 DuckDB에 로드해 schema를 얻은 다음, LLM이 데이터 분석 필요 여부를 판단하고 DuckDB SQL을 생성하도록 합니다(구조화된 출력 DataAnalysisInput). 실행 결과는 MatchTypeDataAnalysis, score=1.0인 합성 SearchResult로 MergeResult에 추가합니다.
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 ≥FAQDirectAnswerThreshold이면 해당 context에match="exact"를 표시합니다(모델이 이 답변을 그대로 채택할 수 있음을 알림). - 일반 경로는 보강된 각 passage를
context id="N"으로 순서대로 번호를 매겨 감쌉니다(getEnrichedPassageForChat이 ImageInfo를 Markdown 이미지+설명으로 본문에 인라인 삽입). - 헤더의
buildDocumentHeader는 중복을 제거한 문서 메타정보(title/description)를 출력합니다. SummaryConfig.ContextTemplate(config/prompt_templates/context_template.yaml에서 가져옴)을{query}/{contexts}/{language}자리표시자로 렌더링합니다. 이미지 설명(비전 미지원 모델), 인용 컨텍스트QuotedContext, 첨부 파일 prompt를 추가합니다.- 구성한
UserContent를 user 메시지의RenderedContent에 비동기로 다시 기록합니다(persistRenderedContent). 감사와 디버깅에 사용하며,RenderedContexts에는 인용 교체용 순수 contexts 문자열을 저장합니다.
CHAT_COMPLETION / CHAT_COMPLETION_STREAM — 생성
두 플러그인은 common.go의 보조 함수를 공유합니다.
prepareChatModel: chat model을 가져오고SummaryConfig에서ChatOptions(Temperature/TopP/Seed/MaxTokens/Thinking 등)를 구성합니다.prepareMessagesWithHistory: system prompt는SystemPromptOverride(의도별 재정의) 또는SummaryConfig.Prompt(system_prompt.yaml)를 사용합니다. 자리표시자를 렌더링한 뒤 검색 컨텍스트에 Markdown 이미지가 있으면 "검색 이미지 출력 요구사항" 단락을 추가합니다(appendRetrievedImageOutputRequirement). 이어서 과거 Q/A 쌍을 시간순으로 넣고 마지막에 현재 user 메시지를 추가합니다(비전 모델은Images포함).
references.go의 prepareMessagesWithReferences는 여기에 인용 별칭 교체를 추가합니다(인용(Citation) 생성 메커니즘 참조). RenderedContexts의 위치 번호 기반 컨텍스트를 llmreference.Registry가 생성한 요청별 격리 chunk 별칭 뷰로 바꾸고 system prompt 끝에 인용 프로토콜을 추가합니다.
스트리밍 버전(chat_completion_stream.go)은 EventBus가 반드시 있어야 하며, chatModel.ChatStream 호출 후 goroutine을 시작해 응답 채널을 소비합니다.
ResponseTypeThinking→llmresource.StreamDecoder(res:// 리소스 별칭 복원)와llmreference.StreamExpander(ref 인용 태그 확장)를 거쳐EventAgentThought로 발행합니다.ResponseTypeAnswer→ 같은 이중 디코딩 후EventAgentFinalAnswer로 발행합니다.Done이 있는 최종 상태 답변은 한 번만 전달합니다. 일부 공급자는finish_reason으로 한 번, 스트림 종료 센티널로 다시 한 번 완료를 보내므로 중복 전달하면 답변 이벤트가 세션 complete 이벤트 뒤에 놓일 수 있습니다.ResponseTypeError→EventError.- 채널이 닫히거나 ctx가 취소되면
flushDecoders로 디코더에 남은 마지막 바이트를 내보낸 뒤(chunk 경계에 걸친 별칭 손실 방지) thinking 스트림을 닫습니다.
비스트리밍 버전(chat_completion.go)은 Chat을 직접 호출한 뒤 resourceRefs.DecodeResponse + sourceRefs.ExpandResponse로 전문을 복원하고 결과를 chatManage.ChatResponse에 저장합니다.
전체 RAG 흐름도
세션과 메시지 관리
Session Service(session.go)
- 전체 CRUD:
CreateSession/GetSession(테넌트+공유 범위) /GetOwnedSession(엄격한 소유자 검증, stop 등 파괴적 작업에 사용) / 페이지별 목록 /SetSessionPinned/UpdateSessionLastRequestState(입력란 상태 기억: 에이전트/모델/KB/웹 검색 선택, UI 전용) / 단일 삭제, 일괄 삭제, 전체 삭제. - 제목 생성:
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(Q/A 쌍을 "채팅 기록 지식 베이스"에 기록하여 세션 간 검색 지원),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(answer 이벤트 ID별 분할. 최종 턴 이전의 "도입부"는 후속 tool_call이 나오면 superseded로 표시하여 영구 저장 답변에서 제외)와knowledgeRefs를 누적하며, 스트림이 끝나면 assistant 메시지를 구성해 DB에 저장합니다.- HTTP 계층의
handleAgentEventsForSSE(stream.go)는 100ms ticker로GetEvents를 폴링합니다. 각StreamEvent를buildStreamResponse로types.StreamResponse에 감싼 뒤c.SSEvent("message", response)로 전송합니다.complete이벤트를 받으면 종료합니다(새 세션은 제목 이벤트를 3s 더 기다릴 수 있음).
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, handler 계층에서 사용하는 stop도 포함):
| response_type | 의미 |
|---|---|
agent_query | 쿼리 접수 완료. session_id / assistant_message_id 포함(클라이언트는 여기서 재개에 필요한 message_id 획득) |
thinking | 사고 과정 증분(reasoning_content) |
answer | 답변 텍스트 증분 |
references | 지식 인용 목록(knowledge_references 필드) |
tool_call / tool_result | 에이전트/진행 도구 호출과 결과(RAG 파이프라인의 knowledge_search, query_understand 진행 상황도 이 두 유형 사용) |
reflection | 에이전트 성찰 |
session_title | 비동기로 생성한 세션 제목 |
error | 오류(Done=true는 최종 오류) |
complete | 스트림 종료 표시(프런트엔드는 이를 기준으로 마무리하며 빈 answer+done에 더 이상 의존하지 않음) |
tool_approval_required / tool_approval_resolved | 위험한 MCP 도구의 승인 요청/결과 |
mcp_oauth_required / mcp_oauth_resolved | MCP OAuth 인증 요청/결과 |
stop | 사용자 중지 알림(handler 계층에서 생성) |
연결 끊김 후 재개(continue-stream)와 중지
재개: GET /sessions/continue-stream/:session_id?message_id=...(stream.go ContinueStream). 세션과 메시지를 검증한 뒤 offset 0부터 GetEvents로 과거 이벤트 전체를 재생합니다. 이미 complete가 있으면 바로 마무리하고, 없으면 complete까지 100ms 폴링으로 새 이벤트를 계속 전송합니다. 생성 goroutine과 SSE 연결이 완전히 분리되어 있으므로(이벤트는 StreamManager에 저장) 페이지 새로고침이나 일시적인 네트워크 단절이 생겨도 생성이 중단되지 않습니다.
중지: POST /sessions/:id/stop(엄격한 소유자 검증)은 StreamManager에 stop 이벤트를 추가합니다. 이를 두 경로가 소비합니다. SSE 폴링 루프는 감지 즉시 EventBus에 EventStop을 보내며, 독립적인 startStopWatcher(300ms 폴링, 클라이언트 연결과 무관, 안전장치로 2시간 타임아웃)는 클라이언트 연결이 이미 끊겨도 stop이 생성을 취소하도록 보장합니다. setupStopEventHandler는 EventStop을 받으면 asyncCtx에 cancel()을 호출하고 context.WithoutCancel로 이미 출력된 부분 내용을 저장합니다.
스트리밍 질의응답 시퀀스 다이어그램
인용(Citation) 생성 메커니즘
llmreference: 요청별 출처 별칭과 ref 확장
internal/llmreference/registry.go. 목표는 내부 ID가 모델 컨텍스트에 들어가지 않고, 모델이 출력한 인용을 안전하게 확장하는 것입니다.
Registry(답변마다 하나의 인스턴스. 에이전트의 모든 도구 턴을 포함하며 절대로 요청 간 영구 저장하지 않음)는 출처에 저엔트로피 별칭을 할당합니다.cN=지식 chunk,wN=웹페이지,dN=문서,bN=지식 베이스.ProtocolPrompt(citationsEnabled)를 system prompt에 추가합니다. 인용이 활성화되면 모델에ref id="cN"형식의 자체 닫힘 태그로 인라인 인용하도록 요구합니다(kb/web 태그 임의 생성 금지). 비활성화되면(PipelineRequest.CitationEnabled=false, 기본값은 활성화) 모든 인용 출력을 금지합니다.references.go의prepareMessagesWithReferences는MergeResult를 FAQ 우선순위에 따라RegisterSearchResults로 등록합니다.ModelOutput(model_output.go)으로 지식/웹 결과를 모델용 간결한 XML 뷰(display_type=search_results/web_search_results)로 렌더링하고 메시지의 기존RenderedContexts를 교체합니다.- 모델 출력의
ref태그는ExpandText/StreamExpander(스트리밍, chunk 경계에서 나뉜 태그 처리)가 공개 태그로 확장합니다. chunk →kb태그(chunk_id, knowledge_id 등의 속성 포함), 웹페이지 →web url title태그. 알 수 없는 별칭은 fail-closed 방식으로 즉시 제거합니다. 프런트엔드는 이를 바탕으로 위첨자 인용을 렌더링합니다. - 인라인 인용과 별개로
MergeResult는 항상referencesSSE 이벤트로 전체를 전송합니다("검색 결과" 패널에 사용). 인라인 인용이 비활성화된 경우에도 동일합니다.
llmresource: 저장 리소스 핸들 별칭
internal/llmresource/registry.go는 또 다른 문제를 해결합니다. resource://, minio://, cos:// 등의 고엔트로피 저장 핸들과 wiki summary/<uuid> slug가 모델 컨텍스트에 들어가면 모델이 재출력할 때 URL을 잘못 바꾸기 쉽습니다. EncodeMessages는 이를 res://0001 형태의 저엔트로피 별칭으로 교체합니다. 스트리밍 출력은 StreamDecoder로 복원하며(Flush가 chunk 경계의 별칭이 잘리거나 손실되지 않도록 보장), 도구 호출 인자에도 디코딩 후 실제 핸들을 다시 넣습니다.
지식 베이스 간 병렬 검색과 융합(HybridSearch)
internal/application/service/knowledgebase_search.go는 모든 검색이 모이는 지점입니다(chat pipeline, 에이전트 도구, 검색 API가 공유).
- 권한 확인과 검증: KB를 일괄 로드하고(테넌트 간 Organization 공유 지식 베이스 포함) 각 KB에
authorizeKBAccess를 수행합니다.validateSameEmbeddingModel은 서로 다른 embedding 공간에 걸친 다중 KB 검색을 거부합니다(wiki/graph 비벡터 지식 베이스는 예외). - 입력 정규화 + 초과 후보 검색:
MatchCount <= 0이면(호출자가 전달하지 않으면 JSON 역직렬화 시 0) 먼저normalizedMatchCount로types.DefaultRetrievalTopK(50)로 정규화합니다. 초과 후보 검색 하한, FAQ 반복 실행 조건, 최종 결과 제한의 세 곳에서 같은 값을 읽도록 하기 위함입니다. 그렇지 않으면 결과 제한이 결과 집합을[:0]으로 잘라 버리고 음수는 범위 초과 panic을 일으킬 수 있습니다. 이후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, 키워드 인덱스 없음), 문서 지식 베이스는 기본 벡터 인덱스 + 키워드 인덱스를 사용합니다. - 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).k와 가중치는 테넌트의RetrievalConfig에서 가져옵니다(기본값 있음). rank는 각 검색기가 반환한 순서(1부터 시작)를 기준으로 하므로 점수 척도의 영향을 받지 않습니다.
- 벡터만 또는 키워드만 →
- FAQ 검색 전략(
knowledgebase_search_faq.go, FAQ 유형 KB에만 적용):- 반복 검색: 중복 제거 후
MatchCount보다 적고 첫 검색이 요청 개수를 모두 채웠다면TopK*3부터 시작해 최대 5회 TopK를 두 배씩 늘려 재검색합니다. store 그룹 전체에 일괄 적용하며 chunk 데이터를 캐시해 원본 테이블 중복 조회를 피합니다. 시작값과 각 반복의 증가값 모두maxRetrievalPoolSize를 상한으로 하며, 상한에 도달하면 중단합니다(계속 반복해도 같은 쿼리만 재전송하기 때문). - 제외 질문 필터: 쿼리가 FAQ의
NegativeQuestions와 정확히 일치하면(소문자 변환 및 공백 제거) 해당 항목을 제외합니다. "이 질문에는 이 FAQ로 답하지 않기" 운영 설정을 지원합니다.
- 반복 검색: 중복 제거 후
MatchCount개로 제한한 뒤processSearchResults가 chunk 메타데이터를 보충합니다(파이프라인에서는SkipContextEnrichment=true로 두고 컨텍스트 구성은 merge 단계에 맡김).
FAQ의 파이프라인 측 연계 전략(에이전트 설정 FAQPriorityEnabled / FAQScoreBoost / FAQDirectAnswerThreshold)은 CHUNK_RERANK — 리랭킹, 복합 점수, MMR, Wiki 가중치와 INTO_CHAT_MESSAGE — 컨텍스트 구성을 참고하세요.
키워드 추출과 searchutil
config/prompt_templates/keywords_extraction.yaml은 "질문에서 최대 5개 키워드 추출" system+user 템플릿 쌍을 제공합니다. internal/config/config.go의 prompt_templates 로더로 로드하고 테넌트 템플릿 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 템플릿과 코드 대응
internal/config/config.go의 loadPromptTemplates가 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 | RAG 답변 system prompt(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 | — | 수집 시 보강(질문 생성/요약, 문서 수집 문서 참조) |
graph_extraction.yaml | ExtractManager.ExtractEntity/ExtractGraph | 쿼리 엔티티 추출(extract_entity.go)과 그래프 구축 |
agent_system_prompt.yaml | — | 에이전트 모드 system prompt(에이전트 문서 참조) |
자리표시자는 모두 types.RenderPromptPlaceholders로 렌더링합니다({query}, {contexts}, {conversation}, {language} 등). 인용 프로토콜(llmreference: 요청별 출처 별칭과 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/llmreference/, internal/llmresource/ |
| 텍스트 도구 | internal/searchutil/ |
| Prompt 템플릿 | config/prompt_templates/, internal/config/config.go |