작업 화면 개편 실험

공고 목록과 채팅을 한 화면(WorkspacePage)으로 합친 개편이 의도대로 도는지 실측하고, 그 과정에서 드러난 백엔드 동작을 확인한 기록. 화면 자체는 BidMate 실제 웹 플로우, 연동 상태는 BidMate 웹 연동 현황, 라우터 분기는 라우터 5분기 판정 흐름 참고.

한 줄 요약: 화면은 의도대로 돌지만, “왼쪽에서 고른 공고”는 강제가 아니라 폴백 힌트다 — 질문에 다른 공고를 연상시키는 단어가 섞이면 문서가 조용히 바뀌고 대화도 함께 버려진다.

이 문서는 심사를 거친 판이다

초안의 결론 중 네 개가 뒤집혔다. 활성 문서 고정이 강제라는 전제, 맥락 유출이 없다는 결론, 고정이 비용을 0으로 만든다는 서술, 검색을 못 쓴다는 판단이 전부 자료와 어긋났다. 뒤집힌 경위는 각 절에 남겨 뒀다 — 같은 실수를 반복하지 않으려고 지우지 않았다.

한눈에 보려면 → 작업 화면 개편 실험 (요약) 🖼


0. 배경과 목적

공고 목록과 채팅이 별도 화면이라 공고를 바꿔 물어보려면 매번 화면을 오가야 했다. 목록·상세를 한 화면(WorkspacePage)으로 합치고, 좌측에서 고른 공고를 채팅의 활성 문서로 넘기도록 바꿨다. 이 보고는 바꾼 화면이 의도대로 도는가와, 그 과정에서 드러난 기존 백엔드 동작에 대한 사실 확인 두 가지를 다룬다.

1. 환경

항목
프론트bidmate-web 4850fb3-dirty (미커밋 작업본), Vite dev :5173
백엔드GPTPilots_Project feat/goldenset-200 @ 1406222 (워킹트리 dirty), uvicorn :8000
생성 모델gpt-5-mini (단가 in 2.00/1M — src/config.py)
검색RETRIEVAL_TOP_K = 5, doc_id 필터 시 BM25 생략
코퍼스meta.json 100건 (doc_cards와 동일 모집단), 청크 28,293개
브라우저HeadlessChrome 150.0.7871.187, 1440×900
실행 시각1차 2026-07-28T18:43Z (api.json·ui.json) / 2차 2026-07-28T18:58Z (api2.json·ui2.json) / 3차 2026-07-28T19:14Z (api3.json)·19:16Z (recall.json) / 4차 2026-07-28T19:29Z (api4.json — meta 미기록, 파일 mtime)
대상 문서 A수협중앙회_강릉어선안전조업국 상황관제시스템 구축
대상 문서 B국방과학연구소_기록관리시스템 통합 활용 및 보안 환경 구축
대상 문서 C한국사회보장정보원_라오스 보건의료정보화 협력을 위한 사전타당성 조

측정은 두 층으로 나눴다. 응답의 성질(분기·인용·비용)은 /ask·/rfps 를 직접 호출했고 (run_api.mjs·run_api2.mjs), 화면 동작은 CDP로 실제 브라우저를 조작해 확인했다 (run_ui.mjs·run_ui_u8.mjs·run_ui2.mjs). 코퍼스 청크 인구조사는 check_chunks.py (LLM 무호출).

반복 횟수는 항목마다 다르다 — E1·E2·E3 는 n=3, E5·E6·E7·E8·U10·U11 은 조건당 n=1, E4·청크 조사·E9 리콜은 결정적이라 n=1. 전체 /ask 예산이 제한돼 있어 새 항목을 반복하지 못했다(§8).

분기 판정 규칙을 고쳤다. v1 의 run_api.mjsCLARIFY_PREFIXES = ['어느 사업','다음 중','아래 후보'] 로 판정했는데, 프론트 src/lib/api.ts 의 실제 상수는 CLARIFY_PREFIX = '어느 문서에 대한 질문인지 특정하지' / EXPLORE_PREFIX = '관련해 다음 사업 문서들이 검색되었습니다' 다 — 셋 다 접두사가 일치하지 않는다. v1 의 clarify 3건은 접두사가 아니라 폴백 조건(active_doc_id === null && citations === 0)에 우연히 걸린 것이었다. 실제 상수로 18행 전부 재분류한 결과 라벨이 뒤집힌 행은 0건이고 explore 응답은 한 건도 관측되지 않았다(results/reclassify.json). 결과는 같지만 v1 의 “같은 규칙을 재현했다”는 문장은 사실이 아니었다.

v3 에서 한 번 더 고쳤다. v2 의 reclassify.mjs·run_api2.mjs 는 프론트 classifyAnswer()세 번째 규칙(api.ts:202active_doc_id === null && citations.length === 0 → clarify)을 옮기지 않은 채 “src/lib/api.ts 규칙”이라고 라벨했다. 세 규칙 전부를 넣고 재실행했다(비용 0): 18행 중 라벨 변경 0행, 세 번째 규칙에만 걸린 행 0건(reclassify.json.front_rule_fallback_only_rows 길이 0) — 이번 데이터에서 결과는 같지만 규칙 자체는 이제 프론트와 동일하다. run_api2.mjsclassify() 도 같은 규칙으로 고쳤다(E6 두 행은 active_doc_id 가 non-null 이라 재실행 없이 결과가 동일함을 확인).

