1 개요
MINERVA의 설정은 세 레이어를 순서대로 통과해 런타임 동작을 결정한다.
Layer 1: 환경변수 (.env)
↓ os.getenv() — dataclass 기본값에서 읽음
Layer 2: YAML 프로파일 (data/configs/*.yaml)
↓ RAGConfig.from_yaml_file() — ${VAR:default} 치환 후 파싱
Layer 3: A/B 실험 override (data/experiments/*.yaml)
↓ apply_overrides() — dotted key → RAGConfig 필드 덮어쓰기
Layer 4: 런타임 동작
└ RAGConfig.retrieval.k = 6, llm.model = "gpt-4.1", ...
각 레이어를 코드와 함께 분석한다.
2 Layer 1: 환경변수
2.1 .env 파일 로딩 우선순위
main.py가 라우터 import 전에 _load_env_files()를 실행해 .env 파일을 로드한다. 우선순위는 .env > .env.cloud > .env.local이고, 첫 번째 존재하는 파일만 사용한다(merge 아님).
# src/services/api/main.py
def _load_env_files() -> None:
repo_root = Path(__file__).resolve().parent.parent.parent.parent
for env_name in [".env", ".env.cloud", ".env.local"]:
env_path = repo_root / env_name
if env_path.exists():
load_dotenv(env_path, override=False) # 시스템 env가 이김
return
# 셋 다 없으면 시스템 env만 사용override=False가 핵심이다. Docker --env-file이나 K8s Secret으로 주입한 환경변수가 .env 파일보다 우선한다. 운영 환경에서는 시크릿을 시스템 env로 주입하고, 개발 환경에서는 .env.local을 사용하는 패턴이 가능하다.
| 환경 | 사용 파일 | 시스템 env | 우선순위 |
|---|---|---|---|
| 로컬 개발 | .env.local |
비활성 | 파일이 유일한 소스 |
| Docker | (.env 없음) | --env-file |
시스템 env만 |
| K8s | (.env 없음) | Secret 주입 | 시스템 env만 |
| 클라우드 VM | .env.cloud |
일부 override | 시스템 env > .env.cloud |
2.2 서비스 운영 토글
main.py가 직접 읽는 환경변수는 두 개다.
_LOG_LEVEL = os.getenv("MINERVA_LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=getattr(logging, _LOG_LEVEL, logging.INFO), ...)
if os.getenv("WARMUP_ON_STARTUP", "true").lower() in ("true", "1", "yes"):
warmup_qna()
warmup_ds()| 변수 | 값 | 영향 |
|---|---|---|
MINERVA_LOG_LEVEL |
DEBUG/INFO/WARNING/ERROR | 전역 로그 레벨 |
WARMUP_ON_STARTUP |
true/false | 기동 시 ALBERT/임베딩 모델 로딩 (~10-15초) |
LLM_PROVIDER |
azure/ollama | LLM 공급자 분기 (core/llm.py get_provider()) |
dev 환경에서 빠른 재기동이 필요하면 WARMUP_ON_STARTUP=false. 첫 실 요청은 cold start를 감수한다.
LLM_PROVIDER는 RAGConfig의 어떤 필드보다도 먼저 분기되는 토글이다. azure로 설정되면 AzureChatOpenAI/AzureOpenAIEmbeddings/AzureSearch로 묶인 클라우드 스택, ollama(또는 local)이면 ChatOllama/HuggingFaceEmbeddings/FAISS로 묶인 로컬 스택이 활성화된다. 같은 RAGConfig라도 provider가 바뀌면 실제 호출되는 SDK·인덱스·임베딩 차원이 모두 달라진다.
2.3 환경변수 의존 필드
RAGConfig의 세 sub-config이 os.getenv()를 기본값으로 사용한다.
# src/core/config.py
@dataclass
class EmbeddingConfig:
model: str = field(default_factory=lambda: os.getenv(
"AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME",
"text-embedding-ada-002"
))
api_version: str = field(default_factory=lambda: os.getenv(
"AZURE_OPENAI_API_VERSION", "2024-08-01-preview"
))
@dataclass
class IndexConfig:
azure_index_name: str = field(default_factory=lambda: os.getenv(
"AZURE_VECTOR_STORE_INDEX_NAME", "data-standardization-prod"
))
local_index_name: str = field(default_factory=lambda: os.getenv(
"LOCAL_VECTOR_STORE_INDEX_NAME", "local-chatbot-index"
))
@dataclass
class LLMConfig:
api_version: str = field(default_factory=lambda: os.getenv(
"AZURE_OPENAI_API_VERSION", "2024-08-01-preview"
))field(default_factory=lambda: os.getenv(...)) 패턴은 dataclass 인스턴스 생성 시점에 환경변수를 읽는다. 프로세스 시작 후 환경변수를 변경해도 기존 인스턴스에는 반영되지 않는다.
2.4 환경변수가 없는 필드
LLM 모델명과 대부분의 검색 파라미터는 환경변수 의존 없이 코드 내 기본값을 사용한다.
@dataclass
class LLMConfig:
model: str = "gpt-4.1" # 하드코딩
temperature: float = 0.7 # 하드코딩
max_tokens: int = 4000 # 하드코딩
@dataclass
class RetrievalConfig:
k: int = 6 # 하드코딩
search_type: str = "hybrid" # 하드코딩
reranker_enabled: bool = False # 하드코딩이 값들을 변경하려면 YAML 프로파일 또는 A/B 실험 override를 사용해야 한다.
3 Layer 2: YAML 프로파일
3.1 프로파일 디렉토리 구조
data/
configs/
default.yaml ← get_config("default") 가 읽는 파일
experiment_v2.yaml ← 실험 전용 프로파일
reranker_test.yaml
3.2 YAML 로드 흐름
# src/core/config.py
def get_config(profile_name: str = "default") -> RAGConfig:
"""YAML 우선 → 파일 없으면 _FALLBACK_DEFAULT 반환"""
config = _load_profile_from_yaml(profile_name)
if config:
return config
return _FALLBACK_DEFAULT
def _load_profile_from_yaml(profile_name: str) -> Optional[RAGConfig]:
yaml_path = CONFIG_DIR / f"{profile_name}.yaml"
if yaml_path.exists() and YAML_AVAILABLE:
try:
return RAGConfig.from_yaml_file(str(yaml_path))
except Exception as e:
print(f"⚠️ {profile_name}.yaml 로드 실패: {e}")
return Nonedata/configs/default.yaml이 없으면 코드에 하드코딩된 _FALLBACK_DEFAULT가 사용된다.
3.3 ${VAR:default} 환경변수 치환
YAML 파일 내에서 환경변수를 참조할 수 있다.
# data/configs/default.yaml (예시)
name: default
embedding:
model: ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME:text-embedding-ada-002}
api_version: ${AZURE_OPENAI_API_VERSION:2024-08-01-preview}
index:
azure_index_name: ${AZURE_VECTOR_STORE_INDEX_NAME:data-standardization-prod}
retrieval:
k: 6
search_type: hybrid
reranker_enabled: false
llm:
model: gpt-4.1
temperature: 0.7
max_tokens: 4000치환 로직은 정규식 기반이다.
@classmethod
def from_yaml_file(cls, file_path: str) -> "RAGConfig":
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
def replace_env_var(match):
var_expr = match.group(1)
if ':' in var_expr:
var_name, default_value = var_expr.split(':', 1)
else:
var_name, default_value = var_expr, ''
return os.getenv(var_name, default_value)
# ${VAR_NAME:default_value} 패턴 치환
content = re.sub(r'\$\{([^}]+)\}', replace_env_var, content)
data = yaml.safe_load(content)
return cls.from_dict(data)주의: ${VAR} (콜론 없음) 형식은 환경변수가 없으면 빈 문자열로 치환된다.
3.4 from_dict — YAML 파싱의 끝점
@classmethod
def from_dict(cls, data: dict) -> "RAGConfig":
return cls(
name=data.get("name", "default"),
chunking=ChunkingConfig(**data.get("chunking", {})),
embedding=EmbeddingConfig(**data.get("embedding", {})),
index=IndexConfig(**data.get("index", {})),
retrieval=RetrievalConfig(**data.get("retrieval", {})),
prompt=PromptConfig(**data.get("prompt", {})),
conversation=ConversationConfig(**data.get("conversation", {})),
llm=LLMConfig(**data.get("llm", {}))
)YAML에 없는 섹션은 빈 dict {}로 전달되어 dataclass 기본값이 사용된다. YAML에 있는 키만 override되므로 부분 설정이 가능하다.
4 Layer 3: A/B 실험 Override
4.1 실험 YAML 구조
# data/experiments/reranker_ab_test.yaml
name: reranker_ab_test
agent: qna_chatbot
active: true
assignment: sticky_hash
description: "FlashRank reranker vs. 기본 검색 A/B 테스트"
arms:
control:
traffic: 0.5
overrides: {}
treatment:
traffic: 0.5
overrides:
retrieval.reranker_enabled: true
retrieval.reranker_type: flashrank
retrieval.reranker_model: ms-marco-MultiBERT-L-12
retrieval.reranker_top_n: 5
metrics:
- latency_ms
- citation_count4.2 실험 resolve 흐름
# src/core/experiments.py
def resolve_config_for_user(
agent_name: str,
user_id: Optional[str] = None,
base_config: Optional[RAGConfig] = None,
force_arm: Optional[str] = None,
) -> tuple[RAGConfig, Optional[str], Optional[str], dict]:
base = base_config or get_config("default") # Layer 2 결과
assignment = get_experiment_assignment(agent_name, user_id, force_arm=force_arm)
if assignment is None:
return base, None, None, {} # 실험 없음 → base config 그대로
exp, arm_id, overrides = assignment
return apply_overrides(base, overrides), exp.name, arm_id, overrides4.3 Arm 할당 — sticky_hash
def assign_arm(experiment: Experiment, user_id: Optional[str] = None) -> str:
if experiment.assignment == "sticky_hash" and user_id:
key = f"{user_id}:{experiment.name}".encode("utf-8")
digest = hashlib.md5(key).hexdigest()
rand_val = int(digest[:8], 16) / 0xFFFFFFFF # 0~1 결정론적 값
else:
rand_val = random.random()
cumulative = 0.0
for arm_id, arm in experiment.arms.items():
cumulative += arm.traffic / experiment.total_weight()
if rand_val <= cumulative:
return arm_id
return list(experiment.arms.keys())[-1] # rounding fallbacksticky_hash는 md5(f"{user_id}:{experiment.name}")로 결정론적 값을 생성한다. 같은 사용자는 항상 같은 arm에 배정된다. user_id가 None이면 매 요청마다 랜덤 배정으로 폴백한다.
4.4 Override 적용 — dotted key
def apply_overrides(config: RAGConfig, overrides: dict) -> RAGConfig:
new_config = copy.deepcopy(config) # 원본 보존
for dotted_key, value in overrides.items():
parts = dotted_key.split(".")
target = new_config
for part in parts[:-1]:
target = getattr(target, part)
setattr(target, parts[-1], value)
return new_config"retrieval.reranker_enabled": True는 다음과 동일하다.
copy.deepcopy()로 원본 config가 보존되므로, 실험 arm별로 독립된 config 인스턴스가 생성된다.
타입 검증 없음: override value는 그대로 setattr()로 설정된다. YAML에서 retrieval.k: "six"처럼 문자열이 들어와도 오류 없이 설정된다. 실제 사용 시점에 타입 오류가 발생한다.
5 Reranker Config 흐름
RAGConfig.retrieval의 reranker 관련 필드가 어떻게 ParentChunkRetriever 동작을 제어하는지 추적한다.
5.1 RAGConfig.retrieval → reranker_config dict
RetrievalConfig의 reranker 필드는 4개다.
@dataclass
class RetrievalConfig:
reranker_enabled: bool = False
reranker_type: Optional[str] = None # "flashrank" | "cross_encoder" | "azure_semantic"
reranker_model: Optional[str] = None
reranker_top_n: int = 5chatbot_retriever()(코드 미공개)가 이 필드를 dict로 변환해 ParentChunkRetriever에 전달한다.
# 추정 흐름 (chatbot_retriever 내부)
reranker_config = None
if config.retrieval.reranker_enabled and config.retrieval.reranker_type:
reranker_config = {
"type": config.retrieval.reranker_type,
"model": config.retrieval.reranker_model,
"top_n": config.retrieval.reranker_top_n,
}
retriever = ParentChunkRetriever(
vectorstore=azure_search,
parent_store=parent_store,
reranker_config=reranker_config,
embeddings=embeddings,
)5.2 동작 분기
ParentChunkRetriever는 reranker_config의 type 값으로 검색 경로를 결정한다.
# src/core/rag/retriever.py hybrid_search()
if self._reranker_type == "azure_semantic":
return self._hybrid_search_with_semantic(query, k)
use_cosine = (
self._reranker_type not in ("flashrank", "cross_encoder")
and self._embeddings is not None
)
if use_cosine:
child_docs, query_vec = self._hybrid_search_with_vectors(query=query, k=k * 2)
else:
child_docs = self.vectorstore.hybrid_search(query=query, k=k * 2)
if self._reranker_type in ("flashrank", "cross_encoder"):
child_docs = self._rerank_children(child_docs, query)
elif self._embeddings is not None:
child_docs = self._compute_cosine_scores(child_docs, query, query_vec=query_vec)5.3 설정 → 동작 매트릭스
reranker_enabled |
reranker_type |
검색 경로 | 점수 부여 |
|---|---|---|---|
| false | - | 표준 hybrid | cosine 유사도 (embeddings 있을 때) |
| true | flashrank | 표준 hybrid | FlashRank score |
| true | cross_encoder | 표준 hybrid | Cross-Encoder score |
| true | azure_semantic | semantic_hybrid_search | Azure Semantic rerankerScore (정규화) |
5.4 A/B 실험에서 reranker 비교
# data/experiments/reranker_comparison.yaml
name: reranker_comparison
agent: qna_chatbot
arms:
baseline:
traffic: 0.33
overrides:
retrieval.reranker_enabled: false
flashrank:
traffic: 0.33
overrides:
retrieval.reranker_enabled: true
retrieval.reranker_type: flashrank
retrieval.reranker_model: ms-marco-MultiBERT-L-12
retrieval.reranker_top_n: 5
cross_encoder:
traffic: 0.34
overrides:
retrieval.reranker_enabled: true
retrieval.reranker_type: cross_encoder
retrieval.reranker_model: BAAI/bge-reranker-v2-m3
retrieval.reranker_top_n: 5세 arm 모두 같은 base config를 deepcopy 후 override되므로 다른 모든 설정(LLM 모델, k, history_turns)은 동일하다.
6 RAGConfig 외부의 분기점 — 라우터 레벨 매핑
설정의 대부분은 RAGConfig로 흘러들어가지만, 라우터 레벨에서 RAGConfig를 거치지 않고 결정되는 분기도 있다. 가장 대표적인 것이 DEFAULT_DOCUMENTS_PROMPTS다(Phase 10.92 도입).
# src/services/api/routers/qna_chatbot.py
DEFAULT_DOCUMENTS_PROMPTS = {
"DAMA": "governance", # DAMA 거버넌스 문서 → governance 시스템 프롬프트
# 그 외는 default
}라우터는 사용자 요청의 agent_params.document_id를 보고 이 매핑에서 시스템 프롬프트 매니페스트를 결정한 뒤, agent의 _get_llm_for_query()에 전달한다. 즉:
RAGConfig.prompt: 에이전트 단위 프롬프트 (template_name, version)DEFAULT_DOCUMENTS_PROMPTS: 문서 단위 시스템 프롬프트 override
같은 사용자가 같은 실험 arm에 있어도, 요청한 document_id가 DAMA면 governance 톤으로, 그 외면 default 톤으로 답변이 나온다. A/B 실험 결과를 분석할 때 document_id별로 구분하지 않으면 “거버넌스 답변과 일반 답변이 섞인 평균”을 비교하게 되어 효과 추정이 흐려진다. runs.jsonl의 extras 필드에 document_id와 prompt_manifest를 함께 기록해두면 사후 슬라이싱이 가능하다.
7 Config 디버깅
7.1 현재 활성 설정 확인
운영 중 어떤 RAGConfig가 사용 중인지 확인하려면 디버그 엔드포인트가 필요하다(현재 미구현).
# 권장 패턴 (services/api/routers/qna_chatbot.py에 추가)
@router.get("/_debug/config/{user_id}")
def debug_config(user_id: str):
config, exp_name, arm_id, overrides = resolve_config_for_user(
"qna_chatbot", user_id=user_id,
)
return {
"experiment": exp_name,
"arm_id": arm_id,
"overrides": overrides,
"effective_config": config.to_dict(),
}7.2 YAML 치환 결과 확인
${VAR:default} 치환 결과를 검증하려면:
7.3 환경변수가 반영되지 않을 때 체크리스트
.env파일이 repo root에 있는가? (src/services/api/main.py기준 4 parents)- 시스템 env에 같은 변수가 있는가? (
override=False로 시스템 env가 이김) _load_env_files()가 어떤 파일을 로드했는지 로그 확인 (loaded /path/to/.env)field(default_factory=lambda: os.getenv(...))은 dataclass 인스턴스 생성 시점에 평가된다. 프로세스 시작 후os.environ을 변경해도 기존 인스턴스에는 반영되지 않는다.
8 런타임 설정 싱글톤
8.1 get_active_config() / set_active_config()
_active_config: RAGConfig = None
def get_active_config() -> RAGConfig:
global _active_config
if _active_config is None:
_active_config = get_config("default")
return _active_config
def set_active_config(config_or_name):
global _active_config
if isinstance(config_or_name, str):
_active_config = get_config(config_or_name)
elif isinstance(config_or_name, RAGConfig):
_active_config = config_or_nameQnaChatbotAgent._get_retriever()가 set_active_config(self.config)를 호출한다. 이는 retriever 생성 시 글로벌 설정을 agent config로 덮어쓴다.
def _get_retriever(self, document_id: str):
if document_id not in self._retrievers:
set_active_config(self.config) # 글로벌 singleton 변경
# ... chatbot_retriever() 생성멀티 스레드 위험: 여러 스레드가 다른 config의 agent를 동시에 _get_retriever()를 호출하면 글로벌 _active_config가 경쟁 상태에 놓인다. chatbot_retriever() 내부가 get_active_config()를 사용한다면 의도치 않은 config로 retriever가 초기화될 수 있다.
9 설정 전파 완성 경로
하나의 요청이 최종 LLM 호출에 도달하기까지의 설정 전파를 추적한다.
1. 프로세스 시작
→ os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "text-embedding-ada-002")
→ EmbeddingConfig.model = "text-embedding-3-small" (예: .env에 설정된 경우)
2. get_config("default")
→ data/configs/default.yaml 읽기
→ ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME:text-embedding-ada-002} 치환
→ RAGConfig.embedding.model = "text-embedding-3-small" ✓
→ RAGConfig.retrieval.k = 6 (YAML에 명시된 값)
→ RAGConfig.llm.model = "gpt-4.1" (YAML에 명시된 값)
3. resolve_config_for_user("qna_chatbot", user_id="user-42")
→ data/experiments/reranker_ab_test.yaml 로드
→ md5("user-42:reranker_ab_test") → treatment arm (예: 0.73 > 0.5)
→ apply_overrides: retrieval.reranker_enabled = True
4. _build_agent() → agent cache [("reranker_ab_test", "treatment")]
→ QnaChatbotAgent(config=modified_config)
5. agent._prepare(query)
→ _ensure_initialized(): self._llm = get_llm(model="gpt-4.1", temperature=0.7)
→ _retrieve_docs(): hybrid_search(k=6) → rerank(top_n=5) ← reranker_enabled=True
6. chain = self._prompt | llm | StrOutputParser()
→ llm.invoke() with model="gpt-4.1"
10 설계 평가
10.1 강점
계층화된 override: 기본값(코드) → YAML → 실험 순으로 override되어 변경 범위를 명확히 제어할 수 있다.
부분 YAML 허용: from_dict()가 누락 섹션을 빈 dict로 처리하므로, YAML에서 변경이 필요한 섹션만 작성하면 된다.
원본 config 보존: apply_overrides()가 copy.deepcopy()를 사용해 실험 arm별 독립 config를 보장한다.
10.2 개선 여지
YAML 로드 실패 시 무음 폴백: _load_profile_from_yaml()이 파싱 오류를 print()로만 출력하고 _FALLBACK_DEFAULT로 조용히 폴백한다. 잘못된 YAML이 배포되어도 에러가 발생하지 않는다.
override 타입 검증 없음: apply_overrides()가 setattr()을 바로 호출해 타입 불일치를 런타임까지 잡지 못한다.
글로벌 singleton과 thread safety: _active_config 싱글톤과 set_active_config() 호출이 멀티 스레드 환경에서 경쟁 상태를 야기할 수 있다. 특히 다른 config를 가진 agent들이 동시에 _get_retriever()를 최초 호출할 때 위험하다.
실험 캐시 무효화 없음: 실험 YAML을 수정해도 agent 캐시가 갱신되지 않는다. 이전 config를 가진 agent가 계속 사용된다.
복수 실험 미지원: get_experiment_assignment()가 active[0]만 사용한다. 복수 실험이 활성화된 경우 첫 번째만 적용된다.
설정의 정적 전파 흐름은 본 글에서 끝나고, 운영에서 그 흐름을 다루는 패턴(Hot Reload 가능성과 한계, Docker/K8s 시크릿 주입, 도커로 Config를 고정시켜 재현 가능한 빌드를 만드는 방법)은 11-1편 Config 운영 패턴으로 분리했다. 정적 추적이 끝난 다음 자연스럽게 이어 읽으면 된다.
11 정리
MINERVA의 설정은 .env → YAML 프로파일 → 실험 override 세 레이어를 순서대로 통과한다. 각 레이어가 이전 레이어의 값을 덮어쓰는 단방향 흐름이라 추적이 명확하다. 그러나 타입 검증 부재, 글로벌 singleton의 thread safety 문제, 실험 캐시 무효화 부재가 프로덕션 안정성 리스크다. 다음 포스트에서는 현재 테스트 커버리지가 이 구조의 어느 범위를 검증하고 있는지 분석한다.
다음: 테스트 전략 분석