공장 업무 AI를 설계하면서 처음 헷갈린 질문은 단순했다. “RAG라면 multi-query를 기본으로 타야 하나?”, “화면에 보이는 think가 multi-query인가?”, “think를 켜면 모델이 재귀적으로 더 깊게 도는 것인가?”였다.
세 질문은 서로 다른 층을 한 단어로 묶어서 생긴 혼동이었다.
multi-query = 검색 전에 질의를 여러 개로 늘리는 query expansion
think mode = 한 번의 모델 호출에서 reasoning token 생성을 허용하는 모델 옵션
MCP loop = 모델이 도구를 고르고, 결과를 넣어 다시 모델을 호출하는 애플리케이션 제어 흐름
RAG = 승인된 문서 근거를 찾아 답변 context에 넣는 흐름
MES와 SCM에서 이 구분은 UX 용어 문제가 아니다. 품목 코드 하나, LOT 하나, 공장 범위 하나, 유효한 표준서 개정본 하나가 바뀌면 그럴듯한 답변도 업무상 오답이 된다. 이 글은 현재 서비스 코드의 실제 실행 경로를 확인하고, 안전한 retrieval baseline을 어디에 두어야 하는지 정리한 기록이다.
독자가 얻을 답과 글의 범위
이 글의 답은 한 문장으로 정리할 수 있다.
MES·SCM 업무 AI의 baseline은 multi-query가 아니라, 권한·공장·모듈·유효시점을 먼저 고정한
Exact → BM25 → 조건부 Dense/RRF → 조건부 rerank → 근거 미달 시 보류다. Multi-query는 긴 문서형 질문에서만 실험으로 추가한다.
다룰 범위는 네 가지다.
- 현재 Python 서비스의 multi-query, think, MCP 실행 경로
- BM25, Dense, RRF, rerank와 query expansion이 맡는 서로 다른 역할
- MES·SCM에서 엔터티·권한·문서 개정본·실시간 데이터를 안전하게 분리하는 baseline
- 기능을 켜기 전에 어떤 gold set과 지표로 비교해야 하는지
다루지 않는 범위도 있다. 특정 LLM이 내부적으로 어떤 은닉 상태를 계산하는지, 또는 특정 벤더의 reasoning trace가 실제 추론을 완전하게 재현하는지는 이 글의 근거만으로 확정할 수 없다. 여기서는 애플리케이션이 어떤 요청을 보내고 어떤 응답 필드를 받아 무엇을 반복하는지만 코드로 확인한다.
검증 기준일은 2026-07-24다. 현재 서비스 코드는 /Users/munseongjun/Desktop/runup/dyna-llm, 새 실험용 retrieval 서버는 /Users/munseongjun/Desktop/runup/llm-test에서 읽었다.
왜 MES·SCM 업무 AI에는 검색 전에 제약을 고정해야 하는가
일반 지식 질의에서는 “비슷한 내용을 넓게 찾고 자연스럽게 요약”해도 사용자가 여러 출처를 다시 확인할 여지가 있다. 공장 업무 질의는 다르다.
“2공장 PRS-02의 금형 교체 기준을 알려줘”
공장 = 2공장
설비 = PRS-02
문서 성격 = 작업/보전 표준
개정본 = 요청 시점에 승인되고 유효한 버전
권한 = 요청자가 읽을 수 있는 문서
여기서 PRS-02를 “프레스 2호기”로 넓히는 일은 도움이 될 수 있다. 반대로 PRS-02를 포함하는 다른 공장의 오래된 문서를 찾은 뒤 LLM에게 제외시키는 일은 안전하지 않다. filter는 점수 계산 뒤의 힌트가 아니라 후보 집합을 만들기 전의 경계여야 한다.
새 실험 서버의 BM25 SQL은 이 방향을 코드로 보여 준다. release, realm, 승인 상태, 승인 시점, 유효 기간, source/revision/unit ACL, plant scope, module을 WHERE 절에서 먼저 제한하고 그 안에서만 BM25를 계산한다. 확인 위치는 /Users/munseongjun/Desktop/runup/llm-test/server/repositories.py:856-906이다.
전체 문서 D
└─ ACL, plant, module, as_of, approved filter
└─ 허용 후보 D_allowed
└─ Exact / BM25 / Dense score
└─ evidence 후보
이 순서가 중요한 이유는 간단하다. 검색기나 LLM의 “관련성” 점수는 권한도 아니고, 올바른 공장도 아니고, 유효한 개정본도 아니다.
무엇인가: RAG, Multi-query, Think mode, MCP
RAG는 모델의 기억을 고치는 기능이 아니다
RAG는 질문 에 대해 corpus 에서 후보 를 찾아 모델 입력에 넣는 구조다.
그 뒤 생성 모델은 질문과 근거를 받아 답변 를 만든다.
여기서 retrieval이 잘못되면 모델이 아무리 유창해도 근거가 잘못된다. generation이 잘못되면 올바른 근거가 있어도 답변이 왜곡될 수 있다. 그래서 retrieval 품질과 answer 품질을 같은 “AI 정확도” 하나로 측정하면 병목을 찾을 수 없다.
Multi-query는 query expansion이다
multi-query는 원 질의 에서 변형 을 만들고 각각 검색하는 방식이다.
문서에는 교대 근무 코드 등록이라고 쓰였고 사용자는 Shift코드 설정이라고 말하는 식의 vocabulary mismatch를 줄이려는 목적이다. Azure AI Search도 query rewrite를 원 질의와 생성된 대체 질의를 함께 검색하는 선택적 기능으로 설명한다. 동시에 rewrite가 고유 식별자나 제품 코드의 정확한 term을 빠뜨릴 수 있다고 경고한다. Azure AI Search Query Rewrite
따라서 multi-query는 “RAG면 당연히 켜는 기능”이 아니다. 재현율 부족이라는 가설을 검증할 때 추가하는 후보 확장기다.
Think mode는 검색 전략이 아니다
현재 API의 isThink는 boolean이고 기본값은 false다. /Users/munseongjun/Desktop/runup/dyna-llm/app/schemas.py:5-11
이 값은 최종 답변 LLM 호출에 전달된다. 모델이 reasoning 또는 thinking field를 스트리밍하면 서버는 그 text를 llm_think SSE 이벤트로 프론트에 보낸다. /Users/munseongjun/Desktop/runup/dyna-llm/app/web/routes/chat.py:551-594
Ollama는 think 요청 값과 분리된 message.thinking 응답 필드를 문서화한다. 지원 모델에서는 message.content가 최종 답변이고 message.thinking이 별도 thinking 출력이다. Ollama Thinking, Ollama Chat API
즉 think mode는 “더 많은 검색어를 생성한다”는 뜻도, “RAG를 한 번 더 돈다”는 뜻도 아니다. 모델이 reasoning token을 생성하도록 허용하는 provider/model 옵션이다. reasoning trace가 보인다고 해서 그 text가 감사 가능한 사실 근거나 실제 모델 내부 계산의 완전한 기록이라는 뜻도 아니다.
MCP는 실시간 데이터와 실행 도구의 경계다
MCP tool은 LLM이 외부 시스템의 API, DB 조회, 계산기 같은 기능을 호출하도록 노출한 실행 경계다. 도구에는 이름, 설명, 입력 JSON Schema가 있고, tool의 설명이나 annotation은 신뢰할 수 없는 서버에서 왔다면 신뢰하지 않아야 한다는 점까지 공식 명세에 적혀 있다. MCP Tools specification
MES·SCM에서는 이 경계를 다음처럼 둔다.
승인된 표준서·매뉴얼·정책·개정 이력 → RAG
현재 재고·LOT 추적·작업지시·실적 → read-only MCP
OEE·수율·납기준수율 → 승인된 공식 + MCP 원천값 + 결정론적 계산
RAG가 현재 재고를 대신하면 문서의 과거 스냅샷을 현재 사실처럼 답할 수 있다. MCP가 실패했다고 RAG 문서로 현재 상태를 추측하는 fallback도 금지해야 한다.
선행 개념: Exact, BM25, Dense, RRF, rerank
Exact는 “관련 문서”가 아니라 “같은 대상”을 찾는다
Exact 단계의 입력은 원 질의에서 보존한 stable key다. 예를 들어 품목 코드, LOT, 작업지시, 설비 ID, 문서 key다. 이 단계는 alias expansion이 나온 broad document를 exact answer로 착각하면 안 된다.
새 실험 서버는 이 원칙을 코드로 분리한다. exact_search는 planner가 BM25용으로 만든 확장 term을 버리고, 전체 normalized query 또는 DEFINES 엔터티 연결만 exact early exit의 근거로 쓴다. /Users/munseongjun/Desktop/runup/llm-test/server/repositories.py:755-854
BM25는 정확한 term의 희소성을 이용한다
BM25는 query term 가 문서 에 나타나는 빈도와 corpus에서의 희소성을 합친다.
- : 문서 안의 term 빈도
- : 드문 term일수록 큰 값
- , : 문서 길이와 corpus 평균 길이
- : 반복 등장 효과가 포화되는 정도
- : 문서 길이 보정 강도
LOT-240701-33, BOM-2024-014, PRS-02처럼 업무상 한 글자 차이가 중요한 term은 BM25가 강한 출발점이다. 단, 한국어 분석기와 user dictionary가 PRS-02, A17, 품목 코드, snake_case를 어떻게 token화하는지 반드시 관찰해야 한다. BM25 식보다 analyzer가 먼저 깨질 수 있다.
Dense는 표현 차이를 보완하지만 식별자를 보장하지 않는다
Dense retrieval은 query와 문서를 vector로 만들고 cosine 또는 inner product로 가깝게 찾는다.
축받이 떨림과 베어링 진동처럼 표현이 달라도 가까운 문서를 찾는 데 유리하다. 반대로 6205와 6206, A17과 A71처럼 의미 공간에서는 가까워 보이지만 업무상 다른 식별자를 뭉개면 안 된다. 그래서 Dense는 Exact/BM25를 없애는 기능이 아니라, query class와 평가 결과가 허용할 때 보강하는 channel이다.
RRF는 서로 다른 점수를 직접 더하지 않는다
BM25와 cosine score의 scale은 보통 다르다. RRF(Reciprocal Rank Fusion)는 raw score 대신 각 채널의 순위만 쓴다.
- : 문서 가 등장한 결과 목록 집합
- : 목록 에서의 1부터 시작하는 순위
- : 채널 weight
- : 상위 1위가 지나치게 지배하지 않게 하는 상수
Azure AI Search도 text와 vector를 병렬 실행한 뒤 RRF로 결과를 합친다고 설명한다. RRF score는 채널 수에 따라 달라지고 cosine/BM25의 절대 score와 직접 비교할 수 없다. Azure AI Search Hybrid Search, Azure AI Search RRF
Rerank는 작은 후보 집합을 비싸게 다시 판단한다
cross-encoder reranker는 query와 candidate 문서를 함께 보고 정렬한다. 그래서 first-stage BM25/Dense보다 느리지만, top 20~50 후보에서 문맥적 오정렬을 줄일 수 있다. rerank는 정답 후보를 새로 만들지 못한다. BM25/Dense 후보에 정답이 없으면 reranker는 잘못된 후보 중 하나를 더 그럴듯하게 고를 뿐이다.
내부 구조와 실제 실행 흐름
현재 multi-query는 데이터 기반 확장이 아니다
현재 서비스의 _MULTI_QUERY_EXAMPLES에는 비밀번호, 주문 취소, BOM 등록, 재고 실사라는 고정 few-shot 예시가 있다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:231-259
그 다음 전용 LLM에 “동의어나 다른 문장 구조를 사용해 원문과 같은 언어로 변형하라”고 요청한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:262-379
원문: “BOM 등록 방법”
고정 예시를 포함한 prompt
→ 전용 LLM
→ “자재명세서 신규 등록 절차”, “BOM 추가 입력하는 법” ...
→ 원문과 함께 검색
여기에는 회사별 domain_entity, 승인된 alias, 설비/품목 카탈로그, 문서 분류, 공장 범위가 query 생성 입력으로 들어가지 않는다. 그러므로 현재 구현을 “MES·SCM 데이터 기반 multi-query”라고 부르면 안 된다. LLM-driven few-shot rewrite가 더 정확한 이름이다.
활성화도 전역 기본값이 아니라 realm workflow의 rag.multi_query 값이 결정한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/workflows/orchestrator.py:455-483 전역 rag_multi_query_enabled 설정이 있어도, 현재 호출 경로에서 on/off 스위치로 읽히지 않는다.
multi-query를 켜면 원문 포함 최대 count개 query를 각각 embedding·검색하고 asyncio.gather로 병렬 실행한다. 같은 chunk ID는 가장 큰 점수 하나만 남긴다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:724-769, /Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:525-535
이 구현에는 두 관찰점이 있다.
count=3은 변형 3개가 아니라 원문 + 최대 2개 변형이다.- chunk가 어느 변형 query에서 발견됐는지와 각 query별 rank는 최종 context에 남지 않는다. 여러 query 결과를 RRF로 다시 합치는 구조도 아니다.
원문 query의 semantic cache hit는 multi-query 생성 전에 반환된다. 즉 cache hit 요청에서는 multi-query가 실제 실행되지 않고 timing의 actual count는 0으로 기록된다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:657-693
화면의 think는 multi-query가 아니다
multi-query 전용 호출은 stream: false다. rag_multi_query_disable_thinking이 켜져 있으면 enable_thinking: false도 보낸다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:187-228
반면 프론트가 받는 llm_think는 최종 답변 스트림의 reasoning_content 또는 Ollama message.thinking 필드다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/llmchat.py:317-379, /Users/munseongjun/Desktop/runup/dyna-llm/app/services/llmchat.py:1124-1145
[내부, 비노출] multi-query rewrite → retrieval → rerank
[스트리밍, 노출] 최종 LLM reasoning token → llm_think
[스트리밍, 노출] 최종 답변 token → llm_chunk
isThink=false일 때 현재 chat route는 마지막 user message에 /no_think를 붙이고 think=false로 전달한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/web/routes/chat.py:348-352, /Users/munseongjun/Desktop/runup/dyna-llm/app/web/routes/chat.py:561-572
Provider별 semantics는 완전히 같지 않다. Ollama 경로는 payload의 think를 직접 설정한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/llmchat.py:1421-1450 Gemini 경로는 현재 true면 budget 1024, false면 0으로 설정한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/llmchat.py:440-453 다만 Gemini 모델군별로 thinking을 완전히 끌 수 있는지와 권장 field는 다를 수 있으므로, 모델 교체 시 공식 문서를 다시 확인해야 한다. Gemini Thinking
“재귀처럼 보이는 것”은 MCP tool loop다
MCP 도구를 사용하는 요청은 다음을 반복할 수 있다.
LLM 호출
→ tool_calls가 없으면 최종 답변
→ tool_calls가 있으면 MCP 실행
→ tool result를 messages에 추가
→ LLM 재호출
현재 코드는 for round_num in range(max_rounds)로 이 루프를 구현하고, 기본 최대 라운드는 5다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/llmchat.py:913-1052, /Users/munseongjun/Desktop/runup/dyna-llm/app/core/config.py:36
따라서 화면에 think → tool call → tool result → think가 보이면, think가 재귀를 만든 것이 아니다. 도구 결과를 다음 모델 입력에 넣는 애플리케이션 레벨 반복이 있고 각 라운드에서 think를 켰기 때문에 그렇게 보이는 것이다.
이 차이는 운영에서 중요하다. think의 지연은 주로 추가 reasoning token decoding에서 오고, MCP loop의 지연은 모델 호출 횟수·도구 API·네트워크·도구 결과 크기에서 온다. 둘을 같은 AI 응답이 느림으로 묶으면 원인을 찾을 수 없다.
직접 검증: baseline을 작은 계산과 trace로 확인하기
RRF는 순위만 합친다
아래 예제는 BM25와 Dense의 raw score를 더하지 않고 rank만 합친다. 실행 환경은 macOS의 Python 3 표준 라이브러리다.
from collections import defaultdict
def rrf(rankings, k=60):
scores = defaultdict(float)
for ranking in rankings:
for rank, document_id in enumerate(ranking, start=1):
scores[document_id] += 1 / (k + rank)
return sorted(scores.items(), key=lambda item: item[1], reverse=True)
bm25 = ["doc-prs02", "doc-general", "doc-old"]
dense = ["doc-general", "doc-prs02", "doc-other"]
for document_id, score in rrf([bm25, dense]):
print(document_id, f"{score:.6f}")
실행 결과는 다음과 같다.
doc-prs02 0.032522
doc-general 0.032522
doc-old 0.015873
doc-other 0.015873
두 문서가 한 채널에서는 1위, 다른 채널에서는 2위이므로 동점이다. 여기서 doc-prs02가 실제로 맞는 문서인지 판단하려면 rank만으로 부족하다. Exact key, plant, revision, reranker, 그리고 사람 검수 label이 필요하다.
현재 실험 서버의 실제 baseline
llm-test bootstrap의 term-lexical-rerank-v1은 현재 다음 정책을 설치한다.
strategy = lexical_only
exact_early_exit = true
exact_min_score = 0.98
bm25_limit = 40
dense_limit = 0
rerank_enabled = true
rerank_policy = CONDITIONAL
query_rewrite_policy = NEVER
final_limit = 5
확인 위치는 /Users/munseongjun/Desktop/runup/llm-test/scripts/bootstrap-control.py:505-553이다. routing validation도 evaluator가 아직 없는 query rewrite 정책을 NEVER 이외로 허용하지 않는다. /Users/munseongjun/Desktop/runup/llm-test/server/routing.py:480-525
이것은 “Dense는 절대 쓰지 않는다”는 최종 결론이 아니다. 검색어 확장, embedding, 대형 reranker를 동시에 켜서 실패 원인을 감추지 않는 최초 비교 기준이다.
요청 하나에 남겨야 할 trace
공장 업무에서 재현 가능한 원인 분석을 하려면 answer text보다 아래 trace가 먼저 남아야 한다.
{
"request_id": "...",
"route": "document_retrieval | live_read | metric | clarify",
"locked_constraints": {
"realm_id": "...",
"plant_id": "P2",
"module_key": "MES",
"as_of": "2026-07-24T09:00:00+09:00",
"entity_keys": ["PRS-02"]
},
"retrieval_profile": "term-lexical-rerank-v1",
"exact": {"candidate_ids": [], "duration_ms": 4},
"bm25": {"query_ids": ["original", "alias:PRS-02"], "duration_ms": 18},
"dense": {"executed": false, "reason": "lexical_only_policy"},
"rerank": {"executed": true, "candidate_ids": ["..."]},
"evidence": [{"unit_id": "...", "revision": "...", "citation": "..."}],
"answer_gate": "grounded | abstained | clarification_required"
}
MCP라면 여기에 tool_name, validated input parameters, authorization decision, source timestamp, response row count, cache 여부를 더한다. LLM이 만든 자연어 reasoning trace는 보조 UI일 수 있어도, 위 trace를 대체할 수 없다.
성능과 트레이드오프
multi-query의 비용은 검색 3배보다 크다
원문 + 두 변형을 검색하면 embedding과 retrieval은 병렬 처리할 수 있다. 현재 코드도 asyncio.gather를 사용한다. 하지만 다음 비용은 사라지지 않는다.
query rewrite LLM prefill + decode
query별 embedding
query별 BM25/Dense index 접근
후보 합집합 증가
reranker pair 증가
더 큰 context 조립 가능성
동시에 요청한다고 해서 GPU, embedding 서버, DB index, reranker의 자원이 무한히 병렬화되지는 않는다. query가 3개면 재현율은 오를 수 있지만 서로 비슷한 rewrite가 같은 후보만 반복해 가져올 수도 있다. 그래서 “3개로 하면 좋아진다”는 설정값은 성능 근거가 아니다.
think mode의 비용은 token budget과 모델별 semantics에 달려 있다
think를 켜면 reasoning token이 먼저 생성될 수 있으므로 TTFT와 총 생성 시간이 늘 수 있다. Gemini 문서도 thinking budget을 reasoning에 사용할 token 수의 가이드로 설명한다. Gemini Thinking
하지만 think가 retrieval recall을 직접 올리지는 않는다. RAG 후보가 다른 공장의 오래된 문서라면 think를 오래 해도 안전한 답이 되지 않는다. 운영 화면에서 think를 노출하면 내부 업무 용어, 도구 선택 방식, 잘못된 중간 가설이 노출될 수 있다는 privacy/UX 비용도 따로 평가해야 한다.
MCP loop의 비용은 라운드 수와 도구 계약에서 나온다
MCP loop는 1회 tool 호출이라도 최소 다음과 같이 지연을 합친다.
두 번째 도구가 필요하면 다시 이 붙는다. 현재 구현은 같은 라운드의 복수 tool을 병렬 실행할 수 있지만, 다음 라운드는 이전 결과에 의존한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/llmchat.py:976-985
따라서 운영 지표는 적어도 아래처럼 분리한다.
| 구간 | 필요한 지표 |
|---|---|
| 라우팅/엔터티 추출 | p50/p95, 실패·모호함 비율 |
| Exact/BM25/Dense | 채널별 후보 수, p50/p95, empty rate |
| rerank | 후보 수, 모델 load 여부, p50/p95 |
| multi-query | 생성 수, 중복률, 원문 대비 recall 변화, 실패 사유 |
| MCP | 라운드 수, tool별 latency, retry, cache hit, 실패율 |
| 생성 | TTFT, output/reasoning token 수, p50/p95 |
| 안전 게이트 | 무근거 보류율, wrong plant/revision/ACL 노출 수 |
고성능 모델을 요청마다 띄우는 설계도 피해야 한다. model process/GPU cold start는 query rewrite나 rerank 개선보다 훨씬 큰 p95 변동을 만들 수 있다. 모델 역할이 필요하면 routing·entity extraction·rerank·answer generation의 처리량과 GPU 상주 정책을 함께 계획해야 한다.
실패, 한계, 운영 기준
금지할 fallback
다음 fallback은 사용자에게 자연스러워 보일 수 있지만 공장 업무에서는 위험하다.
MCP live 조회 실패 → 예전 RAG 문서로 현재 재고를 추정
RAG 근거 0건 → 일반 LLM 지식으로 작업 표준을 단정
다른 plant 문서가 더 높은 점수 → “아마 같은 기준”이라고 답변
metric source 부족 → LLM이 OEE 공식을 임의로 조립
대신 live_data_unavailable, evidence_insufficient, ambiguous_entity, authorization_denied처럼 실패 상태를 명시적으로 반환해야 한다. 답변을 못 하는 것이 실패가 아니라, 근거 없는 답변을 성공처럼 내보내는 것이 실패다.
데이터 기반 query expansion의 최소 모델
현재 고정 few-shot을 단순히 더 많은 MES 예시로 바꾸는 것은 해결이 아니다. query expansion의 source를 tenant 데이터로 바꿔야 한다.
| 데이터 | 역할 | 반드시 가진 범위 |
|---|---|---|
domain_entity | canonical key, entity type | realm, plant/module 적용 범위, enabled |
entity_alias | 현장 별칭·약어·오탈자 승인 목록 | entity FK, 정규화 값, 적용 범위, enabled |
source_revision | 문서 개정과 유효성 | approval, valid_from/to, ACL |
knowledge_unit | section/chunk와 metadata | parent, unit key, plant/module, ACL |
retrieval_profile | 채널·후보 수·rerank·rewrite 정책 | 버전, rollout, evaluation 결과 |
retrieval_trace | 요청별 관찰·재현 | profile, filter, candidate/evidence, latency |
그 뒤 질의는 다음처럼 만든다.
사용자 질문
→ entity extraction: PRS-02, P2, 금형 교체
→ locked constraint: plant=P2, entity=PRS-02
→ approved alias: “프레스 2호기”, “PRESS-2”
→ 결정론적 검색어
1. 원문
2. “PRS-02 금형 교체 기준”
3. “PRESS-2 설비 보전 표준 금형 교체”
→ 동일한 ACL/plant/as_of filter 안에서 검색
LLM rewrite를 추가하더라도, locked entity/기간/공장 token이 모두 보존됐는지 검증하고 document-procedure class에만 허용한다. live, metric, identifier query에는 허용하지 않는다.
think trace를 관측성으로 오해하지 않기
현재 chat route는 think_text가 있으면 assistant message meta에 저장한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/web/routes/chat.py:645-662 이 데이터는 UI 재표시에는 쓸 수 있지만, retrieval/audit trace로 쓰면 안 된다.
모델은 생각을 말로 표현할 뿐, SQL filter가 어떤 row를 제외했는지, reranker가 어떤 후보를 버렸는지, MCP가 어떤 timestamp의 source를 읽었는지 자동으로 증명하지 않는다. 운영자는 모델 자기 설명이 아니라 시스템이 기록한 입력·정책·근거·도구 결과를 봐야 한다.
대안, 비교와 선택 기준
baseline 실험 순서
모든 기능을 켠 “고급 RAG”와 아무 기능 없는 RAG를 비교하면 무엇이 좋아졌는지 알 수 없다. 다음처럼 한 단계씩 비교한다.
| 실험 | 구성 | 확인할 질문 |
|---|---|---|
| B0 | hard filter + Exact + BM25 | ID/용어 질의의 최소 정확도는 충분한가? |
| B1 | B0 + conditional rerank | 문서형 질의 순위가 좋아지는가? |
| B2 | B1 + Dense/RRF | 표현 차이 질의에서 candidate recall이 실제로 늘어나는가? |
| B3 | B2 + 승인 alias 확장 | 현장 용어·약어에서 이득이 있는가? |
| B4 | B3 + 제한적 LLM multi-query | 긴 절차형 문서 질의에서만 순이득이 있는가? |
필터와 ACL은 B0부터 모든 실험에서 고정해야 한다. 그것은 tuning 변수나 비교 대상이 아니라 보안·정합성 경계다.
gold set은 평균 하나가 아니라 실패 유형으로 나눈다
최소한 다음 bucket을 별도로 label한다.
1. 품목/BOM/LOT/작업지시/설비 ID 정확 조회
2. 승인된 현장 별칭과 약어
3. 표준서·절차 문서 질의
4. 길고 모호한 자연어 절차 질의
5. plant/line/date/revision 제약 질의
6. 실시간 운영 데이터: MCP만 정답 경로
7. metric: 공식과 원천값이 모두 필요한 질의
8. 근거 없음, 권한 없음, 다른 공장/만료 문서
각 bucket에서 다음을 본다.
- retrieval: Recall@5/10, MRR@10, nDCG@10
- 안전성: wrong plant, wrong revision, ACL leakage의 건수는 0이어야 함
- generation: 인용 근거 일치율, SME 사실 오류율, evidence 부족 시 보류 정확도
- 운영: p50/p95, cold/warm model 구분, 비용, timeout, retry
- multi-query: 원문 대비 gain, variant 중복률, entity/constraint 보존 실패율
multi-query는 B4에서 긴 절차형 bucket의 recall을 올리고 ID·제약 bucket을 악화시키지 않으며 p95 예산 안에 있을 때만 채택한다. 평균 nDCG가 조금 올라도 다른 공장 문서 노출이 생기면 탈락이다.
흔한 오해와 이 설계의 답
“RAG 기반이면 질문은 모두 문서 검색부터 해야 하지 않나?”
문서 지식 질의라면 그렇다. 하지만 현재 재고, 실적, LOT 상태처럼 source of truth가 운영 DB/API인 요청은 RAG가 아니라 read-only MCP가 기본이다. 복합 진단은 RAG가 표준·절차를, MCP가 현재 관측값을 제공하고 둘을 같은 entity/plant/time 제약으로 조합해야 한다.
“multi-query가 여러 검색을 하니 정확도가 자동으로 높지 않나?”
아니다. 후보가 늘어날 뿐이다. 변형이 식별자나 기간을 바꾸면 정확도는 오히려 떨어질 수 있다. query rewrite는 original query만 쓰는 방식에 대한 선택적 보강이며, 공식 문서도 고유 identifier/product code의 정확한 term이 빠질 수 있음을 경고한다. Azure AI Search Query Rewrite
“프론트의 think가 multi-query 결과인가?”
아니다. multi-query는 현재 비스트리밍 내부 호출이고, 화면 llm_think는 최종 답변 모델이 내보낸 reasoning token이다. 이 둘을 관측 이벤트부터 분리해야 병목도 분리해 측정할 수 있다.
“think를 켜면 재귀적으로 더 깊게 생각하는가?”
현재 서버 코드에서 think는 provider에 전달하는 모델 옵션이다. tool loop는 별도의 for 반복이다. MCP가 없으면 일반 스트리밍 LLM은 한 번 호출된다. MCP가 있으면 최대 라운드만큼 LLM과 도구가 번갈아 호출될 수 있다. think가 loop를 만드는 것이 아니라 loop의 각 LLM 호출이 reasoning token을 더 낼 수 있는 구조다.
“고성능 모델 하나를 요청할 때만 띄우면 비용을 아낄 수 있지 않나?”
콜드 start가 p95를 망가뜨리면 업무용 챗봇에서는 비용 절감이 아니라 사용성 저하다. 모델 상주, queue, concurrency, context 길이, prefill/decode 처리량을 실제 traffic으로 측정해야 한다. 작은 모델로 안전한 routing/parameter extraction을 하고, 근거가 확보된 복합 설명만 고성능 generation 모델에 보내는 역할 분리는 가능하다. 다만 그 분리도 추적 가능한 route policy와 평가 결과가 있어야 한다.
실제 병목에서 V2 설계로 — 왜 Multi-query 기본값을 포기했는가
처음에는 RAG라면 질문을 여러 표현으로 바꿔 더 많이 찾는 편이 당연히 정확할 것이라고 생각했다. 화면에 길게 나오는 think도 그 검색 과정의 일부처럼 보였다. 코드를 따라가 보니 둘은 달랐다. 화면의 think는 최종 모델이 내보내는 reasoning token이고, multi-query는 그보다 앞에서 별도 LLM을 한 번 호출해 검색어를 바꾸는 단계였다.
더 중요한 것은 이 구분이 용어 설명에서 끝나지 않는다는 점이다. 기존 시스템의 실제 지연 기록, quality test, HTML로 정리한 V2 설계를 같이 놓고 보니 문제는 “multi-query 모델이 느리다”가 아니었다. 업무 질문의 증거 종류를 정하기 전에 LLM rewrite, N회 검색, rerank를 답변 전 임계경로에 붙인 것이 문제였다.
문제를 다시 정의했다: SSE가 아니라 첫 토큰 전의 임계경로
기본적인 LLM 호출은 사용자 입력과 context를 한 번 받아 다음 token을 생성하는 일이다. RAG의 최소 경로도 하나의 정규화된 query를 허용된 후보 집합에서 검색하는 것으로 시작한다. multiple query, query rewrite, rerank, tool loop는 모델의 기본 동작이 아니라 애플리케이션이 명시적으로 추가한 제어 흐름이다.
기존 chat route는 orchestrator를 await한 뒤에야 일반 LLM stream을 시작한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/web/routes/chat.py:332-372, 551-572 따라서 SSE 연결을 먼저 열어 init이나 step을 보내더라도, 사용자가 체감하는 첫 답변 토큰은 다음 준비가 모두 끝난 뒤에 나온다.
SSE 연결
→ history / context 준비
→ orchestrator await
→ 조건부 intent 분류
→ RAG: multi-query rewrite → embedding/search fan-out → rerank
→ MCP 준비
→ 최종 LLM stream 시작
→ 첫 llm_chunk
그래서 질문을 바꿨다.
“여러 query를 만들 수 있는가?”
↓
“이 업무 유형에서 여러 query가 없으면 실제로 놓치는 정답이 있는가?”
↓
“늘어난 후보·지연·오류 표면보다 그 이득이 큰가?”
과거의 실제 측정은 이미 경고하고 있었다
아래 수치는 추정이 아니다. 2026-04-27에 남긴 기존 서버의 단발 baseline 측정이다. 당시 realm은 use_rag + use_mcp + intent=hybrid + multi_query=2 + rerank + hybrid + semantic_cache였고, 모델·embedding·MCP의 배치가 지금과 같다고 가정하면 안 된다. 다만 어떤 단계가 임계경로를 지배했는지는 분명히 보여 준다. /Users/munseongjun/Desktop/runup/dyna-llm/docs/2026-04-27_perf_plan.md:299-369
| 시나리오 | 총 시간 | orchestrator/RAG | 최종 LLM | 관찰 |
|---|---|---|---|---|
| RAG+MCP, warm | 약 32.0초 | 22.110초 | 9.942초 | RAG 준비가 전체의 약 69% |
| RAG only | 약 23.8초 | 10.131초 | 13.643초 | RAG와 답변 모델 모두 지배적 |
| MCP only | 약 1.25초 | 65ms | 1.175초 | MCP 자체는 당시 병목이 아님 |
| casual | 약 1.25초 | 24ms | 1.218초 | RAG를 건너뛴 경로는 빠름 |
warm RAG+MCP 요청의 RAG 내부를 더 보면 rag_embed가 15.749초, rag_search가 148ms, rag_rerank가 6.160초였다. 이 환경에는 multi_query=2가 켜져 있으므로 rag_embed에는 원문과 변형 query 처리 비용이 섞여 있다. 이 표만으로 “rewrite 자체가 정확히 몇 초”라고 분리할 수는 없다. 대신 DB search가 아니라 embedding/rerank와 그 이전 준비가 지연의 중심이었다는 사실은 확인된다.
2026-07-07의 다른 실제 main 요청은 더 직접적이다. 로그 시간 분해상 multi-query 완료까지 약 11초, 검색 완료까지 약 1초, remote rerank에 약 14초가 걸렸다. 최종 LLM stream은 요청 뒤 약 26초가 지나서야 시작됐다. /Users/munseongjun/Desktop/runup/dyna-llm/docs/2026-07-07_model_runtime_latency_analysis.md:182-210
요청 수신
├─ multi-query 약 11초 (pre-LLM 대기의 약 42%)
├─ search / 후보 39건 수집 약 1초
└─ remote rerank 약 14초 (pre-LLM 대기의 약 54%)
최종 LLM stream 시작 약 26초 후
여기서 내린 교정은 중요하다. multi-query는 실제 병목이 맞지만 유일한 병목은 아니다. 그 요청에서는 rerank가 더 큰 지연을 만들었다. 따라서 “multi-query를 끄면 끝”도, “더 빠른 rewrite 모델을 붙이면 끝”도 맞지 않는다. 병목은 LLM rewrite 하나가 아니라 질문 종류와 상관없이 여러 고비용 단계를 직렬 임계경로에 묶어 둔 구조다.
왜 현재 Multi-query는 자체 병목을 만들기 쉬운가
현재 구현은 workflow의 multi_query boolean이 true이면 RAG 호출에서 바로 rewrite를 실행한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/workflows/orchestrator.py:468-483, /Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:724-750 이 flag는 “문서형 절차 질문이고 alias로 해결되지 않았으며 1차 retrieval coverage가 부족하다” 같은 조건을 표현하지 않는다. RAG가 선택된 요청 안에서는 질문별 필요성보다 설정값이 우선한다.
원문 query
→ rewrite LLM 1회 ← 검색보다 먼저 기다림
→ 원문 + 변형마다 embedding/search ← candidate fan-out
→ 같은 chunk ID는 최고 score만 보존
→ 후보 제한
→ cross-encoder rerank
→ 최종 답변 모델
현재 asyncio.gather는 query별 embedding/search를 병렬로 시작한다. 그렇다고 전체가 병렬은 아니다. rewrite LLM이 끝나야 변형 query가 생기고, 후보를 합쳐야 rerank가 시작한다. 각 query의 hybrid 검색도 구현상 dense 결과를 받은 뒤 BM25 availability/search를 실행한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:465-522, 724-769
두 번째 식의 는 query별 작업이 충분한 자원에서 병렬로 실행된다는 이상적인 경우다. 실제 GPU, embedding server, PostgreSQL/검색 index, reranker가 공유 자원이라면 queueing 때문에 합계에 가까워질 수도 있다. T_rewrite는 model warm-up으로 줄 수는 있어도 0이 되지 않는다. cold start 제거와 불필요한 inference 제거는 다른 문제다.
결과를 합치는 방식도 품질 근거를 약하게 만든다. 여러 query가 가져온 같은 chunk는 최고 score 하나만 남기고 query별 rank·발견 이유는 보존하지 않는다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:525-535 cross-encoder가 모든 후보를 탈락시켜도 기존 상위 context를 복구한다. /Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:808-845 이 fallback은 모델 장애에 대한 가용성 장치일 수 있지만, “근거가 충분하지 않으면 답하지 않는다”는 공장 업무 안전 정책과는 다르다.
더 근본적인 빈칸은 평가다. 현재 slow quality test는 원문 반복, 한국어 입력의 영어 전환, 반복 출력, count를 확인한다. test 파일 자체도 “품질 점수까지는 못 잡는다”고 밝힌다. /Users/munseongjun/Desktop/runup/dyna-llm/tests/test_rag_multi_query_quality.py:1-84 변형 문장이 자연스럽다는 것과 정답 SOP section을 더 자주 찾는다는 것은 전혀 다른 주장이다.
MES·SCM에서는 이 비용이 정확성 위험으로도 이어진다
웹 검색이나 일반 매뉴얼 검색에서는 후보를 넓히는 것이 종종 안전하다. MES·SCM은 식별자와 범위가 답의 일부다.
| 질문 | 정답의 근거 | 기본 경로 | LLM rewrite 기본 적용 |
|---|---|---|---|
BOM-2024-014 개정 이력 | 승인된 개정 문서와 canonical ID | Exact → BM25 | 금지 |
LOT-240701-33 현재 공정 | 현재 시점의 생산 추적 데이터 | read-only MCP/API | 금지 |
2공장 PRS-02 금형 교체 기준 | 2공장 적용 표준서·유효 개정 | hard filter → 문서 retrieval | alias 후에도 부족할 때만 |
오늘 2공장 OEE | 승인 공식 + 단위·기간·설비 차원의 live 원천값 | metric layer + MCP | 금지 |
라벨 불량 반복 시 조치 절차 | SOP·품질 사례·경고 문맥 | 문서 retrieval | coverage 부족 시 1회 후보 |
PRS-02, 2공장, 오늘, LOT-240701-33을 rewrite가 바꾸거나 누락해도 유창한 답변은 만들 수 있다. 그러나 그 답변은 업무적으로 틀릴 수 있다. 그래서 RAG/MCP를 고르는 기준은 “LLM이 이 질문을 잘 설명할 수 있는가”가 아니라 어떤 source가 정답의 근거인가여야 한다.
이 관점에서는 think도 해결책이 아니다. think는 최종 모델의 reasoning token을 더 생성하게 하는 옵션이고, multi-query는 검색 전에 후보를 넓히는 옵션이다. MCP loop는 도구 결과를 넣어 모델을 다시 호출하는 별도 흐름이다. think를 길게 켜도 다른 공장 문서가 들어왔거나 metric formula가 틀렸다는 사실을 정정하지 못한다.
llm-test에서 바꾼 것은 검색기 하나가 아니라 결정의 순서다
llm-test는 기존 RAG에 BM25를 하나 더 붙이는 실험이 아니다. tenant 용어를 코드에 하드코딩하지 않고, entity resolution·task routing·retrieval profile 선택을 각각 주입 가능한 protocol로 분리한 query planner다. /Users/munseongjun/Desktop/runup/llm-test/server/planner.py:1-250
planner는 정규화된 query, entity, scope, time range를 받아 route policy와 evidence source, risk, retrieval profile을 포함한 QueryPlan을 만든다. clarification이면 retrieval을 실행할 수 없고, retrieval route면 versioned profile이 반드시 있어야 한다. /Users/munseongjun/Desktop/runup/llm-test/server/planner.py:47-89, 179-250 실행기는 CLARIFY, LIVE_TOOL, METRIC_LAYER, DIAGNOSTIC_BUNDLE, DOCUMENT_RETRIEVAL을 같은 RAG boolean으로 뭉개지 않고 별도로 분기한다. /Users/munseongjun/Desktop/runup/llm-test/server/execution.py:455-503
질문
→ entity / alias / scope / time 정규화
→ route policy
→ QueryPlan
├─ exact / document retrieval
├─ live tool
├─ metric layer
├─ diagnostic bundle
└─ clarify / abstain
→ 허용된 evidence만 수집
→ evidence gate
→ 역할별 answer model 또는 명시적 보류
DB도 이 순서를 지지한다. domain_entity와 entity_alias는 canonical key와 승인 별칭을 분리해 저장하고, metric_definition은 formula·차원·원천 binding·승인·유효 기간을 보유한다. /Users/munseongjun/Desktop/runup/llm-test/migrations/001_three_plane_core.sql:250-314 요청은 선택한 plan과 profile, index release, model deployment를 append-only observability plane에 남긴다. /Users/munseongjun/Desktop/runup/llm-test/migrations/001_three_plane_core.sql:365-430, /Users/munseongjun/Desktop/runup/llm-test/migrations/007_runtime_hardening_and_plan_trace.sql:14-140
현재 active profile이 lexical_only, exact early exit, BM25 40, conditional rerank, query_rewrite_policy=NEVER로 시작하는 이유도 여기 있다. /Users/munseongjun/Desktop/runup/llm-test/scripts/bootstrap-control.py:505-553 먼저 하나의 hard-filtered query가 얼마나 맞는지 측정하지 않으면, Dense·rewrite·rerank가 만든 이득과 손해를 구분할 수 없다.
기본값은 하나의 검증 가능한 query, 회복 경로는 제한적 rewrite다
내가 내린 결론은 multi-query를 삭제하자는 것이 아니다. 다음 조건을 만족하는 문서형 route에서만 recovery step으로 남겨야 한다.
조건 1. live / metric / identifier query가 아니다.
조건 2. plant, entity ID, time range, revision 같은 locked constraint가 있다면 변형 후에도 모두 보존됐다.
조건 3. 승인 alias와 원문 BM25를 실행했지만 coverage 또는 confidence가 부족하다.
조건 4. 최대 원문 + 1개 constrained rewrite로 제한한다.
조건 5. rewrite 결과, 후보 변화, recall gain, latency를 같은 trace에 남긴다.
조건 6. gold set에서 해당 bucket의 이득이 p95·오답·ACL 기준을 통과했다.
이 구조라면 “검색이 안 되면 무조건 LLM을 하나 더 부른다”가 아니라, “증거가 부족하다고 관측된 특정 문서 질의에만 제한적으로 회수율을 보정한다”가 된다.
다음 검증에서 무엇을 채택하고 무엇을 버릴 것인가
V2의 8300 benchmark endpoint는 이 글 작성 시점에 실행 중이지 않았고, 해당 endpoint의 저장된 JSON/CSV 결과도 없었다. 그러므로 V2에 대한 새로운 latency 수치를 임의로 만들지는 않는다. 다만 benchmark runner는 SSE total latency, TTFT, 선택된 route/profile, retrieval stage, citation 상태를 수집할 수 있다. /Users/munseongjun/Desktop/runup/llm-test/scripts/benchmark.py:17-120 engine trace는 stage별 duration, candidate 수, gate 사유를 기록하며, raw prompt나 candidate payload를 timing trace에 넣지 않는다. /Users/munseongjun/Desktop/runup/llm-test/server/execution.py:1809-1845, /Users/munseongjun/Desktop/runup/llm-test/server/repositories.py:295-370
다음 측정은 같은 corpus, 같은 role/plant/as_of, 같은 warm-up 조건에서 B0~B4를 질문 bucket별로 반복 실행해야 한다.
| 단계 | 구성 | 채택 기준 |
|---|---|---|
| B0 | hard filter + Exact + BM25 | ID/용어 질문에서 wrong plant/revision/ACL 0건 |
| B1 | B0 + conditional rerank | procedure 질문의 ranking 이득이 latency를 정당화 |
| B2 | B1 + Dense/RRF | 표현 차이 bucket에서 candidate recall 증가 |
| B3 | B2 + 승인 alias expansion | 현장 약어/별칭에서 recall 증가, false match 제한 |
| B4 | B3 + constrained rewrite 1회 | 긴 절차형 질문에서만 순이득, ID/live/metric bucket 악화 없음 |
total p50만으로 결정하면 안 된다. p95, rewrite_ms, embed_ms, bm25_ms, dense_ms, rerank_ms, MCP round, citation 일치율, evidence 부족 시 보류율을 함께 봐야 한다. 그때 비로소 “multi-query가 병목”이라는 문장은 원인이 보이는 측정 결과가 된다.
그래서 현재 설계에 대한 내 결론은 다음과 같다. 전 업무 질의에 LLM-driven multi-query를 기본 적용하는 방식은, 품질 이득을 증명하지 않은 채 자체적인 지연과 오류 표면을 추가한다. MES·SCM에서 기본값은 더 많이 찾는 것이 아니라, 하나의 검증 가능한 query를 정확한 entity·권한·공장·시점·개정본 범위 안에서 찾는 것이다. 그 baseline을 통과한 뒤에만 Dense, rerank, alias, constrained rewrite를 하나씩 추가해 이득을 증명하는 편이 맞다.
출처
1차 자료와 직접 확인
- 현재 multi-query 구현:
/Users/munseongjun/Desktop/runup/dyna-llm/app/services/rag.py:187-228,231-379,525-535,657-769 - 현재 orchestration/workflow 설정 전달:
/Users/munseongjun/Desktop/runup/dyna-llm/app/workflows/orchestrator.py:455-483 - 현재 SSE think 및 MCP tool loop:
/Users/munseongjun/Desktop/runup/dyna-llm/app/web/routes/chat.py:348-352,551-594,645-662;/Users/munseongjun/Desktop/runup/dyna-llm/app/services/llmchat.py:896-1066,1421-1450 - 기존 latency baseline과 2026-07-07 느린 요청의 시간 분해:
/Users/munseongjun/Desktop/runup/dyna-llm/docs/2026-04-27_perf_plan.md:299-393;/Users/munseongjun/Desktop/runup/dyna-llm/docs/2026-07-07_model_runtime_latency_analysis.md:182-230 - 기존 구조와 V2 전환 판단을 시각화한 내부 설계 문서:
/Users/munseongjun/Desktop/runup/llm-test/mes-scm-llm-architecture-plan.html:402-805;/Users/munseongjun/Desktop/runup/llm-test/mes-scm-rag-reference-architecture.html:554-740 - 실험 서버의 filter-first exact/BM25와 retrieval profile:
/Users/munseongjun/Desktop/runup/llm-test/server/repositories.py:755-906;/Users/munseongjun/Desktop/runup/llm-test/server/retrieval/engine.py:133-270;/Users/munseongjun/Desktop/runup/llm-test/scripts/bootstrap-control.py:505-553;/Users/munseongjun/Desktop/runup/llm-test/server/routing.py:480-525 - 실험 서버의 typed planner, 실행 분기, entity/metric/trace schema:
/Users/munseongjun/Desktop/runup/llm-test/server/planner.py:1-250;/Users/munseongjun/Desktop/runup/llm-test/server/execution.py:455-503,1809-1845;/Users/munseongjun/Desktop/runup/llm-test/migrations/001_three_plane_core.sql:250-430;/Users/munseongjun/Desktop/runup/llm-test/migrations/007_runtime_hardening_and_plan_trace.sql:14-140 - Azure AI Search: Query Rewrite — optional query rewrite, original/rewrite retrieval, exact term 누락 주의
- Azure AI Search: Hybrid Search — full-text/vector 병렬 실행과 RRF merge
- Azure AI Search: RRF Ranking — RRF 수식, 채널별 score scale, parallel query fusion
- Ollama Thinking 및 Chat API —
think,message.thinking,message.content - Gemini Thinking — model별 thinking budget/level 제약
- MCP Tools Specification — tool schema와 trust/safety 경계
보조 자료
- Query Expansion in the Age of Pre-trained and Large Language Models: A Comprehensive Survey — query expansion의 grounding, topic drift, evaluation을 정리한 survey. 이 글의 MES·SCM 적용 판단은 해당 survey의 결론을 그대로 일반화한 것이 아니라, 위 코드와 업무 제약을 합쳐 내린 설계 판단이다.
댓글