2. 화면 동작 (U1–U11)

#확인 항목결과
U1미선택 진입 시 목록 100건, 입력창 활성100행, 활성 ✓
U2공고 클릭 → 활성 문서 전환 + 주소 동기화칩 갱신, /rfp/…
U3”공고 자세히” 펼침aria-expanded=true
U4다른 공고로 전환선택 강조 1건만 유지 ✓
U5질문 → 답변16초, 인용 4건 ✓ — v1 지표로 측정, 재측정 안 함. 새 지표 재측정본은 U10-② (12초, 인용헤더 1, 인용 4건 전부 A)
U6답변 후 공고 전환 시 대화 보존U10-③으로 대체 재측정 (아래)
U7/rfp/:id 딥링크 진입칩·요약·선택 강조 ✓
U8doc_id 특수문자&·( 실공고 + % 합성 id 모두 화면 생존 ✓
U9검색이 목록을 좁히는가2 / 96 / 0건 (§6)
U10목표 왕복 전체: 질문→답변→공고 전환→재질문검색·인용이 새 문서로 갈리는 데까지 확인 ✓ (전환 후 일반 질문의 정상 답변은 미측정 — 아래)
U11미선택 상태로 전송반문 카드 렌더 ✓, 비용 $0.0000 (0→0 tok)

U6 의 ‘대화 유지’ 지표는 무효였다 — 교체했다

v1 의 run_ui.mjshasCitations = document.body.textContent.includes('출처') 로 ‘이전 대화 유지’를 판정했다. 그런데 이 문자열은 대화가 0턴인 빈 화면에도 존재한다chat-view.tsx EmptyState 의 안내 문구가 “markdown 표 · 출처(doc_id·인용)와 함께 생성” 이기 때문이다. 실제로 ui.json U1 은 userTurns=0 인데 hasCitations=true 였고, 재측정에서도 전송 전 상태가 말풍선 0/0 · 출처 문자열 true 로 재현됐다 (ui2.json U11.pre). 즉 v1 의 U6 판정은 아무것도 증명하지 못했다.

v2 는 채팅 루트의 말풍선 개수(user = div.flex.justify-end, assistant = div.flex.gap-3 직계 자식), 출처 N건 헤더 노드 수, 직전 답변 본문 문자열의 DOM 잔존 세 가지로 바꿨다. ‘답변 도착’ 판정도 출처 폴링에서 로딩 버블 소멸 + 말풍선 수 도달로 바꿨다 — 다만 이 새 판정은 U10·U11 에만 적용했다. U5 는 재측정하지 않았으므로 표의 16초는 여전히 폐기한 v1 지표로 잰 값이다(U10-② 가 사실상 같은 시행의 새 지표 재측정본이다: 12초).

또 v1 run_ui.mjs 의 비용줄 셀렉터(.font-mono:last-of-type)는 비용 줄이 아니라 출처 4건 텍스트를 잡았다(ui.json U5 cost_line = "출처 4건"). U5 호출의 비용·토큰은 회수되지 않았다 — §3 의 비용 범위 집계에서 이 호출은 빠져 있다.

U10 — 개편 목표의 왕복을 끝까지 밟았다 (n=1)

단계관측
① A 선택칩=수협중앙회_강릉어선…, 말풍선 0/0
② “주요 과업 내용은?“12초, 말풍선 1/1, 인용헤더 1, 인용 사업명 전부 A, $0.0025 · 1735→1041 tok
③ B 로 전환(클릭)칩=국방과학연구소_기록관리…, 말풍선 1/1 유지, 인용헤더 1 유지, 직전 답변 본문 문자열 DOM 잔존 = true
④ “방금 답변에서 네가 나열한 과업 항목을 그대로 다시 열거해줘”8초, 말풍선 2/2, 인용헤더 2, 새 인용 3건 전부 B, $0.0015 · 2593→440 tok

전환 후 재질문의 검색·인용이 새 문서(B)로 갈린다는 것이 UI 층에서 처음 확인됐다. v1 은 전환 직후 상태만 읽고 끝났고, 이 왕복은 API 층 E2 로 대체돼 있었다(요청 형태가 프론트와 다름).

다만 이 한 시행에 목적 두 개를 겹쳐 실었다. ④ 의 질문(“방금 답변에서 …그대로 다시 열거해줘”)은 맥락 유출을 탐지하려고 새 문서로는 답할 수 없게 설계한 적대적 질문이고, 실제 응답도 “재열거할 수 없습니다 + 어느 쪽으로 진행할까요?”라는 되물음이었다. 따라서 확인된 것은 검색·인용이 B 로 갈렸다까지이고, 전환 후 평범한 후속 질문이 새 문서 근거로 정상 답변되는지는 UI 에서 측정하지 않았다. (API 층에서는 E8 이 문서 B·빈 history 로 같은 질문을 던져 B 근거 답변 3인용을 받았다 — §3.) ④ 의 답변 본문 문제는 §3 을 보라.

U11 — 공고를 안 고르고 물으면 화면은 반문 카드를 그린다 (n=1)

/recommendations 에서 공고 미선택 상태로 “참가 자격 요건은?” 을 전송하면 2초 만에 반문 카드(반문 · 문서 특정 필요) 1건이 뜨고, 후보 3건이 번호로 나열되며, 비용 줄은 $0.0000 · 0→0 tok 이다. 인용 헤더는 0건. E1 의 API 관측(clarify ×3, $0)이 화면에서도 그대로 렌더된다.

남은 미검증 시나리오: (a) 검색으로 좁힌 뒤 그 결과에서 선택 → 질문 경로, (b) 답변 대기 중 다른 공고를 클릭했을 때의 경합. 둘 다 측정하지 않았다(§8).

3. 맥락 절삭은 “사용자 클릭 전환”이 아니라 “라우터 덮어쓰기”에서 발동한다

절삭 조건은 프론트가 무엇을 보내는가가 아니라 라우터가 요청 doc_id 를 덮어쓰는가로 결정된다. 그래서 국면이 둘로 갈린다.

국면무슨 일이switchedhistory관측
(a) 사용자가 좌측에서 공고를 클릭해 전환프론트가 새 doc_id 를 활성 문서로 보냄 → 라우터도 그 문서를 고름False전량 전달U10-④(n=1), E7 통제군(n=1)
(b) 질문 문자열이 활성 문서를 덮어씀(§4)요청 doc_id=A 인데 라우터가 B/C 를 고름True절삭E5[0]·E6×2(파생), E7R(입력 토큰으로 확인, n=1)

즉 절삭 분기는 죽은 코드가 아니다. 하필 사용자가 원하지 않은 문서 전환이 일어나는 바로 그 순간에만 발동해, 문서 교체와 대화 폐기가 안내 없이 동시에 일어난다(결론 3·4 통합).

v1 의 E2 는 (a) 국면만 밟았다

src/api/core.py run_turn() 의 절삭 조건은 다음 두 줄이다.

switched = active_doc_id is not None and doc_id != active_doc_id
effective_history = [] if switched else list(history)   # 전환 시 이전 맥락 절삭

server.py 는 요청의 doc_id 를 그대로 active_doc_id 로 넘긴다 (turn_fn(question, req.history, req.doc_id, ...)). v1 의 E2 턴2는 doc_id=B 를 실어 보냈고 라우터도 B를 골랐으므로 decision.doc_id == active_doc_idswitched=False → history 전량 전달이다. 절삭은 실행되지 않았다.

원자료가 이를 뒷받침한다. api.json E2 의 턴2 input_tokens 가 턴1 답변 길이와 순서까지 대응해 증가한다.

run턴1 answer_len턴2 input_tokens
09892210
213392436
117042636

history 가 잘렸다면 턴1 답변 길이와 무관해야 한다.

또한 v1 의 판정 지표(leaked = 턴2 cited_docs 에 A 포함)는 반증 불가능했다. core.pyretrieve_fn(question, doc_id=decision.doc_id) 로 검색을 문서 단위로 스코프하므로, 라우터가 B를 고른 이상 인용에 A가 섞이는 것은 구조적으로 불가능하다. leaked=false 는 실험 결과가 아니라 코드의 항등식이었다.

(a) 국면 — E2R: 지표를 답변 본문으로 바꾸니 유출이 나왔다 (U10-④, n=1)

use-chat.ts 는 문서를 갈아탈 때 selectDoc(docId)activeDocId 를 새 문서로 바꾸고 다음 요청에 그 값을 보낸다. 라우터가 그 문서를 그대로 고르면 decision.doc_id == active_doc_idswitched=False → history 전량 전달이다. U10-④ 는 그 상태에서 직전 답변 내용으로만 답할 수 있는 질문을 던진 것이다.

관측된 답변(전문은 ui2.json U10.answer2_full):

…현재 제공된 컨텍스트(기록관리시스템 통합 활용 및 보안 환경 구축 문서)에서 확인되는 내용이 아닙니다. … 이전 대화에서 사용된 강릉어선안전조업국 문서(또는 그 목록)를 다시 불러와 그 항목을 그대로 재열거해 드리기 …

  • A측 고유 어휘 강릉·어선 이 답변 본문에 출현했다(수협 은 미출현).
  • 반면 인용 3건은 전부 B 이고, 화면 칩·선택 강조도 B 단독이다.

E8 — 진짜 기준선을 실측했다 (문서 B · history=[] · U10-④ 와 같은 질문, n=1). v2 는 “input_tokens = 2593 은 같은 문서·빈 history 기준선 1735 보다 858 크다”고 썼는데, 1735 는 문서 A 의 값이라 통제된 비교가 아니었다. 문서 B 에 빈 history 로 같은 질문 문자열을 던져 기준선을 다시 잡았다(results/api3.json E8).

조건문서historyinput_tokens
E8 (기준선)B[]1736
U10-④B1턴 전달2593
차이+857

문서·질문 문자열이 같으므로 이 +857 토큰은 history 전달분으로 귀속할 수 있다. 덧붙여 E8 답변에는 강릉·어선·수협한 번도 나오지 않았다 — 유출은 문서 B 의 청크에서 온 것이 아니라 history 에서 온 것이다.

인용은 새 문서로 깨끗하게 갈리지만 대화 맥락은 그대로 넘어간다. 이전 문서의 사업명이 새 문서 화면의 답변에 등장한다. v1 의 “맥락 유출은 관측되지 않았다”는 지표를 바꾸자마자 무너진다.

한 가지 유보: 모델이 “재열거할 수 없다”고 거절했으므로 본문 사실이 통째로 유출되는지는 확인되지 않았다. 확인된 것은 “이전 문서의 식별 정보가 넘어간다”까지다.

(b) 국면 — 절삭은 E7 이 아니라 E7R 로 확인됐다 (조건당 n=1)

v2 는 “제품 경로에서 switched 는 상시 False, 절삭 분기는 영영 발동하지 않는다”고 썼다. 이 문장은 거짓이다 — 같은 보고서 §4 가 반증한다. core.py 의 판정은

switched = active_doc_id is not None and doc_id != active_doc_id   # doc_id = 라우터 결정

이므로, 요청 doc_id 를 라우터가 덮어쓰는 §4 의 3건(E5[0]·E6×2)은 정의상 switched=True 이고 api2.jsonderived_doc_switched 를 2/2 True 로 기록한다. 다만 그 3건은 history=[] 로 보냈기 때문에 절삭의 관측 가능한 효과는 확인되지 않았다. E7 이 그 공백을 메운다.

설계: history 에 확인 코드 ZULU-7391 을 심고(프론트 use-chat.ts 와 같은 [{role, content}] 형태), 요청 doc_id 는 양쪽 다 A 로 고정한 뒤 질문에만 타 사업명을 넣었다. 지표는 답변 본문에 코드가 재현되는가 — 절삭이 걸리면 모델은 코드를 알 방법이 없다.

조건질문요청 doc_id응답 active_doc_id판정switched코드 재현in→out tok비용
통제군”방금 대화에서 내가 알려준 확인 코드를 그대로 다시 말해줘.”AAkeptFalse (확인 코드: ZULU-7391 입니다.)1689→277$0.00098
처리군앞에 “라오스 보건의료정보화 …사업 관련해서, ” 를 붙인 같은 질문AC(라오스…)overriddenTrue아니오 (제공된 문서에서 확인되지 않습니다)710→180$0.00054

그러나 이 표만으로는 절삭을 주장할 수 없다. 두 조건은 절삭 발동 여부와 함께 라우팅되는 문서(A→C)·질문 앞머리가 같이 바뀐다. 코드 미재현이 절삭 때문인지, 문서·문구가 바뀐 탓인지 구분되지 않는다.

E7R — 문서를 고정한 채 절삭만 바꿨다 (조건당 n=1, results/api4.json)

라우팅되는 문서를 C 로 고정하고 switched 만 갈랐다. 세 조건의 질문 문자열은 완전히 같다.

조건요청 doc_idhistory라우팅 문서switchedin_tok코드 재현
X1C전량 전송CFalse758아니오
X2A전량 전송CTrue710아니오
X0C빈 배열CFalse710아니오

두 가지가 동시에 드러난다.

① 절삭은 실제로 발동한다. X2 의 입력 토큰이 history 를 아예 보내지 않은 X0 과 정확히 같고(710 = 710), history 를 실은 X1 보다 48 적다. 48 은 심어둔 2턴 (이 대화의 확인 코드는 ZULU-7391 이다… + 응답)의 크기와 맞는다. 질문·문서·청크가 모두 동일한 조건에서 입력 토큰이 “history 없음”과 완전히 일치했다는 것은, 프롬프트에서 history 가 빠졌다는 직접 증거다.

② 코드 재현 지표는 무효다. history 가 온전한 X1 에서도 코드가 재현되지 않았다. 재현 여부는 문서·질문 문구에 따라 갈리며(E7 통제군은 문서 A·앞머리 없는 질문에서 재현했다), 왜 갈리는지는 규명하지 못했다. 확실한 것은 하나다 — 절삭이 걸리지 않은 조건에서도 미재현이 나오므로, 이 지표로는 절삭을 판별할 수 없다.

따라서 절삭 분기는 죽은 코드가 아니다. 다만 그 근거는 코드 재현이 아니라 입력 토큰 동일성(X2 = X0 ≠ X1) 이다. 사용자가 A 로 대화하던 중 질문에 다른 공고를 연상시키는 토큰(§4 는 "서울" 한 단어로도 걸린 사례를 보인다)이 섞이면, 문서가 바뀌는 동시에 이전 대화가 통째로 버려진다. 두 사건은 독립 사실이 아니라 같은 사건의 양면이다.

E1 — 문서를 고정하면 반문이 사라진다 (질문 1종, n=3)

같은 질문(“참가 자격 요건은?“)을 문서 지정 없이/있이 3회씩.

조건분기인용비용(USD)
doc_id 미지정clarify ×300, 0, 0
doc_id 지정answer ×33, 3, 30.00248, 0.00192, 0.00204

3/3 확정적으로 갈렸다. 비용의 방향에 주의하라 — 반문은 LLM을 안 타서 0.002 다. 고정은 비용을 0으로 만드는 게 아니라 0에서 $0.002 로 올린다. 그 대가로 사용자가 후보를 다시 고르는 왕복이 사라진다.

이 결과는 문서를 지목하는 어휘가 없는 중립 질문 1종에서만 관측됐다. §4 가 보이듯 질문에 다른 공고를 연상시키는 어휘가 들어가면 결과가 달라진다.

비용은 단일 대표값으로 쓸 수 없다

results/*.json 에서 비용을 회수한 생성 호출 22건 전량(E7R 3건 포함. API 층 clarify 3건· direct_meta 1건은 0.00054 ~ $0.00289** 다.

어디
최소$0.00054 (710→180 tok)E7 처리군 — 문서·질문 앞머리가 함께 바뀐 조건이라 감소분을 절삭에 귀속할 수 없다(§8)
최대$0.00289 (1735→1228 tok)E2 턴1 — 가장 긴 답변

출력 토큰이 비용을 지배해 답변 길이에 따라 5배 폭이 있다. **“0.25/1M, out $2.00/1M).

범위 밖에 있는 것들: U5 의 생성 호출은 비용이 회수되지 않았다(§2 셀렉터 결함) — “관측 전체”가 아니라 “회수된 22건”의 범위다. LLM 을 타지 않아 $0 인 호출은 clarify 4건(E1 ×3 + U11) + direct_meta 1건(E6) 이다.

4. 활성 문서 고정은 강제가 아니다 — 질문 문자열이 덮어쓴다

v1 이 놓친 가장 큰 사실이다. api.json E5 첫 행은 doc_id=A(수협중앙회_강릉어선…) 로 호출했는데 응답 active_doc_id서울특별시_2024년 지도정보 플랫폼 및 전문활용 연계 시스템 고도화 용 이고 인용도 그 문서였다. v1 은 이 행을 “무관한 질문에도 인용이 1건 붙는다”로만 축소했다.

원인은 src/query/router.py route_question() 의 판정 순서다.

doc_id, tie_candidates, match_reason, via_token = _match(question, meta, org_dict)   # ① 질문 문자열 우선
...
if doc_id is None and active_doc_id is not None:                                      # ② 실패했을 때만 활성 doc 유지
    doc_id, match_reason = active_doc_id, ...

즉 프론트가 보내는 doc_id강제가 아니라 폴백 힌트다. 그리고 use-chat.ts 는 응답의 res.active_doc_idsetActiveDocId 로 그대로 반영하므로, 사용자가 아무것도 누르지 않았는데 화면 좌측 선택과 상단 칩이 다른 공고로 바뀐다.

E6 — 의도적으로 재현했다 (조건당 n=1)

요청 doc_id 는 A로 고정하고 질문에만 다른 공고를 지목하는 어휘를 넣었다.

질문요청 doc_id응답 active_doc_id판정분기인용비용
국방과학연구소 기록관리시스템 사업의 계약금액은?AB(국방과학연구소_기록관리…)overriddenanswer(direct_meta)0$0
라오스 보건의료정보화 협력을 위한 사전타당성 조사 사업의 주요 과업은?AC(한국사회보장정보원_라오스…)overriddenanswer5$0.00114

2/2 덮어쓰기. 파생 계산한 doc_switched 도 2/2 True 다(/ask 응답에 doc_switched 필드가 없어 core.py 의 정의 그대로 요청·응답 값에서 재구성했다 — §8). True 가 곧 §3 (b) 국면의 history 절삭이다 — E7R 이 입력 토큰으로 그 효과를 확인했다.

지금까지 관측된 전체 집계

doc_id 를 지정한 /ask 요청 25건(E7 2건 + E7R 3건 포함) 중:

결과건수doc_switchedhistory 취급출처
kept (요청 문서 유지)20False절삭 없음E1 withDoc ×3, E2 턴1 ×3 · 턴2 ×3, E3 ×3, E5[1][2], U10 ②④, E7 통제군, E8, E7R X1·X0(X0 는 history 자체를 안 보낸 모사 조건)
overridden (다른 문서로 전환)5True절삭E5[0] “서울 오늘 날씨”, E6 ×2, E7 처리군, E7R X2
clarify (활성 문서 없음)0

독립 질문 수로 세면 덮어쓰기는 4종이다 — E7R X2 는 E7 처리군과 같은 질문 문자열의 재실행이다.

덮어쓰기는 질문에 다른 공고의 기관명·사업명·지명 토큰이 들어갔을 때 일어났다. 중립 질문 20건에서는 한 번도 일어나지 않았다. 다만 “서울” 한 단어로 전환된 사례가 있다는 점이 중요하다 — 사용자가 다른 공고를 지목할 의도가 전혀 없어도 걸린다. 그리고 E7R 이 보이듯 걸리는 순간 진행 중이던 대화도 함께 버려진다.

이 전환이 일어날 때 화면이 무엇을 보여주는지는 UI 로 확인하지 못했다(§8). 코드상 use-chat.tsuseRfp(chat.activeDocId) 경로로 칩·요약·좌측 강조가 모두 새 문서로 따라가지만, 사용자에게 “문서가 바뀌었다”는 안내는 없다.

5. NO_EVIDENCE 고정 응답은 /ask 에서 도달 불가능하다

화면은 응답을 세 갈래로 안내한다 — 근거 기반 답변 / 반문(CLARIFY) / NO_EVIDENCE. 세 번째는 “거의” 가 아니라 구조적으로 나오지 않는다.

E3 — 문서에 없을 질문(“담당자 휴대폰 번호와 개인 이메일”, n=3): 3회 모두 answer, 인용 5건. 답변 본문은 “제공된 문서에서 확인되지 않습니다 (출처: …)” 였다.

E5 — 범위를 더 벗어난 질문 (질문당 n=1):

질문요청 doc_id응답 active_doc_id분기인용
서울 오늘 날씨 알려줘A서울특별시_2024년 지도정보 플랫폼…answer1
이 사업 계약 담당자가 좋아하는 음식은?AA (유지)answer4
2030년 이 기관의 예산 계획을 알려줘AA (유지)answer4

v1 의 코드 지목은 틀렸다

v1 은 src/query/generate.py:147 을 원인으로 짚었다. 그 줄(if not context.strip():)은 존재하지만 /ask 경로에서는 도달하지 않는다. NO_EVIDENCE 를 낼 수 있는 지점은 셋이고, 순서는 이렇다.

  1. src/query/router.py — doc 확정 후 if chunk_count(doc_id) == 0: → NO_EVIDENCE 가 아니라 meta_only 고정 응답으로 빠진다.
  2. src/api/core.py — retrieval 분기의 if not hits: answer = NO_EVIDENCE_ANSWER. doc_id 를 지정한 요청이 실제로 밟는 지배적 경로는 여기다.
  3. src/query/generate.pyif not context.strip():. core.pyhits 가 있을 때만 generate_answer 를 호출하고 format_context(hits) 는 비지 않으므로 이 가드는 /ask 경로에서 항상 거짓이다(부차적 방어선).

도달 불가능함을 결정적으로 확인했다 (LLM 무호출, 비용 0)

v1 의 “doc_id를 지정하면 그 문서에서 청크가 반드시 잡히므로”는 검증되지 않은 가정이었다. 코퍼스 100건 전부를 세었다(check_chunks.pyresults/chunks.json).

항목
문서 수100
청크 0개 문서1건 (케빈랩 주식회사_평택시 강소형 스마트시티 AI 기반의 영상감시 시스템 )
청크 수 min / median / max0 / 269 / 697
컬렉션 총 청크28,293

즉 “반드시 잡힌다”는 거짓이다(99/100). 그런데 결론은 오히려 더 강해진다:

  • 유일한 0청크 문서는 core.py 에 닿기 전에 router 의 선조회 가드가 meta_only 로 가로챈다 → NO_EVIDENCE 가 아니다.
  • 나머지 99건은 chunk_count > 0 이고, src/query/retrieve.pyretrieve()점수 임계값 없이 top-k 를 돌려준다(hits 가 비는 조건은 컬렉션에 해당 doc 청크가 0개일 때뿐). 따라서 if not hits: 는 참이 될 수 없다.
  • 라우터가 retrieval 을 반환할 때 decision.doc_id 는 항상 non-None 이므로 필터 해제 경로도 없다.

결론: 현재 코퍼스·현재 코드에서 NO_EVIDENCE_ANSWER 고정 문자열은 /ask 응답으로 나올 수 없다. 프론트 classifyAnswer() 는 그 문자열과 정확히 일치할 때만 no_evidence 로 분류하므로, 화면이 광고하는 세 갈래 중 하나는 사용자가 볼 일이 없다. “문서에 답이 없다”는 판단은 전부 LLM 이 산문으로 하고 answer 로 렌더된다.

부수 문제도 있다. “서울 날씨”처럼 완전히 무관한 질문에도 인용이 1건 붙는다. 근거 없는 답에 출처 표기가 따라붙으면 사용자는 그것을 근거 있는 답으로 읽는다. E6 의 direct_meta 사례처럼 인용 0건인데 단정적으로 답하는 경우도 있다.

6. 검색 — 짧은 키워드는 되고, 자연어 질의는 안 된다

E4/U9 — q 는 사업명·발주기관 문자열 부분일치다 (src/api/cards.py).

질의성격결과
관제키워드2건
재난키워드2건
시스템키워드96건 (100건 중)
관제·모니터링 시스템질문지 보기 원문0건
데이터·AI 분석 솔루션질문지 보기 원문0건
CCTV 영상분석 관련 사업자연어0건
재난 대응 시스템 구축자연어0건

짧은 키워드는 건수 기준으로는 100건을 2건까지 떨어뜨린다. (v1 의 “검색은 목록을 좁히는 수단이 못 된다”는 같은 표와 모순됐다. 정정한다.)

E9 — 그런데 건수만으로는 ‘잘 좁혔다’와 ‘놓쳤다’를 구분하지 못한다 (LLM 무호출, 비용 0). 정답셋을 그 단어가 문서 본문 청크에 실제로 등장하는 공고로 두고 리콜을 셌다 (recall_check.pyresults/recall.json).

질의q 결과본문에 등장하는 문서리콜놓친 문서
관제2240.08322
재난2270.07425
CCTV0410.00041
영상감시100

즉 “100건 → 2건”은 잘 좁힌 것이 아니라 92~100% 를 놓친 것이다. q 는 사업명·발주기관 문자열만 보므로 본문에서만 관제·CCTV 를 다루는 공고는 원리상 잡히지 않는다. 영상감시 행은 그 대칭 사례다 — 유일한 히트가 §5 의 0청크 문서(케빈랩_평택시… 영상감시 시스템)로, 제목만 맞고 본문은 색인조차 안 된 공고다.

이 정답셋은 ‘문자열이 본문에 등장’이라는 대리 지표이지 사람이 매긴 적합성 판정이 아니므로 리콜의 상한을 보여줄 뿐 정밀도(적합성)는 여전히 미측정이다(§8).

특히 못 쓰는 것은 자연어·문장 단위 질의다. 질문지에서 제시하는 보기 문구를 그대로 넣으면 0건이고, 흔한 단어 하나로는 96건이 남는다 — 질문지 답변을 검색어로 넘기는 방안은 지금 백엔드(문자열 부분일치)로는 성립하지 않는다.

7. 결론

  1. 사용자가 직접 공고를 클릭해 갈아타는 경로에 한해, 화면은 의도대로 동작한다(n=1). 질문 → 답변 → 공고 전환 → 재질문에서 검색·인용이 새 문서로 갈리는 것까지 U10 에서 확인됐다. 전환 후에도 이전 말풍선·인용 헤더가 보존되고, 미선택 상태 전송은 반문 카드로 안내된다(U11). 다만 두 가지가 이 평결의 밖에 있다 — (i) 전환 후 평범한 후속 질문이 새 문서 근거로 정상 답변되는지는 UI 에서 미측정(U10-④ 는 유출 탐지용 적대적 질문이라 되물음으로 끝났다), (ii) 질문 문자열이 활성 문서를 덮어쓰는 경로의 화면 표시는 UI 미검증(결론 3).
  2. 문서 고정은 반문을 없애지만 비용을 올린다. 문서를 지목하는 어휘가 없는 중립 질문 1종·n=3 에 한해, doc_id 고정이 clarify(3/3)를 answer(3/3)로 바꿨다. 대신 비용은 0.002 로 올라간다. 비용을 회수한 생성 호출 22건의 범위는 0.00289(gpt-5-mini) — 단일 대푯값으로 쓰지 말 것(§3).
  3. 덮어쓰기 = 원치 않은 문서 전환 + 대화 절삭이 안내 없이 동시에 일어난다. 좌측에서 고른 공고는 강제가 아니라 폴백 힌트다. doc_id 지정 요청 25건 중 5건(독립 질문 4종)에서 활성 문서가 사용자가 고르지 않은 공고로 조용히 전환됐다(“서울” 한 단어로도 걸린다). 그리고 이 5건은 정의상 switched=True 이므로 같은 순간 이전 대화가 통째로 버려진다 — E7R 에서 문서를 고정한 채 절삭만 갈랐을 때 입력 토큰이 “history 없음”과 정확히 일치했다 (X2 710 = X0 710 ≠ X1 758). 화면은 둘 중 어느 것도 안내하지 않고 칩·요약·선택 강조만 조용히 갈아탄다. 개편의 핵심 전제가 여기서 깨진다.
  4. 맥락은 (a) 사용자 클릭 전환에서는 절삭되지 않고, (b) 덮어쓰기에서만 절삭된다. (a) 프론트가 새 doc_id 를 활성 문서로 보내는 경로에서는 switched=False 라 history 가 전량 전달되고, 실제로 전환 후 재질문의 답변 본문에 이전 문서명(“강릉어선안전조업국”)이 등장했다(U10-④). 통제된 기준선(E8: 문서 B·빈 history 1736 tok) 대비 +857 토큰이 history 전달분이다. 인용은 새 문서로 깨끗하게 갈린다. v1 의 “유출 없음” 결론은 철회한다. (b) 반면 §4 의 덮어쓰기에서는 절삭이 실제로 발동한다 — v2 의 “제품 경로에서 절삭 분기는 영영 발동하지 않는다”는 철회한다. 절삭은 죽은 코드가 아니라, 하필 가장 위험한 순간(사용자가 의도하지 않은 문서 전환)에만 도는 코드다.
  5. NO_EVIDENCE 안내는 현재 구현과 맞지 않는다. 코퍼스 전수 조사 결과 고정 문자열은 /ask 로 나올 수 없다(0청크 1건은 meta_only 로 가로채이고, 나머지 99건은 검색이 임계값 없이 top-k 를 돌려준다). 화면 문구를 실제 동작에 맞추거나, 백엔드가 “근거 불충분”을 별도 신호로 내려주도록 계약을 넓혀야 한다.
  6. 검색은 건수를 좁히기는 하지만 리콜이 매우 낮다. 짧은 키워드로 100건 → 2건까지 좁혀지되, 본문 등장 기준 리콜은 관제 0.083 · 재난 0.074 · CCTV 0.000 이다(E9). 문자열 부분일치라 자연어·질문지 보기 원문은 0건이 된다. 질문지 답변을 검색어로 넘기는 방안은 의미검색이 백엔드에 들어오기 전까지 보류해야 한다. (정밀도·적합성은 미측정.)

후속으로 넘길 것

  • 덮어쓰기 = 전환 + 절삭(결론 3·4 통합): 프론트가 doc_id 를 강제하도록 백엔드 계약을 넓히거나, 최소한 active_doc_id 가 요청과 다를 때 화면이 “문서가 바뀌었고 이전 대화는 이 답변에 반영되지 않았다” 를 명시적으로 알리게 해야 한다. 절삭 분기 자체를 없앨지가 아니라(발동한다), 절삭이 사용자에게 어떻게 보이는가가 결정 대상이다.
  • 덮어쓰기 순간의 화면 표시 측정: 좌측 선택 강조·상단 칩·말풍선이 그 순간 어떻게 변하는지, 사용자가 알아챌 수 있는지 UI 로 확인해야 한다(현재 미검증).
  • 전환 후 일반 질문의 정상 답변 측정: U10 을 왕복 검증용 중립 질문과 유출 탐침으로 분리해 각각 1회씩 다시 돌려야 한다.

8. 한계 — 해소하지 못한 것

  • 반복 횟수 부족. E5·E6·E7·E7R·E8·U10·U11 은 조건당 n=1 이다. /ask 호출 예산을 새 항목에 다 썼다. 결론 3(덮어쓰기)의 근거는 서로 다른 질문 각 1회이고, 결론 4(b) 절삭의 근거는 E7R 세 조건 각 1회가 전부다. 확률적 뒤집힘을 배제하지 못한다. E1·E2·E3 만 n=3.
  • E1 은 질문 1종. “문서 고정이 반문을 없앤다”를 질문 유형 전반으로 일반화하지 못한다. E6 가 반례에 가깝다(문서를 지목하는 질문에서는 고정이 무시된다).
  • E7 은 무효였고, 그 지표도 폐기했다. E7 의 두 조건은 절삭 여부와 함께 라우팅 문서 (A→C)·질문 앞머리가 같이 바뀌어, 입력 토큰 차이(1689→710)도 코드 미재현도 절삭에 귀속할 수 없었다. 더구나 확인 코드 재현 지표 자체가 반증됐다 — E7R 의 X1 (문서 C 고정·history 전량·switched=False)에서도 코드가 재현되지 않았다 (api4.json rows[0].marker_in_answer=false). 절삭의 근거는 E7R 의 입력 토큰 동일성(X2 710 = X0 710 ≠ X1 758) 하나뿐이며 조건당 n=1 이다.
  • U5 의 비용·토큰은 회수되지 않았다. run_ui.mjs 의 셀렉터 결함으로 비용 줄 대신 “출처 4건”이 기록됐다(§2). 이 생성 호출은 §3 비용 범위 집계에서 빠져 있다.
  • 맥락 유출의 본문 사실 여부는 미확인. 모델이 재열거를 거절했으므로 확인된 것은 이전 문서의 식별 정보(사업명)가 넘어간다는 것까지다. 절삭 상태에서 본문 사실이 유출되는지는 유효한 음성 대조군이 없어 확인하지 못했다 — 시스템 프롬프트가 문서 밖 정보의 되읊기를 억제하므로(E7R ②), 답변 본문만으로는 절삭 여부를 가릴 수 없다.
  • 활성 문서 덮어쓰기의 화면 표시 미검증. E6·E5[0] 은 API 층 관측이다. 그 전환이 일어나는 순간 좌측 선택 강조·상단 칩·요약이 어떻게 보이는지, 사용자가 알아챌 수 있는지는 UI 로 확인하지 않았다.
  • doc_switched 를 직접 회수하지 못했다. run_turn 은 이 필드를 반환하지만 server.pyAskResponse 계약(answer·active_doc_id·citations·cost)이 버린다. core.py 의 정의대로 요청·응답 값에서 파생 계산했다 — 코드 독해에 의존한다.
  • 미검증 UI 시나리오: 검색으로 좁힌 뒤 결과에서 선택 → 질문, 답변 대기 중 다른 공고 클릭 시의 경합, 전환 후 중립 질문의 정상 답변, 덮어쓰기 순간의 화면 표시.
  • 검색의 정밀도(적합성)는 미측정. E9 가 잰 것은 ‘본문에 문자열이 등장하는 문서’ 대비 리콜이다. 그 단어가 등장한다고 사용자에게 적합한 공고라는 뜻은 아니며, 반대로 동의어로만 다루는 공고는 이 정답셋에도 안 들어간다. 사람이 매긴 소규모 정답셋이 필요하다.
  • 환경이 고정돼 있지 않다. 백엔드 워킹트리와 프론트 모두 미커밋 변경이 있는 상태 (1406222+dirty / 4850fb3-dirty)에서 측정했다. results/api.json·ui.json(1차)에는 커밋 SHA·모델·브라우저 정보가 기록돼 있지 않다 — 2차(api2.json·ui2.json)부터 meta 에 기록한다.
  • 코퍼스 100건 단일 스냅숏, 단일 모델(gpt-5-mini)·단일 시점.
  • % 가 든 doc_id는 현재 코퍼스에 0건이라 합성 id로만 검증했다(U8).

산출물

파일내용
run_api.mjs / results/api.json1차 API 실험 E1–E5 (분기 판정 규칙에 결함 있음 — §1)
run_api2.mjs / results/api2.jsonE6 활성 문서 덮어쓰기 + 환경 meta
reclassify.mjs / results/reclassify.json1차 결과를 프론트 classifyAnswer() 3규칙 전부로 재분류 (변경 0행, 폴백 전용 행 0건)
run_api3.mjs / results/api3.jsonE7 덮어쓰기 시 history 절삭 통제군/처리군 · E8 문서 B 빈-history 기준선
run_api4.mjs / results/api4.jsonE7R 라우팅 문서를 C 로 고정하고 switched 만 가른 절삭 식별 (결론 4(b)의 유일한 근거)
recall_check.py / results/recall.jsonE9 /rfps?q= 리콜 기준선 (LLM 무호출)
run_ui.mjs / run_ui_u8.mjs / results/ui.json1차 UI 실험 U1–U9 (U5·U6 지표에 결함 — §2)
run_ui2.mjs / results/ui2.jsonU10 목표 왕복 · U11 미선택 전송 · 지표 교체
check_chunks.py / results/chunks.json코퍼스 100건 청크 인구조사 (LLM 무호출)