PROJECT DOCUMENTATION

모델평가

임베딩·LLM 평가 완료 · 운영 차원 2560 통일 · 오프라인 전환 절차 정리기준일 2026-09-20담당 김충식

온라인·오프라인 가용성과 고객 개인정보 보호를 위한 온프레미스 환경에 대비해 외부 API(Gemini)와 로컬 모델(Ollama)을 비교한 기록입니다.

서비스 저장소 docs/model-evaluation.md의 평가 결과를 정리한 문서입니다. 파일 경로는 서비스 저장소 기준입니다.

작성: 2026-09-18 · 갱신: 2026-09-20(온프레미스 대비 목적 명시, §11 운영 반영은 9/19) · 상태: 임베딩(§1~§9)·LLM(§10) 가용성 평가 완료, 운영 차원 2560 통일 문서 성격: 모델 비교 평가 기록. 온라인·오프라인 가용성과 고객 개인정보 보호를 위한 온프레미스 환경에 대비해, 외부 API와 로컬 모델을 같은 코퍼스·같은 질문으로 나란히 평가한 결과를 남긴다. 결과 원본: 임베딩 data/eval/results/embed_compare_20260918_230428.json · LLM data/eval/results/llm_compare_20260918_232903.json(1차 ..._232238.json) · 평가셋 data/eval/rag_evalset.jsonl 하네스: 임베딩 backend/apps/rag/adapter/inbound/cli/compare_embedders.py · LLM backend/apps/analysis/adapter/inbound/cli/compare_report_writers.py

0. 왜 하는가 — 가용성과 온프레미스 대비

이 서비스는 온라인과 오프라인 두 상황 모두에서 응대할 수 있어야 하며, 고객 개인정보를 내부에서 처리해야 하는 온프레미스 환경에도 대비한다.

상황 경로 언제
온라인 외부 API — Gemini(gemini-embedding-001 · gemini-3.8-flash) 평상시 운영. 키·네트워크가 정상일 때
오프라인 로컬 모델 — Ollama(qwen3-embedding:4b · gemma4:12b) 외부 API 장애·키 한도 초과·비용 통제·네트워크 없는 시연/심사 환경
온프레미스 내부 인프라의 로컬 모델 — Ollama(qwen3-embedding:4b · gemma4:12b) 고객 개인정보 보호를 위해 상담 내용·재무 입력 등 모델에 전달하는 데이터를 외부 AI API로 보내지 않아야 하는 환경

로컬 모델은 개인정보 처리 위치를 선택하기 위한 경로이기도 하다. 인터넷 연결이 가능한 환경에서도 고객 개인정보가 포함된 상담 데이터를 기관 내부 인프라에서 처리해야 할 수 있다. 이를 위해 검색 임베딩과 상담자료 생성 모두 로컬 모델로 수행할 수 있는 경로를 준비했다. 현재 운영 기본값은 Gemini이며, 로컬 사용은 provider 설정 변경과 해당 임베더 재색인을 거치는 수동 전환이다. 이번 평가는 로컬 모델의 품질·자원 사용을 확인한 것으로, 서비스 전체의 온프레미스 구축이나 개인정보 보안 검증을 완료했다는 뜻은 아니다.

운영 경로는 외부 API(Gemini)를 쓴다. 하지만 로컬 모델로 같은 일을 할 수 있는지 재 보지 않고 외부 API에만 기대는 건 곤란하다. 그래서 외부 API 후보와 로컬 후보를 같은 코퍼스(3,448건)·같은 질문(80건)·같은 지표로 전부 평가했다. 이 문서가 그 증빙이다. 어느 한쪽만 평가한 것이 아니라, 로컬 3종(qwen 2560·1536, bge-m3 1024)과 외부 6종(Gemini 3072·2560·1536·1024, 그중 2560·1024는 API 직접 지정 실호출 포함)을 모두 돌렸다.

임베딩부터 시작한 이유: 검색 결과가 달라지면 그 위의 리포트 LLM이 아무리 좋아도 근거가 달라진다. 임베딩은 결정론적으로 비교할 수 있어 평가 비용도 가장 낮다.

로컬 후보를 bge-m3와 qwen3-embedding으로 잡은 이유는 한국어 친화 임베딩 모델이기 때문이다(사용자 결정). 둘 다 Ollama에 이미 내려받아 있다.

1. 자원·환경 (2026-09-18 실측)

항목 값
GPU NVIDIA GeForce RTX 5060 Ti 16 GB (driver 595.91.07)
Ollama 0.31.2 · bge-m3 1.2 GB(F16, 상주 664 MB) · qwen3-embedding:4b 2.5 GB(Q4_K_M, 상주 4.4 GB)
Gemini gemini-embedding-001, google-genai 1.29.0
코퍼스 rag_chunk 3,448건 — funding 1,693(평균 412자) + news 1,755(평균 77자, 제목만)
평가 당시 DB 컬럼(9/18) vector(1536) + HNSW. 9/19 운영은 halfvec(2560)으로 변경(§11)
코드 기준 dab254c 위에서 작업

평가 당시 제약(9/18): DB 컬럼이 1536차원 고정이라 1024·2560차원 벡터는 저장할 수 없었다. 그래서 이번 평가는 DB 밖(numpy 코사인) 에서 돌렸다. 운영 검색의 만료 공고 제외 필터는 적용하지 않았다(모델 비교가 목적).

2. 연구 질문

  • RQ1 같은 코퍼스·같은 질문에서 로컬 임베더의 검색 정확도(Top-1·Recall@5·MRR)가 Gemini와 얼마나 차이 나는가.
  • RQ2 로컬 임베더가 고르는 문서가 Gemini가 고르는 문서와 얼마나 겹치는가(교차 일치). 정확도가 같아도 다른 문서를 고르면 리포트 근거가 달라진다.
  • RQ3 차원은 로컬 모델의 최고 차원(qwen 2560, bge-m3 1024)을 기준으로 삼고, Gemini를 같은 차원으로 맞췄을 때 손실이 있는가. 1536은 고집하지 않는다(사용자 결정).

3. 후보와 차원

후보 제공 차원 비고
gemini@3072 API 3072 상한 기준선
gemini@2560 API(3072 절단) 2560 qwen 최고 차원 짝
gemini-api@2560 API(차원 직접 지정) 2560 로컬과 같은 차원 실호출 — 절단 등가성 검증 겸용
qwen@2560 Ollama 2560 qwen3-embedding:4b 네이티브 최고 차원
gemini@1536 API 1536 9/18 당시 운영
qwen@1536 Ollama 1536 평가 당시 Ollama 어댑터 기본값
gemini@1024 API(3072 절단) 1024 bge-m3 짝
gemini-api@1024 API(차원 직접 지정) 1024 로컬과 같은 차원 실호출
bge-m3@1024 Ollama 1024 고정 차원

Gemini 차원 축소 실측: output_dimensionality로 받은 1024·1536·2560 벡터는 3072 벡터의 앞부분을 잘라 정규화한 것과 코사인 1.000000(5문장 전부)으로 일치했다(MRL). 그래서 하네스는 3072만 호출하고 나머지는 잘라서 만든다(API 호출 4분의 1). 다만 로컬과 같은 차원에서 실호출로도 재 봐야 한다는 사용자 요구로 1024·2560은 gemini-api@<dim> 후보로 API에 차원을 직접 지정해 코퍼스 전량을 다시 임베딩했다 — 결과는 §6에서 절단 파생과 완전히 일치했다. API가 주는 지정 차원 벡터는 정규화돼 있지 않다(노름 0.62~0.88) — 운영 어댑터도 정규화를 하지 않지만 pgvector 코사인 거리는 노름에 무관해 검색엔 영향 없다.

Ollama 차원 축소 실측: qwen dimensions=1536 응답 대 2560을 잘라 정규화한 벡터의 코사인 평균 0.9997, 최소 0.9975. 사실상 같은 MRL 절단이다.

4. 절차

  1. 어댑터 확장 (backend/apps/rag/adapter/outbound/embeddings/): Ollama 공통 베이스(ollama_embedding_base.py, Template Method) 위에 qwen(차원 인자)·bge-m3 어댑터를 두고, Gemini 어댑터에 output_dimensionality 인자를 추가했다. 기본 차원일 때 model_name은 기존 값 그대로라 DB embedded_by와 호환되고, 다른 차원이면 -2560d 같은 접미사가 붙어 혼용을 막는다. 레지스트리에 bge-m3 등록. 테스트 tests/test_rag_embedding_adapters.py 12건.
  2. 평가셋 생성 (generate_evalset.py): 청크 텍스트를 gemma4:12b(Ollama, think:false)에 보내 “이 문서를 찾을 법한 질문 1개”를 만들었다. funding 60건(chunk_id 오름차순 앞 60 — 2025년 50·2026년 10) + news 20건, 전부 status=candidate(미검수). --source-type news 옵션과 think 차단을 이번에 추가했다.
  3. 비교 하네스 (compare_embedders.py): 코퍼스 전량을 후보별로 임베딩(캐시 data/eval/cache/*.npz), 질문마다 source_type을 걸러 코사인 상위 10을 뽑는다. 테스트 tests/test_rag_compare_embedders.py 4건.
cd backend
python -m apps.rag.adapter.inbound.cli.generate_evalset --model gemma4:12b --limit 60 --source-type funding --output data/eval/rag_evalset_funding.jsonl
python -m apps.rag.adapter.inbound.cli.generate_evalset --model gemma4:12b --limit 20 --source-type news --output data/eval/rag_evalset_news.jsonl
cat ../data/eval/rag_evalset_funding.jsonl ../data/eval/rag_evalset_news.jsonl > ../data/eval/rag_evalset.jsonl
python -m apps.rag.adapter.inbound.cli.compare_embedders

5. 지표

  • Top-1: 1위가 정답 청크. Recall@5·MRR: evaluate_rag.py와 같은 정의.
  • 교차 일치(로컬 ↔ 같은 차원 Gemini, 그리고 ↔ gemini@3072): Top-1 일치율, Jaccard@5, 전체 순위 Spearman ρ.
  • 지연: 질문 임베딩 p50 ms(80건), 문서 임베딩 처리량 docs/s(3,448건 1회).
  • 평가셋이 전부 candidate라 evaluate_rag.py의 “본지표(confirmed)”는 없다. 아래 수치는 모두 미검수 80건 기준이다.

6. 결과 (80건, 2026-09-18 23:04 — 2차 실행, gemini-api 후보 추가)

6-1. 정확도·지연

후보 dim Top-1 Recall@5 MRR funding Top-1 (60) news Top-1 (20) 질문 p50 ms 문서 docs/s
gemini@3072 3072 0.700 0.875 0.779 0.800 0.40 394 44.6
gemini@2560 2560 0.700 0.875 0.781 0.783 0.45 394 (3072에서 절단)
gemini-api@2560 2560 0.700 0.875 0.781 0.783 0.45 381 45.6
qwen@2560 2560 0.762 0.900 0.813 0.883 0.40 96 20.0
gemini@1536 (9/18 당시 운영) 1536 0.713 0.875 0.787 0.800 0.45 394 (3072에서 절단)
qwen@1536 1536 0.725 0.875 0.791 0.850 0.35 94 20.7
gemini@1024 1024 0.738 0.887 0.800 0.817 0.50 394 (3072에서 절단)
gemini-api@1024 1024 0.738 0.887 0.800 0.817 0.50 393 49.1
bge-m3@1024 1024 0.650 0.825 0.727 0.717 0.45 118 94.3

6-2. 질문 단위 승패 (Top-1)

비교 짝 둘 다 정답 로컬만 Gemini만 둘 다 오답
qwen@2560 vs gemini@2560 53 8 3 16
qwen@2560 vs gemini@3072 54 7 2 17
qwen@1536 vs gemini@1536 52 6 5 17
bge-m3@1024 vs gemini@1024 49 3 10 18

6-3. 교차 일치

비교 짝 Top-1 일치 Jaccard@5 Spearman ρ
qwen@2560 vs gemini@2560 0.713 0.449 0.697
qwen@1536 vs gemini@1536 0.700 0.432 0.688
bge-m3@1024 vs gemini@1024 0.675 0.426 0.636
qwen@2560 vs gemini-api@2560 0.713 0.449 0.697
bge-m3@1024 vs gemini-api@1024 0.675 0.426 0.636
gemini-api@2560 vs gemini@2560 1.000 1.000 1.000
gemini-api@1024 vs gemini@1024 1.000 1.000 1.000
gemini@2560 vs gemini@3072 0.963 0.892 0.993
gemini@1536 vs gemini@3072 0.963 0.950 0.997
gemini@1024 vs gemini@3072 0.887 0.895 0.989
qwen@2560 vs gemini@3072 0.725 0.453 0.704
bge-m3@1024 vs gemini@3072 0.675 0.420 0.643

7. 해석

  • RQ1 — qwen@2560이 이 평가셋에선 Gemini보다 낫다. Top-1 0.762 대 0.700, funding만 보면 0.883 대 0.783. 질문 단위로 qwen만 맞힌 게 8건, Gemini만 맞힌 게 3건이다. 다만 80건 표본에서 0.06은 질문 5개 차이라 “동급 이상”까지만 말할 수 있고 “우월”이라고 단정하지 않는다.
  • bge-m3는 뒤진다. Top-1 0.650, Gemini만 맞힌 질문이 10건. 이 평가의 검색 정확도 기준으로는 qwen을 선택한다. 로컬 LLM과의 동시 상주 가능성은 §10-4에서 별도로 확인했다.
  • RQ2 — 로컬과 Gemini는 다른 문서를 꽤 고른다. Top-1 일치 0.71, Jaccard@5 0.45. 즉 정확도가 비슷해도 근거 문서의 절반은 다르다. 로컬로 전환하면 리포트 근거가 바뀌는 사례가 생긴다는 뜻이며, 이는 결함이 아니라 전환 시 알고 있어야 할 사실이다. 반면 Gemini 안에서 차원을 3072→1024로 줄여도 일치 0.89·ρ 0.99라 차원 축소 손실은 거의 없다.
  • 같은 차원 실호출 검증: API에 1024·2560을 직접 지정해 코퍼스 전량을 다시 임베딩한 gemini-api@*는 절단 파생 gemini@*와 80개 질문 전부에서 순위가 완전히 같았다(Top-1 일치·Jaccard@5·Spearman ρ 모두 1.000). 따라서 로컬 대 Gemini 같은 차원 비교(qwen@2560 대 gemini-api@2560 0.762 대 0.700, bge-m3 대 gemini-api@1024 0.650 대 0.738)는 절단 여부와 무관하게 성립한다.
  • RQ3 — 차원은 로컬 최고 차원에 맞춘다. qwen은 2560이 1536보다 낫고(0.762 대 0.725), Gemini는 차원 손실이 미미하다. 따라서 로컬을 쓰면 2560, DB 컬럼도 그에 맞춰야 한다.
  • news 20건은 모든 모델이 Top-1 0.35~0.50. 오답 12건을 열어 보니 제목이 완전히 같은 중복 기사는 1건뿐이고, 나머지는 주제가 겹치는 다른 기사였다(예: “수성구 관광 상권” 질문에 정답은 수성구청장 인터뷰, 1위는 수성못 관광 경쟁력 기사). 제목 한 줄에서 만든 질문이 너무 포괄적이라 생긴 평가셋 한계이지 모델 결함으로 보기 어렵다. news 수치는 순위 비교 용도로만 쓴다.
  • 지연: 로컬 질문 임베딩 p50 94~118 ms, Gemini 394 ms. 문서 색인은 bge-m3 94/s > Gemini 45/s > qwen 20/s. qwen으로 전량 재색인하면 3,448건에 약 3분.

8. 가용성 관점 정리

평가한 것 — 온라인 경로와 오프라인 경로를 둘 다 같은 조건으로 쟀다.

경로 후보 평가 여부 Top-1 질문 p50
온라인(외부 API) gemini-embedding-001 @3072·2560·1536·1024 6종 전부 실행(2560·1024는 절단 파생 + API 직접 지정 양쪽) 0.700~0.738 381~421 ms
오프라인(로컬) qwen3-embedding:4b @2560·1536 실행 0.762 / 0.725 92~96 ms
오프라인(로컬) bge-m3 @1024 실행 0.650 115 ms
경로 리포트 LLM 후보 평가 여부 게이트 전부 통과 총 p50
온라인(외부 API) gemini-3.8-flash 실행(28건) 1.000 2.1 s
오프라인(로컬) gemma4:12b 실행(28건) 1.000 3.0 s
오프라인(로컬) kanana1.5:8b-q4km 실행(28건) 0.857 1.7 s
오프라인(로컬) exaone3.5:7.8b (평가 기준점, NC 라이선스 — 운영 후보 아님) 실행(28건) 0.536 1.5 s

가용성 판정

  • 오프라인 응대 가능. 검색은 로컬 qwen@2560이 외부 API와 동급 이상의 정확도를 내고, 리포트 생성은 로컬 gemma4:12b가 Gemini와 같은 게이트 전부 통과(1.000)를 낸다. 외부 API가 끊겨도 품질을 이유로 서비스를 멈출 필요는 없다.
  • 오프라인 자원 한계 안에 든다. 16 GB GPU를 비운 뒤 qwen3-embedding:4b(2560)와 LLM을 함께 올리면 gemma4:12b 11.6 GiB, kanana 9.0, exaone 8.9로 셋 다 동주한다(§10-4). 임베딩과 LLM을 번갈아 올렸다 내리는 지연 없이 응대할 수 있다.
  • 온라인 응대 가능. Gemini는 차원을 어떻게 잡아도(3072→1024) 결과가 거의 같아, 로컬과 같은 차원으로 맞춰 운영해도 손실이 없다.
  • 두 경로의 결과는 같지 않다. 정확도는 비슷해도 고르는 문서가 절반가량 다르다(Top-1 일치 0.71, Jaccard@5 0.45). 경로를 바꾸면 리포트 근거가 달라질 수 있음을 사용자에게 밝혀야 한다.
  • 전환 장치는 준비돼 있다. EmbeddingPort 뒤에 Gemini·qwen·bge-m3 어댑터가 있고 레지스트리(_INDEX_EMBEDDER_REGISTRY)의 provider 문자열로 고른다. 색인과 검색은 embedded_by로 같은 모델끼리만 비교하므로 두 경로가 섞이지 않는다.

아직 안 된 것

  • 운영 DB 컬럼이 vector(1536)이라 로컬 qwen@2560 색인을 저장할 수 없다. → 09-19 halfvec(2560)으로 마이그레이션하고 Gemini 2560으로 전량 재색인했다(§11).
  • provider가 analysis_dependencies에 고정돼 있다. → 09-19 RAG_EMBEDDING_PROVIDER로 뺐다(§11).
  • 리포트 작성기는 REPORT_WRITER_PROVIDER=ollama·OLLAMA_REPORT_MODEL=gemma4:12b로 전환할 수 있게 레지스트리를 두었지만(§10-1), 기본값은 여전히 gemini다.
  • 자동 폴백(외부 API 실패 → 로컬)은 설계하지 않았다. 지금은 사람이 provider를 바꾸는 수동 전환이다.

9. 결론·권고

  1. 로컬 대체 임베더는 qwen3-embedding:4b@2560. 정확도는 Gemini 동급 이상, 질문 지연은 4분의 1. bge-m3는 후보에서 내린다.
  2. 운영은 Gemini를 유지하되 차원은 2560으로 통일한다(09-19 사용자 결정). ① 컬럼을 halfvec(2560)으로 바꾸고(HNSW는 vector 2000차원 상한) ② Gemini 2560으로 전량 재색인 ③ 임베더·작성기 provider를 환경변수로 뺐다. 로컬 경로는 오프라인 가용성과 고객 개인정보 보호를 위한 온프레미스 환경에 대비한다. 모델 전환은 §11의 수동 절차를 따른다.
  3. 평가셋 검수가 남았다. 80건 전부 candidate다. 사용자가 검수해 confirmed로 올리면 evaluate_rag.py의 본지표가 생긴다. news는 질문을 제목이 아니라 본문에서 만들거나 손으로 써야 의미가 있다.
  4. LLM 평가는 §10. 로컬 대체 리포트 작성기는 gemma4:12b.

10. LLM(리포트 생성) — 로컬 3종 대 gemini-3.8-flash

10-1. 후보와 선정 이유

후보 경로 크기(Q4_K_M) 상주 VRAM 선정 이유
gemini-3.8-flash 온라인 API — — 현 운영 작성기
gemma4:12b 로컬 Ollama 7.6 GB 7.5 GiB 한국어 적합성, thinking 모델(차단해서 사용)
exaone3.5:7.8b 로컬 Ollama 4.8 GB 4.8 GiB LG 한국어 특화 — 평가 기준점(운영 후보 아님), 아래 참조
kanana1.5:8b-q4km 로컬 Ollama 4.9 GB 4.9 GiB 카카오 한국어 특화

로컬 세 후보는 한국어 적합성과, 오프라인에서 임베딩(qwen3-embedding:4b, 4.1 GiB)과 LLM을 같은 16 GB GPU에 동시에 올려야 하는 자원 한계 두 기준으로 골랐다(사용자 결정). 12B급을 상한으로 둔 이유가 이것이다.

exaone을 넣은 의도는 다르다. EXAONE 3.5는 라이선스가 EXAONE AI Model License Agreement 1.1 - NC(ollama show 실측)라 상업 서비스에 쓰기 어렵다. 그런데도 넣은 이유는 한국어 모델 가운데 평가 기준점으로 삼기에 가장 좋은 모델이기 때문이다(사용자 결정). 즉 exaone은 운영 후보가 아니라 “한국어 특화 모델이 이 프롬프트·게이트에서 어디까지 나오는가”를 재는 잣대이고, gemma4·kanana의 점수는 그 잣대 위에서 읽는다. 결과 표에서 exaone이 낮게 나온 것은 탈락이 아니라 기준점의 위치를 알려 주는 수치다.

어댑터: OllamaReportWriter(backend/apps/analysis/adapter/outbound/llm/ollama_report_writer.py)를 ReportWriterPort 구현으로 신설했다. Gemini 작성기와 같은 생성 조건(temperature 0.3·최대 1024토큰·추론 토큰 차단 think:false)이다. analysis_dependencies에 provider 레지스트리(gemini/ollama)와 설정 REPORT_WRITER_PROVIDER·OLLAMA_REPORT_MODEL을 두어 코드 수정 없이 오프라인으로 바꿀 수 있다. 테스트 tests/test_analysis_ollama_writer.py 4건.

10-2. 절차

  • 입력은 운영 그대로. 운영 수집 에이전트(build_agents)로 실제 DB에서 컨텍스트 3개를 채웠다 — review(대신동 카페, 재무 입력+질문 “원두값 오르면?”), review(삼덕동 일반음식점, 재무 없음), handoff(대신동 카페, 재무+상담 정보). 뉴스 5·공고 5·매칭 상품 9건이 프롬프트에 들어간다.
  • 프롬프트는 운영 그대로. LLM이 쓰는 섹션 6종(verdict·market·shock·funding·plan·questions)의 시스템 지시·프롬프트를 report_text에서 그대로 가져왔다. 여기에 verdict 방향 일치 짝(위험 등급만 red 82점 / green 18점으로 치환) 2개를 더해 14 프롬프트 × 2회 = 후보당 28건.
  • 게이트는 결정론(LLM-as-judge 없음) — 완료 / # 제목 금지 / 분량 상한(verdict 3문장·불릿 3개·질문 5개 등) / 대괄호 인용 금지 / 숫자 환각(프롬프트에 없는 2자리 이상 숫자, 만 단위 환산 허용) / 인용 환각(「」 안 제목이 문서 제목과 문장부호 무시 부분일치하지 않음) / 한국어 비율 ≥ 0.9 / 방향 일치(red 짝 첫 문장에 주의·위험, green 짝에 양호·안정). 지연은 첫 조각(TTFT)·총 시간 p50, Ollama eval tok/s.
  • VRAM 동주는 GPU를 비운 뒤 임베딩 → LLM → 임베딩 순으로 호출하고 /api/ps를 읽었다(§10-4).
cd backend
python -m apps.analysis.adapter.inbound.cli.compare_report_writers --repeat 2

10-3. 결과 (후보당 28건, 2026-09-18 23:29)

후보 완료 게이트 전부 통과 분량 숫자 환각 없음 인용 OK 한국어 비율 방향 일치 TTFT p50 총 p50 tok/s 평균 길이
gemini-3.8-flash 1.000 1.000 1.000 1.000 1.000 0.999 1.000 1.68 s 2.12 s — 349자
gemma4:12b 1.000 1.000 1.000 1.000 1.000 0.998 1.000 0.46 s 2.95 s 48 196자
kanana1.5:8b-q4km 1.000 0.857 1.000 1.000 0.857 1.000 1.000 0.17 s 1.67 s 81 202자
exaone3.5:7.8b 1.000 0.536 0.714 1.000 0.750 1.000 1.000 0.11 s 1.50 s 85 263자

제목 금지·대괄호 금지는 네 후보 모두 1.000. 반복 2회의 게이트 결과 일치율은 Gemini·gemma4 14/14, kanana 12/14, exaone 11/14.

위반 내역(전수)

  • kanana 4건(전부 funding 인용): 실재 공고의 [대구] 태그를 빼고 기관명을 병기하거나 세 공고를 한 인용으로 합쳤다(「2026년 소상공인 경영안정자금 지원사업 공고(달성군/동구/북구)」). 지어낸 문서는 없다. 매칭 상품명에 「」를 붙인 경우도 있는데 프롬프트가 금지한 표기다.
  • exaone 13건: market 불릿 5~6개(상한 3) 5건, verdict 4문장 1건, 인용 7건 — 사용자 질문 「원두값 오르면?」과 키워드 「원두값 상승」에 인용 표기를 붙였고, 공고 제목 어순을 바꿨으며, 상품명에 「」를 붙였다. 역시 지어낸 문서·숫자는 없다.
  • 1차 실행(..._232238.json) 에서는 Gemini의 shock 인용이 2건 위반으로 잡혔는데, [내년 최저임금 1만700원] 대구 … 제목을 대괄호만 빼고 인용한 정당한 경우였다. 인용 대조를 문장부호·공백 무시로 고친 뒤(테스트 포함) 재실행했다. 1차에서 Gemini market에 “10곳 중 1곳”(폐업률 10.3%에서 파생)이 숫자 환각으로 잡힌 것은 계산 금지 정책상 위반이 맞다 — 2차에는 나오지 않았다.

방향 일치 표본(rep 0, 첫 문장)

  • gemini red: “…위험도가 82.0점으로 ‘진입 주의’ 등급에 해당하며 … 전반적인 위험 수준이 높습니다.” / green: “…위험도 18.0점으로 등급은 양호한 수준입니다.”
  • gemma4 red: “해당 상권은 위험도 82.0점으로 등급 진입 주의 단계에 해당하며 폐업률이 57.4%로 높습니다.” / green: “위험도 18.0점으로 등급 양호이나, 폐업률이 57.4%로 높고 … 주의가 필요합니다.”
  • 네 후보 모두 등급 치환을 따라갔고, green 짝에서도 폐업률 57.4%를 근거로 단서를 단 점은 동일하다.

10-4. VRAM 동주 (GPU 비운 뒤 qwen3-embedding:4b@2560 + LLM)

LLM /api/ps 상주 합계 nvidia-smi 사용량
gemma4:12b qwen 4.07 GiB + gemma4 7.51 GiB 11.58 GiB 12,544 MiB
exaone3.5:7.8b qwen 4.07 + exaone 4.83 8.90 GiB 9,442 MiB
kanana1.5:8b-q4km qwen 4.07 + kanana 4.91 8.98 GiB 9,524 MiB

셋 다 임베딩과 함께 상주하며 재호출한 임베딩도 재로딩 없이 응답했다. 하네스 본실행의 “동주 False” 표기는 직전 후보 모델이 아직 내려가지 않은 상태에서 잰 측정 순서 문제였다(gemma4 실행 시 앞 후보가 없어 qwen만 밀려남, exaone 실행 시 gemma4 8.2 GB 잔류). 이 표가 정식 수치다. 이 프로젝트의 임베딩 + LLM 하나 구성에서는 12B급까지 동시 상주를 확인했다.

10-5. 해석·결론

  • 오프라인 리포트 작성기는 gemma4:12b. 게이트 전부 통과 1.000으로 Gemini와 같고, 숫자 환각 0·인용 위반 0·방향 일치 1.000·반복 일치 14/14다. TTFT는 0.46 s로 Gemini(1.68 s)보다 빠르고 총 시간은 2.95 s로 0.8 s 느리다(48 tok/s). 출력이 Gemini의 56% 길이로 짧은 편인데 분량 상한 안에서 간결한 쪽이라 결함으로 보지 않는다.
  • kanana는 차선. 빠르고(81 tok/s) 위반이 funding 인용 형식에 한정된다. 인용 표기 규칙을 프롬프트에서 더 강하게 못 박으면 올라올 여지가 있다.
  • exaone은 기준점으로만 읽는다. 라이선스(NC) 때문에 애초에 운영 후보가 아니다. 한국어 특화 모델의 기준점으로 보면, 분량 지시(불릿 3개)를 절반 가까이 어기고 인용 표기를 키워드·질문에 남발하지만 숫자 환각 0·방향 일치 1.0·한국어 비율 1.0은 유지한다. 즉 이 프롬프트에서 한국어 모델의 바닥은 “사실은 지키되 형식 지시를 흘리는” 수준이고, gemma4와 kanana는 그 바닥보다 형식 준수에서 앞선다. 속도는 가장 빠르다(85 tok/s).
  • 한계: 컨텍스트 3개·반복 2회라 표본이 작다. 게이트는 형식·근거 충실도만 보고 문장의 설득력·정확한 해석은 재지 않았다(LLM-as-judge 미도입, 결정론 게이트로 평가). thinking을 켠 gemma4는 평가하지 않았다(평가셋 생성에서 질문 1건 25초를 실측해 운영 부적합으로 봄).

  • A.1 (09-18 22:20) Gemini MRL 실측 — 5문장을 3072·2560·1536·1024로 받아 비교. 절단+정규화와 코사인 1.0. 지정 차원 응답 노름 0.62~0.88(비정규화).
  • A.2 (09-18 22:30) Ollama 차원 — bge-m3 1024(정규화됨), qwen 네이티브 2560, dimensions 인자로 절단 가능(bge-m3도 512로 잘리긴 하나 MRL 학습 모델이 아니라 쓰지 않음).
  • A.3 (09-18 22:35) gemma4:12b thinking 지연 — 평가셋 생성 시 질문 1건에 약 25초. think:false를 주니 0.9초(eval 24토큰). 생성기에 기본 차단으로 넣었다.
  • A.4 (09-18 22:45) 404 회귀 — Gemini 어댑터의 model_name에 차원 접미사를 붙이자 API 호출에도 그 이름이 나가 models/gemini-embedding-001-3072d is not found. API 모델 ID는 상수로 분리하고 테스트에 고정했다.
  • A.5 (09-18 22:55) GPU 상주 — 비교 실행 전 gemma4:12b 상주 8.2 GB. bge-m3+qwen을 올리자 gemma4가 내려가고 5.2 GB. 공유 GPU에 이미 상주한 모델(gemma4:12b·kanana1.5:8b)이 평가 중 내려갔다가 요청 시 다시 로드되는 간섭을 관측했다. 정식 동주 측정에서는 GPU를 비운 뒤 실행했다(§10-4).
  • A.6 (09-18 23:04) Gemini 같은 차원 실호출 — gemini-api@2560·gemini-api@1024를 API output_dimensionality로 코퍼스 3,448건·질문 80건 전량 재임베딩(각 약 75초, 45~49 docs/s). 절단 파생 후보와 순위·Top-1·Jaccard·ρ 전부 1.000으로 일치. 질문 p50은 381~393 ms로 3072 호출(421 ms)과 비슷.
  • A.7 (09-18 23:10) 운영 버그 발견 — LLM 하네스가 운영 에이전트로 컨텍스트를 채우다 CachingRegionUseCaseProxy.summary()가 year 인자를 받지 않아 TypeError. 09-18 연도 전달 작업에서 프록시가 빠져 지도 선택 연도가 리포트에 전달되지 않고 있었다(운영에서는 예외를 삼켜 market 섹션이 빈 채로 나감). 프록시에 year를 이어 주고 테스트로 고정했다.
  • A.8 (09-18 23:22) LLM 1차 실행 — 인용 게이트가 문장부호까지 정확 일치를 요구해 Gemini의 정당한 인용 2건을 위반으로 잡음. 정규화 후 재실행(A.9).
  • A.9 (09-18 23:29) LLM 2차 실행 — §10-3 표. 후보당 28건, 총 112건, 전체 4분 25초.
  • A.11 (09-19 00:50) 운영 2560 통일 — vector(2560) HNSW 생성 실패(2000차원 상한) → halfvec(2560). Gemini 재색인 3,589건 153초. 평가셋 recall 0.438은 만료 필터 때문(필터 OFF 1.000).
  • A.10 (09-18 23:35) 정식 동주 측정 — ollama stop으로 GPU를 비운 뒤 임베딩→LLM→임베딩 순 호출. §10-4 표.

11. 운영 반영 — 차원 2560 통일과 오프라인 전환 절차 (2026-09-19)

결정: 운영은 Gemini를 쓴다. 로컬 경로는 시연 장애에 대비해 배선했으며, 고객 개인정보 보호를 위한 온프레미스 적용에도 활용할 경로다(9/20 목적 명시). 필요할 때 수 분 안에 모델을 바꿀 수 있어야 하므로 운영 Gemini 차원을 로컬 qwen과 같은 2560으로 맞췄다(사용자 결정).

적용 내용

  • 마이그레이션 c1d2e3f4a5b6: rag_chunk.embedding vector(1536) → halfvec(2560). pgvector HNSW 인덱스는 vector 타입에서 2000차원까지만 지원한다는 것을 적용 중 실측했다(column cannot have more than 2000 dimensions for hnsw index). 2560은 fp16 halfvec(인덱스 4000차원까지)으로 저장하며 코사인 검색 정밀도 차이는 무시할 수준이다. 기존 1536 벡터는 형 변환이 불가해 비우고 전량 재색인한다.
  • 어댑터 기본 차원: Gemini EMBEDDING_DIM=2560, qwen DIMENSIONS=2560(접미사 없는 model_name이 이제 2560을 뜻한다). 설정 RAG_EMBEDDING_PROVIDER(기본 gemini)·REPORT_WRITER_PROVIDER(기본 gemini)·OLLAMA_REPORT_MODEL(기본 gemma4:12b).
  • 재색인: build_rag_index --full --provider gemini — 3,589청크, 2분 33초, 429 없음. alembic check 통과, 백엔드 테스트 497건 통과.
  • 검증: 재색인 후 평가셋 80건을 운영 검색 경로(evaluate_rag)로 돌리면 Recall@5 0.438이 나오는데, funding 20건을 만료 공고 필터를 끄고 돌리면 Recall@5 1.000·MRR 0.91이다. 평가셋의 funding 정답이 2025년 공고(만료)라 운영 필터에 걸리는 것이지 색인 문제가 아니다. 평가셋을 검수할 때 만료되지 않은 공고로 다시 뽑아야 한다.

오프라인 전환 절차(시연 장애 시)

# 1) 로컬 모델 준비 확인 (둘 다 상주 시 약 11.6 GiB)
ollama pull qwen3-embedding:4b && ollama pull gemma4:12b
# 2) 검색 색인을 로컬 임베더로 교체 (3,589청크 약 3분, 20 docs/s)
cd backend && python -m apps.rag.adapter.inbound.cli.build_rag_index --full --provider ollama
# 3) 환경변수 2개 바꾸고 백엔드 재시작
RAG_EMBEDDING_PROVIDER=ollama REPORT_WRITER_PROVIDER=ollama uvicorn main:app --port 8300

되돌리기는 --provider gemini로 재색인(약 2.5분)하고 환경변수를 원래대로 두면 된다. 색인은 embedded_by로 모델을 기록하므로 질의 임베더와 색인 모델이 어긋나면 검색 결과가 0건이 된다 — 재색인 없이 환경변수만 바꾸면 안 된다.


맨 위로

053 · DAEGU · 2026