From 244012efc1a645edf08648912c8c198315656337 Mon Sep 17 00:00:00 2001 From: SeMin Kim <83855438+tpals0409@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:37:53 +0900 Subject: [PATCH 01/34] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=EC=A7=80=ED=91=9C=20=ED=95=98=EB=84=A4=EC=8A=A4?= =?UTF-8?q?=EC=99=80=20baseline=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 하네스(sweep.py · word_sweep.py)의 지표는 전부 컷 기준이다 — 정답이 잘렸는지, 무관 질의가 침묵했는지를 센다. 컷은 순위를 바꾸지 않고 자르기만 하므로 그것으로 충분했다. 그런데 P48 1단계 이후는 순위를 바꾸는 변경이다. 정답이 6위에서 2위로 올라와도 컷이 그대로면 kept 가 변하지 않아 개선이 0으로 보인다. 그 눈을 먼저 만든다. 새 데이터는 필요 없었다 — 행마다 rank 가 이미 들어 있어 세기만 하면 된다. DB 0회 · GMS 0회. 세 가지를 가른다. 컷 전 / 컷 후 컷 후만 보면 순위 개선이 가려진다 단어형 / 문장형 두 대역이 겹치지 않는다(-266). 합산 평균은 어느 쪽도 설명하지 못한다 정답 질의 / 무관 질의 좋은 방향이 반대라 섞으면 상쇄된다 MRR 만으로 판정하지 않는다. 단어형 정답은 본문 문자열 포함으로 계산돼 복수이고, MRR 은 첫 정답만 보므로 정답 셋이 1·2·3위든 1·9·14위든 값이 같다. Recall@k 와 nDCG@k 를 함께 낸다. 재구성이 맞다는 증거 두 건이 독립적으로 나왔다. 무관-문장형 침묵률 0.7333 = 11/15 -213 기록과 일치 스팟 정답 유사도 0.2438 config.py 주석의 마진 0.0038 과 일치 판정 하나가 바뀐다 — -255 의 ①(그네는 컷이 잘랐다)은 -266 의 단어형 분기로 이미 해소돼 있다. 남은 신한·부캠은 tau_word 와 r·top1 양쪽 모두 아래라 한쪽만 풀어도 회복되지 않는다. 가드 4종은 수치를 내지 않고 실패한다(profile 불일치 · 필드 누락 · 정답질의에 정답 0건 · 무관질의에 정답 존재). 결정성은 두 번 실행 바이트 일치로 확인했다. Co-Authored-By: Claude Opus 5 --- .../2026-08-05-search-rank-baseline.md | 146 +++++++ docs/implements/README.md | 2 + tools/search_cut/rank_score.py | 379 ++++++++++++++++++ 3 files changed, 527 insertions(+) create mode 100644 docs/implements/2026-08-05-search-rank-baseline.md create mode 100644 tools/search_cut/rank_score.py diff --git a/docs/implements/2026-08-05-search-rank-baseline.md b/docs/implements/2026-08-05-search-rank-baseline.md new file mode 100644 index 0000000..440dd92 --- /dev/null +++ b/docs/implements/2026-08-05-search-rank-baseline.md @@ -0,0 +1,146 @@ +# 검색 순위 지표 baseline — 컷이 아니라 순위를 재는 자리 + +- **티켓**: 미발급([P48](../proposals/P48-search-signal-expansion.md) 0단계, 런타임 변경 없음) +- **날짜**: 2026-08-05 +- **하네스**: `tools/search_cut/rank_score.py` — 기존 행렬만 읽는다. **DB 0회 · GMS 0회** +- **산출**: 이 문서 §3의 표가 **보존본**이다. `--json` 산출물(`.search/rank_baseline.json`)은 + 커밋하지 않는다 — `.gitignore`가 "스윕 결과는 matrix에서 언제든 재구성되므로 남기지 + 않는다"고 정했고, 이 계산은 GMS·DB를 부르지 않아 재생성 비용이 0이다 +- **선행**: [검색 결과 컷](2026-07-31-search-cut.md)(`-213`) · [원인 판별](2026-08-03-search-recall-probe.md)(`-255`) · [단어형 컷](2026-08-03-word-query-cut.md)(`-266`) +- **성격**: 재기만 한다. **컷 값도 모델도 앱 코드도 고치지 않는다.** + +## 요약 + +``` +왜 만들었나 기존 지표가 전부 컷 기준이라 순위 변화를 못 센다 + → P48 1단계 이후는 순위를 바꾸는 변경이다. 개선이 0으로 보인다 + +재구성 확인 무관-문장형 침묵률 0.7333 = 11/15 `-213` 기록과 일치 + `스팟` 정답 0.2438 `config.py` 주석의 마진 0.0038 과 일치 + +판정 갱신 `그네` 는 이미 회복돼 있다 — `-266` 의 단어형 분기가 `-255` ① 을 닫았다 + `신한` · `부캠` 은 두 컷 **모두** 아래다. `-255` ③ 유지, 이유가 정밀해졌다 +``` + +## 1. 왜 지표를 새로 두는가 + +`sweep.py` · `word_sweep.py` 의 지표는 전부 **컷 기준**이다 — `eval_answered` 는 정답이 +잘렸는지, `eval_control` 은 무관 질의가 침묵했는지를 센다. 컷은 순위를 바꾸지 않고 +자르기만 하므로 그것으로 충분했다. + +[P48](../proposals/P48-search-signal-expansion.md) 1단계 이후는 **순위를 바꾸는 변경**이다. +정답이 6위에서 2위로 올라와도 컷이 그대로면 `kept` 가 변하지 않아 **개선이 0으로 보인다.** +그 눈을 먼저 만든다. + +새 데이터는 필요 없었다. 행마다 `rank` 가 이미 들어 있다(`matrix.py` · `word_matrix.py` · +`recall_probe.py`). + +## 2. 무엇을 가르는가 + +| 가른 것 | 이유 | +|---|---| +| 컷 전 / 컷 후 | 컷 후만 보면 순위 개선이 가려지고, 컷 전만 보면 사용자가 보는 것과 무관해진다 | +| 단어형 / 문장형 | 두 대역이 겹치지 않는다(`-266`). 합산 평균은 어느 쪽도 설명하지 못한다 | +| 정답 있는 질의 / 무관 질의 | 무관에서 좋은 것은 0건이고 정답 질의에서는 그 반대다. 섞으면 방향이 상쇄된다 | + +**MRR 만으로 판정하지 않는다.** 단어형 격자는 정답을 본문 문자열 포함으로 계산하므로 +한 질의의 정답이 여럿이다. MRR 은 첫 정답만 보므로 정답 3개가 `1·2·3위` 든 `1·9·14위` 든 +값이 같다. `Recall@k` 와 `nDCG@k` 를 함께 낸다. + +## 3. Baseline + +Record 42건 · 소유자 3명 · `tau_abs=0.30` · `tau_word=0.24` · `r=0.60` · `limit=20`. + +### 컷 적용 전 (순위만) + +| 세그먼트 | n | hit@1 | hit@3 | mrr | recall@3 | ndcg@3 | 반환 | +|---|---|---|---|---|---|---|---| +| 문장형(정답) | 12 | 0.8333 | 1.0000 | 0.9028 | 1.0000 | 0.9276 | 11.83 | +| 단어형(정답) | 66 | 0.8636 | 0.9394 | 0.9076 | 0.9205 | 0.9007 | 13.76 | +| 진단프로브 | 22 | 0.5909 | 0.8182 | 0.7064 | 0.8182 | 0.7164 | 17.00 | + +### 컷 적용 후 (사용자가 보는 것) + +| 세그먼트 | n | hit@1 | hit@3 | mrr | recall@3 | ndcg@3 | zero | 반환 | +|---|---|---|---|---|---|---|---|---| +| 문장형(정답) | 12 | 0.8333 | 1.0000 | 0.9028 | 1.0000 | 0.9276 | 0.0000 | 4.08 | +| 단어형(정답) | 66 | 0.8636 | 0.9394 | 0.9015 | 0.9205 | 0.9007 | 0.0152 | 2.77 | +| 무관-문장형 | 15 | — | — | — | — | — | **0.7333** | 0.33 | +| 무관-단어형 | 45 | — | — | — | — | — | 0.4222 | 1.89 | +| 타인소유-단어형 | 96 | — | — | — | — | — | 0.3646 | 1.74 | +| 진단프로브 | 22 | 0.5909 | 0.8182 | 0.6818 | 0.8182 | 0.7164 | 0.0000 | 3.14 | + +무관·타인소유 세그먼트는 `zero` 가 높을수록 좋고 나머지 지표는 의미가 없다(정답이 없다). + +전체 수치는 `.search/rank_baseline.json` 에 있다. + +## 4. 재구성이 맞다는 두 증거 + +이 하네스는 서비스의 `_cut` 을 옮겨 적었다. 옮긴 것이 맞는지를 **독립적으로 계산된 두 +값이 기존 기록과 일치하는 것**으로 확인했다. + +``` +무관-문장형 침묵률 0.7333 = 11/15 `-213` 이 기록한 「11/15」와 같다 +`스팟` 정답 유사도 0.2438 `config.py` 가 「최저 정답 0.2438, 마진 0.0038」로 + 적어 둔 값과 같다 +``` + +두 값은 이 스크립트가 만든 것이 아니라 **행렬을 다르게 세어 나온 값**이다. 비상 스위치를 +단어형 분기보다 앞에 두는 순서까지 원본과 같게 옮겼다 — 순서가 곧 동작이다. + +## 5. 사례 4건 — 판정 하나가 바뀐다 + +| 질의 | 정답 | top1 | `tau_word` | `r`·top1 | 컷 후 | 판정 | +|---|---|---|---|---|---|---| +| 그네 | 0.2671 (3위) | 0.2871 | 0.24 | 0.1723 | **3위 생존** | ① **해소됨** | +| 스팟 | 0.2438 (1위) | 0.2438 | 0.24 | 0.1463 | **1위 생존** | 마진 0.0038 | +| 신한 | 0.2301 (6위) | 0.3889 | 0.24 | 0.2333 | 잘림 | ③ 유지 | +| 부캠 | 0.2105 (8위) | 0.4102 | 0.24 | 0.2461 | 잘림 | ③ 유지 | + +**`-255` 의 ① 판정(「`그네` 는 컷이 잘랐다」)은 이미 닫혔다.** `-255` 측정 시점의 컷은 +`tau_abs=0.30` 단일이었고, `-266` 이 단어형을 `0.24` 로 가르면서 `그네` 가 회복됐다. +**`-255` 가 제안한 처방이 `-266` 에서 실행된 것을 이 표가 확인한다.** + +**`신한` · `부캠` 은 두 컷 모두 아래다.** `-255` 의 ③ 판정이 유지되며, 이유가 정밀해졌다. + +``` +신한 정답 0.2301 < tau_word 0.24 그리고 < r·top1 0.2333 +부캠 정답 0.2105 < tau_word 0.24 그리고 < r·top1 0.2461 +``` + +한쪽만 풀어도 회복되지 않는다. 그리고 `r` 은 **top1 이 높을수록 세게 자르므로**, 무관한 +1위가 강할수록 정답이 더 확실히 잘린다 — `신한`(top1 0.3889)·`부캠`(0.4102)이 정확히 그 +상황이다. **컷 값 조절로는 닿지 않는다. 순위 자체가 올라와야 한다.** + +이것이 P48 1단계의 표적이다. + +## 6. 가드 + +수치를 내지 못하는 상태를 통과로 만들지 않는다(`check_docs_index.py` 와 같은 태도). + +| 조건 | 동작 | +|---|---| +| 세 행렬의 `profile` 불일치 | 실패. 다른 Profile 의 행렬은 한 표에 놓을 수 없다 | +| 행에 `rank`·`sim` 없음 | 실패. 순위를 셀 수 없다 | +| 정답 질의인데 정답 행 0건 | 실패. 라벨 오류가 분모에서 조용히 빠지면 지표가 부풀려진다 | +| 무관 질의인데 정답 행 존재 | 실패. 통제가 아니라 정답 있는 질의다(`word_matrix.py` 와 같은 가드) | + +`profile` 가드는 실제로 값을 어긋내 `exit 1` 을 확인했다. + +## 7. 결정성 + +입력이 파일뿐이고 난수도 시각도 쓰지 않는다. **두 번 실행해 JSON 이 바이트 단위로 같은 +것을 확인했다.** `--json` 은 입력 세 파일의 SHA-256 을 함께 적으므로, 나중에 나온 수치가 +어떤 행렬에서 나왔는지 대조할 수 있다. + +## 8. 말할 수 없는 것 + +- **절대값을 일반화하지 않는다.** Record 42건·소유자 3명이다. 이 표의 쓸모는 **퇴행 + 탐지**이지 서비스 품질의 추정이 아니다. +- **`진단프로브` 세그먼트는 평가 셋이 아니다.** `-255` 가 세 이슈의 원인을 가르려고 질의 + 표현을 바꿔 가며 같은 Record 를 추적한 것이라 분포가 의도적으로 치우쳐 있다. 사례 추적 + 용도로만 읽는다. +- **`타인소유-단어형` 의 낮은 침묵률(0.3646)을 이 문서는 판정하지 않는다.** 정답이 다른 + 소유자에게 있는 질의라 무관 질의와 성격이 다르고, `-266` 이 이미 다룬 축이다. +- **단어형 정답 세그먼트의 `zero=0.0152`(66건 중 1건)가 어느 질의인지 이 문서는 짚지 + 않는다.** 컷 값 판단은 `-266` 의 소관이고 이 문서는 baseline 을 세우는 것이 목적이다. diff --git a/docs/implements/README.md b/docs/implements/README.md index 4bc1953..7a54f5d 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -51,6 +51,7 @@ | I46 | [2026-08-03-preset-description.md](2026-08-03-preset-description.md) | 검증 | 프리셋 `description`·`examples` 개정 3조건 측정 — **`examples` 만 채택(`E`), `description` 개정은 기각.** 네 티켓 만에 처음 채택한다. 두 필드가 들어가는 곳이 달라(`examples` 는 임베딩만, `description` 은 임베딩+판정 프롬프트) 갈라 쟀고, 갈라 재지 않았으면 `D` 의 해로움이 `DE` 안에 묻혔다. `E` 오분류 11.00→6.90행(p=0.0001)·정상 손실 -0.80(p=0.16)·교환비 0.20·**순이득 부호가 양극단 모두 양수**(네 티켓 중 유일). `D` 는 `fit 0건 Context` 8.20→10.00 으로 **악화**(범위 분리)하고 안 고친 22종이 정상 -2.50 를 치른다. 자기충족 방어 셋(라벨 무관 수정 원칙·안 고친 22종 대조·라벨 밖 홀드아웃 30건). T68(임베딩 비결정성)·T69(τ 격자가 rank 로 밀린 행을 안 셈) 발견·우회 (S15P11A705-228) | | I48 | [2026-08-03-dev-deploy-gap.md](2026-08-03-dev-deploy-gap.md) | 감사 | `dev` 병합이 배포에 닿지 않는 구간 — **`ai-image / publish` SKIPPED 는 고장이 아니라 설계다.** `ai-ci.yml` 안의 `image-publish` job 이고 `push` × `refs/heads/main` 두 조건이 모두 필요하다(별도 워크플로가 아니라 워크플로 목록에 안 보인다). 근거가 주석·계약 테스트·`infra` 어서션·`CONTRIBUTING` 넷이라 한 곳의 실수일 수 없다. `apps/dev/ai` 가 pin 하는 것은 **`ai` 의 `main` HEAD** 이고 `ai` 의 `dev` 브랜치는 배포 경로에 없다 — 끊긴 것은 파이프라인이 아니라 릴리스 PR 이다. `ai-image-update` 의 success 는 「갱신했다」가 아니라 **「확인했고 갱신할 것이 없었다」**(자동화는 08-03 00:37Z 에 되살아나 `infra#172` 로 한 번 동작했다). 새 봉인 값 **`preset-ab321360b0df`** 산출 — 방식은 파일 전체 SHA-256 앞 12자이며 문서에 적힌 64자와 전량 대조해 확정했다. **`bootstrap.version` 은 어떤 자동화도 갱신하지 않고 틀려도 아무 신호가 없다** (티켓 없음) | | I49 | [2026-08-03-gms-image-probe.md](2026-08-03-gms-image-probe.md) | 검증 | GMS 이미지 실사용 조건 두 축 — **축 A 생성은 지원한다**(`gpt-image-1`·`gemini-2.5-flash-image` 둘 다 1024×1024 PNG, 게이트웨이에 모델 allowlist 가 있어 `dall-e-3`·`imagen-3` 은 차단). **축 B 는 가설 C** — 토큰은 **치수**에 붙고 **바이트에는 안 붙는다**(512×512 고정에서 바이트 36배에 `prompt_tokens` 8,523 불변). `-227` 의 8,524 는 게이트웨이 가산이 아니라 `gpt-4o-mini` 의 타일 요금이다(텍스트 23 + 2,833 + 5,667×타일, 7개 조건 잔차 0). **그리고 더 급한 것이 나왔다 — 게이트웨이 요청 본문 상한이 96,024 B 통과 · 129,564 B 거부 사이에 있고, 넘으면 「Model not found in request」로 온다**(`-205` T62·`-225` 와 같은 원인). 실사용 대화 캡처(200 KB~2 MB)는 토큰을 쓰기 전에 이 벽에 먼저 막힌다 — `ai#98` 의 전제가 바뀐다. 정책 거절은 벤더마다 층이 달라 OpenAI 는 400, **Gemini 는 200 + `finishReason=PROHIBITED_CONTENT`** 다 (S15P11A705-253) | +| I51 | [2026-08-05-search-rank-baseline.md](2026-08-05-search-rank-baseline.md) | 구현 | 검색 **순위** 지표 baseline `tools/search_cut/rank_score.py` — 기존 지표가 전부 컷 기준이라 순위 변화를 못 센다(P48 1단계 이후는 순위를 바꾸는 변경이라 개선이 0으로 보인다). 기존 행렬만 읽어 Hit·Recall·MRR·nDCG@1/3/5 를 **컷 전/후 · 단어형/문장형 · 정답질의/무관질의**로 갈라 낸다. **재구성 일치 두 건** — 무관-문장형 침묵률 11/15 와 `스팟` 0.2438 이 기존 기록과 같다. **`-255` ① 판정이 이미 닫혀 있음을 확인**(`-266` 의 단어형 분기가 `그네` 를 회복시켰다). `신한`·`부캠` 은 `tau_word` 와 `r·top1` **양쪽 모두 아래**라 컷 조절로 닿지 않는다 (티켓 없음 · 런타임 변경 없음) | | I50 | [2026-08-03-preset-display-name-deploy-chain.md](2026-08-03-preset-display-name-deploy-chain.md) | 검증 | 표시명 개정의 배포 체인 여섯 단계와 소관 — **봉인 값은 판 표기이지 부트스트랩 재실행 조건이 아니다.** 낡은 봉인 값 위에서 새 표시명이 운영 화면까지 온 것이 곧 재실행의 증거다(`hook-delete-policy` 가 같은 이름 Job 을 지우고 다시 만들고, 적재는 멱등이다). 중앙이 「값이 같으면 재실행 안 된다」로 판정했고 독립 검증이 논거를 반증했으나 클러스터 관측 경로가 없어 「확인 불가」로 끝났던 것을 **배포가 닫았다.** 릴리스가 두 번 막힌다 — base 검사(`main` base 는 `release/*`·`hotfix/*` 만)와 strict 상태 검사(BEHIND 거부). 화면 확인이 유일한 검증이라 이르게 보면 「안 됐다」로 오판한다 (S15P11A705-292) | | I51 | [2026-08-05-short-query-boundary.md](2026-08-05-short-query-boundary.md) | 검증 | 짧은 질의의 층별 탈락과 단어형 경계 두 정의 — **`-266` 이후 남은 실패는 셋뿐이고 경계 정의로는 하나도 안 풀린다.** `신한`(③ τ_abs·6위)·`부캠`(③ τ_abs·8위)·`신한 부캠`(**④ r**·4위)이고 실서버 대조 22/22 일치로 층을 확정했다(전처리·폴백은 **존재하지 않는다** — `min_length=1` 뿐이고 0건이면 0건이 그대로 나간다). **「2자가 안 걸린다」는 부정확** — 2자 7건 중 5건은 걸리고 못 걸리는 둘은 컷 전 순위가 6·8위다. `-266` 이 「재지 않았다」고 적은 B(공백·짧다)·C(무공백·길다) 대역을 **본문 인접 어절쌍 전량**(쌍 176종 × spaced/joined × 소유자 3명 = 1,128행)으로 처음 쟀다 — 손실은 C 에 몰려 있고(16/122 → 어절 수 정의 3/122) **그런데 초점 집합 36행에서는 어느 정의에서도 누락이 0** 이라 두 표가 어긋난다(회복되는 것이 기능어 쌍이다). 새 관측 둘: **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385 · 172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 τ 탈락 — `-266` 이 「안전 방향」이라 부른 판단이 이 대역에서는 손해다). 계약이 「이것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하므로 **세 번째다** (S15P11A705-273) | @@ -87,6 +88,7 @@ | I27 | 임베딩 4조건 측정 하네스 `tools/emb_grid/` — 조건 정본 하나(`conditions.py`)를 셸에 `eval` 로 넘겨 값이 갈라지지 않게 하고, 조건이 환경과 어긋나면 **재기 전에 멈춘다**(profile·차원·프리셋 적재 여부·FastAPI 도달). `alter_dim.py` 는 로컬 DB 차원을 바꾸고 되돌린다 — Flyway 를 만들지 않는 것이 이 측정의 계약이다 | [4조건 측정](2026-07-31-embedding-grid.md), [tools/emb_grid/](../../tools/emb_grid/) | | I28 | GMS 호출·재선점 로그 계측 — 호출 1회당 벤더·모델·상태·결과 분류·지연, 60초 창 실패율 집계, 만료 `PROCESSING` 재선점. `_usage.py`(토큰)와 합치지 않은 근거와 `try_start` CTE 재작성. **httpx 가 요청 URL 을 INFO 로 흘리던 것을 함께 차단** | [GMS 호출 관측](2026-07-31-gms-call-observability.md), [failure-recovery §2.4](../spec/failure-recovery.md) | | I29 | 검색 결과 컷 하네스 `tools/search_cut/` — 질의를 한 번 임베딩해 굳히고 `τ_abs × r` 격자를 오프라인으로 훑는다(GMS 배치 1회). **정답이 없는 무관 질의 15건을 별도 축으로 둔다** — 검증 질의만 재면 `r` 이 무관 질의를 침묵시키지 못한다는 사실이 보이지 않는다. `verify_live.py` 가 실서버와 27/27 정확 일치를 확인(`-210` 과 달리 근사가 아니라 대조군 불필요) | [컷 측정](2026-07-31-search-cut.md), [tools/search_cut/](../../tools/search_cut/) | +| I51 | 검색 순위 지표 하네스 `tools/search_cut/rank_score.py` — 기존 행렬 셋만 읽어(**DB 0회·GMS 0회**) Hit@k·Recall@k·MRR·nDCG@k 를 낸다. **세 가지를 가른다** — 컷 전/후(컷 후만 보면 순위 개선이 가려진다), 단어형/문장형(대역이 겹치지 않아 합산 평균이 어느 쪽도 설명하지 못한다), 정답질의/무관질의(좋은 방향이 반대라 섞으면 상쇄된다). **MRR 만으로 판정하지 않는다** — 단어형 정답이 본문 문자열 포함으로 계산돼 복수라, 정답 3개가 `1·2·3위` 든 `1·9·14위` 든 MRR 이 같다. **재구성이 맞다는 증거 두 건이 독립적으로 나왔다**(무관-문장형 침묵 11/15 = `-213` 기록 · `스팟` 0.2438 = `config.py` 주석의 마진 0.0038). 비상 스위치를 단어형 분기보다 앞에 두는 **순서까지** 원본대로 옮겼다. 가드 4종은 수치를 내지 않고 실패한다(profile 불일치·필드 누락·정답질의에 정답 0건·무관질의에 정답 존재). 결정성은 두 번 실행 바이트 일치로, 입력 추적은 SHA-256 기록으로 확인. **판정 하나가 바뀌었다** — `-255` 의 ①(`그네` 는 컷이 잘랐다)은 `-266` 의 단어형 분기로 **이미 해소**됐고, `신한`·`부캠` 은 `tau_word`·`r·top1` **양쪽 모두 아래**라 한쪽만 풀어도 회복되지 않는다(`r` 은 top1 이 높을수록 세게 자르므로 무관한 1위가 강할수록 정답이 확실히 잘린다) | [순위 baseline](2026-08-05-search-rank-baseline.md), [P48](../proposals/P48-search-signal-expansion.md), [tools/search_cut/rank_score.py](../../tools/search_cut/rank_score.py) | | I30 | 후보 임계값 τ 측정 하네스 `tools/tau_grid/` — 42×27 유사도 행렬을 한 번 떠서 임의의 τ 를 **GMS 호출 없이 재구성**한다. 라벨 83행(`labels.yaml`)이 채점 기준이고 `unclear` 를 따로 둬 낙관·비관 양 끝을 함께 낸다 | [τ 재검증 리포트](2026-07-31-candidate-threshold.md), [tools/tau_grid/](../../tools/tau_grid/) | | I41 | 외부 API 재시도·오류 분류 정합화 `app/core/errors.py`(`classify_http_status` 단일화)·`app/client/retry.py`(`RetryPolicy`) — 두 클라이언트가 상태 코드를 정반대로 분류하던 구현 결함(429→Transient, LLM 400/401/403→Permanent)을 고쳤다. `SchemaViolationError` 로 구조화 출력 위반을 하위 타입 처리, `PermanentError → _fail()` 결선 추가 (S15P11A705-121) | [재시도·오류 분류 리포트](2026-07-30-retry-and-error-classification.md) | | I43 | 실사용자 데이터 E2E 검증 — 가공 14건 + 실사용자 기록 23건 = 37건 통합 경로. 검색 정확도 10/12(83.3%) · 탐색 피드 PASS · Keyword PASS, 시딩 42초 · 토큰 32,912(판정이 임베딩의 17배) (S15P11A705-174) | [실데이터 E2E 리포트](2026-07-30-real-data-e2e.md) | diff --git a/tools/search_cut/rank_score.py b/tools/search_cut/rank_score.py new file mode 100644 index 0000000..e7d3ddc --- /dev/null +++ b/tools/search_cut/rank_score.py @@ -0,0 +1,379 @@ +"""검색 **순위** 지표 baseline — P48 0단계. + +기존 하네스(`sweep.py` · `word_sweep.py`)의 지표는 전부 **컷 기준**이다 +(`eval_answered` 는 정답이 잘렸는지, `eval_control` 은 무관 질의가 침묵했는지). +컷은 순위를 바꾸지 않고 자르기만 하므로 그것으로 충분했다. + +P48 1단계 이후는 **순위를 바꾸는 변경**이다. 정답이 6위에서 2위로 올라와도 컷이 그대로면 +`kept` 값이 변하지 않아 **개선이 0으로 보인다.** 그래서 순위 지표를 따로 둔다. + +이 스크립트는 **DB 도 GMS 도 부르지 않는다.** 기존 행렬 셋만 읽는다 — 행마다 `rank` 가 +이미 들어 있으므로(`matrix.py` · `word_matrix.py` · `recall_probe.py`) 세기만 하면 된다. + + python tools/search_cut/rank_score.py # 표 출력 + python tools/search_cut/rank_score.py --json OUT # baseline 보존용 JSON + +## 무엇을 가르는가 + +**컷 전 / 컷 후를 나눈다.** 컷 후만 보면 순위 개선이 가려진다(위 문단). 컷 전만 보면 +사용자가 실제로 보는 것과 무관해진다. 둘 다 필요하다. + +**단어형 / 문장형을 나눈다.** 두 대역이 겹치지 않는다(`S15P11A705-266` — 문장형 정답 +하한과 단어형 정답 하한 사이에 간격이 있다). 합산 평균은 두 대역의 중간값을 만들어 +**어느 쪽도 설명하지 못한다.** P48 채택 조건도 합산 판정을 금지한다. + +**정답 있는 질의 / 무관 질의를 나눈다.** 무관 질의에서 좋은 것은 0건 반환이고, 정답 있는 +질의에서 좋은 것은 그 반대다. 한 지표에 섞으면 방향이 상쇄된다. + +## 왜 MRR 만으로 판정하지 않는가 + +단어형 격자는 정답을 손으로 짝짓지 않고 **본문 문자열 포함으로 계산**하므로 +(`word_matrix.py`) 한 질의의 정답이 여럿이다(`expect_count` ≥ 2 인 행이 있다). +MRR 은 **첫 정답만** 보므로 나머지가 어디에 있든 값이 같다. + + 정답 3개가 1·2·3위 MRR = 1.0 + 정답 3개가 1·9·14위 MRR = 1.0 ← 명백히 나쁜데 같은 값 + +그래서 `Recall@k` 와 `nDCG@k` 를 함께 낸다. 셋의 역할이 다르다. + + Hit@k 상위 k 안에 정답이 하나라도 있는가 — 사용자 체감에 가장 가깝다 + Recall@k 전체 정답 중 몇 개가 상위 k 에 들어왔나 — 복수 정답을 센다 + MRR 첫 정답이 얼마나 위인가 — 단일 정답에서 민감하다 + nDCG@k 정답들이 얼마나 위에 몰려 있나 — 순서까지 본다 + +## 가드 — 계산하지 못하는 것을 통과로 만들지 않는다 + +`check_docs_index.py` 와 같은 태도다. 다음이면 **수치를 내지 않고 실패한다.** + + profile 불일치 서로 다른 Profile 의 행렬을 한 표에 놓으면 비교가 성립하지 않는다 + 필수 필드 누락 `rank` · `sim` 이 없으면 순위를 셀 수 없다 + 정답 질의에 정답 0건 라벨이 어긋난 것이다. 분모에서 조용히 빠지면 지표가 부풀려진다 + 무관 질의에 정답 존재 그 행은 통제가 아니라 정답 있는 질의다(`word_matrix.py` 와 같은 가드) + +## 결정성 + +입력이 파일뿐이고 난수도 시각도 쓰지 않으므로 **같은 입력에 같은 출력**이다. `--json` 은 +입력 파일의 SHA-256 을 함께 적는다 — 두 번 돌려 나온 JSON 이 같은지로 결정성을, +해시로 어떤 행렬을 읽었는지를 확인한다. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SEARCH = ROOT / ".search" + +# 정본은 `app/core/config.py` 다. 하네스가 앱을 import 하지 않는 것은 기존 sweep 과 같다 +# (앱 import 는 설정·환경변수를 요구해 오프라인 성질을 깬다). 값이 갈리면 --tau 계열로 +# 덮어쓰고, 출력에 실제 사용값을 적어 어긋남이 드러나게 한다. +TAU_ABS = 0.30 # SEARCH_SIMILARITY_FLOOR +TAU_ABS_WORD = 0.24 # SEARCH_SIMILARITY_FLOOR_WORD +RATIO = 0.60 # SEARCH_TOP_RATIO +WORD_MAX_CHARS = 5 # SEARCH_WORD_QUERY_MAX_CHARS +SERVICE_LIMIT = 20 # 공용 계약 08 §6.1 의 size 기본값. word_sweep.py 와 같다 + +KS = (1, 3, 5) + +# `-255` 가 원인을 가른 네 질의. P48 채택 조건이 개별 보고를 요구한다. +CASES = ("신한", "부캠", "그네", "스팟") + + +# --------------------------------------------------------------------------- 컷 + +def is_word_query(query: str, max_chars: int = WORD_MAX_CHARS) -> bool: + """`app/service/search_service.py` 의 `_is_word_query` 와 같은 판정. + + 공백은 `str.isspace()` 로 본다 — U+0020 만 보면 전각 공백으로 띄운 2어절이 「공백 + 없음」으로 통과해 느슨한 하한을 탄다. 원본과 같은 이유로 두 조건을 함께 요구한다. + """ + q = query.strip() + return bool(q) and not any(c.isspace() for c in q) and len(q) <= max_chars + + +def cut( + results: list[dict], + query: str, + *, + tau: float = TAU_ABS, + tau_word: float = TAU_ABS_WORD, + ratio: float = RATIO, + limit: int = SERVICE_LIMIT, +) -> list[dict]: + """`_cut` 을 그대로 옮긴다. **비상 스위치가 단어형 분기보다 앞이다.** + + 원본의 순서를 지킨다 — 뒤에 두면 `SEARCH_SIMILARITY_FLOOR=0` · `SEARCH_TOP_RATIO=0` + 을 넣어도 단어형만 계속 잘린다. 순서가 곧 동작이라 여기서도 같아야 한다. + + `limit` 은 컷보다 앞이다(서비스는 Query 의 `LIMIT` 뒤에 컷을 건다). + """ + rows = results[:limit] + if not rows: + return rows + if tau <= 0 and ratio <= 0: + return rows + floor = tau_word if is_word_query(query) else tau + top = float(rows[0]["sim"]) + return [ + r for r in rows + if float(r["sim"]) >= floor and float(r["sim"]) >= ratio * top + ] + + +# ------------------------------------------------------------------------ 지표 + +def _relevant_ranks(rows: list[dict]) -> list[int]: + """정답 행의 1-기반 위치. 입력 순서(유사도 내림차순)를 그대로 쓴다. + + 행의 `rank` 필드가 아니라 **현재 목록에서의 위치**를 센다 — 컷 후에는 원래 `rank` 가 + 구멍 난 상태라(2·5·9위만 남는 식) 그것으로 지표를 내면 사용자가 보는 목록과 어긋난다. + """ + return [i for i, r in enumerate(rows, 1) if r.get("is_expected")] + + +def metrics_for(rows: list[dict], total_relevant: int) -> dict: + """한 질의의 순위 지표. `total_relevant` 는 **컷 전 기준 전체 정답 수**다. + + 분모를 컷 후로 잡으면 컷이 정답을 지울수록 Recall 이 올라가는 역전이 생긴다. + """ + hits = _relevant_ranks(rows) + out: dict = {"returned": len(rows)} + + out["mrr"] = 1.0 / hits[0] if hits else 0.0 + + for k in KS: + top = [h for h in hits if h <= k] + out[f"hit@{k}"] = 1.0 if top else 0.0 + out[f"recall@{k}"] = (len(top) / total_relevant) if total_relevant else 0.0 + + dcg = sum(1.0 / math.log2(h + 1) for h in top) + ideal_n = min(total_relevant, k) + idcg = sum(1.0 / math.log2(i + 1) for i in range(1, ideal_n + 1)) + out[f"ndcg@{k}"] = (dcg / idcg) if idcg else 0.0 + + return out + + +def aggregate(per_query: list[dict]) -> dict: + """질의 평균. 지표별 macro-average — 질의마다 정답 수가 달라 micro 는 긴 질의에 쏠린다.""" + if not per_query: + return {} + keys = [k for k in per_query[0] if k != "returned"] + agg = {k: sum(q[k] for q in per_query) / len(per_query) for k in keys} + agg["returned"] = sum(q["returned"] for q in per_query) / len(per_query) + agg["n"] = len(per_query) + return agg + + +def zero_rate(per_query: list[dict]) -> float: + """0건 반환 비율. 무관 질의에서는 높을수록 좋고, 정답 질의에서는 낮을수록 좋다.""" + if not per_query: + return 0.0 + return sum(1 for q in per_query if q["returned"] == 0) / len(per_query) + + +# ------------------------------------------------------------------- 적재·가드 + +class GuardError(Exception): + """계산을 진행하면 안 되는 상태. 수치를 내지 않고 종료한다.""" + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _load(path: Path) -> dict: + if not path.exists(): + raise GuardError(f"행렬이 없다: {path.relative_to(ROOT)}") + return json.loads(path.read_text(encoding="utf-8")) + + +def _check_rows(label: str, query: str, rows: list[dict]) -> None: + for r in rows: + if "sim" not in r or "rank" not in r: + raise GuardError(f"{label} `{query}`: 행에 sim·rank 가 없다 — 순위를 셀 수 없다") + + +def _segment( + label: str, entries: list[dict], *, answerable: bool +) -> list[tuple[str, list[dict], int]]: + """(질의, 행, 전체 정답 수) 목록으로 정규화하며 라벨 정합을 검사한다.""" + out = [] + for e in entries: + q = e["query"] + rows = e.get("results") or [] + _check_rows(label, q, rows) + n_rel = sum(1 for r in rows if r.get("is_expected")) + if answerable and n_rel == 0: + raise GuardError( + f"{label} `{q}`: 정답 질의인데 정답 행이 0건이다 — 라벨이 어긋났다" + ) + if not answerable and n_rel > 0: + raise GuardError( + f"{label} `{q}`: 무관 질의인데 정답 행이 있다 — 통제가 아니라 정답 있는 질의다" + ) + out.append((q, rows, n_rel)) + return out + + +def _probe_segment(data: dict) -> list[tuple[str, list[dict], int]]: + """`recall_probe.json` 은 `is_expected` 가 없다 — 상위 `expect`(장소명)로 표시한다.""" + out = [] + for e in data["queries"]: + q, want = e["query"], e.get("expect") + rows = [dict(r, is_expected=(r.get("name") == want)) for r in (e.get("results") or [])] + _check_rows("probe", q, rows) + out.append((q, rows, sum(1 for r in rows if r["is_expected"]))) + return out + + +# ------------------------------------------------------------------------ 실행 + +def score(seg: list[tuple[str, list[dict], int]], *, applied: bool, **cut_kw) -> dict: + per = [] + for q, rows, n_rel in seg: + shown = cut(rows, q, **cut_kw) if applied else rows[: cut_kw.get("limit", SERVICE_LIMIT)] + per.append(metrics_for(shown, n_rel)) + agg = aggregate(per) + agg["zero_rate"] = zero_rate(per) + return agg + + +def case_rows(seg: list[tuple[str, list[dict], int]], **cut_kw) -> list[dict]: + """사례 4건의 컷 전/후 순위. P48 채택 조건이 개별 보고를 요구한다.""" + out = [] + for q, rows, n_rel in seg: + if q not in CASES: + continue + pre = _relevant_ranks(rows) + post = _relevant_ranks(cut(rows, q, **cut_kw)) + out.append({ + "query": q, + "relevant": n_rel, + "rank_pre": pre[0] if pre else None, + "rank_post": post[0] if post else None, + "returned_post": len(cut(rows, q, **cut_kw)), + "top1_sim": round(float(rows[0]["sim"]), 4) if rows else None, + }) + return out + + +def _fmt(v) -> str: + return "—" if v is None else (f"{v:.4f}" if isinstance(v, float) else str(v)) + + +def table(title: str, blocks: list[tuple[str, dict]]) -> None: + print(f"\n{title}") + cols = ["n", "hit@1", "hit@3", "hit@5", "mrr", + "recall@1", "recall@3", "recall@5", + "ndcg@1", "ndcg@3", "ndcg@5", "zero_rate", "returned"] + print(" " + "세그먼트".ljust(22) + "".join(c.rjust(10) for c in cols)) + for name, m in blocks: + if not m: + print(" " + name.ljust(22) + " (0건)") + continue + cells = "".join( + (str(m[c]).rjust(10) if c == "n" else f"{m[c]:.4f}".rjust(10)) for c in cols + ) + print(" " + name.ljust(22) + cells) + + +def main() -> int: + ap = argparse.ArgumentParser(description="검색 순위 지표 baseline (P48 0단계)") + ap.add_argument("--json", help="baseline JSON 저장 경로") + ap.add_argument("--tau", type=float, default=TAU_ABS) + ap.add_argument("--tau-word", type=float, default=TAU_ABS_WORD) + ap.add_argument("--ratio", type=float, default=RATIO) + ap.add_argument("--limit", type=int, default=SERVICE_LIMIT) + args = ap.parse_args() + + paths = { + "matrix": SEARCH / "matrix.json", + "word_grid": SEARCH / "word_grid.json", + "recall_probe": SEARCH / "recall_probe.json", + } + + try: + data = {k: _load(p) for k, p in paths.items()} + + profiles = {k: d.get("profile") for k, d in data.items()} + if len(set(profiles.values())) != 1 or None in profiles.values(): + raise GuardError(f"Profile 이 어긋난다 — 한 표에 놓을 수 없다: {profiles}") + profile = next(iter(profiles.values())) + + segs = { + "문장형(정답)": _segment("matrix", data["matrix"]["queries"], answerable=True), + "단어형(정답)": _segment("word", data["word_grid"]["queries"], answerable=True), + "무관-문장형": _segment("matrix.offtopic", data["matrix"]["offtopic"], answerable=False), + "무관-단어형": _segment("word.offtopic", data["word_grid"]["offtopic"], answerable=False), + "타인소유-단어형": _segment("word.cross", data["word_grid"]["cross"], answerable=False), + "진단프로브": _probe_segment(data["recall_probe"]), + } + except GuardError as exc: + print(f"[가드] {exc}", file=sys.stderr) + return 1 + + cut_kw = dict(tau=args.tau, tau_word=args.tau_word, ratio=args.ratio, limit=args.limit) + + print("=" * 96) + print("검색 순위 지표 baseline — P48 0단계") + print("=" * 96) + print(f" Profile {profile}") + print(f" Record {data['word_grid']['record_count']}건 · 소유자 " + f"{len(data['word_grid']['owners'])}명") + print(f" 컷 tau_abs={args.tau} · tau_word={args.tau_word} · " + f"r={args.ratio} · limit={args.limit}") + print(" 호출 DB 0회 · GMS 0회") + + result: dict = { + "stage": "P48-0", + "profile": profile, + "cut": {"tau_abs": args.tau, "tau_abs_word": args.tau_word, + "ratio": args.ratio, "limit": args.limit}, + "inputs": {k: {"path": str(p.relative_to(ROOT)), "sha256": _sha256(p)} + for k, p in paths.items()}, + "segments": {}, + } + + for applied, title in ((False, "컷 적용 전 (순위만)"), (True, "컷 적용 후 (사용자가 보는 것)")): + blocks = [] + for name, seg in segs.items(): + m = score(seg, applied=applied, **cut_kw) + blocks.append((name, m)) + result["segments"].setdefault(name, {})["cut_after" if applied else "cut_before"] = m + table(title, blocks) + + cases = case_rows(segs["진단프로브"], **cut_kw) + result["cases"] = cases + print("\n사례별 (진단 프로브 · `-255` 가 원인을 가른 넷)") + print(" " + "질의".ljust(10) + "정답수".rjust(8) + "컷전순위".rjust(10) + + "컷후순위".rjust(10) + "컷후반환".rjust(10) + "top1_sim".rjust(12)) + for c in cases: + print(" " + c["query"].ljust(10) + + str(c["relevant"]).rjust(8) + + _fmt(c["rank_pre"]).rjust(10) + + _fmt(c["rank_post"]).rjust(10) + + str(c["returned_post"]).rjust(10) + + _fmt(c["top1_sim"]).rjust(12)) + + print("\n읽는 법") + print(" · 단어형과 문장형을 합산하지 않는다 — 두 대역이 겹치지 않는다(-266).") + print(" · 무관/타인소유 세그먼트는 zero_rate 가 높을수록 좋다(침묵). 나머지 지표는 의미 없다.") + print(" · 복수 정답이 있는 단어형은 MRR 만으로 보지 않는다 — recall@k · ndcg@k 를 함께 본다.") + print(" · Record 수가 작아 절대값을 일반화하지 않는다. **퇴행 탐지 기준**으로만 쓴다.") + + if args.json: + out = Path(args.json) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"\n저장: {out.relative_to(ROOT) if out.is_relative_to(ROOT) else out}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From aa1bd6f1901ef71dee51fea47d678b227f10ade4 Mon Sep 17 00:00:00 2001 From: SeMin Kim <83855438+tpals0409@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:38:19 +0900 Subject: [PATCH 02/34] =?UTF-8?q?feat:=20keyword=20=EC=8B=A0=ED=98=B8=20fu?= =?UTF-8?q?sion=20=EB=A1=9C=EC=A7=81=EA=B3=BC=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20sweep=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P48 1단계를 런타임에 넣기 전에 오프라인으로 재구성해 효과를 재기 위한 것이다. app/ 은 건드리지 않았고 응답 계약도 그대로다. fusion.py 순수 로직. DB·GMS·파일을 읽지 않고 인자만 받는다 keyword_matrix.py artifact 생성 (GMS 배치 1회 + DB 읽기) fusion_sweep.py 오프라인 비교 (GMS 0회 · DB 0회) word_matrix.py results 행에 context_id 추가 recall_probe.py 같음 — context_keyword 는 context_id 조인이다 fusion.py 가 순수한 덕분에 실데이터가 없어도 조인·NULL·visibility·상태·집계·결합식이 맞는지는 지금 검증된다. 픽스처 24종이 P48 규칙을 하나씩 고정한다. §1-a Record 집계는 max — 평균이 아님을 명시 검증 §1-b keyword_status 미완료는 신호만 제거. Context 는 후보로 유지 §1-c BLOCKED 는 코사인 1위여도 제외, PRIVATE_ONLY 는 사용 §1-d NULL confidence 를 0 으로 치환하지 않음. 세 정책 비교 §1-e IDF 는 Record 기준 · COMPLETED · 비BLOCKED 모집단 §2.1 합집합 후 limit. 벡터 상위 안에서 재정렬하지 않는다 §2.2 가중합은 컷 재측정, RRF 는 cosine gate + cutoff 두 관문 §2.3 similarity 는 cosine float 계약. fusion 은 정렬 전용 픽스처가 실데이터 전에 결함을 하나 잡았다. recall_probe.json 은 user_id 를 최상위에만 두는데 질의 행에서 읽고 있어 by_user 조인이 조용히 비었다 — 사례 4건이 전 방식에서 순위 불변이었고, 실데이터로 먼저 돌렸다면 "keyword 신호는 효과 없다"로 방식 자체를 기각했을 오판이다. user_id 가 없으면 계산하지 않고 멈추도록 고쳤다. 모집단 결함도 함께 제거했다. keyword_matrix 의 Context 질의에 embedding_status = 'COMPLETED' 가 빠져 있어 검색 후보가 아닌 Context 가 유령 후보로 올라왔고, 그것이 코사인 없는 행(sim=None)으로 나타났다. 검색 Query 와 모집단을 맞췄고, WHERE 계약을 테스트로 고정해 반대 방향 퇴행(keyword_status 를 WHERE 에 넣는 것)도 막는다. rrf_k 는 하드코딩돼 있어 바뀌어도 흔적이 남지 않았다. CLI 축으로 빼고 결과에 행마다 기록한다 — 스케일이 바뀌면 cutoff 를 다시 재야 하기 때문이다. rrf_cutoff 기본값 0 은 비활성이지 개념 부재가 아니며, cutoff=0 결과는 단독 채택 불가로 표시된다. fusion_sweep 의 --search-dir 은 픽스처로 배관을 검증하기 위한 것이다. 실측 전용인 .search/ 에 가짜 행렬이 들어가지 않는다. Co-Authored-By: Claude Opus 5 --- tests/test_search_fusion.py | 352 +++++++++++++++++++++++++ tools/search_cut/fusion.py | 369 ++++++++++++++++++++++++++ tools/search_cut/fusion_sweep.py | 408 +++++++++++++++++++++++++++++ tools/search_cut/keyword_matrix.py | 279 ++++++++++++++++++++ tools/search_cut/recall_probe.py | 2 + tools/search_cut/word_matrix.py | 4 + 6 files changed, 1414 insertions(+) create mode 100644 tests/test_search_fusion.py create mode 100644 tools/search_cut/fusion.py create mode 100644 tools/search_cut/fusion_sweep.py create mode 100644 tools/search_cut/keyword_matrix.py diff --git a/tests/test_search_fusion.py b/tests/test_search_fusion.py new file mode 100644 index 0000000..e2a28cd --- /dev/null +++ b/tests/test_search_fusion.py @@ -0,0 +1,352 @@ +"""Keyword fusion 순수 로직 검증 (P48 1단계). + +**DB 도 GMS 도 부르지 않는다.** `tools/search_cut/fusion.py` 는 인자만 받는 순수 함수라 +픽스처로 전부 검증된다 — 실데이터가 없어도 **조인·NULL·visibility·상태·집계·결합식**이 +맞는지는 지금 확정할 수 있다. + +여기서 쓰는 값은 **가짜다.** 검색이 좋아졌는지는 이 파일이 답하지 않는다(실측은 +`.search/` 의 행렬만 쓴다). 이 파일이 답하는 것은 **계산이 규칙대로인가** 하나다. +그 둘을 섞지 않는 것이 `word_matrix.py` 가 포트 가드를 둔 이유와 같다. + +`pytest` 로도 돌고, venv 가 없는 환경에서 직접 돌려도 된다. + + python tests/test_search_fusion.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "search_cut")) + +import fusion as F # noqa: E402 + +# ── 픽스처 ─────────────────────────────────────────────────────────────────── +# +# Preset 4종으로 세 갈래를 모두 덮는다. `BLOCKED` 가 실 시드에 없더라도 **낡은 데이터에 +# 남아 있을 가능성을 방어**하는 것이 P48 §1-c 의 요구다. +PRESETS = { + 1: F.Preset(id=1, version=1, visibility="PUBLIC"), + 2: F.Preset(id=2, version=1, visibility="PRIVATE_ONLY"), + 3: F.Preset(id=3, version=1, visibility="BLOCKED"), + 4: F.Preset(id=4, version=1, visibility="PUBLIC"), +} + +QUERY_COS = {1: 0.42, 2: 0.31, 3: 0.55, 4: 0.12} + + +def ctx(cid, rid, kws, status="COMPLETED"): + return F.ContextKeywords( + context_id=cid, record_id=rid, keyword_status=status, keywords=tuple(kws) + ) + + +# ── 1. query → Preset 후보 ─────────────────────────────────────────────────── + +def test_blocked_preset_is_never_a_candidate(): + """`BLOCKED` 는 코사인이 1위여도 후보가 아니다 (keyword-preset.md §2).""" + cand = F.preset_candidates(QUERY_COS, PRESETS, top_k=10, floor=0.0) + ids = [pid for pid, _ in cand] + assert 3 not in ids, "BLOCKED(3)이 후보에 들어왔다 — 코사인 0.55로 1위인데도 빠져야 한다" + assert ids[0] == 1, "BLOCKED를 뺀 뒤 1위는 preset 1(0.42)이어야 한다" + + +def test_private_only_is_usable(): + """`PRIVATE_ONLY` 는 개인 검색 신호로 쓴다 (P48 §1-c).""" + cand = F.preset_candidates(QUERY_COS, PRESETS, top_k=10, floor=0.0) + assert 2 in [pid for pid, _ in cand] + + +def test_floor_applies_before_top_k(): + """하한이 먼저다 — 반대면 후보가 모자랄 때 하한 아래가 채워진다.""" + cand = F.preset_candidates(QUERY_COS, PRESETS, top_k=10, floor=0.35) + assert [pid for pid, _ in cand] == [1], "0.35 이상은 preset 1 뿐이다" + + +def test_candidates_are_deterministic_on_ties(): + tied = {1: 0.5, 2: 0.5, 4: 0.5} + a = F.preset_candidates(tied, PRESETS, top_k=2, floor=0.0) + b = F.preset_candidates(tied, PRESETS, top_k=2, floor=0.0) + assert a == b == [(1, 0.5), (2, 0.5)], "동점은 preset_id 오름차순으로 깨야 한다" + + +# ── 2. keyword_status — Context 제외와 신호 제외의 구분 ────────────────────── + +def test_incomplete_status_removes_signal_only(): + """`COMPLETED` 가 아니면 신호가 0이다. **Context 를 후보에서 빼지는 않는다**(§1-b).""" + cand = F.preset_candidates(QUERY_COS, PRESETS, top_k=10, floor=0.0) + for status in ("PENDING", "PROCESSING", "FAILED", "CANCELLED"): + c = ctx(10, 100, [(1, 0.9)], status=status) + assert F.context_signal(c, cand, PRESETS, method=F.BINARY) == 0.0, status + + # 그 Record 는 벡터 후보로 여전히 살아 있어야 한다 — 사라지면 명백한 퇴행이다. + rows = [{"record_id": 100, "sim": 0.31, "is_expected": True}] + fused = F.fuse(rows, {}, method=F.CONFIDENCE, weight=0.2, limit=20) + assert [r["record_id"] for r in fused] == [100] + assert fused[0]["sim"] == 0.31, "벡터 점수가 유지돼야 한다" + + +# ── 3. confidence = NULL (P48 §1-d) ────────────────────────────────────────── + +def test_null_confidence_does_not_kill_binary_match(): + """binary 는 confidence 를 쓰지 않으므로 NULL 이 match 를 없애지 않는다.""" + cand = F.preset_candidates(QUERY_COS, PRESETS, top_k=10, floor=0.0) + c = ctx(10, 100, [(1, None)]) + assert F.context_signal(c, cand, PRESETS, method=F.BINARY) == 1.0 + + +def test_null_policies_differ_and_none_is_zero(): + """세 정책이 서로 다른 값을 내고, **어느 것도 0 이 아니다**. + + 0 이 나오면 「판정된 적 없음」과 구분이 사라진다 — P48 이 금지한 처리다. + """ + cand = F.preset_candidates(QUERY_COS, PRESETS, top_k=10, floor=0.0) + c = ctx(10, 100, [(1, None)]) + vals = { + p: F.context_signal( + c, cand, PRESETS, method=F.CONFIDENCE, null_policy=p, null_fill=0.5 + ) + for p in (F.NULL_INCLUDE, F.NULL_FILL) + } + assert vals[F.NULL_INCLUDE] == 0.42, "include 는 confidence 를 1.0 으로 본다" + assert vals[F.NULL_FILL] == 0.42 * 0.5 + assert all(v > 0 for v in vals.values()), "NULL 이 0 으로 뭉개지면 안 된다" + + +def test_null_exclude_skips_that_keyword_only(): + """`exclude` 는 그 keyword 만 빼고 다른 keyword 는 살린다.""" + cand = F.preset_candidates(QUERY_COS, PRESETS, top_k=10, floor=0.0) + c = ctx(10, 100, [(1, None), (2, 0.8)]) + got = F.context_signal( + c, cand, PRESETS, method=F.CONFIDENCE, null_policy=F.NULL_EXCLUDE + ) + assert got == 0.31 * 0.8, "preset 1(NULL)은 빠지고 preset 2 만 남아야 한다" + + only_null = ctx(11, 101, [(1, None)]) + assert F.context_signal( + only_null, cand, PRESETS, method=F.CONFIDENCE, null_policy=F.NULL_EXCLUDE + ) == 0.0 + + +# ── 4. Record 집계 = max (P48 §1-a) ────────────────────────────────────────── + +def test_record_signal_is_max_over_contexts(): + """평균·합계가 아니라 최댓값 — 하나만 강하게 맞아도 그 Record 는 찾는 대상이다.""" + cand = F.preset_candidates(QUERY_COS, PRESETS, top_k=10, floor=0.0) + contexts = [ + ctx(1, 500, [(1, 0.2)]), # 0.42 * 0.2 = 0.084 + ctx(2, 500, [(1, 0.9)]), # 0.42 * 0.9 = 0.378 ← 최댓값 + ctx(3, 500, []), + ] + sig = F.record_signals(contexts, cand, PRESETS, method=F.CONFIDENCE) + assert abs(sig[500] - 0.378) < 1e-9 + mean = (0.084 + 0.378 + 0) / 3 + assert abs(sig[500] - mean) > 1e-6, "평균으로 계산하면 안 된다" + + +# ── 5. 합집합 → 정렬 → limit (P48 §2.1) ────────────────────────────────────── + +def test_keyword_signal_without_cosine_is_an_error(): + """코사인 없는 후보를 만들지 않는다 — `similarity` 는 cosine float 계약이다(P48 §2.3). + + 이런 행이 나오는 것은 두 모집단이 어긋났다는 뜻이고, 원인은 대개 `keyword_matrix` 가 + `embedding_status='COMPLETED'` 를 안 건 것이다. **지어내지 않고 실패시킨다.** + """ + rows = [{"record_id": 1, "sim": 0.40, "is_expected": False}] + try: + F.fuse(rows, {99: 1.0}, method=F.CONFIDENCE, weight=0.5, limit=20) + raise AssertionError("코사인 없는 keyword 후보가 통과했다") + except F.FusionError as exc: + assert "embedding_status" in str(exc), "원인을 짚는 메시지여야 한다" + + +def test_no_row_ever_carries_none_sim(): + """어떤 경로로도 `sim=None` 행이 나오지 않는다.""" + rows = [{"record_id": i, "sim": 0.5 - i * 0.01, "is_expected": False} for i in range(1, 4)] + for method in (F.BINARY, F.CONFIDENCE, F.IDF, F.RRF): + fused = F.fuse(rows, {2: 1.0}, method=method, weight=0.3, limit=20) + assert all(isinstance(r["sim"], float) for r in fused), method + + +def test_limit_applies_after_union_and_sort(): + """벡터 하위였던 Record 가 신호로 1위에 오고, `limit` 은 그 뒤에 걸린다.""" + rows = [{"record_id": i, "sim": 0.5 - i * 0.01, "is_expected": False} for i in range(1, 6)] + fused = F.fuse(rows, {5: 1.0}, method=F.CONFIDENCE, weight=10.0, limit=3) + assert len(fused) == 3 + assert fused[0]["record_id"] == 5, "가중치가 크면 신호 있는 Record 가 1위여야 한다" + assert [r["rank"] for r in fused] == [1, 2, 3] + + +def test_fusion_score_never_overwrites_sim(): + """응답의 `similarity` 는 기존 코사인 의미를 유지한다(P48 §2.3).""" + rows = [{"record_id": 1, "sim": 0.31, "is_expected": True}] + fused = F.fuse(rows, {1: 1.0}, method=F.CONFIDENCE, weight=0.5, limit=20) + assert fused[0]["sim"] == 0.31 + assert fused[0]["fusion"] != fused[0]["sim"], "fusion 은 별도 필드여야 한다" + + +# ── 6. 컷은 방식마다 다르다 (P48 §2.2) ─────────────────────────────────────── + +def test_rrf_does_not_apply_cosine_tau_to_fusion_score(): + """RRF 점수에 코사인 `tau=0.30` 을 걸면 전부 잘린다 — 그래서 걸지 않는다.""" + rows = [{"record_id": 1, "sim": 0.45, "is_expected": True}] + fused = F.fuse(rows, {1: 1.0}, method=F.RRF, limit=20) + assert fused[0]["fusion"] < 0.30, "RRF 점수는 코사인 스케일보다 훨씬 작다(전제 확인)" + + kept = F.apply_cut(fused, method=F.RRF, tau=0.30, ratio=0.60) + assert len(kept) == 1, "RRF 인데 코사인 tau 로 잘렸다" + + +def test_weighted_sum_cut_uses_fusion_score(): + """가중합은 fusion 점수에 컷을 건다 — 그래서 컷 재측정이 완료 조건이다.""" + rows = [ + {"record_id": 1, "sim": 0.50, "is_expected": True}, + {"record_id": 2, "sim": 0.20, "is_expected": False}, + ] + fused = F.fuse(rows, {}, method=F.CONFIDENCE, weight=0.0, limit=20) + kept = F.apply_cut(fused, method=F.CONFIDENCE, tau=0.30, ratio=0.60) + assert [r["record_id"] for r in kept] == [1] + + +def test_rrf_applies_cosine_eligibility_gate(): + """RRF 라도 **무관한 Record 는 코사인 관문에서 걸러야 한다** — 자동 통과가 없다.""" + rows = [ + {"record_id": 1, "sim": 0.45, "is_expected": True}, + {"record_id": 2, "sim": 0.12, "is_expected": False}, # 절대 하한 아래 + ] + fused = F.fuse(rows, {2: 1.0}, method=F.RRF, limit=20) + kept = F.apply_cut(fused, method=F.RRF, tau=0.30, ratio=0.60) + assert [r["record_id"] for r in kept] == [1], \ + "코사인 0.12 인 Record 가 keyword 신호만으로 통과했다" + + +def test_cutoff_zero_does_not_disable_cosine_gate(): + """`rrf_cutoff=0` 은 **비활성일 뿐 cosine eligibility 를 없애지 않는다**. + + 「cutoff 를 껐으니 아무거나 통과」가 되면 무관 질의 침묵이 무너진다. + """ + rows = [ + {"record_id": 1, "sim": 0.45, "is_expected": True}, + {"record_id": 2, "sim": 0.10, "is_expected": False}, + ] + fused = F.fuse(rows, {2: 1.0}, method=F.RRF, limit=20) + kept = F.apply_cut(fused, method=F.RRF, tau=0.30, ratio=0.60, rrf_cutoff=0.0) + assert [r["record_id"] for r in kept] == [1], \ + "cutoff=0 이 코사인 관문까지 끈다 — 비활성과 면제는 다르다" + + +def test_rrf_k_changes_score_scale(): + """`rrf_k` 가 바뀌면 점수 스케일이 바뀐다 — 그래서 cutoff 를 다시 재야 한다. + + 이 사실이 코드로 고정돼 있지 않으면 `rrf_k` 를 바꾼 뒤 낡은 cutoff 를 그대로 쓰게 된다. + """ + rows = [{"record_id": 1, "sim": 0.45, "is_expected": True}] + a = F.fuse(rows, {1: 1.0}, method=F.RRF, limit=20, rrf_k=60.0)[0]["fusion"] + b = F.fuse(rows, {1: 1.0}, method=F.RRF, limit=20, rrf_k=10.0)[0]["fusion"] + assert a != b, "rrf_k 가 점수에 영향을 주지 않는다 — 전제가 틀렸다" + assert b > a, "k 가 작을수록 점수가 커야 한다(1/(k+rank))" + + +def test_rrf_cutoff_is_a_separate_gate(): + """`rrf_cutoff` 는 코사인 관문과 별개다 — 어느 신호로도 위로 못 온 행을 막는다.""" + rows = [{"record_id": i, "sim": 0.50, "is_expected": False} for i in range(1, 4)] + fused = F.fuse(rows, {}, method=F.RRF, limit=20) + assert len(F.apply_cut(fused, method=F.RRF, tau=0.30, ratio=0.60, rrf_cutoff=0.0)) == 3 + high = max(r["fusion"] for r in fused) + kept = F.apply_cut(fused, method=F.RRF, tau=0.30, ratio=0.60, rrf_cutoff=high) + assert len(kept) == 1, "cutoff 가 fusion 점수에 걸려야 한다" + + +# ── 7. IDF (참고용) ────────────────────────────────────────────────────────── + +def test_idf_gives_rare_keyword_more_weight(): + contexts = [ctx(i, i, [(1, 0.5)]) for i in range(1, 5)] + [ctx(9, 9, [(4, 0.5)])] + idf = F.idf_weights(contexts, PRESETS) + assert idf[4] > idf[1], "희소한 keyword 의 IDF 가 커야 한다" + + +def test_idf_counts_records_not_contexts(): + """문서 단위는 **Record** 다 — Context 로 세면 Context 가 많은 Record 가 df 를 부풀린다. + + 아래에서 keyword 1 은 Record 2개에, keyword 4 도 Record 2개에 있다. Context 로 세면 + 1 이 4건·4 가 2건이라 IDF 가 갈리지만, Record 로 세면 같아야 한다. + """ + contexts = [ + ctx(1, 100, [(1, 0.5)]), ctx(2, 100, [(1, 0.5)]), ctx(3, 100, [(1, 0.5)]), + ctx(4, 200, [(1, 0.5)]), + ctx(5, 300, [(4, 0.5)]), ctx(6, 400, [(4, 0.5)]), + ] + idf = F.idf_weights(contexts, PRESETS) + assert abs(idf[1] - idf[4]) < 1e-9, \ + f"Record 기준이면 같아야 한다 (1={idf[1]}, 4={idf[4]}) — Context 로 세고 있다" + + +def test_idf_excludes_incomplete_status_and_blocked(): + """모집단이 신호와 같아야 한다 — `COMPLETED` 만, `BLOCKED` 제외(P48 §1-b·§1-c).""" + contexts = [ + ctx(1, 100, [(1, 0.5), (3, 0.5)]), # 3 은 BLOCKED + ctx(2, 200, [(1, 0.5)], status="PROCESSING"), # 신호 없음 + ctx(3, 300, [(4, 0.5)]), + ] + idf = F.idf_weights(contexts, PRESETS) + assert 3 not in idf, "BLOCKED preset 이 IDF 에 들어갔다" + # 분모 N 은 COMPLETED Record 2개(100·300). keyword 1 은 그중 1개에만 있다. + import math + assert abs(idf[1] - math.log(2 / 1)) < 1e-9, \ + "PROCESSING Context 의 Record 가 분모·분자에 섞였다" + + +# ── 8. 가드 ────────────────────────────────────────────────────────────────── + +def test_keyword_matrix_population_matches_search_query(): + """`keyword_matrix` 의 Context 모집단이 검색 Query 와 같아야 한다. + + 두 상태의 역할이 다르다 — 이것이 「코사인 없는 후보」 결함의 뿌리였으므로 회귀를 막는다. + + embedding_status = COMPLETED **검색 후보 자체의 조건.** WHERE 에 있어야 한다 + keyword_status **신호의 조건일 뿐.** WHERE 에 있으면 퇴행이다(§1-b) + + 소스를 읽어 검사한다 — `keyword_matrix` 는 `app.*` 를 import 하므로 의존성 없이 + 불러올 수 없고, 이 검사에 필요한 것은 SQL 문자열뿐이다. + """ + src = (Path(__file__).resolve().parents[1] + / "tools" / "search_cut" / "keyword_matrix.py").read_text(encoding="utf-8") + body = src.split("_CONTEXTS = ", 1)[1].split('"""', 2)[1] + assert "embedding_status = 'COMPLETED'" in body, \ + "검색 후보 조건이 빠졌다 — 벡터 행렬에 없는 Record 가 신호로만 올라온다" + where = body.split("WHERE", 1)[1] + assert "keyword_status" not in where, \ + "keyword_status 가 WHERE 에 들어갔다 — Context 제외는 퇴행이다(§1-b)" + assert "s.keyword_status" in body.split("WHERE", 1)[0], \ + "keyword_status 는 신호 판단용으로 컬럼에 실려 있어야 한다" + + +def test_unknown_method_and_policy_raise(): + cand = F.preset_candidates(QUERY_COS, PRESETS, top_k=10, floor=0.0) + try: + F.fuse([], {}, method="nope", limit=20) + raise AssertionError("알 수 없는 방식이 통과했다") + except F.FusionError: + pass + try: + F.context_signal( + ctx(1, 1, [(1, None)]), cand, PRESETS, + method=F.CONFIDENCE, null_policy="nope", + ) + raise AssertionError("알 수 없는 NULL 정책이 통과했다") + except F.FusionError: + pass + + +if __name__ == "__main__": + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = 0 + for fn in fns: + try: + fn() + print(f" PASS {fn.__name__}") + except AssertionError as exc: + failed += 1 + print(f" FAIL {fn.__name__}\n {exc}") + print(f"\n{len(fns) - failed}/{len(fns)} 통과") + raise SystemExit(1 if failed else 0) diff --git a/tools/search_cut/fusion.py b/tools/search_cut/fusion.py new file mode 100644 index 0000000..89489af --- /dev/null +++ b/tools/search_cut/fusion.py @@ -0,0 +1,369 @@ +"""Keyword 신호 fusion — 순수 로직 (P48 1단계). + +**이 모듈은 DB 도 GMS 도 파일도 읽지 않는다.** 입력은 전부 인자로 받는다. `fusion_sweep.py` +가 artifact 를 읽어 여기에 넘기고, `tests/test_search_fusion.py` 가 픽스처로 같은 함수를 +부른다 — 실데이터가 없어도 **조인·NULL·visibility·집계·결합식이 맞는지는 지금 검증된다.** + +계산 규칙의 정본은 [P48](../../docs/proposals/P48-search-signal-expansion.md) §1·§2 다. +여기서는 그 규칙을 코드로 옮기고, 어긋나기 쉬운 곳에 이유를 적는다. + +## 옮긴 규칙 + + §2.1 합집합이 먼저다 벡터 상위 limit 안에서 재정렬하지 않는다. + 벡터 후보 ∪ keyword 후보 → 정렬 → limit + §1-a Record 집계는 max 벡터가 DISTINCT ON 으로 최댓값을 쓰는 것과 같은 규칙. + 두 신호가 서로 다른 Context 에서 최댓값을 받아도 된다 + §1-b Context 제외 ≠ 신호 제외 keyword_status != COMPLETED 는 **신호만** 없앤다. + 벡터 점수는 유지된다. 제외하면 명백한 퇴행이다 + §1-c BLOCKED 만 뺀다 PUBLIC · PRIVATE_ONLY 는 쓴다(keyword-preset.md §2·§6) + §1-d NULL 은 0 이 아니다 0 으로 치환하면 「판정된 적 없음」과 구분이 사라진다 + +## 이 모듈이 정하지 않는 것 + +`top_k` · `floor` · `weight` · NULL 정책의 **값**은 정하지 않는다. sweep 의 조절 축이므로 +전부 인자다. 기본값은 실험의 출발점일 뿐 채택값이 아니다. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +# ── NULL confidence 정책 (P48 §1-d) ────────────────────────────────────────── +# +# **0 으로 치환하는 정책은 없다.** 그것이 이 세 갈래를 두는 이유다. +NULL_INCLUDE = "include" # 판정은 됐으므로 확신도를 최대(1.0)로 본다 +NULL_EXCLUDE = "exclude" # 그 keyword 를 confidence 계산에서 뺀다 (match 자체는 살아 있다) +NULL_FILL = "fill" # 고정 대체값을 쓴다 (null_fill 인자) + +NULL_POLICIES = (NULL_INCLUDE, NULL_EXCLUDE, NULL_FILL) + +# ── fusion 방식 (P48 §1-e) ─────────────────────────────────────────────────── +BINARY = "binary" # keyword 가 맞으면 고정 보너스. confidence 를 쓰지 않는다 +CONFIDENCE = "confidence" # query-preset cosine × context confidence +IDF = "idf" # confidence 에 IDF 감쇠. **참고용** — Record 42건에서 df 가 불안정하다 +RRF = "rrf" # 순위 기반. 점수 스케일이 무관하다 + +METHODS = (BINARY, CONFIDENCE, IDF, RRF) + +BLOCKED = "BLOCKED" + + +class FusionError(Exception): + """계산을 진행하면 안 되는 상태. 값을 지어내지 않고 멈춘다.""" + + +@dataclass(frozen=True) +class Preset: + id: int + version: int + visibility: str + + @property + def usable(self) -> bool: + """`BLOCKED` 만 뺀다 — `PUBLIC` · `PRIVATE_ONLY` 는 쓴다(P48 §1-c). + + 개인 검색은 본인 Context 만 대상이고 Keyword 를 응답에 노출하지 않으므로 + `PRIVATE_ONLY` 는 공개 범위 판단과 충돌하지 않는다(keyword-preset.md §6). + """ + return self.visibility != BLOCKED + + +@dataclass(frozen=True) +class ContextKeywords: + context_id: int + record_id: int + keyword_status: str + # (keyword_id, confidence|None). **NULL 을 그대로 보존한다.** + keywords: tuple[tuple[int, float | None], ...] = field(default=()) + + @property + def signal_usable(self) -> bool: + """`COMPLETED` 일 때만 keyword 신호를 쓴다. + + **Context 를 후보에서 빼는 것이 아니다**(P48 §1-b). 재판정 중이거나 무효화된 + 판정(`PROCESSING`·`CANCELLED`·`FAILED`)이 신호로 살아나면 안 될 뿐이고, + 그 Context 의 벡터 점수는 그대로 남는다. + """ + return self.keyword_status == "COMPLETED" + + +# ── 1. query → Preset 후보 ─────────────────────────────────────────────────── + +def preset_candidates( + query_cos: dict[int, float], + presets: dict[int, Preset], + *, + top_k: int, + floor: float, +) -> list[tuple[int, float]]: + """질의-Preset 코사인에서 후보를 고른다. **추가 모델 호출이 없다**(P48 1단계). + + `floor` 를 top_k 보다 먼저 건다 — 반대로 하면 후보가 부족할 때 하한 아래가 채워진다. + 동점은 `preset_id` 오름차순으로 깨어 결과를 결정적으로 만든다. + """ + if top_k <= 0: + return [] + usable = [ + (pid, cos) for pid, cos in query_cos.items() + if pid in presets and presets[pid].usable and cos >= floor + ] + usable.sort(key=lambda x: (-x[1], x[0])) + return usable[:top_k] + + +# ── 2. Context 단위 keyword 신호 ───────────────────────────────────────────── + +def _confidence_of( + raw: float | None, *, null_policy: str, null_fill: float +) -> float | None: + """NULL 정책을 적용한 확신도. `None` 이면 그 keyword 를 계산에서 뺀다는 뜻이다.""" + if raw is not None: + return raw + if null_policy == NULL_INCLUDE: + return 1.0 + if null_policy == NULL_FILL: + return null_fill + if null_policy == NULL_EXCLUDE: + return None + raise FusionError(f"알 수 없는 NULL 정책: {null_policy}") + + +def context_signal( + ctx: ContextKeywords, + candidates: list[tuple[int, float]], + presets: dict[int, Preset], + *, + method: str, + null_policy: str = NULL_INCLUDE, + null_fill: float = 0.5, + idf: dict[int, float] | None = None, +) -> float: + """Context 하나의 keyword 신호. 0.0 이면 신호 없음. + + `binary` 는 `confidence` 를 **쓰지 않는다**(P48 §1-d) — 그래서 NULL 정책과 무관하게 + match 만으로 값이 정해진다. NULL 이 match 를 없애지 않는다는 규칙이 여기서 보장된다. + """ + if not ctx.signal_usable or not candidates: + return 0.0 + + have = {kid: conf for kid, conf in ctx.keywords + if kid in presets and presets[kid].usable} + if not have: + return 0.0 + + hits = [(pid, qcos) for pid, qcos in candidates if pid in have] + if not hits: + return 0.0 + + if method == BINARY: + return 1.0 + + if method in (CONFIDENCE, IDF): + scores = [] + for pid, qcos in hits: + conf = _confidence_of(have[pid], null_policy=null_policy, null_fill=null_fill) + if conf is None: # NULL_EXCLUDE — 이 keyword 는 세지 않는다 + continue + s = qcos * conf + if method == IDF: + s *= (idf or {}).get(pid, 1.0) + scores.append(s) + return max(scores) if scores else 0.0 + + if method == RRF: + # RRF 는 점수가 아니라 순위를 쓴다. 여기서는 「가장 잘 맞은 후보의 순위」를 돌려주고 + # 실제 RRF 합산은 fuse() 가 한다 — 순위는 질의 단위 정렬에서만 정의되기 때문이다. + best = min(i for i, (pid, _) in enumerate(candidates, 1) if pid in have) + return 1.0 / best + + raise FusionError(f"알 수 없는 fusion 방식: {method}") + + +# ── 3. Record 집계 — max (P48 §1-a) ────────────────────────────────────────── + +def record_signals( + contexts: list[ContextKeywords], + candidates: list[tuple[int, float]], + presets: dict[int, Preset], + **kw, +) -> dict[int, float]: + """Record 별 keyword 신호 = 소속 Context 중 **최댓값**. + + 평균·합계를 쓰지 않는다 — 벡터가 `DISTINCT ON` 으로 최댓값을 고르는 것과 같은 규칙이다 + (personal-search.md §5: Context 는 서로 독립적인 저장 이유이므로 하나만 강하게 일치해도 + 그 Record 는 찾는 대상이다). + """ + out: dict[int, float] = {} + for ctx in contexts: + s = context_signal(ctx, candidates, presets, **kw) + if s > 0: + out[ctx.record_id] = max(out.get(ctx.record_id, 0.0), s) + return out + + +# ── 4. 합집합 → 정렬 → limit (P48 §2.1) ────────────────────────────────────── + +def _rrf(rank: int, k: float) -> float: + return 1.0 / (k + rank) + + +def fuse( + vector_rows: list[dict], + keyword_signal: dict[int, float], + *, + method: str, + weight: float = 0.0, + limit: int = 20, + rrf_k: float = 60.0, +) -> list[dict]: + """벡터 후보와 keyword 후보의 **합집합**을 만든 뒤 정렬하고 마지막에 `limit`. + + **벡터 상위 `limit` 안에서만 재정렬하지 않는다**(P48 §2.1). 목적이 벡터 순위 밖의 + 정답을 끌어올리는 것인데 재정렬만 하면 그 정답이 후보에 없어 구조적으로 불가능하다. + + `vector_rows` 는 유사도 내림차순이어야 한다(행렬이 그렇게 저장한다). + + 반환 행은 `sim`(코사인 원값)을 **그대로 보존한다** — 응답의 `similarity` 는 기존 코사인 + 의미를 유지해야 하고, `fusion` 점수는 정렬에만 쓰며 API 에 노출하지 않는다(P48 §2.3). + + **모든 후보에 실제 코사인이 있어야 한다.** `similarity` 는 cosine float 계약이므로 + `None` 을 허용하지 않는다. keyword 신호가 있는데 코사인이 없는 Record 는 **지어내지 + 않고 실패시킨다** — 그런 행이 나오는 것은 두 모집단이 어긋났다는 뜻이고, 원인은 대개 + `keyword_matrix` 가 `embedding_status = 'COMPLETED'` 를 걸지 않은 것이다. 검색 Query 는 + 그 조건을 걸므로 미완료 Context 는 **검색 후보 자체가 아니다**(신호만 빠지는 + `keyword_status` 와 다르다). + + 오프라인에서 `vector_rows` 는 소유자 Record 전량이므로(행렬이 `NO_LIMIT` 으로 뜬다) + 합집합은 벡터 후보 집합과 같아진다. 런타임에서 `LIMIT` 뒤에 합치려면 keyword 후보의 + 코사인을 **조회해서 채운 뒤** 이 함수에 넘겨야 한다 — 그것이 §2.1 이 요구하는 순서다. + """ + if method not in METHODS: + raise FusionError(f"알 수 없는 fusion 방식: {method}") + + by_record = {r["record_id"]: r for r in vector_rows} + vec_rank = {r["record_id"]: i for i, r in enumerate(vector_rows, 1)} + + orphan = sorted(rid for rid, s in keyword_signal.items() + if s > 0 and rid not in by_record) + if orphan: + raise FusionError( + f"keyword 신호가 있는데 코사인이 없는 Record: {orphan}\n" + " `similarity` 는 cosine float 계약이라 None 을 넣을 수 없다(P48 §2.3).\n" + " keyword_matrix 의 Context 모집단에 embedding_status='COMPLETED' 가 " + "걸렸는지 확인한다 — 미완료 Context 는 검색 후보 자체가 아니다." + ) + + union = list(by_record.keys()) + + kw_ranked = sorted(keyword_signal.items(), key=lambda x: (-x[1], x[0])) + kw_rank = {rid: i for i, (rid, _) in enumerate(kw_ranked, 1)} + + out = [] + for rid in union: + base = by_record[rid] + sim = float(base["sim"]) + ksig = keyword_signal.get(rid, 0.0) + + if method == RRF: + score = 0.0 + if rid in vec_rank: + score += _rrf(vec_rank[rid], rrf_k) + if rid in kw_rank and ksig > 0: + score += _rrf(kw_rank[rid], rrf_k) + else: + # 가중합. 코사인 스케일을 유지하므로 컷을 **재측정**해야 한다(P48 §2.2). + score = (sim if sim is not None else 0.0) + weight * ksig + + row = dict(base) + row["fusion"] = score + row["keyword_signal"] = ksig + out.append(row) + + # 동점은 원래 벡터 순위 → record_id 로 깨어 결정적으로 만든다. + out.sort(key=lambda r: (-r["fusion"], vec_rank.get(r["record_id"], 10**9), r["record_id"])) + for i, r in enumerate(out[:limit], 1): + r["rank"] = i + return out[:limit] + + +# ── 5. 컷 — 방식마다 다르다 (P48 §2.2) ─────────────────────────────────────── + +def apply_cut( + rows: list[dict], + *, + method: str, + tau: float, + ratio: float, + rrf_cutoff: float = 0.0, +) -> list[dict]: + """fusion 결과에 컷을 건다. **방식마다 다르다**(P48 §2.2). + + **가중합** — 새 점수 분포에 맞춘 `tau` · `ratio` 를 받아야 한다. 기존 코사인 값을 그대로 + 쓰면 안 된다. 재측정은 sweep 의 몫이고 이 함수는 받은 값을 걸 뿐이다. + + **RRF** — 코사인 `tau` 를 **fusion 점수에** 적용하지 않는다(스케일이 무관해 전부 + 잘린다). 대신 두 관문을 둔다. **어느 것도 자동 통과가 아니다.** + + ① cosine eligibility gate 원래 코사인에 tau · ratio 를 건다. + 모든 후보가 코사인을 갖는다는 전제 위에 선다(fuse 참조) + ② rrf_cutoff fusion 점수 자체의 하한. 0 이면 끄지만 **끄는 것이 + 기본값이라는 뜻이지 「없다」는 뜻이 아니다** — 값은 + 실측으로 정한다 + + ①과 ②는 서로를 대체하지 않는다. ①은 「이 Record 가 질의와 무관하다」를, ②는 「어느 + 신호에서도 위로 오지 못했다」를 막는다. + """ + if not rows: + return rows + + if method == RRF: + if tau <= 0 and ratio <= 0 and rrf_cutoff <= 0: + return rows + # 코사인이 없는 행은 존재할 수 없다(fuse 가 막는다). 그래서 자동 통과 경로가 없다. + top = max(r["sim"] for r in rows) + return [ + r for r in rows + if r["sim"] >= tau and r["sim"] >= ratio * top + and r["fusion"] >= rrf_cutoff + ] + + if tau <= 0 and ratio <= 0: + return rows + top = rows[0]["fusion"] + return [r for r in rows if r["fusion"] >= tau and r["fusion"] >= ratio * top] + + +# ── 6. IDF (참고용) ────────────────────────────────────────────────────────── + +def idf_weights( + contexts: list[ContextKeywords], + presets: dict[int, Preset] | None = None, +) -> dict[int, float]: + """keyword 별 IDF. **참고 실험 전용**(P48 §1-e). + + **문서 단위는 Record 다 — Context 가 아니다.** 검색이 Record 를 반환하고 신호도 Record + 단위로 집계하므로(§1-a) df 도 같은 단위여야 한다. Context 로 세면 Context 가 많은 + Record 가 df 를 부풀려 **흔한 keyword 를 희소한 것으로 보이게** 만든다. + + 모집단은 신호로 실제 쓰이는 것과 같아야 한다. + + keyword_status = COMPLETED 인 Context 만 (§1-b — 그 외는 신호가 없다) + BLOCKED 가 아닌 Preset 만 (§1-c — 후보가 될 수 없다) + + Record 42건 규모에서 df 가 한 자릿수라 통계가 아니라 잡음이 된다. binary · + confidence 방식보다 우선하지 않으며, 결과 보고서에 일반화 한계를 명시해야 한다. + """ + usable = [c for c in contexts if c.signal_usable] + records = {c.record_id for c in usable} + n = len(records) + if n == 0: + return {} + + # (keyword_id, record_id) 중복을 접어 **Record 단위**로 센다. + seen: dict[int, set[int]] = {} + for c in usable: + for kid, _ in c.keywords: + if presets is not None and (kid not in presets or not presets[kid].usable): + continue + seen.setdefault(kid, set()).add(c.record_id) + return {kid: math.log(n / len(rs)) if rs else 0.0 for kid, rs in seen.items()} diff --git a/tools/search_cut/fusion_sweep.py b/tools/search_cut/fusion_sweep.py new file mode 100644 index 0000000..19997a2 --- /dev/null +++ b/tools/search_cut/fusion_sweep.py @@ -0,0 +1,408 @@ +"""Keyword fusion 오프라인 비교 — P48 1단계. + +**DB 도 GMS 도 부르지 않는다.** `keyword_matrix.py` 가 만든 artifact 와 기존 벡터 행렬만 +읽는다. 방식·가중치·top-k·하한을 바꿔 가며 훑는 것이 목적이므로, 한 번 뜬 artifact 위에서 +모든 조합이 재구성돼야 한다(`sweep.py` 가 컷 격자에 대해 하는 것과 같은 성질). + + python tools/search_cut/fusion_sweep.py + python tools/search_cut/fusion_sweep.py --keyword-matrix PATH --json OUT + +지표는 `rank_score.py` 의 것을 **그대로 쓴다** — baseline 과 다른 자로 재면 비교가 +성립하지 않으므로 정본을 하나로 둔다. + +## 채택 조건 (P48 §6.1) + +이 스크립트는 수치를 낼 뿐 채택하지 않는다. 판정은 사람이 하며 기준은 넷이다. + + 단어형과 문장형을 합산한 평균만으로 채택하지 않는다 + 기존 정답의 Hit@3 · Recall@3 퇴행이 없어야 한다 + 최소 한 세그먼트에서 MRR 또는 nDCG 가 개선되어야 한다 + 무관 질의 통과율이 유의미하게 나빠지면 기각한다 + +**Preset 으로 표현할 수 없는 질의를 별도 세그먼트로 낸다** — 이 신호가 닿지 않는 영역을 +평균이 가리면 안 된다. `--floor` 위의 Preset 후보가 0건인 질의가 그것이다. + +## 컷은 방식마다 다르다 (P48 §2.2) + +`fusion.apply_cut` 이 갈라 처리한다. RRF 에 코사인 `τ_abs` 를 적용하지 않으며, 가중합은 +**새 점수 분포에 맞춘 값을 받아야 한다** — 이 스크립트는 받은 값을 걸 뿐이고, 재측정은 +`--tau` 격자를 훑어 사람이 정한다. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import fusion as F # noqa: E402 +from rank_score import ( # noqa: E402 + GuardError, RATIO, SERVICE_LIMIT, TAU_ABS, TAU_ABS_WORD, + _sha256, aggregate, is_word_query, metrics_for, zero_rate, +) + +ROOT = Path(__file__).resolve().parents[2] +SEARCH = ROOT / ".search" + +CASES = ("신한", "부캠", "그네", "스팟") + + +def log(msg: str = "") -> None: + print(msg, flush=True) + + +# ── 적재와 가드 ────────────────────────────────────────────────────────────── + +def load(keyword_path: Path, search_dir: Path = SEARCH) -> tuple[dict, dict]: + """행렬 셋과 keyword artifact 를 읽고 가드를 건다. + + `search_dir` 이 인자인 것은 **픽스처로 배관을 검증하기 위해서다** — 실측 전용인 + `.search/` 에 가짜 행렬을 넣지 않는다(`word_matrix.py` 의 포트 가드와 같은 원칙). + """ + paths = { + "matrix": search_dir / "matrix.json", + "word_grid": search_dir / "word_grid.json", + "recall_probe": search_dir / "recall_probe.json", + } + for k, p in paths.items(): + if not p.exists(): + raise GuardError(f"행렬이 없다: {p.relative_to(ROOT)}") + if not keyword_path.exists(): + raise GuardError( + f"keyword artifact 가 없다: {keyword_path}\n" + " python tools/search_cut/keyword_matrix.py 를 먼저 돌린다 " + "(GMS 배치 1회 + DB 읽기)." + ) + + data = {k: json.loads(p.read_text(encoding="utf-8")) for k, p in paths.items()} + kw = json.loads(keyword_path.read_text(encoding="utf-8")) + + # ① Profile 정합. 하나라도 어긋나면 비교가 성립하지 않는다. + profiles = {k: d.get("profile") for k, d in data.items()} + profiles["keyword_matrix"] = kw.get("profile") + if len(set(profiles.values())) != 1 or None in profiles.values(): + raise GuardError(f"Profile 이 어긋난다: {profiles}") + + # ② 행렬에 context_id 가 있어야 keyword 를 조인할 수 있다. + missing = [ + k for k, d in data.items() + for sec in ("queries",) + for e in (d.get(sec) or [])[:1] + for r in (e.get("results") or [])[:1] + if "context_id" not in r + ] + if missing: + raise GuardError( + f"행렬에 context_id 가 없다: {sorted(set(missing))}\n" + " word_matrix.py · recall_probe.py 를 다시 떠야 한다(P48 §4.1)." + ) + + # ③ 낡은 artifact 감지. Record 수와 Preset 판이 어긋나면 계산하지 않는다. + src = (kw.get("source") or {}).get("matrices") or {} + for name, m in src.items(): + cur = data[name.replace(".json", "")].get("record_count") + if m.get("record_count") is not None and cur is not None and m["record_count"] != cur: + raise GuardError( + f"낡은 keyword artifact — {name} record_count " + f"{m['record_count']} → {cur}. keyword_matrix.py 를 다시 돌린다." + ) + return data, kw + + +def build_index(kw: dict) -> tuple[dict, dict, dict]: + presets = { + p["id"]: F.Preset(id=p["id"], version=p["version"], visibility=p["visibility"]) + for p in kw["presets"] + } + contexts = [ + F.ContextKeywords( + context_id=c["context_id"], + record_id=c["record_id"], + keyword_status=c["keyword_status"], + keywords=tuple((k["keyword_id"], k["confidence"]) for k in c["keywords"]), + ) + for c in kw["contexts"] + ] + by_user: dict[int, list] = {} + for c, raw in zip(contexts, kw["contexts"]): + by_user.setdefault(raw["user_id"], []).append(c) + + query_cos = { + e["query"]: {c["preset_id"]: c["cos"] for c in e["cos"]} + for e in kw["query_preset"] + } + return presets, by_user, query_cos + + +# ── 한 조합 평가 ───────────────────────────────────────────────────────────── + +def evaluate( + entries: list[dict], + *, + answerable: bool, + presets, by_user, query_cos, idf, + method: str, weight: float, top_k: int, floor: float, + null_policy: str, null_fill: float, + tau: float, tau_word: float, ratio: float, limit: int, + rrf_cutoff: float, rrf_k: float, +) -> tuple[dict, int]: + """세그먼트 하나를 재고 (지표, Preset 미표현 질의 수)를 돌려준다.""" + per, unexpressible = [], 0 + for e in entries: + q, rows = e["query"], (e.get("results") or []) + n_rel = sum(1 for r in rows if r.get("is_expected")) + + qc = query_cos.get(q) + if qc is None: + raise GuardError(f"keyword artifact 에 질의가 없다: `{q}` — 다시 뜬다") + cand = F.preset_candidates(qc, presets, top_k=top_k, floor=floor) + if not cand: + unexpressible += 1 + + # **소유자가 없으면 신호가 조용히 0 이 된다.** `recall_probe.json` 은 `user_id` 를 + # 최상위에만 두므로 호출자가 행에 넣어 줘야 하고, 빠뜨리면 「keyword 가 아무것도 + # 못 올렸다」가 결론으로 나온다. 픽스처가 실제로 이 결함을 잡았다. + uid = e.get("user_id") + if uid is None: + raise GuardError(f"질의 `{q}` 에 user_id 가 없다 — 신호를 조인할 수 없다") + + sig = F.record_signals( + by_user.get(uid, []), cand, presets, + method=method, null_policy=null_policy, null_fill=null_fill, + idf=idf if method == F.IDF else None, + ) + fused = F.fuse(rows, sig, method=method, weight=weight, limit=limit, rrf_k=rrf_k) + kept = F.apply_cut( + fused, method=method, + tau=(tau_word if is_word_query(q) else tau), ratio=ratio, + rrf_cutoff=rrf_cutoff, + ) + per.append(metrics_for(kept, n_rel)) + + agg = aggregate(per) + if agg: + agg["zero_rate"] = zero_rate(per) + return agg, unexpressible + + +def case_ranks(entries, *, presets, by_user, query_cos, idf, **kw) -> dict: + out = {} + for e in entries: + q = e["query"] + if q not in CASES: + continue + rows = e.get("results") or [] + qc = query_cos.get(q, {}) + cand = F.preset_candidates(qc, presets, top_k=kw["top_k"], floor=kw["floor"]) + uid = e.get("user_id") + if uid is None: + raise GuardError(f"사례 `{q}` 에 user_id 가 없다 — 신호를 조인할 수 없다") + sig = F.record_signals( + by_user.get(uid, []), cand, presets, + method=kw["method"], null_policy=kw["null_policy"], + null_fill=kw["null_fill"], idf=idf if kw["method"] == F.IDF else None, + ) + fused = F.fuse(rows, sig, method=kw["method"], weight=kw["weight"], + limit=kw["limit"], rrf_k=kw["rrf_k"]) + kept = F.apply_cut( + fused, method=kw["method"], + tau=(kw["tau_word"] if is_word_query(q) else kw["tau"]), ratio=kw["ratio"], + rrf_cutoff=kw["rrf_cutoff"], + ) + hit = next((i for i, r in enumerate(kept, 1) if r.get("is_expected")), None) + out[q] = {"rank": hit, "returned": len(kept), "candidates": len(cand)} + return out + + +# ── 실행 ───────────────────────────────────────────────────────────────────── + +# (label, method, weight, rrf_cutoff) — 값은 출발점이며 채택값이 아니다. +BASE_GRID = [ + ("binary w=0.05", F.BINARY, 0.05), + ("binary w=0.10", F.BINARY, 0.10), + ("conf w=0.10", F.CONFIDENCE, 0.10), + ("conf w=0.25", F.CONFIDENCE, 0.25), + ("idf w=0.10", F.IDF, 0.10), # 참고용 — 주 채택 근거로 쓰지 않는다 +] + + +def build_grid(cutoffs: list[float]) -> list[tuple]: + """RRF 는 cutoff 후보만큼 행이 늘어난다 — `cutoff=0` 만으로는 채택할 수 없기 때문이다.""" + rows = [(lb, m, w, 0.0) for lb, m, w in BASE_GRID] + for c in cutoffs: + rows.append((f"rrf c={c:g}", F.RRF, 0.0, c)) + return rows + + +def parse_cutoffs(grid: str, single: float) -> list[float]: + if not grid.strip(): + return [single] + out = [] + for x in grid.split(","): + x = x.strip() + if x: + out.append(float(x)) + return out or [single] + +COLS = ("n", "hit@1", "hit@3", "mrr", "recall@3", "ndcg@3", "zero_rate", "returned") + + +def row_str(label: str, m: dict, extra: str = "") -> str: + if not m: + return f" {label:<16} (0건)" + cells = "".join( + (str(m[c]).rjust(9) if c == "n" else f"{m[c]:.4f}".rjust(9)) for c in COLS + ) + return f" {label:<16}{cells} {extra}" + + +def main() -> int: + ap = argparse.ArgumentParser(description="Keyword fusion 오프라인 비교 (P48 1단계)") + ap.add_argument("--keyword-matrix", default=str(SEARCH / "keyword_matrix.json")) + ap.add_argument("--search-dir", default=str(SEARCH), + help="행렬 디렉터리. 기본은 .search/ (실측 전용) — 픽스처 검증용 인자다") + ap.add_argument("--json") + ap.add_argument("--top-k", type=int, default=3) + ap.add_argument("--floor", type=float, default=0.25) + ap.add_argument("--null-policy", default=F.NULL_INCLUDE, choices=F.NULL_POLICIES) + ap.add_argument("--null-fill", type=float, default=0.5) + ap.add_argument("--tau", type=float, default=TAU_ABS) + ap.add_argument("--tau-word", type=float, default=TAU_ABS_WORD) + ap.add_argument("--ratio", type=float, default=RATIO) + ap.add_argument("--limit", type=int, default=SERVICE_LIMIT) + # RRF 의 fusion 점수 하한. **0 은 「개념이 없다」가 아니라 「실험 기본 실행에서 비활성」** + # 이다. 채택값은 실측으로 정하며, `cutoff=0` 결과만으로 RRF 를 채택하지 않는다. + ap.add_argument("--rrf-cutoff", type=float, default=0.0) + # 양수 후보를 한 번에 훑는 축. `--rrf-cutoff` 는 이 격자가 없을 때의 단일값이다. + ap.add_argument("--rrf-cutoff-grid", default="", + help='쉼표 구분 양수 후보. 예: "0,0.008,0.016"') + # **rrf_k 가 바뀌면 RRF 점수 스케일이 바뀌므로 cutoff 를 다시 재야 한다.** 그래서 축으로 + # 노출하고 결과에 반드시 기록한다 — 하드코딩해 두면 바뀌어도 흔적이 남지 않는다. + ap.add_argument("--rrf-k", type=float, default=60.0) + args = ap.parse_args() + + kw_path = Path(args.keyword_matrix) + search_dir = Path(args.search_dir) + try: + data, kw = load(kw_path, search_dir) + presets, by_user, query_cos = build_index(kw) + except GuardError as exc: + print(f"[가드] {exc}", file=sys.stderr) + return 1 + + cutoffs = parse_cutoffs(args.rrf_cutoff_grid, args.rrf_cutoff) + grid = build_grid(cutoffs) + + all_ctx = [c for cs in by_user.values() for c in cs] + idf = F.idf_weights(all_ctx, presets) # Record 기준 · 상태/visibility 반영(P48 §1-e) + + segs = { + "문장형(정답)": (data["matrix"]["queries"], True), + "단어형(정답)": (data["word_grid"]["queries"], True), + "무관-문장형": (data["matrix"]["offtopic"], False), + "무관-단어형": (data["word_grid"]["offtopic"], False), + } + + log("=" * 100) + log("Keyword fusion 비교 — P48 1단계") + log("=" * 100) + log(f" Profile {kw['profile']}") + log(f" Preset {kw['preset_count']}건 · version={kw['preset_version']}") + log(f" query→Preset top_k={args.top_k} · floor={args.floor}") + log(f" NULL 정책 {args.null_policy}" + + (f" (fill={args.null_fill})" if args.null_policy == F.NULL_FILL else "")) + log(f" 컷 tau={args.tau} · tau_word={args.tau_word} · r={args.ratio}") + log(f" RRF k={args.rrf_k} · cutoff={args.rrf_cutoff}" + + (f" · 격자={args.rrf_cutoff_grid}" if args.rrf_cutoff_grid else "")) + log(" 호출 DB 0회 · GMS 0회") + + common = dict( + presets=presets, by_user=by_user, query_cos=query_cos, idf=idf, + top_k=args.top_k, floor=args.floor, + null_policy=args.null_policy, null_fill=args.null_fill, + tau=args.tau, tau_word=args.tau_word, ratio=args.ratio, limit=args.limit, + rrf_cutoff=args.rrf_cutoff, rrf_k=args.rrf_k, + ) + result: dict = { + "stage": "P48-1", + "params": {k: v for k, v in vars(args).items() if k != "json"}, + "inputs": { + **{k: _sha256(search_dir / f"{k}.json") for k in ("matrix", "word_grid")}, + "keyword_matrix": _sha256(kw_path), + }, + "grid": {}, + } + + for seg_name, (entries, answerable) in segs.items(): + log(f"\n{seg_name}") + log(" " + "방식".ljust(16) + "".join(c.rjust(9) for c in COLS) + + " Preset 미표현") + for label, method, weight, cutoff in grid: + try: + m, unexpr = evaluate( + entries, answerable=answerable, method=method, weight=weight, + **{**common, "rrf_cutoff": cutoff}, + ) + except GuardError as exc: + print(f"[가드] {exc}", file=sys.stderr) + return 1 + note = f"{unexpr}건" if answerable else "" + if method == F.RRF and cutoff <= 0: + note = (note + " ⚠ 잠정(cutoff=0 단독 채택 불가)").strip() + log(row_str(label, m, note)) + result["grid"].setdefault(seg_name, {})[label] = { + "metrics": m, "unexpressible": unexpr, + "method": method, "weight": weight, + # **실제 사용한 값을 행마다 기록한다.** 표만 보고 나중에 되짚을 수 없으면 + # 「어느 조건에서 나온 수치인가」가 사라진다. + "rrf_cutoff": cutoff if method == F.RRF else None, + "rrf_k": args.rrf_k if method == F.RRF else None, + "adoptable_alone": not (method == F.RRF and cutoff <= 0), + } + + log("\n사례별 (컷 후 정답 순위 · `—` 는 잘림)") + log(" " + "방식".ljust(16) + "".join(q.rjust(10) for q in CASES)) + # `recall_probe.json` 은 소유자 한 명을 최상위에 두고 질의 행에는 넣지 않는다. + # 여기서 내려 주지 않으면 신호 조인이 조용히 실패한다(위 가드가 그때 멈춘다). + probe_uid = data["recall_probe"].get("user_id") + probe = data["recall_probe"]["queries"] + entries = [ + dict(e, + user_id=e.get("user_id", probe_uid), + results=[dict(r, is_expected=(r.get("name") == e.get("expect"))) + for r in (e.get("results") or [])]) + for e in probe + ] + for label, method, weight, cutoff in grid: + ranks = case_ranks(entries, method=method, weight=weight, + **{**common, "rrf_cutoff": cutoff}) + cells = "".join( + (str(ranks[q]["rank"]) if ranks.get(q, {}).get("rank") else "—").rjust(10) + for q in CASES + ) + log(f" {label:<16}{cells}") + result.setdefault("cases", {})[label] = ranks + + log("\n읽는 법 (P48 §6.1)") + log(" · 단어형·문장형 합산 평균만으로 채택하지 않는다.") + log(" · Hit@3·Recall@3 퇴행이 없어야 하고, 최소 한 세그먼트에서 MRR 또는 nDCG 가 올라야 한다.") + log(" · 무관 세그먼트는 zero_rate 가 떨어지면(=침묵이 무너지면) 기각 사유다.") + log(" · `idf` 는 참고용이다 — Record 수가 작아 df 가 불안정하다. 주 채택 근거로 쓰지 않는다.") + log(" · 「Preset 미표현」이 큰 세그먼트는 이 신호가 닿지 않는 영역이다. 평균이 가리지 않게 본다.") + log(" · **`cutoff=0` 결과만으로 RRF 를 채택하지 않는다**(⚠ 표시). 양수 후보를 실측해") + log(" Hit@3·Recall@3 · nDCG/MRR · 무관 zero_rate 를 비교하고, 채택 시 측정된 양수 값") + log(" 또는 0 유지 중 하나를 근거와 함께 명시한다.") + log(f" · rrf_k={args.rrf_k} 를 바꾸면 점수 스케일이 바뀌므로 cutoff 를 다시 재야 한다.") + + if args.json: + out = Path(args.json) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + log(f"\n저장: {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/search_cut/keyword_matrix.py b/tools/search_cut/keyword_matrix.py new file mode 100644 index 0000000..fee1909 --- /dev/null +++ b/tools/search_cut/keyword_matrix.py @@ -0,0 +1,279 @@ +"""Keyword 신호 artifact 생성 — P48 1단계 §4. + +`matrix.json` · `word_grid.json` · `recall_probe.json` 은 **벡터 유사도만** 담는다. 여기에 +keyword 신호를 붙이려면 셋이 더 필요하고, 그것을 이 스크립트가 별도 artifact 로 만든다. + + 질의별 **전체 활성 Preset** 코사인 top-k · 하한을 sweep 에서 바꾸려면 전량이 필요하다 + Context 별 keyword · confidence · 상태 confidence 는 **NULL 을 그대로 보존한다** + Preset 의 version · visibility BLOCKED 판정용(P48 §1-c) + +**호출은 GMS 임베딩 배치 1회 + DB 읽기다**(`matrix.py` 와 같은 수준). 이후 `fusion_sweep.py` +는 이 파일만 읽고 GMS·DB 를 부르지 않는다. + + python tools/search_cut/keyword_matrix.py + +## 질의를 손으로 적지 않는다 + +질의 목록을 이 파일에 다시 쓰면 행렬과 어긋난다 — 어긋나면 fusion sweep 이 조인하지 못하고, +그 사실이 「신호가 없다」로 조용히 나타난다. **기존 artifact 에서 읽어 온다.** 행렬이 재는 +질의와 여기서 재는 질의가 같다는 것이 구조로 보장된다. + +## 왜 질의 벡터가 아니라 코사인을 담나 + +이번 실험의 조절 대상은 `query→Preset` 의 **top-k 와 하한**이고, 둘 다 「Preset 별 코사인 +목록을 자르는」 연산이다. 목록을 통째로 담으면 sweep 에서 자유롭게 바꿀 수 있다. + +질의 벡터(1536 float)를 담으면 Preset 이 바뀌어도 재계산할 수 있다는 이점이 있으나, +Preset 이 개정되면 `preset_version` 이 바뀌고 그때는 **어차피 행렬을 다시 떠야 한다.** +그래서 이점이 실제 상황에서 크지 않다(P48 §4.2). + +## 낡은 artifact 를 감지한다 + +`profile` · `preset_version` · Record 수 · 생성 조건을 함께 적는다. `fusion_sweep.py` 가 +행렬의 값과 대조해 **어긋나면 계산하지 않고 실패한다.** 낡은 조합으로 낸 수치는 근거가 +아니라 오답이다. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from app.client.embedding_client import EmbeddingClient # noqa: E402 +from app.core.config import get_settings # noqa: E402 +from app.core.db import Database # noqa: E402 + +for _s in (sys.stdout, sys.stderr): + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + +SEARCH = ROOT / ".search" + +# 시연 정본은 15432 다(T33). `matrix.py`·`word_matrix.py`·`recall_probe.py` 와 같은 가드 — +# 데이터가 없는 DB 를 재면 「keyword 신호가 아무 데도 없다」가 결론으로 나온다. +EXPECT_PORT = "15432" + +MATRICES = ("matrix.json", "word_grid.json", "recall_probe.json") + +# Preset 은 `is_active` 로 적재 범위가 정해진다(keyword-preset.md §2). `visibility` 는 +# **거르지 않고 담는다** — BLOCKED 제외는 소비 시점 판단이고, 낡은 데이터에 BLOCKED 행이 +# 남아 있을 가능성을 방어하려면 artifact 가 그 사실을 담고 있어야 한다(P48 §1-c). +_PRESETS = """ +SELECT id, code, version, visibility, embedding +FROM ai.keyword_preset +WHERE is_active = true AND embedding_profile = $1 +ORDER BY id +""" + +# `confidence` 는 NULL 을 그대로 가져온다. 0 으로 치환하면 「판정된 적 없음」과 구분이 +# 사라진다(P48 §1-d). `keyword_status` 는 Context 제외가 아니라 **신호 제외** 판단에 쓴다. +# +# **모집단은 검색 Query 와 같아야 한다**(personal-search.md §4). 두 상태의 역할이 다르다. +# +# embedding_status = COMPLETED **검색 후보 자체의 조건.** 검색 Query 가 이 조건을 걸므로 +# 미완료 Context 는 애초에 결과에 없다. 여기서 빼지 않으면 +# 벡터 행렬에 없는 Record 가 keyword 신호로만 올라와 +# 「코사인 없는 후보」가 되고, `similarity` 의 cosine float +# 계약(P48 §2.3)이 깨진다 +# keyword_status **신호의 조건일 뿐이다.** 미완료여도 Context 는 남기고 +# 신호만 뺀다(§1-b). 그래서 WHERE 가 아니라 컬럼으로 싣는다 +_CONTEXTS = """ +SELECT e.context_id, + e.record_id, + e.user_id, + s.keyword_status, + k.keyword_id, + k.confidence, + k.preset_version +FROM ai.context_embedding e +JOIN ai.context_ai_state s ON s.context_id = e.context_id +LEFT JOIN ai.context_keyword k ON k.context_id = e.context_id +WHERE e.is_deleted = false + AND e.embedding_profile = $1 + AND s.embedding_status = 'COMPLETED' +ORDER BY e.context_id, k.keyword_id +""" + + +def log(msg: str = "") -> None: + print(msg, flush=True) + + +def _cos(a: list[float], b: list[float]) -> float: + dot = sum(x * y for x, y in zip(a, b)) + na = sum(x * x for x in a) ** 0.5 + nb = sum(y * y for y in b) ** 0.5 + return dot / (na * nb) if na and nb else 0.0 + + +def collect_queries() -> tuple[list[str], dict]: + """행렬 셋에서 질의를 모은다. **여기서 손으로 적지 않는다.**""" + queries: list[str] = [] + seen: set[str] = set() + meta: dict = {} + for name in MATRICES: + p = SEARCH / name + if not p.exists(): + raise SystemExit(f"행렬이 없다: {p.relative_to(ROOT)} — 먼저 그것부터 뜬다") + d = json.loads(p.read_text(encoding="utf-8")) + meta[name] = {"profile": d.get("profile"), "record_count": d.get("record_count")} + for section in ("queries", "cross", "offtopic"): + for e in d.get(section, []) or []: + q = e["query"] + if q not in seen: + seen.add(q) + queries.append(q) + profiles = {m["profile"] for m in meta.values()} + if len(profiles) != 1: + raise SystemExit(f"행렬의 Profile 이 어긋난다: {meta} — 재지 않고 멈춘다") + return queries, meta + + +def _parse_vector(raw) -> list[float]: + """pgvector 는 드라이버에 따라 문자열로 온다(`'[0.1,0.2,...]'`).""" + if isinstance(raw, str): + return [float(x) for x in raw.strip("[]").split(",")] + return [float(x) for x in raw] + + +async def build(db: Database, settings, queries: list[str], meta: dict) -> dict: + async with db.acquire() as conn: + preset_rows = await conn.fetch(_PRESETS, settings.embedding_profile) + ctx_rows = await conn.fetch(_CONTEXTS, settings.embedding_profile) + + if not preset_rows: + raise SystemExit( + "활성 Preset 이 0건이다 — 부트스트랩이 안 된 DB 다. 재지 않고 멈춘다." + ) + + versions = {r["version"] for r in preset_rows} + if len(versions) != 1: + raise SystemExit( + f"Preset version 이 섞여 있다: {sorted(versions)} — " + "판정 세트가 한 판이어야 신호를 조인할 수 있다. 재지 않고 멈춘다." + ) + preset_version = versions.pop() + + presets = [ + {"id": r["id"], "code": r["code"], "version": r["version"], + "visibility": r["visibility"]} + for r in preset_rows + ] + vis = {} + for p in presets: + vis[p["visibility"]] = vis.get(p["visibility"], 0) + 1 + log(f" Preset {len(presets)}건 · version={preset_version} · {vis}") + + preset_vecs = [(r["id"], _parse_vector(r["embedding"])) for r in preset_rows] + + # Context 를 접는다. LEFT JOIN 이라 keyword 가 없는 Context 는 keyword_id 가 NULL 이다. + contexts: dict[int, dict] = {} + for r in ctx_rows: + c = contexts.setdefault(r["context_id"], { + "context_id": r["context_id"], + "record_id": r["record_id"], + "user_id": r["user_id"], + "keyword_status": r["keyword_status"], + "keywords": [], + }) + if r["keyword_id"] is not None: + c["keywords"].append({ + "keyword_id": r["keyword_id"], + # **NULL 을 그대로 둔다.** 0 으로 치환하지 않는다(P48 §1-d). + "confidence": float(r["confidence"]) if r["confidence"] is not None else None, + "preset_version": r["preset_version"], + }) + + n_kw = sum(len(c["keywords"]) for c in contexts.values()) + n_null = sum(1 for c in contexts.values() for k in c["keywords"] if k["confidence"] is None) + n_incomplete = sum(1 for c in contexts.values() if c["keyword_status"] != "COMPLETED") + log(f" Context {len(contexts)}건 · keyword {n_kw}건 " + f"(confidence NULL {n_null}건) · keyword_status≠COMPLETED {n_incomplete}건") + + client = EmbeddingClient( + base_url=settings.gms_base_url, + api_key=settings.gms_api_key, + model=settings.embedding_model, + dimension=settings.embedding_dimension, + ) + log(f" GMS 임베딩 배치 1회 ({len(queries)}건) …") + vecs = await client.embed(queries) + + query_preset = [ + { + "query": q, + # **전체 활성 Preset** 을 담는다 — top-k·하한이 sweep 의 조절 축이므로 + # 여기서 자르면 그 축이 사라진다(P48 §4.2). + "cos": [{"preset_id": pid, "cos": round(_cos(qv, pv), 6)} + for pid, pv in preset_vecs], + } + for q, qv in zip(queries, vecs) + ] + + return { + "stage": "P48-1", + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "profile": settings.embedding_profile, + "model": settings.embedding_model, + "preset_version": preset_version, + "source": { + "db_port": EXPECT_PORT, + "gms_batch": 1, + "matrices": meta, + }, + "preset_count": len(presets), + "context_count": len(contexts), + "query_count": len(queries), + "presets": presets, + "contexts": sorted(contexts.values(), key=lambda c: c["context_id"]), + "query_preset": query_preset, + } + + +async def main() -> int: + ap = argparse.ArgumentParser(description="Keyword 신호 artifact (P48 1단계)") + ap.add_argument("--out", default=str(SEARCH / "keyword_matrix.json")) + args = ap.parse_args() + + settings = get_settings() + if EXPECT_PORT not in settings.database_url: + raise SystemExit( + f"DATABASE_URL 이 :{EXPECT_PORT} 를 가리키지 않는다 — " + f"{settings.database_url.rsplit('@', 1)[-1]}\n" + f"시연 정본은 :{EXPECT_PORT}(pinlog-demo)다(T33). 재지 않고 멈춘다." + ) + + queries, meta = collect_queries() + if meta["matrix.json"]["profile"] != settings.embedding_profile: + raise SystemExit( + f"행렬 Profile({meta['matrix.json']['profile']})과 설정" + f"({settings.embedding_profile})이 다르다 — 재지 않고 멈춘다." + ) + + log(f" profile={settings.embedding_profile}") + log(f" 질의 {len(queries)}건 (행렬 {len(MATRICES)}종에서 수집, 중복 제거)") + + db = Database(settings.database_url) + await db.connect() + try: + data = await build(db, settings, queries, meta) + finally: + await db.disconnect() + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + log(f"\n → {out} ({out.stat().st_size:,} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/tools/search_cut/recall_probe.py b/tools/search_cut/recall_probe.py index 2ff4afe..3f57f21 100644 --- a/tools/search_cut/recall_probe.py +++ b/tools/search_cut/recall_probe.py @@ -298,6 +298,8 @@ async def build(db: Database, settings) -> dict: { "rank": i, "record_id": r["record_id"], + # `context_keyword` 는 context_id 조인이다(P48 §4.1). + "context_id": r["context_id"], "name": name_by_record.get(r["record_id"], f"record={r['record_id']}"), "sim": round(float(r["similarity"]), 6), } diff --git a/tools/search_cut/word_matrix.py b/tools/search_cut/word_matrix.py index a6fce12..f7e3798 100644 --- a/tools/search_cut/word_matrix.py +++ b/tools/search_cut/word_matrix.py @@ -233,6 +233,9 @@ async def build(db: Database, settings) -> dict: { "rank": i, "record_id": r["record_id"], + # `context_keyword` 는 context_id 조인이다(P48 §4.1). Record 대표 + # Context 의 id 를 남기지 않으면 keyword 신호를 붙일 수 없다. + "context_id": r["context_id"], "name": name_by_record.get(r["record_id"], f"record={r['record_id']}"), "sim": round(float(r["similarity"]), 6), "is_expected": r["record_id"] in want, @@ -270,6 +273,7 @@ async def build(db: Database, settings) -> dict: { "rank": i, "record_id": r["record_id"], + "context_id": r["context_id"], "name": name_by_record.get(r["record_id"], f"record={r['record_id']}"), "sim": round(float(r["similarity"]), 6), "is_expected": False, From 88857773aa3542c1517f1e1b0106cd44f0c2a4ce Mon Sep 17 00:00:00 2001 From: SeMin Kim <83855438+tpals0409@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:38:37 +0900 Subject: [PATCH 03/34] =?UTF-8?q?docs:=20P48=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=8B=A0=ED=98=B8=20=ED=99=95=EC=9E=A5=20=EC=A0=9C=EC=95=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현행 검색은 판단 근거가 질의-Context 코사인 하나다. 순위를 만드는 것도 그것이고 그 순위에서 무엇을 남길지 정하는 것도 그것이다. 원인이 여러 개인데 대응 수단이 하나뿐이라 그 하나를 질의 길이로 가르는 데까지 갔다(-266). -255 가 가른 결정적 한 줄이 근거다. 본문에 그대로 있는 짧은 질의가 본문에 없는 같은 길이의 질의보다 낮은 유사도를 받는다 — 「본문에 있음」이 현행 신호에 반영되지 않는다. 컷의 문제도 임계값의 문제도 아니고 그 정보를 담은 신호가 없다는 문제다. 구조를 갈아엎지 않는다. 신호를 늘리고 그것을 합치는 자리를 만든다. 4단계로 나누고 0단계(순위 지표)를 나머지의 선결 조건으로 둔다. 0 순위 지표 완료 — 이 브랜치의 rank_score.py · I51 1 context_keyword 이 레포 안에서 끝남. 추가 모델 호출 없음 2 질의 이해 LLM 「검색 경로에 LLM 없음」 원칙 변경. 1단계 결과 후 판단 3 어휘 신호 본문이 core 소유라 back 협의 필요 RAG · ANN · 청킹 · placeMeta 결합 · HyDE 는 채택하지 않거나 보류하며 사유를 적었다. 특히 RAG 는 검색 정확도를 올리지 않는다 — 순위가 틀린 상태에서는 침묵을 유창한 오답으로 바꾼다. 두 차례 개정 이력을 머리말에 남겼다. 1차는 "컷은 그대로 둔다"가 RRF 에서 성립하지 않는 것과 visibility 판단(§6 만 읽고 BLOCKED 제외를 놓쳤다), 2차는 sim=None 허용 · embedding_status 누락 · RRF 자동 통과 · IDF 단위 넷이다. Co-Authored-By: Claude Opus 5 --- docs/proposals/P48-search-signal-expansion.md | 479 ++++++++++++++++++ docs/proposals/README.md | 1 + 2 files changed, 480 insertions(+) create mode 100644 docs/proposals/P48-search-signal-expansion.md diff --git a/docs/proposals/P48-search-signal-expansion.md b/docs/proposals/P48-search-signal-expansion.md new file mode 100644 index 0000000..20adc79 --- /dev/null +++ b/docs/proposals/P48-search-signal-expansion.md @@ -0,0 +1,479 @@ +# P48: 개인 검색의 신호 확장 — 단일 코사인에서 다신호로 + +- **상태**: Proposed +- **날짜**: 2026-08-05 +- **주도(Driver)**: AI 파트 +- **관련 PR/커밋**: 없음(구현 전) +- **관련 티켓**: 미발급 +- **근거 리포트**: [implements/2026-08-05-search-rank-baseline.md](../implements/2026-08-05-search-rank-baseline.md)(I51 · 0단계 산출) · [implements/2026-07-31-search-cut.md](../implements/2026-07-31-search-cut.md)(`S15P11A705-213`) · [implements/2026-08-03-search-recall-probe.md](../implements/2026-08-03-search-recall-probe.md)(`S15P11A705-255`) · [implements/2026-08-03-word-query-cut.md](../implements/2026-08-03-word-query-cut.md)(`S15P11A705-266`) + +> **0단계는 반영이 끝났고 나머지는 여전히 제안이다.** `tools/search_cut/rank_score.py` 와 +> baseline 이 있으므로 §3 0단계는 제안이 아니라 기록이다(I51). **앱 런타임은 아직 어느 +> 절도 건드리지 않았고 티켓도 발급되지 않았다.** +> +> 0단계는 다른 절의 **선결 조건**이었다 — 그것 없이 1단계 이후를 실행하면 순위 개선이 +> 컷 기준 지표에서 0으로 보여 판정할 수단이 없다. +> +> **2026-08-05 2차 개정 — 1단계 코드 검토 반영.** 넷을 고쳤다. +> ① §2.1 — 합집합 후보는 **전부 실제 코사인과 대표 `context_id` 를 갖는다.** `sim=None` +> 후보를 허용하던 것을 없앴다(`similarity` 는 cosine float 계약이다). +> ② §4.1 — artifact 모집단에 **`embedding_status='COMPLETED'`** 를 넣었다. ①의 뿌리였다. +> ③ §2.2 — RRF 의 컷을 **cosine eligibility gate + RRF cutoff** 두 관문으로 정의했다. +> 코사인이 없다는 이유의 자동 통과를 없앴다. +> ④ §1-e — IDF 를 **Record 기준**으로 세고 `COMPLETED`·비`BLOCKED` 모집단을 반영했다. +> +> **2026-08-05 1차 개정.** §2.2는 원래 *"컷은 그대로 둔다"* 였다. 그 서술은 점수 스케일이 +> 코사인으로 유지될 때만 성립하며 §7 위험표와 모순이었다. fusion 방식별로 갈랐다. +> §1-c는 원래 *"visibility를 필터하지 않는다"* 로 판단했으나 +> [`spec/keyword-preset.md`](../spec/keyword-preset.md) §2가 `BLOCKED` 제외를 이미 정하고 +> 있었다 — §6만 읽고 내린 판단이었고 정정한다. + +## 0. 읽는 법 — 값이 아니라 좌표 + +[P47](P47-keyword-preset-label-axis.md) §0의 규율을 따른다. **이 문서에는 측정 수치·현행 +상수·코드 행 번호가 없다.** 값은 시점에 묶이고 문서는 그것을 따라가지 않는다. + +| 무엇 | 정본 | +|---|---| +| 컷 상수(`τ_abs`·`r`·단어형 분기 경계) | `app/core/config.py` | +| 컷 적용 지점 | `app/service/search_service.py`의 `_cut` | +| 검색 Query | `app/repository/context_embedding_repo.py` | +| 검색 계약 | [`spec/personal-search.md`](../spec/personal-search.md) · 공용 계약 `static/05_AI_설계.md` | +| ANN 미도입 결정 | [P5](P5-exact-cosine.md) | +| 측정 하네스와 실행 절차 | `tools/search_cut/README.md` | +| 측정 결과 행렬 | `.search/matrix.json` · `.search/word_grid.json` · `.search/recall_probe.json` | +| 측정 수치와 판정 근거 | 위 근거 리포트 | + +## 1. 문제 — 조절 지점이 컷 하나뿐이다 + +현행 검색은 판단 근거가 **질의-Context 코사인 하나**다. 순위를 만드는 것도 그것이고, +그 순위에서 무엇을 남길지 정하는 것도 그것이다. + +근거 리포트 셋이 이미 이 구조의 한계를 각각 다른 각도에서 기록했다. + +``` +S15P11A705-213 컷 두 겹(τ_abs · r)이 서로를 대체하지 못한다 +S15P11A705-255 세 이슈의 원인이 서로 다르다 — 컷 · 약어 · 짧은 질의 +S15P11A705-266 τ_abs 단일값으로는 단어형과 문장형 중 한쪽이 반드시 손해를 본다 +``` + +**세 리포트가 같은 것을 말하고 있다.** 원인이 여러 개인데 대응 수단이 하나뿐이라, +그 하나를 질의 길이로 가르는 데까지 갔다. `-266`의 분기는 결함이 아니라 **단일 신호 +시스템이 도달할 수 있는 최선**이다. 다만 상한이 있다 — `-255`가 판정한 세 이슈 중 +둘(`③` 판정)은 컷을 어떻게 조절해도 회복되지 않는다. + +특히 `-255`가 가른 한 줄이 결정적이다. + +> 본문에 그대로 있는 짧은 질의가, 본문에 없는 같은 길이의 질의보다 낮은 유사도를 받는다. + +**「본문에 있음」이라는 정보가 현행 신호에 반영되지 않는다.** 이것은 컷의 문제도 임계값의 +문제도 아니고, 그 정보를 담은 신호가 없다는 문제다. + +## 2. 제안의 골자 + +구조를 갈아엎지 않는다. **신호를 늘리고, 그것을 합치는 자리를 만든다.** + +``` +현행 벡터 후보 → limit → 컷(τ_abs · r) → 반환 + +제안 벡터 후보 ─┐ + ├─ 합집합 → fusion 정렬 → limit → 컷(방식별) → 반환 + keyword 후보 ┘ +``` + +### 2.1 합집합이 먼저다 — 재정렬이 아니다 + +**벡터 상위 `limit` 안에서만 재정렬하지 않는다.** 목적이 벡터 순위 밖에 있는 정답을 +끌어올리는 것인데, 재정렬만 하면 그 정답이 애초에 후보에 없어 구조적으로 불가능하다. + +``` +벡터 후보 ∪ keyword 후보 → fusion 점수로 정렬 → 최종 limit +``` + +`limit` 은 **합집합과 정렬 뒤**에 적용한다. + +**합집합에 들어오는 모든 후보는 실제 query–Context 코사인과 대표 `context_id` 를 갖는다.** +`similarity` 가 cosine float 계약이므로(§2.3) 코사인이 없는 후보는 존재할 수 없다. + +``` +오프라인 행렬이 소유자 Record 전량을 담으므로(NO_LIMIT) 합집합이 벡터 후보 집합과 같다. + artifact 는 **검색 가능한 Context 전량의 코사인**을 보존해야 한다 +런타임 LIMIT 뒤에 합치려면 keyword 후보의 코사인을 **조회해 채운 뒤** 합친다 +``` + +keyword 신호가 있는데 코사인이 없는 Record 가 나오면 그것은 **두 모집단이 어긋난 것**이다. +값을 지어내지 않고 실패시킨다 — 원인은 대개 §4.1 의 `embedding_status` 누락이다. + +### 2.2 컷은 fusion 방식마다 다르다 + +> **개정 전 이 절은 "컷은 그대로 둔다"였다. 삭제한다.** +> 그 서술은 점수 스케일이 코사인으로 유지될 때만 성립하며, §6 위험표의 「컷 상수가 새 +> 점수 스케일과 어긋난다」와 모순이었다. + +| fusion 방식 | 컷 규칙 | +|---|---| +| 가중합(코사인 스케일 유지) | 새 점수 분포에 맞춰 **`τ_abs`·`r` 을 재측정**한다. 기존 값을 그대로 쓰지 않는다 | +| RRF(순위 기반) | **기존 코사인 `τ_abs` 를 fusion 점수에 적용하지 않는다.** 대신 아래 두 관문을 둔다 | + +**RRF 의 두 관문 — 어느 것도 자동 통과가 아니다.** + +``` +① cosine eligibility gate 원래 코사인에 τ_abs · r 을 건다. + 모든 후보가 코사인을 갖는다는 §2.1 전제 위에 선다 +② RRF cutoff fusion 점수 자체의 하한. **값은 실측 대상이다** — + 0 은 「끈다」이지 「없다」가 아니다 +``` + +①과 ②는 서로를 대체하지 않는다. ①은 「이 Record 가 질의와 무관하다」를, ②는 「어느 +신호에서도 위로 오지 못했다」를 막는다. **코사인이 없다는 이유로 컷을 면제받는 행은 없다.** + +#### RRF cutoff 의 기본값과 채택 규칙 + +오프라인 실험의 기본값은 **0(비활성)**이다. 채택값을 측정하지 않았으므로 임의의 양수를 +기본값으로 정하지 않는다. **0 은 「RRF cutoff 개념이 없다」가 아니라 「실험 기본 실행에서 +비활성」이라는 뜻이다.** 이 승인은 오프라인 실험의 중립적 출발점에 대한 것이며 **런타임 +기본값 승인이 아니다.** + +``` +① rrf_cutoff 를 CLI sweep 축으로 유지한다 --rrf-cutoff · --rrf-cutoff-grid +② 결과에 실제 사용한 값을 반드시 기록한다 행마다 rrf_cutoff · rrf_k 를 남긴다 +③ cutoff=0 결과만으로 RRF 를 채택하지 않는다 표에 ⚠ 로 표시된다 +④ 양수 후보를 실측해 비교한다 Hit@3·Recall@3 · nDCG/MRR · 무관 zero-rate +⑤ 채택 시 측정된 양수 값 또는 0 유지 중 근거와 함께 명시한다 + 하나를 명시한다 +⑥ rrf_k 가 바뀌면 cutoff 를 다시 측정한다 점수 스케일이 바뀐다. rrf_k 도 축이자 기록 대상 +⑦ cutoff 비활성이 cosine eligibility 를 없애지 않는다 합집합 후보는 여전히 실제 코사인과 + context_id 를 갖는다(§2.1) +``` + +⑥이 축으로 노출된 이유는 `rrf_k` 를 하드코딩해 두면 **바뀌어도 흔적이 남지 않기** 때문이다. +⑦은 「끄는 것」과 「면제하는 것」의 구분이며, 둘을 섞으면 무관 질의 침묵이 무너진다. + +컷 재측정은 각 방식의 **완료 조건에 포함**된다. 컷 없이 순위만 비교하고 채택하지 않는다. + +### 2.3 응답 계약은 바꾸지 않는다 + +- 응답의 `similarity` 는 **기존 코사인 의미를 유지한다.** fusion 점수로 대체하지 않는다. +- **fusion 점수는 정렬에만 쓰고 API 에 노출하지 않는다.** +- 필드 구성(`recordId`·`contextId`·`similarity`)이 그대로이므로 3단계를 제외하면 Spring + 쪽 변경이 없다. + +이 선을 지키면 실험이 실패해도 계약 되돌리기가 필요 없다. + +## 3. 단계 + +### 0단계 — 순위 지표 (선결 조건) · **완료** + +> **2026-08-05 반영됨.** `tools/search_cut/rank_score.py` 와 baseline +> `.search/rank_baseline.json` 이 있다. 결과와 판정은 +> [implements/2026-08-05-search-rank-baseline.md](../implements/2026-08-05-search-rank-baseline.md)(I51). +> 이 절은 제안이 아니라 기록이다. +> +> **판정 하나가 바뀌었다** — `-255` 의 ①(`그네` 는 컷이 잘랐다)은 `-266` 의 단어형 분기로 +> 이미 해소돼 있다. 남은 `신한`·`부캠` 은 `tau_word` 와 `r·top1` **양쪽 모두 아래**라 +> 컷 조절로 닿지 않는다. **1단계의 표적이 이것으로 확정됐다.** + +현행 하네스의 지표는 전부 **컷 기준**이다(`tools/search_cut/word_sweep.py`의 +`eval_answered`·`eval_control`). 컷은 순위를 바꾸지 않으므로 그것으로 충분했다. + +1단계 이후는 **순위를 바꾸는 변경**이다. 정답이 위로 올라와도 컷 기준 지표에서는 +`kept` 값이 그대로일 수 있고, 그러면 **개선이 0으로 보인다.** + +``` +tools/search_cut/rank_score.py (신규) + + 입력 기존 행렬 셋 — 새 데이터도 GMS 호출도 필요 없다 + 출력 recall@k · MRR + · 단어형 / 문장형 분리 두 대역이 겹치지 않으므로(-266) 합산은 무의미 + · 컷 적용 전 / 후 각각 컷 후만 보면 순위 개선이 가려진다 +``` + +행렬에는 `rank`가 이미 들어 있다(`matrix.py`·`word_matrix.py`·`recall_probe.py`가 +행마다 기록한다). **읽어서 세기만 하면 된다.** + +산출물은 baseline 수치이며 근거 리포트로 보존한다. 이후 모든 단계는 이 수치와의 차이로 +판정한다. + +### 1단계 — `ai.context_keyword`를 검색 신호로 + +파이프라인은 Context마다 판정한 Keyword를 확신도와 함께 저장하는데, **검색 Query가 이 +테이블을 조인하지 않는다.** 저장 시점에 만든 판정을 검색 시점에 버리고 있다. + +``` +질의 → Preset 매칭 → 해당 Keyword 보유 Context 가산 +``` + +- 추가 모델 호출이 **없다.** 저장된 판정을 읽을 뿐이다. +- Preset은 유한 집합이므로 신호의 상한이 명확하다. +- 이 레포 안에서 끝난다. 파트 간 협의가 없다. + +`query → Preset` 매핑은 **LLM 없이 기존 질의 임베딩과 Preset 임베딩의 코사인**으로 한다. +top-k 와 하한은 오프라인 sweep의 조절 축이므로 **문서에 값을 박지 않는다.** + +#### 1-a. Record에 Context가 여럿일 때 + +벡터 신호는 이미 **최댓값**으로 집계한다(`DISTINCT ON` — 하나만 강하게 일치해도 그 Record는 +찾는 대상, [`spec/personal-search.md`](../spec/personal-search.md) §5). **Keyword 신호도 같은 +규칙을 따른다** — Record 점수는 소속 Context 중 최댓값이며 평균·합계를 쓰지 않는다. + +두 신호가 서로 다른 Context에서 최댓값을 받을 수 있다. 그것을 허용한다 — Context는 서로 +독립적인 저장 이유이므로 「같은 Context에서 둘 다 강해야 한다」는 제약에 근거가 없다. + +#### 1-b. `keyword_status` — Context 제외와 신호 제외를 구분한다 + +**`keyword_status != COMPLETED` 인 Context를 검색 후보에서 제외하지 않는다.** 제외하면 +Embedding은 끝났는데 Keyword가 미완인 Context가 검색에서 사라져 명백한 퇴행이다. + +``` +Context 제외 하지 않는다. 벡터 점수는 그대로 유지한다 +Keyword 신호 제외 한다. 「신호 없음」으로 처리한다 +``` + +`CANCELLED`·`FAILED`·`PROCESSING` 상태에 남아 있는 낡은 `context_keyword` 행은 **가산에 +쓰지 않는다.** 재판정 도중이거나 무효화된 판정이 신호로 살아나면 안 된다. + +#### 1-c. `visibility` — `BLOCKED` 만 제외한다 + +[`spec/keyword-preset.md`](../spec/keyword-preset.md) §2가 이미 정했다. + +> `BLOCKED` Preset은 후보 집합에서 제외합니다. 본인 제공·타인 공개·개인화 Profile·Feed +> 어디에도 쓰이지 않으므로 판정 대상으로 삼을 이유가 없습니다. + +| 값 | 검색 신호 | 근거 | +|---|---|---| +| `PUBLIC` | 사용 | — | +| `PRIVATE_ONLY` | **사용** | 개인 검색은 본인 Context만 대상이고 Keyword를 응답에 노출하지 않으므로 §6의 공개 범위 판단과 충돌하지 않는다 | +| `BLOCKED` | **제외** | §2. 어디에도 쓰지 않는다 | + +「전혀 필터하지 않는다」가 아니다. **낡은 데이터에 `BLOCKED` 행이 남아 있을 가능성도 +방어한다** — 현행 시드에는 없으나(`PRIVATE_ONLY` 2건, `BLOCKED` 0건) 없음을 전제하지 않는다. + +#### 1-d. `confidence = NULL` + +NULL은 **정상적인 선택 결과**다(스키마가 `CHECK (confidence IS NULL OR ...)` 로 허용한다). +판정은 됐으나 확신도를 남기지 않은 것이므로 **keyword match 자체를 무효화하지 않는다.** + +「중립값」이라는 말만으로는 계산할 수 없으므로 fusion 방식별로 규칙을 명시한다. + +| 방식 | NULL 처리 | +|---|---| +| binary match | `confidence` 를 쓰지 않는다. NULL 여부가 무관하다 | +| confidence 가중 | **포함 · 제외 · 고정 대체값**을 별도 실험 축으로 비교한다 | + +**NULL을 0으로 치환해 match를 없애지 않는다** — 그러면 「판정된 적 없음」과 구분이 사라진다. +채택 규칙은 실험 결과로 정하고, **artifact에는 원래 NULL을 그대로 보존한다.** + +#### 1-e. fusion 방식 비교 + +`binary match` · `query-preset cosine × confidence` · `IDF 감쇠` · `RRF` 를 비교한다. + +**IDF 의 문서 단위는 Record 다 — Context 가 아니다.** 검색이 Record 를 반환하고 신호도 +Record 단위로 집계하므로(§1-a) df 도 같은 단위여야 한다. Context 로 세면 Context 가 많은 +Record 가 df 를 부풀려 **흔한 keyword 를 희소한 것처럼** 보이게 만든다. + +모집단도 신호와 같아야 한다 — `keyword_status = COMPLETED` 인 Context 만, `BLOCKED` 아닌 +Preset 만 센다(§1-b·§1-c). + +**그럼에도 IDF 는 참고 실험으로만 둔다.** Record 42건 규모에서 df 가 한 자릿수라 통계가 +아니라 잡음이 된다. binary·confidence 방식보다 우선하지 않으며, 결과 보고서에 작은 +코퍼스의 IDF 일반화 한계를 명시한다. + +각 방식과 가중치는 **artifact만 읽는 오프라인 sweep으로 비교 가능해야 한다**(§4). + +### 2단계 — 질의 이해 (LLM), 짧은 질의 한정 + +`-255`가 `③`으로 판정한 약어 이슈는 1단계로도 풀리지 않는다. 본문과 질의가 글자로도 +벡터로도 겹치지 않기 때문이다. 이것은 **질의가 도착하기 전에** 다뤄야 한다. + +두 가지를 함께 요구한다. + +``` +① 출력을 Preset 집합 안으로 제한한다 + 자유 생성이면 도메인 의도를 벗어난다. 유한 집합이면 환각의 여지가 구조적으로 없다. + +② 전 질의에 걸지 않는다 + 약한 구간은 짧은 질의다. 판별 함수가 `_is_word_query`로 이미 있다. +``` + +**폴백이 필수다.** LLM 실패 시 원본 질의를 임베딩하는 경로로 내려앉아야 하며, 그때의 +동작은 현행과 동일하다. 기존 벤더 폴백·재시도(`app/client/`) 위에 얹는다. + +이 단계는 **「검색 경로에 LLM을 두지 않는다」는 현행 설계 원칙을 바꾼다.** 지연·비용· +비결정성을 감수하는 결정이므로, 0단계 지표로 이득이 확인되기 전에는 실행하지 않는다. + +비결정성은 질의 단위 캐시로 흡수한다 — 같은 질의가 같은 결과를 내야 하네스가 성립한다. + +### 3단계 — 어휘 신호 (파트 간) + +「본문에 있음」을 직접 재는 신호다. §1의 결정적 한 줄에 정면으로 대응한다. + +**이 레포 단독으로는 불가능하다.** `ai` 스키마는 본문을 보유하지 않고, `core.*` 접근은 +P9([`spec/architecture.md`](../spec/architecture.md) §1)가 금지한다. + +| 경로 | 판단 | +|---|---| +| `ai`에 본문 복제 + 어휘 인덱스 | 본문 이중 보유. Migration은 back 소유. 경계 훼손 | +| Spring이 어휘 검색, AI가 벡터 검색, Spring이 병합 | **권장** — 본문 소유자가 본문을 검색한다 | + +권장안은 접근 경계를 건드리지 않는다. 다만 응답 계약과 검색 책임 분할이 바뀌므로 +**레포별 티켓과 PR로 분리하고 Jira에서 연결한다**(`CONTRIBUTING.md`). + +## 4. 하네스에 미치는 영향 — 오프라인 재구성 + +`tools/search_cut/README.md`가 기록한 현행 하네스의 성질이다. + +> 검색 경로에는 LLM이 없고 임베딩은 결정적이라 이쪽 재구성은 정확하다 — +> `verify_live.py`가 실서버와 일치를 확인한다. + +`sweep`류가 DB도 GMS도 부르지 않는 것은 행렬에 (질의 × Record) 코사인이 **전량** 굳어 +있기 때문이다. 점수가 코사인만의 함수가 아니게 되면 **그 성질이 깨진다.** + +**현행 artifact로는 1단계를 재구성할 수 없다.** 2026-08-05 확인 결과다. + +``` +artifact context_id 질의 벡터 / query-preset cosine keyword +matrix.json O X X +word_grid.json X X X ← 단어형 66질의 +recall_probe.json X X X ← 사례 4건 +``` + +`context_keyword` 는 `context_id` 조인이므로 **가장 약한 대역(단어형)과 표적 사례 4건이 +전부 조인 불가**다. 따라서 각 단계는 **행렬 생성 스크립트의 확장을 동반한다.** + +#### 4.1 artifact 확장 요구 + +**모집단은 검색 Query 와 같아야 한다**([`spec/personal-search.md`](../spec/personal-search.md) §4). +두 상태의 역할이 다르다. + +| 상태 | 역할 | 반영 위치 | +|---|---|---| +| `embedding_status = COMPLETED` | **검색 후보 자체의 조건.** 검색 Query 가 이 조건을 걸므로 미완료 Context 는 애초에 결과에 없다 | artifact 질의의 `WHERE` | +| `keyword_status` | **신호의 조건일 뿐.** 미완료여도 Context 는 남기고 신호만 뺀다(§1-b) | 컬럼으로 싣고 소비 시점에 판단 | + +`embedding_status` 를 빼먹으면 벡터 행렬에 없는 Record 가 keyword 신호로만 올라와 +**「코사인 없는 후보」**가 되고 §2.3 의 cosine float 계약이 깨진다. 반대로 `keyword_status` +를 `WHERE` 에 넣으면 §1-b 가 금지한 Context 제외가 된다. + +| 항목 | 비고 | +|---|---| +| `query` · `embedding_profile` | — | +| `context_id` | **`word_matrix.py` · `recall_probe.py` 에 추가한다** | +| **검색 가능한 Context 전량의 코사인** | §2.1 — 합집합 후보가 전부 코사인을 갖기 위한 조건 | +| `record_id` · vector cosine | 현행 유지 | +| 질의별 **전체 활성 Preset cosine** | 아래 §4.2 | +| Context의 `keyword_id` · `confidence` · `preset_version` | `confidence` 는 NULL 을 그대로 보존 | +| Context의 `keyword_status` | §1-b 판정용 | +| Preset의 `id` · `version` · `visibility` | §1-c 판정용 | +| 생성 시각 · 데이터셋 식별 정보 · 생성 조건 | **낡은 artifact 감지용** | + +#### 4.2 질의 벡터를 저장할 것인가, query-preset cosine을 저장할 것인가 + +이번 실험의 조절 대상은 **`query→Preset` 의 top-k 와 하한**이다. 그렇다면 질의 벡터 +자체가 아니라 **모든 활성 Preset에 대한 코사인을 전부 저장하는 것으로 충분하다** — +top-k 와 하한은 그 목록을 자르는 연산이므로 sweep에서 자유롭게 바꿀 수 있다. + +| 방식 | 크기 | 재현 범위 | +|---|---|---| +| 질의 벡터(1536 float) | 질의당 큼 | Preset이 바뀌어도 재계산 가능 | +| 전체 Preset cosine | 질의당 활성 Preset 수만큼 | **top-k·하한 조절에 충분**. Preset 개정 시 재생성 필요 | + +**후자를 우선 검토한다.** Preset이 개정되면 `preset_version` 이 바뀌고 그때는 어차피 +행렬을 다시 떠야 하므로, 전자의 이점이 실제 상황에서 크지 않다. 최종 선택은 크기 실측 +후 정하며 **어느 쪽이든 sweep에서 top-k와 하한을 바꿀 수 있어야 한다**는 것이 요구다. + +#### 4.3 호출 경계 + +``` +artifact 생성 GMS 임베딩 배치 1회 + DB 읽기 허용 (현행 matrix.py 와 같다) +rank·fusion sweep GMS 0회 · DB 0회 필수 +``` + +**기능보다 하네스를 먼저 확장한다.** 순서가 뒤집히면 가중치 하나 바꿀 때마다 GMS를 +부르게 되고, 이 레포의 실측 문화가 그 비용을 못 견딘다. + +`profile` · `preset_version` · 생성 조건을 artifact에 함께 저장해 **낡은 artifact로 +계산하는 것을 막는다** — 어긋나면 수치를 내지 않고 실패한다. + +## 5. 채택하지 않는 것 + +| 대안 | 기각 사유 | +|---|---| +| RAG(생성) | 검색 정확도를 올리지 않는다. 검색 결과를 소비할 뿐이며, 순위가 틀린 상태에서는 **침묵을 유창한 오답으로 바꾼다.** 답변 생성이 필요하면 별도 엔드포인트로 다루고 검색 개선 이후에 판단한다 | +| ANN 인덱스(HNSW·IVFFlat) | [P5](P5-exact-cosine.md)의 결정을 유지한다. 이것은 **규모** 대응이며 정확도 대응이 아니다. 현행 병목은 지연이 아니다 | +| Context 청킹 | 처리 단위가 이미 Context이고 그 경계는 사용자 입력이 정의한다([`spec/context-processing.md`](../spec/context-processing.md) §2). 시스템이 임의로 자를 근거가 없다 | +| 임베딩 입력에 `placeMeta` 결합 | **보류.** Profile 변경 대상이며 저장된 벡터 전량 재생성을 요구한다([`spec/model-profile.md`](../spec/model-profile.md)). 0단계 지표로 이득을 먼저 증명한 뒤 별도 제안으로 다룬다 | +| HyDE(가상 문서 임베딩) | **보류.** 짧은 질의 대역에 원리상 부합하나 대규모 운영 사례를 확인하지 못했고 지연 증가 보고가 있다. 2단계로 풀리지 않는 잔여분에 한해 재검토한다 | + +## 6. 채택 조건과 검증 + +### 6.1 채택 조건 + +``` +단어형과 문장형을 합산한 평균만으로 채택하지 않는다 두 대역이 겹치지 않는다 +기존 정답의 Hit@3 · Recall@3 퇴행이 없어야 한다 +최소 한 세그먼트에서 MRR 또는 nDCG 가 개선되어야 한다 +무관 질의 통과율이 유의미하게 나빠지면 기각한다 +``` + +**복수 정답 질의는 MRR 만으로 평가하지 않는다.** 단어형 정답은 본문 문자열 포함으로 +계산돼 한 질의에 여럿이며, MRR 은 첫 정답만 본다 — 정답 셋이 `1·2·3위` 든 `1·9·14위` 든 +값이 같다. `Recall@k` 와 `nDCG@k` 를 함께 본다. + +**개별 보고를 요구하는 것** + +- 사례 4건(`신한`·`부캠`·`그네`·`스팟`)의 순위 변화를 결과표에 남긴다. +- **Keyword Preset으로 표현할 수 없는 질의**는 별도 세그먼트로 보고한다. 이 신호가 닿지 + 않는 영역을 평균이 가리면 안 된다. + +**작은 코퍼스이므로 결과를 일반화하지 않는다.** 퇴행 탐지 근거로만 쓴다. + +### 6.2 검증 — RED/GREEN 이 아니라 재현성 + +이번 범위는 **앱 런타임 동작을 바꾸지 않고** `tools/` 하네스만 만든다. `CONTRIBUTING.md` +의 RED/GREEN 은 「동작 변경」에 걸리는 규율이므로 강제하지 않는다. 대신 넷을 검증한다. + +``` +① baseline 계산의 결정성 같은 입력에 같은 출력 +② 확장 artifact 재구성 = 원본 계산 DB/GMS 실계산과 정확 일치 (verify_live.py 와 같은 요구) +③ sweep 반복 실행 결과 일치 artifact 만 읽으므로 흔들릴 이유가 없다 +④ 잘못된 profile·version·context 매핑이면 계산하지 않고 실패 +``` + +**런타임 구현 단계에서는 RED/GREEN/Regression 절차를 적용한다.** 그 단계는 동작 변경이다. + +> ①과 ④는 0단계에서 확인됐다(I51 §6·§7 — 두 번 실행 바이트 일치, `profile` 가드 `exit 1`). + +## 7. 위험 + +| 위험 | 대응 | +|---|---| +| 신호를 늘렸는데 나빠진다 | 0단계 baseline 대비 판정. 각 단계는 되돌릴 수 있는 단위로 분리한다 | +| 오프라인 재구성이 깨져 측정 비용이 폭증 | §4 — 하네스를 기능보다 먼저 확장한다 | +| 2단계로 검색이 비결정적이 된다 | 질의 단위 캐시. 폴백 시 현행 동작으로 내려앉는다 | +| 컷 상수가 새 점수 스케일과 어긋난다 | §2.2 — fusion 방식별로 컷 규칙을 분리한다. 컷 재측정은 각 방식의 완료 조건이다 | +| 낡은 artifact 로 계산한다 | §4.1 — `profile`·`preset_version`·생성 조건을 함께 저장하고, 어긋나면 실패시킨다 | +| 코퍼스 규모의 일반화 한계 | 현행 행렬의 Record 수로는 개선폭이 실서비스로 일반화되지 않는다. **퇴행 탐지로만 쓰고**, 확대는 별도 트랙(`tools/demo_seed`)으로 분리한다 | + +## 8. 선결 조건과 미결 + +**선결 조건** + +1. ~~0단계 완료와 baseline 보존~~ — **완료**(I51). +2. artifact 확장. 데모 DB와 GMS 키 환경이 필요하다. +3. **티켓 발급.** 단계별로 분리하며, 3단계는 back 티켓과 연결한다. + **Jira 키 없이 런타임 구현을 시작하지 않는다**(`CONTRIBUTING.md`). + +**환경이 없을 때** + +- 0단계 하네스와 이 문서 개정까지만 진행한다. +- **artifact 값을 추정하거나 임의 생성하지 않는다.** +- DB/GMS 미실행으로 막힌 항목과 필요한 실행 명령을 결과에 남긴다. + +**미결** + +- 신호 합산 방식(가중합 · RRF)과 가중치 — 1단계 sweep으로 정한다. +- `confidence` NULL 처리 규칙 — 실험 축으로 비교해 정한다(§1-d). +- artifact가 질의 벡터를 담을지 전체 Preset cosine을 담을지 — 크기 실측 후(§4.2). +- 2단계의 실행 여부 — 「검색 경로에 LLM 없음」 원칙 변경이므로 1단계 결과를 보고 판단한다. +- 3단계의 병합 위치와 응답 계약 — back과 합의 전이다. +- 코퍼스 확대 시점과 방법. diff --git a/docs/proposals/README.md b/docs/proposals/README.md index b08a2bb..ed67efa 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -24,6 +24,7 @@ | [P43](P43-s1-judgment-recovery.md) | S1 구현 판단 변경·기각 대안 복원 | Accepted | AI | | [P44](P44-ai-repository-governance.md) | AI 레포 협업 운영 기준 | Accepted | AI | | [P47](P47-keyword-preset-label-axis.md) | Keyword 프리셋 표시 라벨·축 정의·스키마 개정안 | Proposed | AI | +| [P48](P48-search-signal-expansion.md) | 개인 검색의 신호 확장 — 단일 코사인에서 다신호로 | Proposed | AI | ## 제안 — 전수 (Accepted) From 907314585f466eac6be5b1774787c4eee83ec34f Mon Sep 17 00:00:00 2001 From: colosair Date: Wed, 5 Aug 2026 21:52:46 +0900 Subject: [PATCH 04/34] =?UTF-8?q?docs(S15P11A705-336):=20leo=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20I51=E2=86=92I52=20=EC=9E=AC=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=E2=80=94=20dev=20=EB=B3=91=ED=95=A9=EB=B6=84(-273)?= =?UTF-8?q?=EC=9D=B4=20=EC=84=A0=EC=A0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/implements/README.md | 4 ++-- docs/proposals/P48-search-signal-expansion.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/implements/README.md b/docs/implements/README.md index 7a54f5d..afa317c 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -51,7 +51,7 @@ | I46 | [2026-08-03-preset-description.md](2026-08-03-preset-description.md) | 검증 | 프리셋 `description`·`examples` 개정 3조건 측정 — **`examples` 만 채택(`E`), `description` 개정은 기각.** 네 티켓 만에 처음 채택한다. 두 필드가 들어가는 곳이 달라(`examples` 는 임베딩만, `description` 은 임베딩+판정 프롬프트) 갈라 쟀고, 갈라 재지 않았으면 `D` 의 해로움이 `DE` 안에 묻혔다. `E` 오분류 11.00→6.90행(p=0.0001)·정상 손실 -0.80(p=0.16)·교환비 0.20·**순이득 부호가 양극단 모두 양수**(네 티켓 중 유일). `D` 는 `fit 0건 Context` 8.20→10.00 으로 **악화**(범위 분리)하고 안 고친 22종이 정상 -2.50 를 치른다. 자기충족 방어 셋(라벨 무관 수정 원칙·안 고친 22종 대조·라벨 밖 홀드아웃 30건). T68(임베딩 비결정성)·T69(τ 격자가 rank 로 밀린 행을 안 셈) 발견·우회 (S15P11A705-228) | | I48 | [2026-08-03-dev-deploy-gap.md](2026-08-03-dev-deploy-gap.md) | 감사 | `dev` 병합이 배포에 닿지 않는 구간 — **`ai-image / publish` SKIPPED 는 고장이 아니라 설계다.** `ai-ci.yml` 안의 `image-publish` job 이고 `push` × `refs/heads/main` 두 조건이 모두 필요하다(별도 워크플로가 아니라 워크플로 목록에 안 보인다). 근거가 주석·계약 테스트·`infra` 어서션·`CONTRIBUTING` 넷이라 한 곳의 실수일 수 없다. `apps/dev/ai` 가 pin 하는 것은 **`ai` 의 `main` HEAD** 이고 `ai` 의 `dev` 브랜치는 배포 경로에 없다 — 끊긴 것은 파이프라인이 아니라 릴리스 PR 이다. `ai-image-update` 의 success 는 「갱신했다」가 아니라 **「확인했고 갱신할 것이 없었다」**(자동화는 08-03 00:37Z 에 되살아나 `infra#172` 로 한 번 동작했다). 새 봉인 값 **`preset-ab321360b0df`** 산출 — 방식은 파일 전체 SHA-256 앞 12자이며 문서에 적힌 64자와 전량 대조해 확정했다. **`bootstrap.version` 은 어떤 자동화도 갱신하지 않고 틀려도 아무 신호가 없다** (티켓 없음) | | I49 | [2026-08-03-gms-image-probe.md](2026-08-03-gms-image-probe.md) | 검증 | GMS 이미지 실사용 조건 두 축 — **축 A 생성은 지원한다**(`gpt-image-1`·`gemini-2.5-flash-image` 둘 다 1024×1024 PNG, 게이트웨이에 모델 allowlist 가 있어 `dall-e-3`·`imagen-3` 은 차단). **축 B 는 가설 C** — 토큰은 **치수**에 붙고 **바이트에는 안 붙는다**(512×512 고정에서 바이트 36배에 `prompt_tokens` 8,523 불변). `-227` 의 8,524 는 게이트웨이 가산이 아니라 `gpt-4o-mini` 의 타일 요금이다(텍스트 23 + 2,833 + 5,667×타일, 7개 조건 잔차 0). **그리고 더 급한 것이 나왔다 — 게이트웨이 요청 본문 상한이 96,024 B 통과 · 129,564 B 거부 사이에 있고, 넘으면 「Model not found in request」로 온다**(`-205` T62·`-225` 와 같은 원인). 실사용 대화 캡처(200 KB~2 MB)는 토큰을 쓰기 전에 이 벽에 먼저 막힌다 — `ai#98` 의 전제가 바뀐다. 정책 거절은 벤더마다 층이 달라 OpenAI 는 400, **Gemini 는 200 + `finishReason=PROHIBITED_CONTENT`** 다 (S15P11A705-253) | -| I51 | [2026-08-05-search-rank-baseline.md](2026-08-05-search-rank-baseline.md) | 구현 | 검색 **순위** 지표 baseline `tools/search_cut/rank_score.py` — 기존 지표가 전부 컷 기준이라 순위 변화를 못 센다(P48 1단계 이후는 순위를 바꾸는 변경이라 개선이 0으로 보인다). 기존 행렬만 읽어 Hit·Recall·MRR·nDCG@1/3/5 를 **컷 전/후 · 단어형/문장형 · 정답질의/무관질의**로 갈라 낸다. **재구성 일치 두 건** — 무관-문장형 침묵률 11/15 와 `스팟` 0.2438 이 기존 기록과 같다. **`-255` ① 판정이 이미 닫혀 있음을 확인**(`-266` 의 단어형 분기가 `그네` 를 회복시켰다). `신한`·`부캠` 은 `tau_word` 와 `r·top1` **양쪽 모두 아래**라 컷 조절로 닿지 않는다 (티켓 없음 · 런타임 변경 없음) | +| I52 | [2026-08-05-search-rank-baseline.md](2026-08-05-search-rank-baseline.md) | 구현 | 검색 **순위** 지표 baseline `tools/search_cut/rank_score.py` — 기존 지표가 전부 컷 기준이라 순위 변화를 못 센다(P48 1단계 이후는 순위를 바꾸는 변경이라 개선이 0으로 보인다). 기존 행렬만 읽어 Hit·Recall·MRR·nDCG@1/3/5 를 **컷 전/후 · 단어형/문장형 · 정답질의/무관질의**로 갈라 낸다. **재구성 일치 두 건** — 무관-문장형 침묵률 11/15 와 `스팟` 0.2438 이 기존 기록과 같다. **`-255` ① 판정이 이미 닫혀 있음을 확인**(`-266` 의 단어형 분기가 `그네` 를 회복시켰다). `신한`·`부캠` 은 `tau_word` 와 `r·top1` **양쪽 모두 아래**라 컷 조절로 닿지 않는다 (티켓 없음 · 런타임 변경 없음) | | I50 | [2026-08-03-preset-display-name-deploy-chain.md](2026-08-03-preset-display-name-deploy-chain.md) | 검증 | 표시명 개정의 배포 체인 여섯 단계와 소관 — **봉인 값은 판 표기이지 부트스트랩 재실행 조건이 아니다.** 낡은 봉인 값 위에서 새 표시명이 운영 화면까지 온 것이 곧 재실행의 증거다(`hook-delete-policy` 가 같은 이름 Job 을 지우고 다시 만들고, 적재는 멱등이다). 중앙이 「값이 같으면 재실행 안 된다」로 판정했고 독립 검증이 논거를 반증했으나 클러스터 관측 경로가 없어 「확인 불가」로 끝났던 것을 **배포가 닫았다.** 릴리스가 두 번 막힌다 — base 검사(`main` base 는 `release/*`·`hotfix/*` 만)와 strict 상태 검사(BEHIND 거부). 화면 확인이 유일한 검증이라 이르게 보면 「안 됐다」로 오판한다 (S15P11A705-292) | | I51 | [2026-08-05-short-query-boundary.md](2026-08-05-short-query-boundary.md) | 검증 | 짧은 질의의 층별 탈락과 단어형 경계 두 정의 — **`-266` 이후 남은 실패는 셋뿐이고 경계 정의로는 하나도 안 풀린다.** `신한`(③ τ_abs·6위)·`부캠`(③ τ_abs·8위)·`신한 부캠`(**④ r**·4위)이고 실서버 대조 22/22 일치로 층을 확정했다(전처리·폴백은 **존재하지 않는다** — `min_length=1` 뿐이고 0건이면 0건이 그대로 나간다). **「2자가 안 걸린다」는 부정확** — 2자 7건 중 5건은 걸리고 못 걸리는 둘은 컷 전 순위가 6·8위다. `-266` 이 「재지 않았다」고 적은 B(공백·짧다)·C(무공백·길다) 대역을 **본문 인접 어절쌍 전량**(쌍 176종 × spaced/joined × 소유자 3명 = 1,128행)으로 처음 쟀다 — 손실은 C 에 몰려 있고(16/122 → 어절 수 정의 3/122) **그런데 초점 집합 36행에서는 어느 정의에서도 누락이 0** 이라 두 표가 어긋난다(회복되는 것이 기능어 쌍이다). 새 관측 둘: **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385 · 172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 τ 탈락 — `-266` 이 「안전 방향」이라 부른 판단이 이 대역에서는 손해다). 계약이 「이것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하므로 **세 번째다** (S15P11A705-273) | @@ -88,7 +88,7 @@ | I27 | 임베딩 4조건 측정 하네스 `tools/emb_grid/` — 조건 정본 하나(`conditions.py`)를 셸에 `eval` 로 넘겨 값이 갈라지지 않게 하고, 조건이 환경과 어긋나면 **재기 전에 멈춘다**(profile·차원·프리셋 적재 여부·FastAPI 도달). `alter_dim.py` 는 로컬 DB 차원을 바꾸고 되돌린다 — Flyway 를 만들지 않는 것이 이 측정의 계약이다 | [4조건 측정](2026-07-31-embedding-grid.md), [tools/emb_grid/](../../tools/emb_grid/) | | I28 | GMS 호출·재선점 로그 계측 — 호출 1회당 벤더·모델·상태·결과 분류·지연, 60초 창 실패율 집계, 만료 `PROCESSING` 재선점. `_usage.py`(토큰)와 합치지 않은 근거와 `try_start` CTE 재작성. **httpx 가 요청 URL 을 INFO 로 흘리던 것을 함께 차단** | [GMS 호출 관측](2026-07-31-gms-call-observability.md), [failure-recovery §2.4](../spec/failure-recovery.md) | | I29 | 검색 결과 컷 하네스 `tools/search_cut/` — 질의를 한 번 임베딩해 굳히고 `τ_abs × r` 격자를 오프라인으로 훑는다(GMS 배치 1회). **정답이 없는 무관 질의 15건을 별도 축으로 둔다** — 검증 질의만 재면 `r` 이 무관 질의를 침묵시키지 못한다는 사실이 보이지 않는다. `verify_live.py` 가 실서버와 27/27 정확 일치를 확인(`-210` 과 달리 근사가 아니라 대조군 불필요) | [컷 측정](2026-07-31-search-cut.md), [tools/search_cut/](../../tools/search_cut/) | -| I51 | 검색 순위 지표 하네스 `tools/search_cut/rank_score.py` — 기존 행렬 셋만 읽어(**DB 0회·GMS 0회**) Hit@k·Recall@k·MRR·nDCG@k 를 낸다. **세 가지를 가른다** — 컷 전/후(컷 후만 보면 순위 개선이 가려진다), 단어형/문장형(대역이 겹치지 않아 합산 평균이 어느 쪽도 설명하지 못한다), 정답질의/무관질의(좋은 방향이 반대라 섞으면 상쇄된다). **MRR 만으로 판정하지 않는다** — 단어형 정답이 본문 문자열 포함으로 계산돼 복수라, 정답 3개가 `1·2·3위` 든 `1·9·14위` 든 MRR 이 같다. **재구성이 맞다는 증거 두 건이 독립적으로 나왔다**(무관-문장형 침묵 11/15 = `-213` 기록 · `스팟` 0.2438 = `config.py` 주석의 마진 0.0038). 비상 스위치를 단어형 분기보다 앞에 두는 **순서까지** 원본대로 옮겼다. 가드 4종은 수치를 내지 않고 실패한다(profile 불일치·필드 누락·정답질의에 정답 0건·무관질의에 정답 존재). 결정성은 두 번 실행 바이트 일치로, 입력 추적은 SHA-256 기록으로 확인. **판정 하나가 바뀌었다** — `-255` 의 ①(`그네` 는 컷이 잘랐다)은 `-266` 의 단어형 분기로 **이미 해소**됐고, `신한`·`부캠` 은 `tau_word`·`r·top1` **양쪽 모두 아래**라 한쪽만 풀어도 회복되지 않는다(`r` 은 top1 이 높을수록 세게 자르므로 무관한 1위가 강할수록 정답이 확실히 잘린다) | [순위 baseline](2026-08-05-search-rank-baseline.md), [P48](../proposals/P48-search-signal-expansion.md), [tools/search_cut/rank_score.py](../../tools/search_cut/rank_score.py) | +| I52 | 검색 순위 지표 하네스 `tools/search_cut/rank_score.py` — 기존 행렬 셋만 읽어(**DB 0회·GMS 0회**) Hit@k·Recall@k·MRR·nDCG@k 를 낸다. **세 가지를 가른다** — 컷 전/후(컷 후만 보면 순위 개선이 가려진다), 단어형/문장형(대역이 겹치지 않아 합산 평균이 어느 쪽도 설명하지 못한다), 정답질의/무관질의(좋은 방향이 반대라 섞으면 상쇄된다). **MRR 만으로 판정하지 않는다** — 단어형 정답이 본문 문자열 포함으로 계산돼 복수라, 정답 3개가 `1·2·3위` 든 `1·9·14위` 든 MRR 이 같다. **재구성이 맞다는 증거 두 건이 독립적으로 나왔다**(무관-문장형 침묵 11/15 = `-213` 기록 · `스팟` 0.2438 = `config.py` 주석의 마진 0.0038). 비상 스위치를 단어형 분기보다 앞에 두는 **순서까지** 원본대로 옮겼다. 가드 4종은 수치를 내지 않고 실패한다(profile 불일치·필드 누락·정답질의에 정답 0건·무관질의에 정답 존재). 결정성은 두 번 실행 바이트 일치로, 입력 추적은 SHA-256 기록으로 확인. **판정 하나가 바뀌었다** — `-255` 의 ①(`그네` 는 컷이 잘랐다)은 `-266` 의 단어형 분기로 **이미 해소**됐고, `신한`·`부캠` 은 `tau_word`·`r·top1` **양쪽 모두 아래**라 한쪽만 풀어도 회복되지 않는다(`r` 은 top1 이 높을수록 세게 자르므로 무관한 1위가 강할수록 정답이 확실히 잘린다) | [순위 baseline](2026-08-05-search-rank-baseline.md), [P48](../proposals/P48-search-signal-expansion.md), [tools/search_cut/rank_score.py](../../tools/search_cut/rank_score.py) | | I30 | 후보 임계값 τ 측정 하네스 `tools/tau_grid/` — 42×27 유사도 행렬을 한 번 떠서 임의의 τ 를 **GMS 호출 없이 재구성**한다. 라벨 83행(`labels.yaml`)이 채점 기준이고 `unclear` 를 따로 둬 낙관·비관 양 끝을 함께 낸다 | [τ 재검증 리포트](2026-07-31-candidate-threshold.md), [tools/tau_grid/](../../tools/tau_grid/) | | I41 | 외부 API 재시도·오류 분류 정합화 `app/core/errors.py`(`classify_http_status` 단일화)·`app/client/retry.py`(`RetryPolicy`) — 두 클라이언트가 상태 코드를 정반대로 분류하던 구현 결함(429→Transient, LLM 400/401/403→Permanent)을 고쳤다. `SchemaViolationError` 로 구조화 출력 위반을 하위 타입 처리, `PermanentError → _fail()` 결선 추가 (S15P11A705-121) | [재시도·오류 분류 리포트](2026-07-30-retry-and-error-classification.md) | | I43 | 실사용자 데이터 E2E 검증 — 가공 14건 + 실사용자 기록 23건 = 37건 통합 경로. 검색 정확도 10/12(83.3%) · 탐색 피드 PASS · Keyword PASS, 시딩 42초 · 토큰 32,912(판정이 임베딩의 17배) (S15P11A705-174) | [실데이터 E2E 리포트](2026-07-30-real-data-e2e.md) | diff --git a/docs/proposals/P48-search-signal-expansion.md b/docs/proposals/P48-search-signal-expansion.md index 20adc79..29a22a9 100644 --- a/docs/proposals/P48-search-signal-expansion.md +++ b/docs/proposals/P48-search-signal-expansion.md @@ -5,10 +5,10 @@ - **주도(Driver)**: AI 파트 - **관련 PR/커밋**: 없음(구현 전) - **관련 티켓**: 미발급 -- **근거 리포트**: [implements/2026-08-05-search-rank-baseline.md](../implements/2026-08-05-search-rank-baseline.md)(I51 · 0단계 산출) · [implements/2026-07-31-search-cut.md](../implements/2026-07-31-search-cut.md)(`S15P11A705-213`) · [implements/2026-08-03-search-recall-probe.md](../implements/2026-08-03-search-recall-probe.md)(`S15P11A705-255`) · [implements/2026-08-03-word-query-cut.md](../implements/2026-08-03-word-query-cut.md)(`S15P11A705-266`) +- **근거 리포트**: [implements/2026-08-05-search-rank-baseline.md](../implements/2026-08-05-search-rank-baseline.md)(I52 · 0단계 산출) · [implements/2026-07-31-search-cut.md](../implements/2026-07-31-search-cut.md)(`S15P11A705-213`) · [implements/2026-08-03-search-recall-probe.md](../implements/2026-08-03-search-recall-probe.md)(`S15P11A705-255`) · [implements/2026-08-03-word-query-cut.md](../implements/2026-08-03-word-query-cut.md)(`S15P11A705-266`) > **0단계는 반영이 끝났고 나머지는 여전히 제안이다.** `tools/search_cut/rank_score.py` 와 -> baseline 이 있으므로 §3 0단계는 제안이 아니라 기록이다(I51). **앱 런타임은 아직 어느 +> baseline 이 있으므로 §3 0단계는 제안이 아니라 기록이다(I52). **앱 런타임은 아직 어느 > 절도 건드리지 않았고 티켓도 발급되지 않았다.** > > 0단계는 다른 절의 **선결 조건**이었다 — 그것 없이 1단계 이후를 실행하면 순위 개선이 @@ -166,7 +166,7 @@ keyword 신호가 있는데 코사인이 없는 Record 가 나오면 그것은 * > **2026-08-05 반영됨.** `tools/search_cut/rank_score.py` 와 baseline > `.search/rank_baseline.json` 이 있다. 결과와 판정은 -> [implements/2026-08-05-search-rank-baseline.md](../implements/2026-08-05-search-rank-baseline.md)(I51). +> [implements/2026-08-05-search-rank-baseline.md](../implements/2026-08-05-search-rank-baseline.md)(I52). > 이 절은 제안이 아니라 기록이다. > > **판정 하나가 바뀌었다** — `-255` 의 ①(`그네` 는 컷이 잘랐다)은 `-266` 의 단어형 분기로 @@ -441,7 +441,7 @@ rank·fusion sweep GMS 0회 · DB 0회 필수 **런타임 구현 단계에서는 RED/GREEN/Regression 절차를 적용한다.** 그 단계는 동작 변경이다. -> ①과 ④는 0단계에서 확인됐다(I51 §6·§7 — 두 번 실행 바이트 일치, `profile` 가드 `exit 1`). +> ①과 ④는 0단계에서 확인됐다(I52 §6·§7 — 두 번 실행 바이트 일치, `profile` 가드 `exit 1`). ## 7. 위험 @@ -458,7 +458,7 @@ rank·fusion sweep GMS 0회 · DB 0회 필수 **선결 조건** -1. ~~0단계 완료와 baseline 보존~~ — **완료**(I51). +1. ~~0단계 완료와 baseline 보존~~ — **완료**(I52). 2. artifact 확장. 데모 DB와 GMS 키 환경이 필요하다. 3. **티켓 발급.** 단계별로 분리하며, 3단계는 back 티켓과 연결한다. **Jira 키 없이 런타임 구현을 시작하지 않는다**(`CONTRIBUTING.md`). From b0921d9a7362cd8529febfb96af62cc3dba677f8 Mon Sep 17 00:00:00 2001 From: colosair Date: Wed, 5 Aug 2026 21:54:06 +0900 Subject: [PATCH 05/34] =?UTF-8?q?fix(S15P11A705-336):=20=ED=95=98=EB=84=A4?= =?UTF-8?q?=EC=8A=A4=20=EB=B2=84=EA=B7=B8=202=EA=B1=B4=20=E2=80=94=20pgvec?= =?UTF-8?q?tor=20Vector=20=EB=B0=98=ED=99=98=ED=98=95=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=C2=B7stdout=20UTF-8=20=EC=9E=AC=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keyword_matrix._parse_vector 가 코덱 등록 커넥션의 Vector 객체에서 TypeError 로 중단됐다(T76 잠정). 문자열·Vector·iterable 세 반환형을 모두 받는다 + 회귀 테스트. rank_score.py·fusion_sweep.py 는 cp949 콘솔에서 — 출력 시 죽었다(T77 잠정) — keyword_matrix.py 와 같은 reconfigure 방어를 복제한다. Co-Authored-By: Claude Fable 5 --- tests/test_keyword_matrix_parse.py | 36 ++++++++++++++++++++++++++++++ tools/search_cut/fusion_sweep.py | 7 ++++++ tools/search_cut/keyword_matrix.py | 8 ++++++- tools/search_cut/rank_score.py | 8 +++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/test_keyword_matrix_parse.py diff --git a/tests/test_keyword_matrix_parse.py b/tests/test_keyword_matrix_parse.py new file mode 100644 index 0000000..d7b8ad0 --- /dev/null +++ b/tests/test_keyword_matrix_parse.py @@ -0,0 +1,36 @@ +"""`keyword_matrix._parse_vector` — pgvector 반환형 3종을 모두 받는다 (T76 회귀). + +pgvector 는 코덱 등록 여부에 따라 반환형이 다르다. `app.core.db.Database` 경유는 +`Vector` 객체(iterable 아님, `to_list()` 보유)로, raw asyncpg 는 문자열로 온다. +`Vector` 분기가 빠지면 실측이 TypeError 로 중단된다 — 실제로 그랬다(T76). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "search_cut")) + +from keyword_matrix import _parse_vector # noqa: E402 + + +class _FakeVector: + """pgvector `Vector` 의 형태 — iterable 이 아니고 `to_list()` 만 있다.""" + + def __init__(self, values): + self._values = tuple(values) + + def to_list(self): + return list(self._values) + + +def test_string_form(): + assert _parse_vector("[0.1, 0.2,0.3]") == [0.1, 0.2, 0.3] + + +def test_vector_object_form(): + assert _parse_vector(_FakeVector((0.1, 0.25))) == [0.1, 0.25] + + +def test_iterable_form(): + assert _parse_vector([1, 2]) == [1.0, 2.0] diff --git a/tools/search_cut/fusion_sweep.py b/tools/search_cut/fusion_sweep.py index 19997a2..4049586 100644 --- a/tools/search_cut/fusion_sweep.py +++ b/tools/search_cut/fusion_sweep.py @@ -46,6 +46,13 @@ ROOT = Path(__file__).resolve().parents[2] SEARCH = ROOT / ".search" +# cp949 콘솔에서 `—` 한 글자에 죽지 않게 한다(T28·T77). `keyword_matrix.py` 와 같은 방어다. +for _s in (sys.stdout, sys.stderr): + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + CASES = ("신한", "부캠", "그네", "스팟") diff --git a/tools/search_cut/keyword_matrix.py b/tools/search_cut/keyword_matrix.py index fee1909..7d16c23 100644 --- a/tools/search_cut/keyword_matrix.py +++ b/tools/search_cut/keyword_matrix.py @@ -138,9 +138,15 @@ def collect_queries() -> tuple[list[str], dict]: def _parse_vector(raw) -> list[float]: - """pgvector 는 드라이버에 따라 문자열로 온다(`'[0.1,0.2,...]'`).""" + """pgvector 의 반환형은 코덱 등록 여부에 따라 다르다 — 세 형태를 모두 받는다. + + `app.core.db.Database` 는 커넥션에 pgvector 코덱을 등록하므로 `Vector` 객체로 오고 + (iterable 이 아니라 `to_list()` 로 꺼낸다 — T17·T76), raw asyncpg 는 문자열로 온다. + """ if isinstance(raw, str): return [float(x) for x in raw.strip("[]").split(",")] + if hasattr(raw, "to_list"): + return [float(x) for x in raw.to_list()] return [float(x) for x in raw] diff --git a/tools/search_cut/rank_score.py b/tools/search_cut/rank_score.py index e7d3ddc..270d59a 100644 --- a/tools/search_cut/rank_score.py +++ b/tools/search_cut/rank_score.py @@ -68,6 +68,14 @@ ROOT = Path(__file__).resolve().parents[2] SEARCH = ROOT / ".search" +# cp949 콘솔에서 `—` 한 글자에 죽지 않게 한다(T28·T77). 호출자가 PYTHONIOENCODING 을 +# 기억해야 하는 상태를 남기지 않는다 — `keyword_matrix.py` 와 같은 방어다. +for _s in (sys.stdout, sys.stderr): + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + # 정본은 `app/core/config.py` 다. 하네스가 앱을 import 하지 않는 것은 기존 sweep 과 같다 # (앱 import 는 설정·환경변수를 요구해 오프라인 성질을 깬다). 값이 갈리면 --tau 계열로 # 덮어쓰고, 출력에 실제 사용값을 적어 어긋남이 드러나게 한다. From 84844243aa793a5f28dfde31e5fef44399a63314 Mon Sep 17 00:00:00 2001 From: colosair Date: Wed, 5 Aug 2026 21:54:53 +0900 Subject: [PATCH 06/34] =?UTF-8?q?docs(S15P11A705-336):=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=A0=ED=98=B8=20=EC=A1=B0=EC=82=AC=20=ED=8A=B8=EB=9F=AC?= =?UTF-8?q?=EB=B8=94=EC=8A=88=ED=8C=85(T73~T78)=C2=B7P49=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=20=EB=8F=99=EC=8A=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션 조사·대행 실측의 영구 기록 2건. P49 의 P48 링크가 이 브랜치에서 해소된다. 번호는 잠정이며 병합 직전 확정한다(T48·T60). Co-Authored-By: Claude Fable 5 --- docs/proposals/P49-multi-signal-search.md | 210 +++++++++++++++ docs/proposals/README.md | 1 + .../2026-08-05-multi-signal-investigation.md | 247 ++++++++++++++++++ docs/troubleshooting/README.md | 7 + 4 files changed, 465 insertions(+) create mode 100644 docs/proposals/P49-multi-signal-search.md create mode 100644 docs/troubleshooting/2026-08-05-multi-signal-investigation.md diff --git a/docs/proposals/P49-multi-signal-search.md b/docs/proposals/P49-multi-signal-search.md new file mode 100644 index 0000000..ec2bac1 --- /dev/null +++ b/docs/proposals/P49-multi-signal-search.md @@ -0,0 +1,210 @@ +# P49. 다신호 검색 개선 제안 + +- **상태**: Proposed +- **날짜**: 2026-08-05 +- **주도(Driver)**: AI +- **관련 PR/커밋**: 없음(구현 전) +- **관련 문서**: [P48](P48-search-signal-expansion.md)(`origin/leo`, 인수 예정) · 근거 실측 기록 [2026-08-05-multi-signal-investigation.md](../troubleshooting/2026-08-05-multi-signal-investigation.md)(T73~T78 잠정) · 관련 트랙 `S15P11A705-336` +- **번호는 잠정이다.** P48 은 `origin/leo` 브랜치가 사용 중이다. 병합 직전에 번호를 재확인해 확정한다(T48·T60 규칙). + +> 이 문서의 실측 수치는 2026-08-05 측정 시점의 관측값이다. 현행 상수의 정본은 `app/core/config.py` 이고, 측정 기록의 정본은 `-336` 실측 리포트로 정식화할 예정이다. 부록 D 에 근거 문서의 좌표를 모아 두었다. + +## 1. 제안 요약 + +**현재 검색의 문제.** 현행 검색의 판단 근거는 질의와 기록 사이의 임베딩 유사도 하나다. 이 구조로 해결할 수 없는 실패 사례 3건이 측정으로 확정됐다. 유사도 컷 값을 어떻게 조정해도 이 3건은 회복되지 않는다. + +**추가하려는 검색 방식.** 기존 벡터 검색은 그대로 두고, 세 가지를 더한다. + +1. **LLM 질의 재작성** — 검색 전에 LLM 이 약어·구어를 임베딩이 이해하는 표기로 바꿔 쓴다. +2. **Preset 키워드 점수** — 저장 시점에 이미 판정해 둔 키워드를 검색 순위에 반영한다. leo 가 설계·구현한 P48 1단계를 그대로 인수한다. +3. **문자열 검색** — 기록 본문에 질의 문자열이 그대로 있는 경우를 별도 경로로 찾는다. 본문을 소유한 Spring 이 수행하고 병합한다. + +**기대 효과.** 실패 사례 3건이 모두 해소된다. `부캠`과 `신한 부캠`은 질의 재작성으로, `신한`은 문자열 검색으로 해결된다. Preset 키워드 점수는 그 외 질의의 순위를 개선한다. + +**가장 큰 위험.** 관련 없는 질의에서 결과가 노출되기 시작하는 것이다. 현행 검색은 관련 없는 문장형 질의 15건 중 11건에서 결과를 노출하지 않는데, 새 신호를 잘못 병합하면 이 성질이 깨진다는 것이 실측으로 확인됐다(§5). 그래서 병합 규칙마다 별도 검증을 요구한다. + +**검증 전에는 배포하지 않는다.** 모든 작업은 통합 브랜치에 격리하고, §7 의 검증 기준을 전부 통과한 뒤에만 dev 로 병합한다(§6). + +## 2. 현재 검색으로 해결하지 못하는 문제 + +측정으로 확정된 실패 사례가 3건 있다. 세 건의 원인이 서로 다르고, 해결 수단도 서로 다르다. + +**사례 1 — `부캠` (약어).** 사용자가 `부캠`으로 검색하면 「신한 부트캠프 친구들과 자주 먹었던 돈카츠 집」 기록이 나와야 한다. 그런데 임베딩 모델은 `부캠`과 `부트캠프`가 같은 말이라는 것을 연결하지 못한다. 정답의 유사도 순위는 컷 적용 전 기준으로도 8위(유사도 0.2105)라, 컷 값을 낮춰도 회복되지 않는다. 본문에는 「부트캠프」만 있고 「부캠」이 없어 문자열 검색으로도 찾을 수 없다. 「부캠이 부트캠프의 줄임말」이라는 일반 지식이 필요한 문제이고, 검토한 대안 중에서는 LLM 재작성이 가장 현실적인 해결 수단이다. 재작성이 만들 목표 문장은 이미 측정되어 있다. `부트캠프`로 검색하면 정답이 1위(유사도 0.3254)다. + +**사례 2 — `신한 부캠` (약어 + 상대 컷).** 이 질의의 정답은 유사도 기준을 넘고도(0.3187 > 0.30) 검색 결과에서 빠진다. 1위 결과의 유사도(0.5867)가 높아서, 1위 대비 일정 비율(r=0.6) 아래의 결과를 자르는 상대 컷에 걸리기 때문이다. 재작성으로 `신한 부트캠프`가 되면 유사도가 0.3793 으로 올라 컷을 통과한다. + +**사례 3 — `신한` (문자열은 있는데 순위가 낮음).** 본문에 「신한」이 글자 그대로 있는 기록이 있는데도, 그 기록의 유사도(0.2301)가 본문에 아무 관련이 없는 질의의 1위 유사도(0.2953)보다 낮다. 관련 있는 것과 없는 것의 유사도 대역이 역전되어 있어, 어떤 컷 값으로도 정답만 남길 수 없다. `신한`은 온전한 고유명사라 재작성으로 바꿔 쓸 것도 없다. 본문에 문자열이 그대로 있으므로 문자열 검색이 해결 수단이다. + +세 사례의 자세한 측정 과정은 `-255`(원인 판별)와 `-273`(층별 관측)에 있다. + +## 3. 제안하는 검색 구조 + +네 가지 검색 방식이 각자 다른 문제를 맡는다. 비교표에서는 보조 식별자 A~D 를 함께 쓴다. + +**기존 벡터 검색 (A).** 질의를 임베딩해 코사인 유사도로 후보를 찾고, 절대 하한(τ)과 상대 컷(r)으로 관련 없는 결과를 자른다. 이 경로는 바꾸지 않는다. 컷 값과 적용 위치도 그대로 둔다. 세 차례 측정(`-213`·`-266`·`-273`)으로 검증된 유일한 경로이고, 관련 없는 질의에서 결과를 노출하지 않는 성질을 이 경로의 컷이 보증하기 때문이다. + +**LLM 질의 재작성 (C).** 검색 요청이 들어오면 임베딩 전에 LLM 이 질의를 한 번 다듬는다. 약어를 풀고(`부캠`→`부트캠프`), 구어를 서술형으로 편다. 재작성된 문장을 임베딩해 벡터 검색에 넘긴다. LLM 호출이 실패하거나 시간을 초과하면 원래 질의를 그대로 임베딩한다. 이 경우의 동작은 현행 검색과 완전히 같다. 같은 질의는 캐시로 같은 재작성 결과를 보장한다. + +이 방식은 P48 2단계의 개정이다. P48 2단계는 LLM 출력을 Preset 목록으로 제한하는데, 그러면 Preset 에 없는 `부트캠프`로 확장할 수 없어 약어 문제를 해결하지 못한다(근거: T74). 제한을 풀고, 잘못된 재작성에 대한 방어는 원문 복귀·캐시·프롬프트 규칙(확신이 없으면 원문 유지)으로 옮긴다. + +**Preset 키워드 점수 (B).** 기록을 저장할 때 파이프라인이 이미 각 기록에 Preset 키워드를 판정해 둔다. 현행 검색은 이 판정을 읽지 않는다. 이 방식은 질의와 Preset 의 임베딩 유사도로 질의에 맞는 키워드를 고르고, 그 키워드를 가진 기록의 순위를 올린다. LLM 호출이 없다. leo 가 P48 1단계로 설계와 오프라인 구현을 완료했고 측정 하네스와 테스트까지 있으므로 그대로 인수한다. + +**문자열 검색 (D).** 기록 본문에 질의 문자열이 그대로 있는지 확인한다. 본문(`core.context.body`)은 Spring 소유 스키마에 있고 FastAPI 의 접근은 공용 계약이 금지하므로, 이 검색과 결과 병합은 Spring 이 수행한다. P48 3단계가 권장한 위치와 같다. 문자열이 있다고 해서 그 기록이 질의의 대상이라는 뜻은 아니므로, §5 의 검증 절차를 함께 둔다. + +## 4. 검색 결과를 합치는 방법 + +처리 순서대로 적는다. + +1. 질의가 들어오면 LLM 재작성을 시도한다. 실패하면 원문을 쓴다. +2. (재작성된) 질의를 임베딩해 벡터 검색을 수행하고, 기존 컷(τ·r)을 원래 코사인 값 기준으로 적용한다. 여기서 살아남은 후보가 결과의 기본 집합이다. +3. Preset 키워드 점수는 이 기본 집합의 순서를 조정한다. 어떤 후보를 결과에 넣을지는 바꾸지 않는다. +4. 문자열 검색의 결과 중 §5 의 검증을 통과한 것을 결과에 추가한다. 추가 위치는 보수적으로 정한다. 벡터 검색이 찾은 후보를 밀어내지 않는다. +5. 응답의 `similarity` 필드는 원래 코사인 값을 유지한다. 병합 점수는 노출하지 않는다. back 의 소비 코드(`RecordSearchService.java`)는 순서를 재정렬하지 않고 값으로 필터하지 않는 것을 확인했으므로, 순서 변경은 그대로 반영되고 계약 변경은 없다. + +원칙은 세 가지다. 첫째, 어떤 후보도 근거 없이 결과에 들어오지 않는다. 벡터 후보는 측정된 컷을, 문자열 후보는 검증 절차를 통과해야 한다. 둘째, 한 검색 방식의 안전장치를 다른 방식이 대체하지 않는다. 셋째, 신규 처리가 실패하면 기존 검색 방식으로 되돌린다. 되돌린 상태의 동작은 현행과 동일하다. + +병합 방식(순위 결합 방법과 가중치)의 구체 값은 여기서 정하지 않는다. 키워드 점수 실측에서 병합 방식이 결과에 결정적이라는 것이 확인됐으므로(§5), 문자열 검색의 병합 규칙은 별도 실측으로 정한다(§8 의 작업 3). + +## 5. 관련 없는 결과를 막는 방법 + +새 신호를 더할 때 가장 먼저 깨지는 것이 「관련 없는 질의에서 결과를 노출하지 않는다」는 성질이라는 것이 실측으로 확인됐다. + +키워드 점수 실측에서, 가중합 방식은 일부 정답을 컷 기준 위로 끌어올려 결과에 복구시켰다. 그러나 같은 계산이 관련 없는 후보도 기준 위로 끌어올렸다. 관련 없는 문장형 질의 15건 중 결과가 노출되지 않는 질의가 11건에서 9건으로 줄었다. 반대로 순위 융합(RRF) 방식은 관련 없는 결과를 늘리지 않았지만, 문장형 정답의 1위 적중률을 0.8333 에서 0.5833 으로 떨어뜨렸다. 자세한 수치는 근거 기록 T73 에 있다. + +따라서 차단 장치를 검색 방식마다 분리해서 둔다. + +- **기존 컷 유지.** 벡터 후보의 포함 여부는 지금처럼 원래 코사인에 대한 τ·r 로 판정한다. 병합 점수에는 컷을 걸지 않는다. 측정 근거가 없는 새 임계값을 만들지 않기 위해서다. +- **키워드 floor.** 질의-Preset 유사도가 floor 미만이면 키워드 점수를 만들지 않는다. floor 0.35 에서 관련 없는 질의의 무노출 비율이 현행과 같아지는 것을 측정했다. floor 는 sweep 의 필수 축으로 둔다. +- **문자열 경계 검사.** 문자열 검색은 단어형 질의에서만 켠다. 문장형 질의의 조사·부사(「자주」·「좋은」)가 만드는 우연한 일치를 차단하기 위해서다. 매치는 어절 시작 경계에서만 인정한다. 「신한은행」·「신한에서」는 인정하고 「대신한」은 차단한다. 이 두 검사는 결정적이라 지연이 없다. 그래도 남는 유형이 있다. 문자열은 있으나 기록의 주제가 아닌 경우(「신한은행 ATM 옆 골목의 라멘집」)와 같은 표기의 다른 대상(가게 이름 `우주` 와 「우주처럼 넓은」)이다. 이를 의미 수준에서 거를지, 어디서 거를지는 미결이다(§9). +- **실패 시 기존 검색으로 복귀.** 재작성 실패·키워드 신호 없음·문자열 검증 불가 등 어떤 단계가 실패해도 응답은 실패하지 않는다. 해당 단계만 생략하고, 전부 생략되면 현행 검색과 같은 응답을 낸다. + +## 6. 개발 및 브랜치 격리 방식 + +이 트랙 전체에 「검증 완료 전 배포 금지」 제약이 있다. dev 에 병합만 해 둔 코드가 다른 작업의 릴리스와 함께 main 에 반영되어 배포된 선례가 있으므로(근거: T78), 기능 플래그가 아니라 브랜치로 격리한다. + +ai 와 back 각각 dev 에서 통합 브랜치 `search-upgrade` 를 만든다. 검색 고도화의 모든 작업 PR 은 dev 가 아니라 통합 브랜치를 대상으로 연다. 「티켓 1개 = 브랜치 1개 = PR 1개」 규약은 그대로 유지하고 PR 의 목적지만 바뀐다. + +``` +ai 레포 + dev ─────────────────────────────────────────────── (검증 전 무접촉) + └─ search-upgrade (통합 브랜치) + ├─ -leo-acquisition leo 하네스 인수 + ├─ -query-rewrite LLM 질의 재작성 + ├─ -preset-revision Preset 개선 + ├─ -fusion-adoption 키워드 점수 채택값 확정 + └─ -lexical-offline 문자열 검색 병합 규칙 실측 +back 레포 + dev + └─ search-upgrade + └─ -lexical-merge 문자열 검색 구현과 병합 +``` + +병합과 동기화 규칙은 다음과 같다. + +- 작업 브랜치에서 통합 브랜치로는 일반 PR 과 같다. 리뷰와 CI 를 거쳐 squash 병합한다. +- dev 에서 통합 브랜치로는 정기적으로 단방향 동기화한다. 매일 1회, 그리고 측정과 최종 검증 직전에 한다. 충돌은 그때그때 통합 브랜치에서 해소한다. +- 통합 브랜치에서 dev 로는 검증 기준(§7) 통과 전에 병합하지 않는다. 통과 후 검증 증거를 첨부한 PR 한 번으로 병합한다. + +브랜치로 격리되지 않는 것이 하나 있다. 공유 시연 DB 다. Preset 재판정 같은 DB 변경을 시연 DB 에 하면 dev 코드의 검색도 영향을 받는다. 그래서 개발·측정용으로 시연 DB 의 스냅샷을 별도 컨테이너(`:25432`)에 복원해 쓰고, 시연 DB 반영은 검증 통과와 dev 병합 뒤로 미룬다. + +기능 플래그(`SEARCH_LLM_ENABLED` 등)는 유지하되 역할이 다르다. 배포 통제 수단이 아니라, 운영 중 LLM 장애 시 기존 검색으로 되돌리는 장치다. + +CI 는 확인이 필요하다. 대상 브랜치가 `search-upgrade` 인 PR 에서도 ai-ci 가 실행되는지 확인하고, 트리거가 dev 한정이면 브랜치 패턴 추가를 인수 PR 범위에 넣는다. back 도 같은 확인이 필요하다. + +## 7. 검증 기준 + +통합 브랜치에서 dev 로 병합하기 전에, 통합 브랜치 빌드와 스냅샷 DB 로 아래 기준을 전부 통과해야 한다. 하나라도 미달이면 병합하지 않는다. 시연 리허설도 이 기준 통과 뒤에 한다. + +| 번호 | 기준 | 확인 방법 | +|---|---|---| +| 1 | 실패 사례 3건(`신한`·`부캠`·`신한 부캠`)이 검색 결과에 나온다 | 실서버 E2E | +| 2 | 관련 없는 질의의 무노출이 현행 이상이다 (문장형 15건 중 11건 이상) | 무관 질의셋 재측정 | +| 3 | 시연 정본 질의 12건의 기대 정답이 전부 유지된다 | `demo_data.yaml` 기대 정답 대조 | +| 4 | 모든 플래그를 끄면 현행과 동일한 응답이 나온다 | 계약 테스트 | +| 5 | LLM 타임아웃 시 기존 검색으로 실제로 되돌아간다 | 강제 타임아웃 E2E | + +## 8. 구현 순서 + +Preset 개선을 포함한 전체 순서다. 원칙은 하나다. Preset 개정에 종속된 작업(키워드 점수 채택값 확정)만 개정 뒤에 세우고, 나머지는 전부 병렬로 진행한다. + +``` +[작업 0] leo 인수 ─┬─→ [작업 1] LLM 재작성 (Preset 무관) ──────────────────────┐ + ├─→ [작업 2] Preset 개선 설계→개정→재판정 → [작업 4] 키워드 채택값 확정 ─┐ + └─→ [작업 3] 문자열 병합 규칙 실측 → [작업 5] back 구현 ────────┼─→ [작업 6] 검증(§7) + ┘ +``` + +- **작업 0. leo 인수.** leo 브랜치를 rebase 해 하네스·테스트·문서를 통합 브랜치에 들인다. 발견된 버그 2건(T76·T77) 수정, 리포트 번호 충돌(I51→I52) 해소, artifact 커밋, 실측 리포트 작성, 통합 브랜치 생성과 CI 확인, DB 스냅샷 준비를 포함한다. 모든 후속 작업의 기반이므로 유일한 전면 선행 작업이다. +- **작업 1. LLM 재작성 (병렬).** Preset 과 무관하므로 기다릴 이유가 없다. 실패 사례 3건 중 2건을 해소하는, 시연 가치 대비 위험이 가장 낮은 작업이다. 관련 없는 질의 15건의 재측정을 채택 조건으로 포함한다. +- **작업 2. Preset 개선 (병렬).** 키워드 점수가 반영되지 않은 단어형 질의 54건 목록(근거: T75)을 설계 입력으로 쓴다. 개정 후 재임베딩과 기록 재판정은 스냅샷 DB 에서 한다. 시연 규모(기록 42건)에서는 실행 비용이 작고, 병목은 설계 합의다. +- **작업 3. 문자열 병합 규칙 실측 (병렬).** 측정 artifact 에 본문 매치 정보를 추가해, 병합 방식과 경계 검사 조합을 오프라인으로 훑는다. back 이 구현을 시작하기 전에 규칙을 확정해서 넘기기 위한 작업이다. 파트 간 재작업을 줄이는 것이 목적이다. +- **작업 4. 키워드 채택값 확정.** Preset 개정을 기다리는 유일한 작업이다. 개정 전 값으로 확정하면 개정 후 다시 재야 한다. floor 축과 반복 측정(재현성 회차)을 포함한다. +- **작업 5. back 구현.** 문자열 검색과 병합을 작업 3 의 확정 규칙대로 구현한다. 유일한 타 파트 의존 작업이라 일정 위험이 가장 크다. +- **작업 6. 검증.** §7 기준 전부 통과 후 dev 병합, 시연 DB 반영, 리허설. + +어느 작업이 늦어져도 검색이 현행보다 나빠지지는 않는다. 재작성이 미완이면 현행 검색 그대로, 키워드 값이 미확정이면 그 신호만 끈 상태, 문자열 검색이 미완이면 벡터 검색 단독으로 시연한다. + +## 9. 아직 결정하지 않은 사항 + +- **문자열 검색의 병합 규칙과 값** — 작업 3 의 실측으로 정한다. 키워드 실측의 결론(가중합·RRF 의 상반된 결과)을 그대로 가져다 쓰지 않는다. +- **문자열 매치의 의미 검증 위치** — 결정적 검사(단어형 한정·어절 경계)만으로 충분한지, LLM 판정이 필요한지, 필요하다면 ai 에 판정 API 를 추가할지. 작업 3 의 실측 후 판정한다. +- **키워드 점수 채택값** — floor 0.35 · binary weight 0.05 는 개정 전 Preset 기준의 관측이다. 개정 후 재측정으로 확정한다. +- **통합 브랜치의 최종 병합 방식** — PR 단위 squash 규약을 통합 브랜치 전체에 적용하면 티켓별 이력이 사라진다. merge commit 을 권고하나 규약 예외이므로 중앙 판정이 필요하다. +- **Preset 개선의 구체 범위** — 미반영 54건 목록 기반의 설계는 작업 2 의 몫이다. 이 문서는 순서와 종속 관계만 정한다. +- **재작성의 실제 효과** — 목표 문장의 검색 성공은 측정됐지만, LLM 이 그 목표 문장을 안정적으로 만들어내는지는 미측정이다. 작업 1 에서 검증한다. + +--- + +## 부록 A. 조사한 검색 방식 전체와 실패 사례 대응표 + +조사 과정에서 검토한 축 전체다. 본문에서 다루지 않은 축은 기각 사유와 함께 부록 C 에 있다. + +| 식별자 | 방식 | 실측 상태 (2026-08-05) | +|---|---|---| +| A | 기존 벡터 검색 + 이중 컷 | `-213`·`-266`·`-273` 세 차례 실측, 실서버 대조 22/22 일치 | +| B | Preset 키워드 점수 (P48 1단계) | 대행 실측 완료. floor 0.35 · binary weight 0.05 만 채택 기준 4종 통과 (T73) | +| C | LLM 질의 재작성 | 재작성 자체는 미실측. 목표 문장의 검색 성공은 실측됨 (`-273`) | +| D | 문자열 검색 (P48 3단계) | 미실측. `신한`의 본문 존재는 확인 (`-255`) | +| E | 병합 규칙 (가중합·RRF) | 키워드 점수에 한해 실측 (T73) | +| F | 문자열 매치 경계·의미 검사 | 설계만. 미실측 | + +실패 사례별로 어느 방식이 해결하는지의 대응은 다음과 같다. ◎ 는 해결, △ 는 부분 기여, ✗ 는 영향 없음이다. + +| 사례 | A 벡터 | B 키워드 | C 재작성 | D 문자열 | +|---|---|---|---|---| +| `부캠` | ✗ | ✗ | ◎ | ✗ | +| `신한 부캠` | ✗ | ✗ | ◎ | △ | +| `신한` | ✗ | ✗ | ✗ | ◎ | +| Preset 축 질의 순위 | — | ◎ | — | — | +| 무관 질의 무노출 유지 | ◎ (기반) | 조건부 (floor≥0.35) | 위협 가능 | 검증 필요 | + +## 부록 B. 키워드 점수 실측 수치 요약 + +측정 조건: `origin/leo` `fcb397c` 하네스 · 시연 DB 스냅샷 시점 기록 42건 · 소유자 3명 · Preset 27종 v1. 상세 표와 산출물 경로는 근거 기록 T73 에 있다. + +- 기본 설정(floor 0.25): 가중합에서 `신한` 복구(컷 후 4위). 동시에 관련 없는 문장형 질의의 무노출이 11/15 에서 9/15 로 감소. RRF 는 무노출 유지, 문장형 hit@1 0.8333→0.5833 퇴행. +- floor 0.35 + binary weight 0.05: 단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129. 문장형 hit@3 1.0 유지 · MRR 0.9028→0.9167. 관련 없는 질의 무노출은 현행과 동일. 단 키워드 점수가 반영되는 단어형 질의가 66건 중 12건으로 축소되고 `신한` 복구는 사라진다. +- 미측정: floor 0.30~0.35 구간 · weight 세밀 격자 · 반복 측정. + +## 부록 C. 채택하지 않은 대안 + +| 대안 | 사유 | +|---|---| +| FastAPI 가 `core.context.body` 를 직접 읽기 | 공용 계약 4곳이 금지한다(`05_AI_설계.md:40`·`:877`, `05-1_파트간_요구사항.md:94·97-98`, `07_ERD.md:171`). 특히 05-1 은 인프라에 「FastAPI 계정에 core 권한을 주지 마라」고 요청해 둔 문서라, 이를 뒤집으면 타 파트 작업이 생긴다. 측정 도구의 JOIN 은 런타임이 아니므로 선례가 되지 않는다 | +| 약어 사전 (`부캠→부트캠프` 정적 매핑) | 지연 없이 결정적으로 동작하는 유효한 대안이다. 다만 등록한 항목만 처리하고 신조어·개인별 줄임말의 등록을 계속 유지해야 한다. 현재 일정에서는 LLM 재작성을 먼저 검증한다 | +| 임베딩 모델 교체 | 의미 연결이 개선될 가능성은 있으나 미측정이다. 저장 벡터 전량 재생성과 기존 컷 값 전면 재측정이 필요해 시연 일정(08-10) 안에서는 비현실적이다 | +| 단어형 판정 경계 변경 | 회복되는 질의가 사용자가 실제로 치지 않는 기능어 조합뿐이라는 측정 결과가 있다(`-273` 의 처방 P1 항목) | +| 상대 컷(r) 완화 | 사용자 보고가 없는 증상이고, r 을 나누지 않기로 한 기존 측정 판단(`-266`)을 뒤집으려면 격자 재측정이 선행되어야 한다 | +| RAG·ANN 인덱스·Context 청킹·HyDE·placeMeta 결합 | P48 §5 의 기각·보류 판단을 유지한다 | + +## 부록 D. 근거 문서 좌표 + +| 무엇 | 어디 | +|---|---| +| 컷 상수·단어형 판정·컷 적용 지점 | `app/core/config.py` · `app/service/search_service.py` | +| 검색 Query 와 응답 계약 | `app/repository/context_embedding_repo.py` · `spec/personal-search.md` · 공용 계약 `static/05_AI_설계.md` | +| 키워드 점수 계산 규칙·채택 기준 | P48 §1·§2·§6.1 (`origin/leo`) · `tools/search_cut/fusion.py` | +| 실패 사례 3건의 측정 | `-255`(원인 판별) · `-273`(층별 관측) | +| 컷 값의 측정 근거 | `-213` · `-266` | +| 이번 실측과 발견 문제 | [2026-08-05-multi-signal-investigation.md](../troubleshooting/2026-08-05-multi-signal-investigation.md) (T73~T78 잠정) | +| back 소비 경로 확인 | `back` `RecordSearchService.java` (순서 유지 · 값 필터 없음 · null 만 제외) | diff --git a/docs/proposals/README.md b/docs/proposals/README.md index ed67efa..7406e8a 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -25,6 +25,7 @@ | [P44](P44-ai-repository-governance.md) | AI 레포 협업 운영 기준 | Accepted | AI | | [P47](P47-keyword-preset-label-axis.md) | Keyword 프리셋 표시 라벨·축 정의·스키마 개정안 | Proposed | AI | | [P48](P48-search-signal-expansion.md) | 개인 검색의 신호 확장 — 단일 코사인에서 다신호로 | Proposed | AI | +| [P49](P49-multi-signal-search.md) | 다신호 검색 개선 — 질의 재작성·키워드 점수·문자열 검색 추가와 검증 기준 | Proposed | AI | ## 제안 — 전수 (Accepted) diff --git a/docs/troubleshooting/2026-08-05-multi-signal-investigation.md b/docs/troubleshooting/2026-08-05-multi-signal-investigation.md new file mode 100644 index 0000000..1072d37 --- /dev/null +++ b/docs/troubleshooting/2026-08-05-multi-signal-investigation.md @@ -0,0 +1,247 @@ +# 다신호 검색 조사 및 실측 결과 + +- **티켓**: 미발급. `S15P11A705-336` 인수 트랙과 [P49](../proposals/P49-multi-signal-search.md) 제안의 근거 기록이다. +- **날짜**: 2026-08-05 +- **대상 코드**: `origin/leo` `fcb397c`. 대상 파일은 `tools/search_cut/{rank_score,fusion,fusion_sweep,keyword_matrix}.py` 와 `tests/test_search_fusion.py` 다. +- **실측 환경**: 시연 DB `:15432`(`pinlog-demo-postgres-1`) · profile `openai-text-embedding-3-small-1536-cosine-v1` · Preset 27건 v1(PUBLIC 25 · PRIVATE_ONLY 2) · Context 42건 · keyword 판정 83건 · GMS 임베딩 3배치 +- **선행 기록**: [검색 실패 원인 판별](2026-08-03-search-recall-probe.md)(`-255`) · [단어형 컷 격자](2026-08-03-word-query-cut.md)(`-266`) · [짧은 질의 층 관측](2026-08-05-short-query-boundary.md)(`-273`) · leo 의 순위 지표 baseline 리포트 I52(`origin/leo`) +- **번호는 잠정이다.** T73~T78 은 병합 직전 `origin/dev` 로 rebase 한 뒤 확정한다(T48·T60 규칙). +- **수치의 성격**: 이 문서의 수치는 2026-08-05 실측 시점의 관측값이다. 산출물 JSON 5종은 실측 worktree 에 있고 아직 커밋되지 않았다. 측정 기록의 정본은 `-336` 실측 리포트로 정식화할 예정이다. 값이 앞으로도 같다고 주장하지 않는다(T70/R-18). + +측정으로 확인한 내용과 해석을 구분해 적는다. 확인하지 않은 내용은 `추가 검증 필요`로 표시한다. + +## 한눈에 보는 결과 + +| 번호 | 확인한 내용 | 현재 판단 | +|---|---|---| +| T73 | 키워드 점수를 가중합으로 더하자 일부 정답이 검색 결과에 다시 포함됐지만, 같은 계산으로 관련 없는 후보도 함께 노출됐다 | floor 0.35 · binary weight 0.05 조합만 기존 채택 기준 4종을 통과했다 | +| T74 | P48 2단계가 LLM 출력을 Preset 목록으로 제한해, Preset 에 없는 `부트캠프`로 `부캠`을 확장할 수 없다 | 출력 제한을 풀고 자유 텍스트 재작성을 검증하는 방향으로 개정을 제안한다 | +| T75 | 안전한 floor(0.35)에서는 단어형 질의 66건 중 12건에만 키워드 점수가 반영됐다 | 반영되지 않은 54건 목록을 Preset 개선의 설계 자료로 사용한다 | +| T76 | pgvector 의 `Vector` 반환형을 처리하지 못해 `keyword_matrix.py` 실행이 중단됐다 | 문자열·`Vector`·iterable 세 반환형을 모두 처리하도록 수정한다 | +| T77 | cp949 콘솔에서 특수문자 출력 중 인코딩 오류로 실행이 중단됐다 | 각 실행 파일에 UTF-8 출력 설정을 넣는다 | +| T78 | dev 에 미리 병합한 코드는 다른 작업의 릴리스와 함께 배포될 수 있다 | 검증 완료 전까지 통합 브랜치에 격리한다 | + +--- + +## T73. 키워드 가중합은 정답과 관련 없는 후보를 함께 상승시켰다 + +### 확인한 내용 — 측정 결과 + +실측에 앞서 환경이 leo 의 환경과 같은 결과를 내는지 확인했다. leo 가 작성한 픽스처 테스트 24종이 통과했다. 순위 지표 재현값도 leo 의 baseline 리포트(I51) 기록과 일치했다. 예를 들어 관련 없는 문장형 질의 15건 중 11건에서 결과가 노출되지 않았고(무노출 비율 0.7333), 질의 `스팟`의 정답 유사도는 0.2438 이었다. + +하네스 기본 설정(floor 0.25 · top-k 3)에서 병합 방식별 결과가 갈렸다. + +가중합 방식은 순위를 개선했다. binary weight 0.05 에서 문장형 MRR 이 0.9028 에서 0.9167 로 올랐다. confidence weight 0.25 에서는 단어형 hit@1 이 0.8636 에서 0.8939 로 올랐다. 질의 `신한`의 정답은 원래 컷 기준(τ 0.24)에 못 미쳐 제외됐는데, 키워드 점수가 더해지자 기준을 넘어 4위로 결과에 포함됐다. + +그러나 같은 설정에서 관련 없는 질의의 결과 노출이 늘었다. 관련 없는 문장형 질의의 무노출 비율이 11/15(0.7333)에서 9/15(0.60)로 떨어졌다. 단어형 무노출 비율도 0.4222 에서 0.40 으로 떨어졌다. confidence weight 0.25 에서는 문장형 무노출이 0.5333 까지 떨어졌다. + +RRF 방식(cutoff 0~0.016)은 반대였다. 관련 없는 질의의 무노출 비율은 기존과 같았다(0.7333 / 0.4222). 그러나 정답 순위가 나빠졌다. 문장형 hit@1 이 0.8333 에서 0.5833 으로, hit@3 이 1.0 에서 0.8333 으로 떨어졌다. cutoff 를 0.033 까지 올리면 모든 세그먼트에서 검색 결과가 전부 제거됐다. + +floor 를 0.25 / 0.30 / 0.35 / 0.40 네 값으로 훑었다. floor 0.35 에서 관련 없는 질의의 무노출 비율이 기존 기준선으로 완전히 돌아왔다. 이 조건의 binary weight 0.05 는 순위 퇴행 없이 개선만 남았다. 단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129 · recall@3 0.9205→0.9356 · nDCG@3 0.9007→0.9159 였고, 문장형은 hit@3 1.0 을 유지하며 MRR 0.9028→0.9167 로 올랐다. P48 §6.1 의 채택 기준 4종(순위 퇴행 없음 · 한 세그먼트 이상 개선 · 무관 노출 악화 없음 · RRF cutoff=0 단독 채택 금지)을 전부 통과한 조합은 이것 하나였다. + +이 조건에는 대가가 둘 있었다. 키워드 점수가 반영되는 질의 수가 크게 줄었고(T75), floor 0.25 에서 회복됐던 `신한`은 다시 컷 기준에 못 미쳐 제외됐다. + +### 원인 — 측정 결과를 바탕으로 한 해석 + +가중합 방식은 컷을 가중합 점수에 적용한다. 컷 기준 바로 아래에 있는 정답을 기준 위로 끌어올리는 계산은, 기준 바로 아래에 있는 관련 없는 후보도 똑같이 끌어올린다. floor 0.25 에서는 관련 없는 질의도 Preset 후보를 얻으므로 두 효과가 함께 나타난다. + +RRF 는 컷을 가중합 점수가 아니라 원래 코사인에 적용한다(P48 §2.2 의 별도 관문). 그래서 관련 없는 결과의 노출은 늘지 않는다. 대신 최종 순서를 벡터 순위와 키워드 순위의 합산으로 정하므로, 벡터 검색에서 1위였던 정답이 키워드 순위가 낮으면 아래로 밀린다. + +### 현재 판단 + +검색 방식을 추가하는 것만으로 안전성과 정확도가 함께 개선되지는 않는다. 같은 키워드 신호로도 병합 방식에 따라 「순위 개선 + 무관 노출 증가」와 「무관 노출 유지 + 순위 퇴행」으로 갈렸다. 어떤 후보를 최종 결과에 남길지는 병합 규칙이 정한다. + +문자열 검색을 추가할 때도 같은 검증이 필요하다. 문자열 매치는 연속적인 코사인 점수와 성격이 다르므로, 이번 키워드 실측의 결론을 그대로 가져다 쓸 수 없다. 이것은 해석이며, 문자열 검색 쪽 실측은 하지 않았다. 추가 검증 필요. + +### 대응 + +- floor 를 sweep 의 필수 축으로 삼는다. 하네스 기본값(0.25)은 관련 없는 결과가 노출되는 대역이므로, floor 를 훑지 않은 결과로는 무관 노출 조건을 잘못 통과시킬 수 있다. +- 현재 채택 후보는 floor 0.35 · binary weight 0.05 다. 다만 Preset 개정이 예정되어 있어 값 확정은 개정 후 재측정으로 미룬다(T75). +- 검색 방식마다 컷과 게이트를 분리하는 설계를 P49 에 반영했다. + +측정하지 않은 구간: floor 0.30~0.35 사이 · weight 세밀 격자(하네스 내장값 binary 0.05/0.10 · conf 0.10/0.25 · idf 0.10 만 측정) · 같은 조건의 반복 측정(재현성 회차). 채택값을 확정할 때는 반복 측정이 필요하다. 추가 검증 필요. + +### 근거 + +- 실측 산출물: 실측 worktree `.search/fusion_sweep.json`(floor 0.25) · `fusion_sweep_floor035.json` (미커밋, `-336` 리포트로 정식화 예정) +- 기준선: leo I51 리포트(`origin/leo`) · `-213`(컷 채택 근거) · `-266`(단어형 컷) +- 채택 기준의 정본: P48 §6.1 · `tools/search_cut/fusion_sweep.py` + +--- + +## T74. Preset 출력 제한 때문에 약어를 확장할 수 없다 + +### 확인한 내용 — 문서·실측 대조 + +P48 2단계(질의 이해 LLM)의 목적은 약어 문제 해결이다. 이전 원인 분석은 `부캠` 문제를 약어와 원래 표현 사이의 의미 연결 실패로 판정했고, 2단계가 그 처방으로 제시됐다. 자세한 근거는 `-255`에 있다. + +그런데 같은 절이 LLM 출력을 Preset 목록 안으로 제한한다. `부트캠프`는 Preset 이 아니다. 따라서 이 제한 아래에서는 `부캠`을 `부트캠프`로 바꿔 쓸 수 없다. 2단계는 자신의 목적인 약어 문제를 해결할 수 없는 구조다. + +`부캠`이 다른 검색 방식으로 해결되는지도 확인했다. 결과는 전부 부정적이었다. + +- **문자열 검색**: 정답 기록(카츠요)의 본문에는 「부트캠프」만 있고 「부캠」이 없다. `부캠`으로 문자열 검색을 하면 그 글자가 본문에 있는 다른 기록(쿠로코·플랜트)만 나온다. +- **현행 임베딩**: 모델은 「부캠」이라는 표기 자체는 인식한다. 그 글자가 본문에 있는 기록을 상위로 올린다(쿠로코, 유사도 0.4102). 그러나 `부캠`과 `부트캠프` 사이의 의미 연결은 하지 못해, 정답은 컷 적용 전 기준으로도 8위(유사도 0.2105)다. 컷 값을 어떻게 바꿔도 8위는 회복되지 않는다. +- **Preset 키워드 점수**: Preset 27종은 동행·활동·분위기·상황 축이라 「부트캠프」를 표현하는 항목이 없다. 이번 실측에서도 안전한 floor 조건에서 `부캠`은 회복되지 않았다. + +### 원인 — 측정 결과를 바탕으로 한 해석 + +이 실패의 본질은 「부캠이 부트캠프의 줄임말이다」라는 일반 지식이 필요하다는 것이다. 검색 파이프라인의 어느 신호도 그 지식을 담고 있지 않다. + +### 현재 판단 + +현재 검토한 대안 중에서는 LLM 재작성이 가장 현실적인 방법이다. 재작성이 만들어낼 목표 문장의 값은 이미 측정되어 있다. `부트캠프`는 컷 적용 전 1위(유사도 0.3254)이고, `신한 부트캠프`는 3위(유사도 0.3793)로 컷을 통과한다. 즉 재작성이 성공하면 검색은 이미 정답을 찾을 수 있는 상태다. + +다른 대안도 존재한다. 약어 사전(`부캠→부트캠프` 정적 매핑)은 지연 없이 결정적으로 동작하지만, 등록한 항목만 처리할 수 있고 신조어와 개인별 줄임말을 계속 등록해야 한다. 임베딩 모델 교체는 의미 연결이 개선될 가능성이 있으나 측정된 바 없고, 저장된 벡터 전체를 다시 만들어야 하며 기존 컷 값도 전부 다시 재야 한다. 현재 일정(시연 08-10)과 유지 비용을 고려하면 LLM 재작성을 먼저 검증하는 것이 가장 현실적이다. + +### 대응 + +P48 2단계의 출력 제한을 풀고 자유 텍스트 재작성을 허용하는 개정을 제안한다(P49 §3). 출력을 유한 집합으로 제한하면 잘못된 값이 나올 여지가 없다는 원래 논리는 참이지만, 그 대가로 목적 자체를 잃는다. 잘못된 재작성에 대한 방어는 제한 대신 다른 층에 둔다. 실패하거나 시간이 초과되면 원래 질의로 검색한다. 같은 질의는 캐시로 같은 재작성을 보장한다. 프롬프트에 「확신이 없으면 원문을 유지하라」는 규칙을 넣는다. + +재작성 자체의 효과와 부작용은 아직 측정하지 않았다. 이는 현재 측정 결과를 바탕으로 한 설계 판단이며, 실제 효과는 추가 실험이 필요하다. + +### 근거 + +- 원인 판정: `-255`(약어 = 의미 연결 실패 판정) · GitHub 이슈 `ai#88` +- 순위·유사도 값: `-273` 층 관측 표 +- 이번 실측의 사례별 표: T73 근거의 fusion_sweep 산출물 + +--- + +## T75. 안전한 floor 에서는 키워드 점수가 대부분의 질의에 반영되지 않는다 + +### 확인한 내용 — 측정 결과 + +관련 없는 결과가 노출되지 않는 floor 값(0.35)에서, 단어형 질의 66건 중 54건은 floor 를 넘는 Preset 후보가 하나도 없었다. 이 54건에서는 키워드 점수가 최종 검색 결과에 아무 영향을 주지 못한다. 키워드 점수가 실제로 반영된 질의는 12건이다. + +floor 를 0.25 로 내리면 반영되지 않는 질의가 10건으로 줄어든다. 그러나 그 값에서는 관련 없는 질의의 결과 노출이 늘어난다(T73). 키워드 점수의 적용 범위와 무관 결과 차단이 floor 값 하나로 서로 맞바뀌는 관계다. + +### 원인 — 측정 결과를 바탕으로 한 해석 + +floor 는 질의 임베딩과 Preset 임베딩 사이 코사인의 하한이다. 현행 Preset 27종의 정의문이 단어형 질의 대부분과 코사인 0.35 를 넘지 못한다. Preset 이 표현하는 축(동행·활동·분위기·상황)과 사용자가 치는 단어형 질의(음식·장소·고유명사)가 어긋나 있기 때문으로 해석한다. + +### 현재 판단 + +반영되지 않은 54건의 질의 목록은 Preset 을 어떻게 개선해야 검색 신호가 실제로 넓어지는지 알려주는 설계 자료다. 이 목록은 실측 산출물에서 질의 단위로 재구성할 수 있다. + +작업 순서에도 영향이 있다. Preset 정의가 바뀌면 임베딩이 바뀌고, floor 0.35 라는 측정값과 저장된 keyword 판정이 모두 무효가 된다. 따라서 키워드 점수의 채택값 확정은 Preset 개정 뒤로 미뤄야 한다. 반대로 leo 하네스 인수·질의 재작성·문자열 검색은 Preset 을 사용하지 않으므로 개정과 무관하게 진행할 수 있다. Preset 개선의 설계가 이 실측 목록을 입력으로 삼아야 하므로, 개선을 실측보다 먼저 하는 순서는 같은 측정을 두 번 하게 만든다. + +### 대응 + +- Preset 개정 후 `keyword_matrix.py` 로 artifact 를 다시 만들고 sweep 을 다시 돌린다. 하네스는 artifact 의 `preset_version` 이 어긋나면 계산을 거부하도록 이미 설계되어 있다. +- 미반영 54건 목록을 Preset 개선 작업(P49 §8 의 T2)에 설계 입력으로 전달한다. + +### 근거 + +- 실측 산출물: T73 과 동일 (`fusion_sweep_floor035.json` 의 「Preset 미표현」 카운트) +- 종속 관계 정리: [P49 §8](../proposals/P49-multi-signal-search.md) + +--- + +## T76. pgvector 의 `Vector` 반환형을 처리하지 못해 실행이 중단됐다 + +### 확인한 내용 — 측정 결과 + +`keyword_matrix.py` 를 실행하자 다음 오류로 중단됐다. + +``` +TypeError: 'Vector' object is not iterable (_parse_vector) +``` + +이 스크립트는 `app.core.db.Database` 로 DB 에 접속한다. 이 클래스는 커넥션에 pgvector 코덱을 등록하므로, `ai.keyword_preset.embedding` 컬럼이 문자열이 아니라 `Vector` 객체로 반환된다. leo 가 작성한 `_parse_vector` 는 문자열과 iterable 두 형태만 처리한다. + +### 원인 + +pgvector 는 코덱 등록 여부에 따라 컬럼 반환형이 달라진다. T17 에서도 같은 반환형 차이로 오류가 발생한 적이 있다. leo 의 환경에서는 문자열로 반환되어 문제가 드러나지 않았을 가능성이 있다. leo 환경 재현은 하지 않았다. 추가 검증 필요. + +### 대응 + +실측을 진행하기 위해 `to_list()` 분기 3줄을 로컬로 추가했고, 이 패치로 실측을 완료했다. 인수 시점에는 문자열·`Vector`·iterable 세 반환형을 모두 처리하도록 수정한다. + +### 근거 + +- 오류 위치: `tools/search_cut/keyword_matrix.py` 의 `_parse_vector` +- 같은 유형의 선례: T17 (`to_numpy()`/`to_list()` 변환) + +--- + +## T77. cp949 콘솔에서 특수문자 출력 중 인코딩 오류가 발생했다 + +### 확인한 내용 — 측정 결과 + +`rank_score.py` 와 `fusion_sweep.py` 를 Windows 기본 콘솔(cp949)에서 실행하자 다음 오류로 중단됐다. + +``` +UnicodeEncodeError: 'cp949' codec can't encode character '—' +``` + +같은 브랜치의 `keyword_matrix.py` 에는 stdout 을 UTF-8 로 재설정하는 코드가 있어 이 문제가 없다. 두 파일에만 그 코드가 빠져 있다. + +### 원인 + +출력 문자열에 cp949 로 표현할 수 없는 문자(`—` 등)가 있고, stdout 재설정이 없으면 출력 시점에 예외가 발생한다. 한 파일에만 방어를 넣고 다른 진입점에서 다시 발생하는 유형은 T28 과 T38 에서도 기록된 바 있다. + +### 대응 + +실측은 `PYTHONIOENCODING=utf-8` 환경변수로 우회해 진행했다. 인수 시점에는 `keyword_matrix.py` 의 재설정 3줄을 두 파일에 복제한다. 호출자가 환경변수를 기억해야 하는 상태를 남기지 않는다. + +### 근거 + +- 같은 유형의 선례: T28(재설정 처방) · T38(진입점별 재발) + +--- + +## T78. dev 에 미리 병합한 코드는 다른 작업과 함께 배포될 수 있다 + +### 확인한 내용 — 선례 확인 + +검색 고도화 전체에 「검증 완료 전 배포 금지」 제약이 걸렸다. 처음 설계한 방법은 신규 코드를 기본값 off 플래그 뒤에 두고 dev 에 병합하는 것이었다. off 상태의 응답이 기존과 동일하므로 배포되어도 무해하다는 논리였다. + +이 방법으로 막을 수 없는 경로가 실제로 있었다. `-266` 의 검색 코드 변경은 dev 병합 후 별도의 릴리스 판단 없이 `origin/main` 에 반영됐다. infra 저장소의 이미지 태그가 main 의 최신 커밋을 가리키고 있어, 그 코드는 dev 환경에 배포까지 이어졌다. 이 확인은 `-273` 리포트에 기록되어 있다. + +### 원인 + +릴리스는 커밋 단위가 아니라 브랜치 단위로 일어난다. main 반영 시점에 dev 에 있는 커밋은 모두 함께 나간다. 플래그가 off 라도 코드가 dev 에 있는 한, 설정 실수나 리뷰 누락으로 의도하지 않게 활성화될 수 있는 경로가 남는다. + +### 현재 판단 + +플래그 방식은 기각됐다. 사용자 결정으로, 검증 전에는 코드가 dev 와 main 에 존재하지 않아야 한다는 기준이 확정됐다. + +### 대응 + +- ai 와 back 각각 dev 에서 통합 브랜치(`search-upgrade`)를 만들고, 검색 고도화의 모든 작업 PR 은 통합 브랜치를 대상으로 연다. +- 동기화는 dev 에서 통합 브랜치 방향으로만 정기적으로 한다. 통합 브랜치에서 dev 로의 병합은 검증 기준 통과 후 한 번만 한다. 검증 기준은 P49 §7 에 있다. +- 플래그는 배포 통제 수단이 아니라, 운영 중 LLM 장애 시 기존 검색으로 되돌리는 장치로만 유지한다. +- 브랜치로 격리되지 않는 것이 하나 있다. 공유 시연 DB 다. Preset 재판정 같은 DB 변경은 별도 스냅샷 DB 에서 수행한다(P49 §6). + +### 근거 + +- 배포 경로 확인: `-273` 리포트(infra 이미지 태그와 main HEAD 대조) +- 격리 구조 상세: [P49 §6](../proposals/P49-multi-signal-search.md) + +--- + +## 검증 — 실행한 것과 못 한 것 + +**실행한 것** [측정] + +- leo 픽스처 테스트 24종 통과 (`tests/test_search_fusion.py`, DB·GMS 호출 없음) +- `rank_score.py` 순위 지표 재현 결과가 leo I52 기록과 일치 (환경 동등성 확인) +- `word_matrix.py`·`recall_probe.py` 재생성(`context_id` 포함) 과 `keyword_matrix.py` 신규 artifact 생성 (GMS 3배치) +- `fusion_sweep.py` 실행 — 기본 축, RRF cutoff 격자(0/0.004/0.008/0.016/0.033), floor 축(0.25/0.30/0.35/0.40) +- back 의 검색 응답 소비 경로 확인 — `RecordSearchService.java` 는 FastAPI 가 준 순서를 재정렬하지 않고, similarity 값으로 필터하지 않으며, 값이 null 인 항목만 버린다 + +**못 한 것** — 전부 추가 검증 필요 + +- 같은 조건의 반복 측정(재현성 회차). 채택값 확정 시 필수다. `keyword_matrix` 재생성에 쓰이는 임베딩 API 가 비결정적이어서(`-266` 의 T68 절차 참조), floor 경계 근처의 판정이 흔들릴 수 있다. +- weight 세밀 격자. 하네스 내장값만 측정했다. +- floor 0.30~0.35 사이 구간. +- 운영 DB 측정. 시연 DB 만 측정했다. 선행 리포트들과 같은 한계다. +- leo 환경에서의 T76 재현. + +## 산출물 + +| 경로 | 상태 | 처리 방침 | +|---|---|---| +| 실측 JSON 5종 (`word_grid`·`recall_probe` 재생성분, `keyword_matrix`, `fusion_sweep` 2종) | 실측 worktree 로컬, 미커밋 | 인수 시 artifact 3종(word_grid·recall_probe·keyword_matrix)은 커밋. sweep 결과 2종은 artifact 에서 재구성 가능하므로 기존 규약대로 커밋하지 않는다 | +| 이 문서 | — | 측정 수치의 정본은 `-336` 실측 리포트로 정식화 예정 | +| [P49](../proposals/P49-multi-signal-search.md) | — | 이 기록을 근거로 한 설계 제안 | diff --git a/docs/troubleshooting/README.md b/docs/troubleshooting/README.md index d9e2def..a3b51cf 100644 --- a/docs/troubleshooting/README.md +++ b/docs/troubleshooting/README.md @@ -34,6 +34,7 @@ | [2026-08-03-dead-config-key-audit.md](2026-08-03-dead-config-key-audit.md) | 죽은 설정 키 전수조사 — alias 매칭은 grep 으로 못 잡는다·「N 대 M」차집합은 한 방향이 아닐 수 있다 (T66·T67) | | [2026-08-03-preset-description.md](2026-08-03-preset-description.md) | 프리셋 개정 측정 — 임베딩이 결정적이라는 전제·rank 로 밀려난 손실이 τ 격자에서 사라진다 (T68·T69) | | [2026-08-03-repeat-incidents.md](2026-08-03-repeat-incidents.md) | 하루에 갈래마다 서너 번 — 사본에서 꺼낸 값·반만 덮은 자동화·공개 저장소 실데이터 (T70~T72) | +| [2026-08-05-multi-signal-investigation.md](2026-08-05-multi-signal-investigation.md) | 다신호 검색 조사·실측 — 가중합의 정답·무관 동반 상승, Preset 출력 제한과 약어, 키워드 신호 적용 범위, 하네스 버그 2건, 사전 병합 코드의 배포 경로 (T73~T78 잠정) | ## 문제 해결 — 전수 (AI 소유) @@ -109,5 +110,11 @@ | T70 | 낡은 값이 새 결정의 근거가 되고 **그 사실이 드러나지 않는다.** 하루에 넷 — 철회된 단정이 그 문서를 근거로 인용하며 되살아나고, 코드 한 행을 인용하며 **바로 다음 행**을 놓친 오독이 **이미 병합된 작업의 설계 근거**가 되고, 원장에 적어 둔 정정이 계약에서 원래 문구로 되돌아오고, 계약에 실은 수치가 여러 번 틀렸다. **넷 다 사본이 아니라 원문을 연 다른 사람이 잡았다** — 사본을 읽는 쪽은 확인할 것이 있다는 사실 자체를 모르고, 「쓰기 직전 재조회」 규칙은 이미 있었는데도 났다 | 읽는 쪽이 아니라 **쓰는 쪽에 규칙을 둔다**(`R-18` 「값 대신 출처」) — 측정 수치·현행 상수·코드 행 번호·배포 상태 값을 옮기지 않고 파일명·모듈명·티켓·PR 번호로 **좌표만** 가리킨다. **기계 검사는 없다** — 「제안 내용인가 관측값인가」는 사람이 묻는다. 철회하는 쪽도 대상을 좁혀 적는다 — **폐기된 것은 값이 아니라 값의 항상성이다** | | T71 | 자동화가 `success` 로 끝나는데 아무것도 안 바뀐다. 병합 직전 재검사가 경합에 지면 PR 이 낡은 SHA 를 든 채 남고 **갱신 워크플로가 다시 밀어야만 재시도되는데 그 cron 이 연속으로 빠졌다.** 봉인 값은 갱신 도구의 **수정 대상 필드 밖**이라 틀려도 실패하는 검사가 없고, 감시 스크립트는 세션이 죽어도 살아남아 **출력이 아무데도 안 닿는 고아**가 된다. 개수로 세는 판정은 원리상 부정확해 이것을 오래 숨긴다 | **성공 판정을 네 층으로 가른다** — 프로세스 생존 ≠ 실행 성공 ≠ 실제 변경 발생 ≠ 최종 전달 성공. 감시는 heartbeat 로 자기 상태를 파일에 남기고 확인 경로가 나이·출처·중복을 보고하되 **판정은 사람이 한다.** 배포 자동화 둘은 인프라 소관이라 이슈로 기록만 했다. **「다음 스케줄이 복구한다」는 전제 자체가 약하다** | | T72 | **방어를 세운 뒤에도 같은 종류의 노출이 새로 생긴다.** 대응을 정한 직후 측정 산출물이 새로 커밋됐다 — 훅은 **바깥으로 나가는 발화**(이슈·PR·코멘트·티켓)만 검사하는데 그 산출물은 스크립트가 만들고 사람이 `git add` 해 **검사 지점을 한 번도 안 거쳤다.** 커밋 시점 검사가 없고, `.gitignore` 예외가 「커밋하라」고 **적극적으로 지시**하고 있었다. 마스킹한 이슈의 코멘트가 가리지 않은 쪽을 안내하고 있었다 | **미정.** 선택지 넷 — 산출물 마스킹본 교체(그것을 읽는 재판정 도구가 깨질 수 있다) · 산출물 제외(재측정 비용 감수) · 검사 범위를 커밋 시점으로 이동 · 마스킹 문구의 경로 안내 제거(**이것만 즉시 가능**). 재측정 비용과 노출이 정면으로 부딪히는 구조라 「지우면 끝」이 아니다. **위치·규모는 문서에 적지 않는다**(위치 비공개 선례) | +| T73 | (잠정) 키워드 점수를 가중합으로 더하자 컷 기준 아래의 정답이 결과에 복구됐지만, **같은 계산으로 관련 없는 후보도 기준을 넘어 노출됐다** — 관련 없는 문장형 질의의 무노출이 11/15 에서 9/15 로 감소. RRF 는 무노출을 유지했지만 문장형 hit@1 이 0.8333→0.5833 으로 퇴행. 검색 방식 수가 아니라 병합 규칙이 결과를 정한다 | floor 를 sweep 필수 축으로. floor 0.35 · binary weight 0.05 조합만 채택 기준 4종 통과 — 대가는 키워드 적용 범위 축소와 `신한` 재제외. 검색 방식별 컷·게이트 분리(P49 §5) | +| T74 | (잠정) P48 2단계가 **LLM 출력을 Preset 목록으로 제한해, Preset 에 없는 `부트캠프`로 `부캠`을 확장할 수 없다** — 2단계의 목적인 약어 문제를 그 제한이 막는다. 문자열(본문에 「부캠」 없음)·임베딩(의미 연결 실패, 컷 전 8위)·Preset(표현 항목 없음) 모두 이 질의를 해결하지 못해, 검토한 대안 중 LLM 재작성이 가장 현실적 | 출력 제한을 풀고 자유 재작성으로 개정 제안(P49 §3). 잘못된 재작성 방어는 원문 복귀·질의 캐시·보수 프롬프트로. 재작성 목표 문장의 검색 성공은 기측정(`부트캠프` 1위) | +| T75 | (잠정) 관련 없는 결과가 노출되지 않는 floor(0.35)에서 **키워드 점수가 단어형 질의 66건 중 12건에만 반영된다**(미반영 54건). 키워드 적용 범위와 무관 결과 차단이 floor 값 하나로 맞바뀐다 | 미반영 54건 목록을 Preset 개선의 설계 입력으로. 키워드 채택값 확정만 Preset 개정에 종속 — 인수·재작성·문자열 검색은 무관(P49 §8) | +| T76 | (잠정) `keyword_matrix.py` `_parse_vector` 가 pgvector **코덱 등록 커넥션이 반환하는 `Vector` 객체를 처리하지 못해** TypeError 로 실행 중단 — 코덱 미등록 환경(문자열 반환)에서는 재현되지 않을 수 있다. T17 과 같은 반환형 차이 | 문자열·`Vector`·iterable 세 반환형을 모두 받는 분기(`to_list`). 실측은 로컬 패치 3줄로 완료 | +| T77 | (잠정) `rank_score.py`·`fusion_sweep.py` 에 stdout UTF-8 재설정이 없어 **cp949 콘솔에서 `—` 출력 시 인코딩 예외로 중단** — 같은 브랜치 `keyword_matrix.py` 에는 재설정이 있다. 한 파일만 방어된 T28·T38 과 같은 유형 | 재설정 3줄을 두 파일에 복제. 실측은 `PYTHONIOENCODING=utf-8` 로 우회 | +| T78 | (잠정) **dev 에 미리 병합한 코드가 다른 작업의 main 반영에 포함되어 배포될 수 있다** — `-266` 코드가 별도 릴리스 판단 없이 main 에 반영되고 dev 환경까지 배포된 선례. 기본 off 플래그 방식은 dev 에 코드가 존재하는 한 의도치 않은 배포 경로가 남아 기각 | 통합 브랜치(`search-upgrade`) 격리 + dev→통합 단방향 동기화 + 검증 기준 통과 후 한 번만 병합(P49 §6). 플래그는 운영 중 복귀 장치로만 | > T9(H2·pgvector)·T10(flyway.schemas)은 백엔드 아티팩트라 **back 레포** `docs/ai/troubleshooting`에 있습니다. From 16e08dbc310110770456dd82f8e659e1586e6d82 Mon Sep 17 00:00:00 2001 From: colosair Date: Wed, 5 Aug 2026 21:55:21 +0900 Subject: [PATCH 07/34] =?UTF-8?q?chore(S15P11A705-336):=20=EC=8B=A4?= =?UTF-8?q?=EC=B8=A1=20artifact=203=EC=A2=85=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=E2=80=94=20word=5Fgrid=C2=B7recall=5Fprobe=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EB=B6=84,=20keyword=5Fmatrix=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit word_grid·recall_probe 는 context_id 를 포함해 재생성했다(P48 §4.1 — keyword 조인용). keyword_matrix.json 은 .gitignore 예외로 등록 — GMS 배치 비용이 있는 artifact 는 커밋한다는 기존 원칙(matrix.json 계열)을 따른다. 스윕 결과는 재구성 가능하므로 미커밋. Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + .search/keyword_matrix.json | 11340 ++++++++++++++++++++++++++++++++++ .search/recall_probe.json | 1397 +++-- .search/word_grid.json | 6588 +++++++++++++------- 4 files changed, 16725 insertions(+), 2605 deletions(-) create mode 100644 .search/keyword_matrix.json diff --git a/.gitignore b/.gitignore index 0979f20..defa741 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,11 @@ tools/keyword_eval/compare_out.txt # 소유자 3명의 행렬이고 담는 수준은 위 셋과 같다(장소명까지). 행이 1,128 개라 들여쓰기 # 없이 저장한다. `boundary_sweep.py` 가 이 파일과 `word_grid.json` 을 함께 읽는다. !.search/boundary_grid.json +# `keyword_matrix.json` 도 같다(S15P11A705-336) — 질의 92건 × 전체 활성 Preset 코사인 + +# Context 별 keyword·confidence·상태의 artifact 다. 다시 뜨려면 GMS 배치를 부른다. +# `fusion_sweep.py` 가 이 파일과 행렬 셋만 읽으므로(DB·GMS 0회) 유실되면 재측정이 막힌다. +# 스윕 결과(`fusion_sweep*.json`·`rank_baseline.json`)는 여기서 재구성되므로 커밋하지 않는다. +!.search/keyword_matrix.json # `layer_probe.json` 은 층별 잔존 건수와 실서버 대조 결과다(S15P11A705-273). 유사도가 # 아니라 **판정**이라 값이 작고, 다음 사람이 「그때 서버가 무엇을 뱉었나」를 대조할 수 # 있어야 한다. diff --git a/.search/keyword_matrix.json b/.search/keyword_matrix.json new file mode 100644 index 0000000..9591cc9 --- /dev/null +++ b/.search/keyword_matrix.json @@ -0,0 +1,11340 @@ +{ + "stage": "P48-1", + "generated_at": "2026-08-05T08:57:04+00:00", + "profile": "openai-text-embedding-3-small-1536-cosine-v1", + "model": "text-embedding-3-small", + "preset_version": 1, + "source": { + "db_port": "15432", + "gms_batch": 1, + "matrices": { + "matrix.json": { + "profile": "openai-text-embedding-3-small-1536-cosine-v1", + "record_count": 42 + }, + "word_grid.json": { + "profile": "openai-text-embedding-3-small-1536-cosine-v1", + "record_count": 42 + }, + "recall_probe.json": { + "profile": "openai-text-embedding-3-small-1536-cosine-v1", + "record_count": null + } + } + }, + "preset_count": 27, + "context_count": 42, + "query_count": 92, + "presets": [ + { + "id": 101, + "code": "WITH_FRIENDS", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 102, + "code": "WITH_PARTNER", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 103, + "code": "WITH_FAMILY", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 104, + "code": "WITH_COLLEAGUES", + "version": 1, + "visibility": "PRIVATE_ONLY" + }, + { + "id": 105, + "code": "WITH_KIDS", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 106, + "code": "ALONE", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 201, + "code": "COFFEE_CHAT", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 202, + "code": "MEAL", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 203, + "code": "DRINK", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 204, + "code": "DESSERT", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 205, + "code": "STUDY_WORK", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 206, + "code": "WALK", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 207, + "code": "SHOPPING", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 208, + "code": "EXHIBITION", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 301, + "code": "QUIET", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 302, + "code": "COZY", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 303, + "code": "TRENDY", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 304, + "code": "LIVELY", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 305, + "code": "SPACIOUS", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 306, + "code": "VIEW_GOOD", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 307, + "code": "RETRO", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 401, + "code": "DATE_COURSE", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 402, + "code": "ANNIVERSARY", + "version": 1, + "visibility": "PRIVATE_ONLY" + }, + { + "id": 403, + "code": "GATHERING", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 404, + "code": "RAINY_DAY", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 405, + "code": "QUICK_STOP", + "version": 1, + "visibility": "PUBLIC" + }, + { + "id": 406, + "code": "CELEBRATION", + "version": 1, + "visibility": "PUBLIC" + } + ], + "contexts": [ + { + "context_id": 252, + "record_id": 252, + "user_id": 75, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 302, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 307, + "confidence": 0.85, + "preset_version": 1 + }, + { + "keyword_id": 404, + "confidence": 0.8, + "preset_version": 1 + } + ] + }, + { + "context_id": 253, + "record_id": 253, + "user_id": 75, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 106, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 205, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 301, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 254, + "record_id": 254, + "user_id": 75, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 101, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 202, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 304, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 255, + "record_id": 255, + "user_id": 75, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 102, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 306, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 401, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 402, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 256, + "record_id": 256, + "user_id": 75, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 103, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 202, + "confidence": 0.7, + "preset_version": 1 + }, + { + "keyword_id": 305, + "confidence": 0.8, + "preset_version": 1 + } + ] + }, + { + "context_id": 257, + "record_id": 257, + "user_id": 75, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 206, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 208, + "confidence": 0.8, + "preset_version": 1 + } + ] + }, + { + "context_id": 258, + "record_id": 258, + "user_id": 76, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 106, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 301, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 259, + "record_id": 259, + "user_id": 76, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 101, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 206, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 260, + "record_id": 260, + "user_id": 77, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 201, + "confidence": 0.7, + "preset_version": 1 + }, + { + "keyword_id": 204, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 261, + "record_id": 261, + "user_id": 77, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 204, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 303, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 262, + "record_id": 262, + "user_id": 78, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 101, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 202, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 403, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 263, + "record_id": 263, + "user_id": 78, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 203, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 304, + "confidence": 0.8, + "preset_version": 1 + } + ] + }, + { + "context_id": 264, + "record_id": 264, + "user_id": 79, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 106, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 301, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 302, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 404, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 265, + "record_id": 265, + "user_id": 79, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 206, + "confidence": 0.8, + "preset_version": 1 + } + ] + }, + { + "context_id": 266, + "record_id": 266, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 201, + "confidence": 0.7, + "preset_version": 1 + }, + { + "keyword_id": 204, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 303, + "confidence": 0.8, + "preset_version": 1 + } + ] + }, + { + "context_id": 267, + "record_id": 267, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 101, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 202, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 268, + "record_id": 268, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 202, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 269, + "record_id": 269, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 207, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 301, + "confidence": 0.7, + "preset_version": 1 + } + ] + }, + { + "context_id": 270, + "record_id": 270, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 102, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 302, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 401, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 271, + "record_id": 271, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 302, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 272, + "record_id": 272, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [] + }, + { + "context_id": 273, + "record_id": 273, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 202, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 405, + "confidence": 0.5, + "preset_version": 1 + } + ] + }, + { + "context_id": 274, + "record_id": 274, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 203, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 275, + "record_id": 275, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 204, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 303, + "confidence": 0.8, + "preset_version": 1 + } + ] + }, + { + "context_id": 276, + "record_id": 276, + "user_id": 80, + "keyword_status": "COMPLETED", + "keywords": [] + }, + { + "context_id": 277, + "record_id": 277, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 101, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 202, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 278, + "record_id": 278, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 202, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 279, + "record_id": 279, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 206, + "confidence": 0.3, + "preset_version": 1 + } + ] + }, + { + "context_id": 280, + "record_id": 280, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 101, + "confidence": 1.0, + "preset_version": 1 + } + ] + }, + { + "context_id": 281, + "record_id": 281, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 202, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 405, + "confidence": 0.7, + "preset_version": 1 + } + ] + }, + { + "context_id": 282, + "record_id": 282, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 202, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 206, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 405, + "confidence": 0.7, + "preset_version": 1 + } + ] + }, + { + "context_id": 283, + "record_id": 283, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 204, + "confidence": 0.7, + "preset_version": 1 + } + ] + }, + { + "context_id": 284, + "record_id": 284, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 103, + "confidence": 0.6, + "preset_version": 1 + }, + { + "keyword_id": 202, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 285, + "record_id": 285, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 202, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 286, + "record_id": 286, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [] + }, + { + "context_id": 287, + "record_id": 287, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 202, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 204, + "confidence": 0.7, + "preset_version": 1 + }, + { + "keyword_id": 206, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 288, + "record_id": 288, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 104, + "confidence": 0.7, + "preset_version": 1 + }, + { + "keyword_id": 202, + "confidence": 0.8, + "preset_version": 1 + } + ] + }, + { + "context_id": 289, + "record_id": 289, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 203, + "confidence": 0.8, + "preset_version": 1 + } + ] + }, + { + "context_id": 290, + "record_id": 290, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 204, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 303, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 306, + "confidence": 0.7, + "preset_version": 1 + } + ] + }, + { + "context_id": 291, + "record_id": 291, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 207, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 301, + "confidence": 0.6, + "preset_version": 1 + } + ] + }, + { + "context_id": 292, + "record_id": 292, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 102, + "confidence": 0.8, + "preset_version": 1 + }, + { + "keyword_id": 303, + "confidence": 0.7, + "preset_version": 1 + }, + { + "keyword_id": 401, + "confidence": 0.9, + "preset_version": 1 + } + ] + }, + { + "context_id": 293, + "record_id": 293, + "user_id": 81, + "keyword_status": "COMPLETED", + "keywords": [ + { + "keyword_id": 202, + "confidence": 0.9, + "preset_version": 1 + }, + { + "keyword_id": 406, + "confidence": 0.5, + "preset_version": 1 + } + ] + } + ], + "query_preset": [ + { + "query": "비 오는 날 가려고 저장한 곳", + "cos": [ + { + "preset_id": 101, + "cos": 0.212809 + }, + { + "preset_id": 102, + "cos": 0.262239 + }, + { + "preset_id": 103, + "cos": 0.196233 + }, + { + "preset_id": 104, + "cos": 0.255564 + }, + { + "preset_id": 105, + "cos": 0.145049 + }, + { + "preset_id": 106, + "cos": 0.223122 + }, + { + "preset_id": 201, + "cos": 0.221115 + }, + { + "preset_id": 202, + "cos": 0.232314 + }, + { + "preset_id": 203, + "cos": 0.299061 + }, + { + "preset_id": 204, + "cos": 0.231234 + }, + { + "preset_id": 205, + "cos": 0.230673 + }, + { + "preset_id": 206, + "cos": 0.294066 + }, + { + "preset_id": 207, + "cos": 0.222529 + }, + { + "preset_id": 208, + "cos": 0.292585 + }, + { + "preset_id": 301, + "cos": 0.181922 + }, + { + "preset_id": 302, + "cos": 0.188302 + }, + { + "preset_id": 303, + "cos": 0.228146 + }, + { + "preset_id": 304, + "cos": 0.224606 + }, + { + "preset_id": 305, + "cos": 0.230092 + }, + { + "preset_id": 306, + "cos": 0.237939 + }, + { + "preset_id": 307, + "cos": 0.260698 + }, + { + "preset_id": 401, + "cos": 0.261673 + }, + { + "preset_id": 402, + "cos": 0.339014 + }, + { + "preset_id": 403, + "cos": 0.219272 + }, + { + "preset_id": 404, + "cos": 0.506919 + }, + { + "preset_id": 405, + "cos": 0.273146 + }, + { + "preset_id": 406, + "cos": 0.218563 + } + ] + }, + { + "query": "혼자 조용히 작업하기 좋은 카페", + "cos": [ + { + "preset_id": 101, + "cos": 0.29351 + }, + { + "preset_id": 102, + "cos": 0.214935 + }, + { + "preset_id": 103, + "cos": 0.235197 + }, + { + "preset_id": 104, + "cos": 0.306351 + }, + { + "preset_id": 105, + "cos": 0.20825 + }, + { + "preset_id": 106, + "cos": 0.58682 + }, + { + "preset_id": 201, + "cos": 0.419938 + }, + { + "preset_id": 202, + "cos": 0.268795 + }, + { + "preset_id": 203, + "cos": 0.322295 + }, + { + "preset_id": 204, + "cos": 0.267434 + }, + { + "preset_id": 205, + "cos": 0.412811 + }, + { + "preset_id": 206, + "cos": 0.296131 + }, + { + "preset_id": 207, + "cos": 0.231731 + }, + { + "preset_id": 208, + "cos": 0.272715 + }, + { + "preset_id": 301, + "cos": 0.476878 + }, + { + "preset_id": 302, + "cos": 0.315482 + }, + { + "preset_id": 303, + "cos": 0.317082 + }, + { + "preset_id": 304, + "cos": 0.245997 + }, + { + "preset_id": 305, + "cos": 0.317349 + }, + { + "preset_id": 306, + "cos": 0.373445 + }, + { + "preset_id": 307, + "cos": 0.161287 + }, + { + "preset_id": 401, + "cos": 0.344503 + }, + { + "preset_id": 402, + "cos": 0.275122 + }, + { + "preset_id": 403, + "cos": 0.281864 + }, + { + "preset_id": 404, + "cos": 0.290078 + }, + { + "preset_id": 405, + "cos": 0.344166 + }, + { + "preset_id": 406, + "cos": 0.290528 + } + ] + }, + { + "query": "친구들이랑 시끌벅적하게 놀 만한 곳", + "cos": [ + { + "preset_id": 101, + "cos": 0.555046 + }, + { + "preset_id": 102, + "cos": 0.42247 + }, + { + "preset_id": 103, + "cos": 0.348344 + }, + { + "preset_id": 104, + "cos": 0.405991 + }, + { + "preset_id": 105, + "cos": 0.257781 + }, + { + "preset_id": 106, + "cos": 0.34972 + }, + { + "preset_id": 201, + "cos": 0.35171 + }, + { + "preset_id": 202, + "cos": 0.343019 + }, + { + "preset_id": 203, + "cos": 0.395813 + }, + { + "preset_id": 204, + "cos": 0.330158 + }, + { + "preset_id": 205, + "cos": 0.297767 + }, + { + "preset_id": 206, + "cos": 0.309647 + }, + { + "preset_id": 207, + "cos": 0.206333 + }, + { + "preset_id": 208, + "cos": 0.280264 + }, + { + "preset_id": 301, + "cos": 0.329286 + }, + { + "preset_id": 302, + "cos": 0.330432 + }, + { + "preset_id": 303, + "cos": 0.336445 + }, + { + "preset_id": 304, + "cos": 0.339658 + }, + { + "preset_id": 305, + "cos": 0.308406 + }, + { + "preset_id": 306, + "cos": 0.377672 + }, + { + "preset_id": 307, + "cos": 0.196076 + }, + { + "preset_id": 401, + "cos": 0.34066 + }, + { + "preset_id": 402, + "cos": 0.298531 + }, + { + "preset_id": 403, + "cos": 0.357288 + }, + { + "preset_id": 404, + "cos": 0.293887 + }, + { + "preset_id": 405, + "cos": 0.391864 + }, + { + "preset_id": 406, + "cos": 0.256491 + } + ] + }, + { + "query": "기념일에 야경 보면서 식사할 곳", + "cos": [ + { + "preset_id": 101, + "cos": 0.25031 + }, + { + "preset_id": 102, + "cos": 0.32068 + }, + { + "preset_id": 103, + "cos": 0.313853 + }, + { + "preset_id": 104, + "cos": 0.236546 + }, + { + "preset_id": 105, + "cos": 0.25942 + }, + { + "preset_id": 106, + "cos": 0.234244 + }, + { + "preset_id": 201, + "cos": 0.416093 + }, + { + "preset_id": 202, + "cos": 0.418928 + }, + { + "preset_id": 203, + "cos": 0.26281 + }, + { + "preset_id": 204, + "cos": 0.42497 + }, + { + "preset_id": 205, + "cos": 0.257862 + }, + { + "preset_id": 206, + "cos": 0.339418 + }, + { + "preset_id": 207, + "cos": 0.246494 + }, + { + "preset_id": 208, + "cos": 0.29642 + }, + { + "preset_id": 301, + "cos": 0.21479 + }, + { + "preset_id": 302, + "cos": 0.233675 + }, + { + "preset_id": 303, + "cos": 0.382897 + }, + { + "preset_id": 304, + "cos": 0.237437 + }, + { + "preset_id": 305, + "cos": 0.217433 + }, + { + "preset_id": 306, + "cos": 0.563389 + }, + { + "preset_id": 307, + "cos": 0.209212 + }, + { + "preset_id": 401, + "cos": 0.312199 + }, + { + "preset_id": 402, + "cos": 0.430488 + }, + { + "preset_id": 403, + "cos": 0.247298 + }, + { + "preset_id": 404, + "cos": 0.320785 + }, + { + "preset_id": 405, + "cos": 0.377946 + }, + { + "preset_id": 406, + "cos": 0.207773 + } + ] + }, + { + "query": "돈카츠 먹으러 자주 갔던 곳", + "cos": [ + { + "preset_id": 101, + "cos": 0.328092 + }, + { + "preset_id": 102, + "cos": 0.30541 + }, + { + "preset_id": 103, + "cos": 0.327359 + }, + { + "preset_id": 104, + "cos": 0.245673 + }, + { + "preset_id": 105, + "cos": 0.204508 + }, + { + "preset_id": 106, + "cos": 0.225424 + }, + { + "preset_id": 201, + "cos": 0.421058 + }, + { + "preset_id": 202, + "cos": 0.361405 + }, + { + "preset_id": 203, + "cos": 0.362659 + }, + { + "preset_id": 204, + "cos": 0.482645 + }, + { + "preset_id": 205, + "cos": 0.292718 + }, + { + "preset_id": 206, + "cos": 0.36294 + }, + { + "preset_id": 207, + "cos": 0.237137 + }, + { + "preset_id": 208, + "cos": 0.250736 + }, + { + "preset_id": 301, + "cos": 0.168558 + }, + { + "preset_id": 302, + "cos": 0.212308 + }, + { + "preset_id": 303, + "cos": 0.226067 + }, + { + "preset_id": 304, + "cos": 0.157112 + }, + { + "preset_id": 305, + "cos": 0.248888 + }, + { + "preset_id": 306, + "cos": 0.300051 + }, + { + "preset_id": 307, + "cos": 0.181353 + }, + { + "preset_id": 401, + "cos": 0.311093 + }, + { + "preset_id": 402, + "cos": 0.269268 + }, + { + "preset_id": 403, + "cos": 0.216827 + }, + { + "preset_id": 404, + "cos": 0.248681 + }, + { + "preset_id": 405, + "cos": 0.326813 + }, + { + "preset_id": 406, + "cos": 0.237267 + } + ] + }, + { + "query": "미슐랭에 오른 라멘집", + "cos": [ + { + "preset_id": 101, + "cos": 0.153202 + }, + { + "preset_id": 102, + "cos": 0.191277 + }, + { + "preset_id": 103, + "cos": 0.170146 + }, + { + "preset_id": 104, + "cos": 0.174741 + }, + { + "preset_id": 105, + "cos": 0.17839 + }, + { + "preset_id": 106, + "cos": 0.16826 + }, + { + "preset_id": 201, + "cos": 0.244364 + }, + { + "preset_id": 202, + "cos": 0.164464 + }, + { + "preset_id": 203, + "cos": 0.183193 + }, + { + "preset_id": 204, + "cos": 0.264645 + }, + { + "preset_id": 205, + "cos": 0.240241 + }, + { + "preset_id": 206, + "cos": 0.282691 + }, + { + "preset_id": 207, + "cos": 0.216867 + }, + { + "preset_id": 208, + "cos": 0.291302 + }, + { + "preset_id": 301, + "cos": 0.179053 + }, + { + "preset_id": 302, + "cos": 0.106608 + }, + { + "preset_id": 303, + "cos": 0.222674 + }, + { + "preset_id": 304, + "cos": 0.179091 + }, + { + "preset_id": 305, + "cos": 0.141988 + }, + { + "preset_id": 306, + "cos": 0.240704 + }, + { + "preset_id": 307, + "cos": 0.144767 + }, + { + "preset_id": 401, + "cos": 0.174489 + }, + { + "preset_id": 402, + "cos": 0.19223 + }, + { + "preset_id": 403, + "cos": 0.240015 + }, + { + "preset_id": 404, + "cos": 0.121144 + }, + { + "preset_id": 405, + "cos": 0.213358 + }, + { + "preset_id": 406, + "cos": 0.219206 + } + ] + }, + { + "query": "친구들이랑 피자에 맥주 마신 곳", + "cos": [ + { + "preset_id": 101, + "cos": 0.524151 + }, + { + "preset_id": 102, + "cos": 0.320774 + }, + { + "preset_id": 103, + "cos": 0.352588 + }, + { + "preset_id": 104, + "cos": 0.342247 + }, + { + "preset_id": 105, + "cos": 0.185296 + }, + { + "preset_id": 106, + "cos": 0.313143 + }, + { + "preset_id": 201, + "cos": 0.376339 + }, + { + "preset_id": 202, + "cos": 0.31387 + }, + { + "preset_id": 203, + "cos": 0.484565 + }, + { + "preset_id": 204, + "cos": 0.36615 + }, + { + "preset_id": 205, + "cos": 0.239469 + }, + { + "preset_id": 206, + "cos": 0.291248 + }, + { + "preset_id": 207, + "cos": 0.186195 + }, + { + "preset_id": 208, + "cos": 0.179922 + }, + { + "preset_id": 301, + "cos": 0.214855 + }, + { + "preset_id": 302, + "cos": 0.191289 + }, + { + "preset_id": 303, + "cos": 0.221127 + }, + { + "preset_id": 304, + "cos": 0.194808 + }, + { + "preset_id": 305, + "cos": 0.238452 + }, + { + "preset_id": 306, + "cos": 0.32954 + }, + { + "preset_id": 307, + "cos": 0.119106 + }, + { + "preset_id": 401, + "cos": 0.307276 + }, + { + "preset_id": 402, + "cos": 0.241442 + }, + { + "preset_id": 403, + "cos": 0.317008 + }, + { + "preset_id": 404, + "cos": 0.238562 + }, + { + "preset_id": 405, + "cos": 0.302264 + }, + { + "preset_id": 406, + "cos": 0.222644 + } + ] + }, + { + "query": "밥 먹고 산책하면서 쉬어가는 공원", + "cos": [ + { + "preset_id": 101, + "cos": 0.21514 + }, + { + "preset_id": 102, + "cos": 0.196585 + }, + { + "preset_id": 103, + "cos": 0.286209 + }, + { + "preset_id": 104, + "cos": 0.131292 + }, + { + "preset_id": 105, + "cos": 0.184498 + }, + { + "preset_id": 106, + "cos": 0.280129 + }, + { + "preset_id": 201, + "cos": 0.336283 + }, + { + "preset_id": 202, + "cos": 0.314111 + }, + { + "preset_id": 203, + "cos": 0.270145 + }, + { + "preset_id": 204, + "cos": 0.358543 + }, + { + "preset_id": 205, + "cos": 0.340067 + }, + { + "preset_id": 206, + "cos": 0.523196 + }, + { + "preset_id": 207, + "cos": 0.293744 + }, + { + "preset_id": 208, + "cos": 0.258466 + }, + { + "preset_id": 301, + "cos": 0.243902 + }, + { + "preset_id": 302, + "cos": 0.241193 + }, + { + "preset_id": 303, + "cos": 0.253903 + }, + { + "preset_id": 304, + "cos": 0.252001 + }, + { + "preset_id": 305, + "cos": 0.193971 + }, + { + "preset_id": 306, + "cos": 0.378491 + }, + { + "preset_id": 307, + "cos": 0.167549 + }, + { + "preset_id": 401, + "cos": 0.198136 + }, + { + "preset_id": 402, + "cos": 0.24075 + }, + { + "preset_id": 403, + "cos": 0.142293 + }, + { + "preset_id": 404, + "cos": 0.278847 + }, + { + "preset_id": 405, + "cos": 0.335418 + }, + { + "preset_id": 406, + "cos": 0.21425 + } + ] + }, + { + "query": "채식 샌드위치 먹던 단골집", + "cos": [ + { + "preset_id": 101, + "cos": 0.257648 + }, + { + "preset_id": 102, + "cos": 0.259525 + }, + { + "preset_id": 103, + "cos": 0.30428 + }, + { + "preset_id": 104, + "cos": 0.203476 + }, + { + "preset_id": 105, + "cos": 0.171277 + }, + { + "preset_id": 106, + "cos": 0.223747 + }, + { + "preset_id": 201, + "cos": 0.365745 + }, + { + "preset_id": 202, + "cos": 0.339323 + }, + { + "preset_id": 203, + "cos": 0.254383 + }, + { + "preset_id": 204, + "cos": 0.556263 + }, + { + "preset_id": 205, + "cos": 0.277991 + }, + { + "preset_id": 206, + "cos": 0.304299 + }, + { + "preset_id": 207, + "cos": 0.227515 + }, + { + "preset_id": 208, + "cos": 0.21336 + }, + { + "preset_id": 301, + "cos": 0.173442 + }, + { + "preset_id": 302, + "cos": 0.183256 + }, + { + "preset_id": 303, + "cos": 0.277766 + }, + { + "preset_id": 304, + "cos": 0.165278 + }, + { + "preset_id": 305, + "cos": 0.238849 + }, + { + "preset_id": 306, + "cos": 0.37684 + }, + { + "preset_id": 307, + "cos": 0.132458 + }, + { + "preset_id": 401, + "cos": 0.283542 + }, + { + "preset_id": 402, + "cos": 0.231095 + }, + { + "preset_id": 403, + "cos": 0.208056 + }, + { + "preset_id": 404, + "cos": 0.201402 + }, + { + "preset_id": 405, + "cos": 0.346517 + }, + { + "preset_id": 406, + "cos": 0.195072 + } + ] + }, + { + "query": "책 사면 꽃을 주는 서점", + "cos": [ + { + "preset_id": 101, + "cos": 0.179211 + }, + { + "preset_id": 102, + "cos": 0.181647 + }, + { + "preset_id": 103, + "cos": 0.161954 + }, + { + "preset_id": 104, + "cos": 0.107471 + }, + { + "preset_id": 105, + "cos": 0.141769 + }, + { + "preset_id": 106, + "cos": 0.219733 + }, + { + "preset_id": 201, + "cos": 0.298574 + }, + { + "preset_id": 202, + "cos": 0.193819 + }, + { + "preset_id": 203, + "cos": 0.22385 + }, + { + "preset_id": 204, + "cos": 0.264348 + }, + { + "preset_id": 205, + "cos": 0.234347 + }, + { + "preset_id": 206, + "cos": 0.386592 + }, + { + "preset_id": 207, + "cos": 0.271541 + }, + { + "preset_id": 208, + "cos": 0.263529 + }, + { + "preset_id": 301, + "cos": 0.182566 + }, + { + "preset_id": 302, + "cos": 0.175923 + }, + { + "preset_id": 303, + "cos": 0.271798 + }, + { + "preset_id": 304, + "cos": 0.127867 + }, + { + "preset_id": 305, + "cos": 0.239013 + }, + { + "preset_id": 306, + "cos": 0.365546 + }, + { + "preset_id": 307, + "cos": 0.197322 + }, + { + "preset_id": 401, + "cos": 0.229254 + }, + { + "preset_id": 402, + "cos": 0.292267 + }, + { + "preset_id": 403, + "cos": 0.231774 + }, + { + "preset_id": 404, + "cos": 0.14891 + }, + { + "preset_id": 405, + "cos": 0.207922 + }, + { + "preset_id": 406, + "cos": 0.286138 + } + ] + }, + { + "query": "화덕에 구운 피자집", + "cos": [ + { + "preset_id": 101, + "cos": 0.230716 + }, + { + "preset_id": 102, + "cos": 0.189486 + }, + { + "preset_id": 103, + "cos": 0.286371 + }, + { + "preset_id": 104, + "cos": 0.26331 + }, + { + "preset_id": 105, + "cos": 0.143699 + }, + { + "preset_id": 106, + "cos": 0.224892 + }, + { + "preset_id": 201, + "cos": 0.255471 + }, + { + "preset_id": 202, + "cos": 0.311323 + }, + { + "preset_id": 203, + "cos": 0.277774 + }, + { + "preset_id": 204, + "cos": 0.314746 + }, + { + "preset_id": 205, + "cos": 0.148201 + }, + { + "preset_id": 206, + "cos": 0.271842 + }, + { + "preset_id": 207, + "cos": 0.135972 + }, + { + "preset_id": 208, + "cos": 0.134129 + }, + { + "preset_id": 301, + "cos": 0.163849 + }, + { + "preset_id": 302, + "cos": 0.169803 + }, + { + "preset_id": 303, + "cos": 0.211521 + }, + { + "preset_id": 304, + "cos": 0.236521 + }, + { + "preset_id": 305, + "cos": 0.218119 + }, + { + "preset_id": 306, + "cos": 0.290704 + }, + { + "preset_id": 307, + "cos": 0.143633 + }, + { + "preset_id": 401, + "cos": 0.209049 + }, + { + "preset_id": 402, + "cos": 0.229587 + }, + { + "preset_id": 403, + "cos": 0.247264 + }, + { + "preset_id": 404, + "cos": 0.151612 + }, + { + "preset_id": 405, + "cos": 0.282986 + }, + { + "preset_id": 406, + "cos": 0.248894 + } + ] + }, + { + "query": "양갱 파는 분위기 좋은 카페", + "cos": [ + { + "preset_id": 101, + "cos": 0.255365 + }, + { + "preset_id": 102, + "cos": 0.246723 + }, + { + "preset_id": 103, + "cos": 0.247891 + }, + { + "preset_id": 104, + "cos": 0.196393 + }, + { + "preset_id": 105, + "cos": 0.1999 + }, + { + "preset_id": 106, + "cos": 0.254593 + }, + { + "preset_id": 201, + "cos": 0.365117 + }, + { + "preset_id": 202, + "cos": 0.194038 + }, + { + "preset_id": 203, + "cos": 0.272386 + }, + { + "preset_id": 204, + "cos": 0.247065 + }, + { + "preset_id": 205, + "cos": 0.212111 + }, + { + "preset_id": 206, + "cos": 0.222706 + }, + { + "preset_id": 207, + "cos": 0.143354 + }, + { + "preset_id": 208, + "cos": 0.223933 + }, + { + "preset_id": 301, + "cos": 0.313973 + }, + { + "preset_id": 302, + "cos": 0.294799 + }, + { + "preset_id": 303, + "cos": 0.320434 + }, + { + "preset_id": 304, + "cos": 0.2896 + }, + { + "preset_id": 305, + "cos": 0.309337 + }, + { + "preset_id": 306, + "cos": 0.413182 + }, + { + "preset_id": 307, + "cos": 0.208037 + }, + { + "preset_id": 401, + "cos": 0.347883 + }, + { + "preset_id": 402, + "cos": 0.255612 + }, + { + "preset_id": 403, + "cos": 0.265364 + }, + { + "preset_id": 404, + "cos": 0.220667 + }, + { + "preset_id": 405, + "cos": 0.193532 + }, + { + "preset_id": 406, + "cos": 0.274786 + } + ] + }, + { + "query": "자동차 엔진오일 교환 정비소", + "cos": [ + { + "preset_id": 101, + "cos": 0.117447 + }, + { + "preset_id": 102, + "cos": 0.146438 + }, + { + "preset_id": 103, + "cos": 0.123457 + }, + { + "preset_id": 104, + "cos": 0.167314 + }, + { + "preset_id": 105, + "cos": 0.123982 + }, + { + "preset_id": 106, + "cos": 0.125118 + }, + { + "preset_id": 201, + "cos": 0.120243 + }, + { + "preset_id": 202, + "cos": 0.110835 + }, + { + "preset_id": 203, + "cos": 0.122542 + }, + { + "preset_id": 204, + "cos": 0.084725 + }, + { + "preset_id": 205, + "cos": 0.141927 + }, + { + "preset_id": 206, + "cos": 0.148177 + }, + { + "preset_id": 207, + "cos": 0.121217 + }, + { + "preset_id": 208, + "cos": 0.115012 + }, + { + "preset_id": 301, + "cos": 0.128315 + }, + { + "preset_id": 302, + "cos": 0.070908 + }, + { + "preset_id": 303, + "cos": 0.048121 + }, + { + "preset_id": 304, + "cos": 0.181674 + }, + { + "preset_id": 305, + "cos": 0.074421 + }, + { + "preset_id": 306, + "cos": 0.050564 + }, + { + "preset_id": 307, + "cos": 0.090374 + }, + { + "preset_id": 401, + "cos": 0.12903 + }, + { + "preset_id": 402, + "cos": 0.103006 + }, + { + "preset_id": 403, + "cos": 0.167321 + }, + { + "preset_id": 404, + "cos": 0.166093 + }, + { + "preset_id": 405, + "cos": 0.112458 + }, + { + "preset_id": 406, + "cos": 0.196851 + } + ] + }, + { + "query": "치과 임플란트 상담 받을 곳", + "cos": [ + { + "preset_id": 101, + "cos": 0.190804 + }, + { + "preset_id": 102, + "cos": 0.272424 + }, + { + "preset_id": 103, + "cos": 0.226851 + }, + { + "preset_id": 104, + "cos": 0.292099 + }, + { + "preset_id": 105, + "cos": 0.288665 + }, + { + "preset_id": 106, + "cos": 0.18151 + }, + { + "preset_id": 201, + "cos": 0.334623 + }, + { + "preset_id": 202, + "cos": 0.327193 + }, + { + "preset_id": 203, + "cos": 0.12473 + }, + { + "preset_id": 204, + "cos": 0.224292 + }, + { + "preset_id": 205, + "cos": 0.306082 + }, + { + "preset_id": 206, + "cos": 0.181652 + }, + { + "preset_id": 207, + "cos": 0.157372 + }, + { + "preset_id": 208, + "cos": 0.259313 + }, + { + "preset_id": 301, + "cos": 0.261181 + }, + { + "preset_id": 302, + "cos": 0.120417 + }, + { + "preset_id": 303, + "cos": 0.144868 + }, + { + "preset_id": 304, + "cos": 0.1854 + }, + { + "preset_id": 305, + "cos": 0.274519 + }, + { + "preset_id": 306, + "cos": 0.218152 + }, + { + "preset_id": 307, + "cos": 0.135962 + }, + { + "preset_id": 401, + "cos": 0.185692 + }, + { + "preset_id": 402, + "cos": 0.205247 + }, + { + "preset_id": 403, + "cos": 0.278058 + }, + { + "preset_id": 404, + "cos": 0.159578 + }, + { + "preset_id": 405, + "cos": 0.316833 + }, + { + "preset_id": 406, + "cos": 0.308802 + } + ] + }, + { + "query": "겨울 스키장 리프트권 파는 데", + "cos": [ + { + "preset_id": 101, + "cos": 0.10559 + }, + { + "preset_id": 102, + "cos": 0.160469 + }, + { + "preset_id": 103, + "cos": 0.084562 + }, + { + "preset_id": 104, + "cos": 0.138183 + }, + { + "preset_id": 105, + "cos": 0.179204 + }, + { + "preset_id": 106, + "cos": 0.133384 + }, + { + "preset_id": 201, + "cos": 0.133277 + }, + { + "preset_id": 202, + "cos": 0.215331 + }, + { + "preset_id": 203, + "cos": 0.222883 + }, + { + "preset_id": 204, + "cos": 0.290271 + }, + { + "preset_id": 205, + "cos": 0.287513 + }, + { + "preset_id": 206, + "cos": 0.220728 + }, + { + "preset_id": 207, + "cos": 0.288863 + }, + { + "preset_id": 208, + "cos": 0.234144 + }, + { + "preset_id": 301, + "cos": 0.076312 + }, + { + "preset_id": 302, + "cos": 0.141288 + }, + { + "preset_id": 303, + "cos": 0.196572 + }, + { + "preset_id": 304, + "cos": 0.134306 + }, + { + "preset_id": 305, + "cos": 0.15043 + }, + { + "preset_id": 306, + "cos": 0.181417 + }, + { + "preset_id": 307, + "cos": 0.215753 + }, + { + "preset_id": 401, + "cos": 0.195622 + }, + { + "preset_id": 402, + "cos": 0.199063 + }, + { + "preset_id": 403, + "cos": 0.18637 + }, + { + "preset_id": 404, + "cos": 0.104485 + }, + { + "preset_id": 405, + "cos": 0.223118 + }, + { + "preset_id": 406, + "cos": 0.205618 + } + ] + }, + { + "query": "노트북 액정 수리 서비스센터", + "cos": [ + { + "preset_id": 101, + "cos": 0.14316 + }, + { + "preset_id": 102, + "cos": 0.130457 + }, + { + "preset_id": 103, + "cos": 0.108266 + }, + { + "preset_id": 104, + "cos": 0.191435 + }, + { + "preset_id": 105, + "cos": 0.2116 + }, + { + "preset_id": 106, + "cos": 0.13126 + }, + { + "preset_id": 201, + "cos": 0.197085 + }, + { + "preset_id": 202, + "cos": 0.203779 + }, + { + "preset_id": 203, + "cos": 0.095634 + }, + { + "preset_id": 204, + "cos": 0.174523 + }, + { + "preset_id": 205, + "cos": 0.331443 + }, + { + "preset_id": 206, + "cos": 0.152932 + }, + { + "preset_id": 207, + "cos": 0.244419 + }, + { + "preset_id": 208, + "cos": 0.214872 + }, + { + "preset_id": 301, + "cos": 0.092169 + }, + { + "preset_id": 302, + "cos": 0.106711 + }, + { + "preset_id": 303, + "cos": 0.11772 + }, + { + "preset_id": 304, + "cos": 0.116562 + }, + { + "preset_id": 305, + "cos": 0.136844 + }, + { + "preset_id": 306, + "cos": 0.099484 + }, + { + "preset_id": 307, + "cos": 0.164935 + }, + { + "preset_id": 401, + "cos": 0.104833 + }, + { + "preset_id": 402, + "cos": 0.124365 + }, + { + "preset_id": 403, + "cos": 0.138908 + }, + { + "preset_id": 404, + "cos": 0.135527 + }, + { + "preset_id": 405, + "cos": 0.223153 + }, + { + "preset_id": 406, + "cos": 0.205455 + } + ] + }, + { + "query": "강아지 예방접종 동물병원", + "cos": [ + { + "preset_id": 101, + "cos": 0.155871 + }, + { + "preset_id": 102, + "cos": 0.137702 + }, + { + "preset_id": 103, + "cos": 0.189531 + }, + { + "preset_id": 104, + "cos": 0.184517 + }, + { + "preset_id": 105, + "cos": 0.318851 + }, + { + "preset_id": 106, + "cos": 0.194685 + }, + { + "preset_id": 201, + "cos": 0.116474 + }, + { + "preset_id": 202, + "cos": 0.220882 + }, + { + "preset_id": 203, + "cos": 0.078842 + }, + { + "preset_id": 204, + "cos": 0.153177 + }, + { + "preset_id": 205, + "cos": 0.225549 + }, + { + "preset_id": 206, + "cos": 0.163121 + }, + { + "preset_id": 207, + "cos": 0.205895 + }, + { + "preset_id": 208, + "cos": 0.195522 + }, + { + "preset_id": 301, + "cos": 0.201562 + }, + { + "preset_id": 302, + "cos": 0.172762 + }, + { + "preset_id": 303, + "cos": 0.141669 + }, + { + "preset_id": 304, + "cos": 0.157173 + }, + { + "preset_id": 305, + "cos": 0.159787 + }, + { + "preset_id": 306, + "cos": 0.184544 + }, + { + "preset_id": 307, + "cos": 0.0951 + }, + { + "preset_id": 401, + "cos": 0.182641 + }, + { + "preset_id": 402, + "cos": 0.188718 + }, + { + "preset_id": 403, + "cos": 0.169567 + }, + { + "preset_id": 404, + "cos": 0.132301 + }, + { + "preset_id": 405, + "cos": 0.217894 + }, + { + "preset_id": 406, + "cos": 0.269276 + } + ] + }, + { + "query": "그네", + "cos": [ + { + "preset_id": 101, + "cos": 0.228349 + }, + { + "preset_id": 102, + "cos": 0.188694 + }, + { + "preset_id": 103, + "cos": 0.181238 + }, + { + "preset_id": 104, + "cos": 0.193427 + }, + { + "preset_id": 105, + "cos": 0.204995 + }, + { + "preset_id": 106, + "cos": 0.193496 + }, + { + "preset_id": 201, + "cos": 0.178358 + }, + { + "preset_id": 202, + "cos": 0.163454 + }, + { + "preset_id": 203, + "cos": 0.184888 + }, + { + "preset_id": 204, + "cos": 0.183545 + }, + { + "preset_id": 205, + "cos": 0.198211 + }, + { + "preset_id": 206, + "cos": 0.168481 + }, + { + "preset_id": 207, + "cos": 0.17014 + }, + { + "preset_id": 208, + "cos": 0.203685 + }, + { + "preset_id": 301, + "cos": 0.188419 + }, + { + "preset_id": 302, + "cos": 0.185292 + }, + { + "preset_id": 303, + "cos": 0.226457 + }, + { + "preset_id": 304, + "cos": 0.17016 + }, + { + "preset_id": 305, + "cos": 0.244629 + }, + { + "preset_id": 306, + "cos": 0.188994 + }, + { + "preset_id": 307, + "cos": 0.217661 + }, + { + "preset_id": 401, + "cos": 0.188429 + }, + { + "preset_id": 402, + "cos": 0.200874 + }, + { + "preset_id": 403, + "cos": 0.193829 + }, + { + "preset_id": 404, + "cos": 0.138884 + }, + { + "preset_id": 405, + "cos": 0.177952 + }, + { + "preset_id": 406, + "cos": 0.204259 + } + ] + }, + { + "query": "신한", + "cos": [ + { + "preset_id": 101, + "cos": 0.221234 + }, + { + "preset_id": 102, + "cos": 0.234749 + }, + { + "preset_id": 103, + "cos": 0.178933 + }, + { + "preset_id": 104, + "cos": 0.260987 + }, + { + "preset_id": 105, + "cos": 0.183632 + }, + { + "preset_id": 106, + "cos": 0.258905 + }, + { + "preset_id": 201, + "cos": 0.184109 + }, + { + "preset_id": 202, + "cos": 0.283659 + }, + { + "preset_id": 203, + "cos": 0.219817 + }, + { + "preset_id": 204, + "cos": 0.15637 + }, + { + "preset_id": 205, + "cos": 0.261382 + }, + { + "preset_id": 206, + "cos": 0.186835 + }, + { + "preset_id": 207, + "cos": 0.24178 + }, + { + "preset_id": 208, + "cos": 0.306803 + }, + { + "preset_id": 301, + "cos": 0.256839 + }, + { + "preset_id": 302, + "cos": 0.199549 + }, + { + "preset_id": 303, + "cos": 0.229878 + }, + { + "preset_id": 304, + "cos": 0.200844 + }, + { + "preset_id": 305, + "cos": 0.227159 + }, + { + "preset_id": 306, + "cos": 0.21055 + }, + { + "preset_id": 307, + "cos": 0.218982 + }, + { + "preset_id": 401, + "cos": 0.169061 + }, + { + "preset_id": 402, + "cos": 0.349962 + }, + { + "preset_id": 403, + "cos": 0.259352 + }, + { + "preset_id": 404, + "cos": 0.161124 + }, + { + "preset_id": 405, + "cos": 0.239769 + }, + { + "preset_id": 406, + "cos": 0.260937 + } + ] + }, + { + "query": "부캠", + "cos": [ + { + "preset_id": 101, + "cos": 0.206331 + }, + { + "preset_id": 102, + "cos": 0.321449 + }, + { + "preset_id": 103, + "cos": 0.20098 + }, + { + "preset_id": 104, + "cos": 0.249441 + }, + { + "preset_id": 105, + "cos": 0.190019 + }, + { + "preset_id": 106, + "cos": 0.180227 + }, + { + "preset_id": 201, + "cos": 0.290971 + }, + { + "preset_id": 202, + "cos": 0.217498 + }, + { + "preset_id": 203, + "cos": 0.195815 + }, + { + "preset_id": 204, + "cos": 0.264416 + }, + { + "preset_id": 205, + "cos": 0.294908 + }, + { + "preset_id": 206, + "cos": 0.263364 + }, + { + "preset_id": 207, + "cos": 0.270965 + }, + { + "preset_id": 208, + "cos": 0.309056 + }, + { + "preset_id": 301, + "cos": 0.198101 + }, + { + "preset_id": 302, + "cos": 0.147105 + }, + { + "preset_id": 303, + "cos": 0.243615 + }, + { + "preset_id": 304, + "cos": 0.203489 + }, + { + "preset_id": 305, + "cos": 0.189942 + }, + { + "preset_id": 306, + "cos": 0.242023 + }, + { + "preset_id": 307, + "cos": 0.235382 + }, + { + "preset_id": 401, + "cos": 0.190129 + }, + { + "preset_id": 402, + "cos": 0.22444 + }, + { + "preset_id": 403, + "cos": 0.234661 + }, + { + "preset_id": 404, + "cos": 0.213984 + }, + { + "preset_id": 405, + "cos": 0.256872 + }, + { + "preset_id": 406, + "cos": 0.25822 + } + ] + }, + { + "query": "스팟", + "cos": [ + { + "preset_id": 101, + "cos": 0.147471 + }, + { + "preset_id": 102, + "cos": 0.188736 + }, + { + "preset_id": 103, + "cos": 0.122538 + }, + { + "preset_id": 104, + "cos": 0.159902 + }, + { + "preset_id": 105, + "cos": 0.122161 + }, + { + "preset_id": 106, + "cos": 0.129378 + }, + { + "preset_id": 201, + "cos": 0.115863 + }, + { + "preset_id": 202, + "cos": 0.154777 + }, + { + "preset_id": 203, + "cos": 0.226686 + }, + { + "preset_id": 204, + "cos": 0.169713 + }, + { + "preset_id": 205, + "cos": 0.222782 + }, + { + "preset_id": 206, + "cos": 0.126404 + }, + { + "preset_id": 207, + "cos": 0.189596 + }, + { + "preset_id": 208, + "cos": 0.151863 + }, + { + "preset_id": 301, + "cos": 0.071548 + }, + { + "preset_id": 302, + "cos": 0.102936 + }, + { + "preset_id": 303, + "cos": 0.138342 + }, + { + "preset_id": 304, + "cos": 0.173363 + }, + { + "preset_id": 305, + "cos": 0.133171 + }, + { + "preset_id": 306, + "cos": 0.072915 + }, + { + "preset_id": 307, + "cos": 0.141455 + }, + { + "preset_id": 401, + "cos": 0.072746 + }, + { + "preset_id": 402, + "cos": 0.106137 + }, + { + "preset_id": 403, + "cos": 0.097344 + }, + { + "preset_id": 404, + "cos": 0.058734 + }, + { + "preset_id": 405, + "cos": 0.180339 + }, + { + "preset_id": 406, + "cos": 0.134901 + } + ] + }, + { + "query": "산책", + "cos": [ + { + "preset_id": 101, + "cos": 0.135576 + }, + { + "preset_id": 102, + "cos": 0.196027 + }, + { + "preset_id": 103, + "cos": 0.177369 + }, + { + "preset_id": 104, + "cos": 0.12046 + }, + { + "preset_id": 105, + "cos": 0.101068 + }, + { + "preset_id": 106, + "cos": 0.180788 + }, + { + "preset_id": 201, + "cos": 0.179453 + }, + { + "preset_id": 202, + "cos": 0.136488 + }, + { + "preset_id": 203, + "cos": 0.165538 + }, + { + "preset_id": 204, + "cos": 0.220566 + }, + { + "preset_id": 205, + "cos": 0.280668 + }, + { + "preset_id": 206, + "cos": 0.531238 + }, + { + "preset_id": 207, + "cos": 0.278687 + }, + { + "preset_id": 208, + "cos": 0.212803 + }, + { + "preset_id": 301, + "cos": 0.134198 + }, + { + "preset_id": 302, + "cos": 0.10498 + }, + { + "preset_id": 303, + "cos": 0.151803 + }, + { + "preset_id": 304, + "cos": 0.161342 + }, + { + "preset_id": 305, + "cos": 0.124431 + }, + { + "preset_id": 306, + "cos": 0.170675 + }, + { + "preset_id": 307, + "cos": 0.112569 + }, + { + "preset_id": 401, + "cos": 0.107587 + }, + { + "preset_id": 402, + "cos": 0.209059 + }, + { + "preset_id": 403, + "cos": 0.156254 + }, + { + "preset_id": 404, + "cos": 0.096122 + }, + { + "preset_id": 405, + "cos": 0.200314 + }, + { + "preset_id": 406, + "cos": 0.26367 + } + ] + }, + { + "query": "라멘", + "cos": [ + { + "preset_id": 101, + "cos": 0.209539 + }, + { + "preset_id": 102, + "cos": 0.159997 + }, + { + "preset_id": 103, + "cos": 0.126172 + }, + { + "preset_id": 104, + "cos": 0.173163 + }, + { + "preset_id": 105, + "cos": 0.176159 + }, + { + "preset_id": 106, + "cos": 0.12147 + }, + { + "preset_id": 201, + "cos": 0.246193 + }, + { + "preset_id": 202, + "cos": 0.180442 + }, + { + "preset_id": 203, + "cos": 0.186091 + }, + { + "preset_id": 204, + "cos": 0.220975 + }, + { + "preset_id": 205, + "cos": 0.182113 + }, + { + "preset_id": 206, + "cos": 0.204456 + }, + { + "preset_id": 207, + "cos": 0.172638 + }, + { + "preset_id": 208, + "cos": 0.163208 + }, + { + "preset_id": 301, + "cos": 0.160259 + }, + { + "preset_id": 302, + "cos": 0.219324 + }, + { + "preset_id": 303, + "cos": 0.222355 + }, + { + "preset_id": 304, + "cos": 0.204828 + }, + { + "preset_id": 305, + "cos": 0.225512 + }, + { + "preset_id": 306, + "cos": 0.197403 + }, + { + "preset_id": 307, + "cos": 0.226991 + }, + { + "preset_id": 401, + "cos": 0.19367 + }, + { + "preset_id": 402, + "cos": 0.18867 + }, + { + "preset_id": 403, + "cos": 0.278604 + }, + { + "preset_id": 404, + "cos": 0.121292 + }, + { + "preset_id": 405, + "cos": 0.1911 + }, + { + "preset_id": 406, + "cos": 0.232802 + } + ] + }, + { + "query": "피맥", + "cos": [ + { + "preset_id": 101, + "cos": 0.168148 + }, + { + "preset_id": 102, + "cos": 0.128914 + }, + { + "preset_id": 103, + "cos": 0.16293 + }, + { + "preset_id": 104, + "cos": 0.179643 + }, + { + "preset_id": 105, + "cos": 0.11017 + }, + { + "preset_id": 106, + "cos": 0.178409 + }, + { + "preset_id": 201, + "cos": 0.18718 + }, + { + "preset_id": 202, + "cos": 0.188473 + }, + { + "preset_id": 203, + "cos": 0.18086 + }, + { + "preset_id": 204, + "cos": 0.201462 + }, + { + "preset_id": 205, + "cos": 0.24045 + }, + { + "preset_id": 206, + "cos": 0.248844 + }, + { + "preset_id": 207, + "cos": 0.183221 + }, + { + "preset_id": 208, + "cos": 0.176832 + }, + { + "preset_id": 301, + "cos": 0.137567 + }, + { + "preset_id": 302, + "cos": 0.180128 + }, + { + "preset_id": 303, + "cos": 0.136464 + }, + { + "preset_id": 304, + "cos": 0.186754 + }, + { + "preset_id": 305, + "cos": 0.170756 + }, + { + "preset_id": 306, + "cos": 0.108762 + }, + { + "preset_id": 307, + "cos": 0.204864 + }, + { + "preset_id": 401, + "cos": 0.100576 + }, + { + "preset_id": 402, + "cos": 0.073227 + }, + { + "preset_id": 403, + "cos": 0.228576 + }, + { + "preset_id": 404, + "cos": 0.116739 + }, + { + "preset_id": 405, + "cos": 0.189737 + }, + { + "preset_id": 406, + "cos": 0.17357 + } + ] + }, + { + "query": "비건", + "cos": [ + { + "preset_id": 101, + "cos": 0.137074 + }, + { + "preset_id": 102, + "cos": 0.183131 + }, + { + "preset_id": 103, + "cos": 0.112886 + }, + { + "preset_id": 104, + "cos": 0.209707 + }, + { + "preset_id": 105, + "cos": 0.145811 + }, + { + "preset_id": 106, + "cos": 0.19928 + }, + { + "preset_id": 201, + "cos": 0.099707 + }, + { + "preset_id": 202, + "cos": 0.199072 + }, + { + "preset_id": 203, + "cos": 0.123936 + }, + { + "preset_id": 204, + "cos": 0.184452 + }, + { + "preset_id": 205, + "cos": 0.283663 + }, + { + "preset_id": 206, + "cos": 0.242515 + }, + { + "preset_id": 207, + "cos": 0.216865 + }, + { + "preset_id": 208, + "cos": 0.256654 + }, + { + "preset_id": 301, + "cos": 0.203886 + }, + { + "preset_id": 302, + "cos": 0.157137 + }, + { + "preset_id": 303, + "cos": 0.202082 + }, + { + "preset_id": 304, + "cos": 0.255421 + }, + { + "preset_id": 305, + "cos": 0.257008 + }, + { + "preset_id": 306, + "cos": 0.186028 + }, + { + "preset_id": 307, + "cos": 0.170642 + }, + { + "preset_id": 401, + "cos": 0.11278 + }, + { + "preset_id": 402, + "cos": 0.17891 + }, + { + "preset_id": 403, + "cos": 0.199951 + }, + { + "preset_id": 404, + "cos": 0.291482 + }, + { + "preset_id": 405, + "cos": 0.232966 + }, + { + "preset_id": 406, + "cos": 0.230445 + } + ] + }, + { + "query": "서점", + "cos": [ + { + "preset_id": 101, + "cos": 0.181441 + }, + { + "preset_id": 102, + "cos": 0.207703 + }, + { + "preset_id": 103, + "cos": 0.166199 + }, + { + "preset_id": 104, + "cos": 0.267963 + }, + { + "preset_id": 105, + "cos": 0.189258 + }, + { + "preset_id": 106, + "cos": 0.215493 + }, + { + "preset_id": 201, + "cos": 0.236082 + }, + { + "preset_id": 202, + "cos": 0.24337 + }, + { + "preset_id": 203, + "cos": 0.233945 + }, + { + "preset_id": 204, + "cos": 0.214625 + }, + { + "preset_id": 205, + "cos": 0.235807 + }, + { + "preset_id": 206, + "cos": 0.138045 + }, + { + "preset_id": 207, + "cos": 0.207387 + }, + { + "preset_id": 208, + "cos": 0.242106 + }, + { + "preset_id": 301, + "cos": 0.197069 + }, + { + "preset_id": 302, + "cos": 0.16361 + }, + { + "preset_id": 303, + "cos": 0.235365 + }, + { + "preset_id": 304, + "cos": 0.158195 + }, + { + "preset_id": 305, + "cos": 0.25493 + }, + { + "preset_id": 306, + "cos": 0.256602 + }, + { + "preset_id": 307, + "cos": 0.234642 + }, + { + "preset_id": 401, + "cos": 0.23524 + }, + { + "preset_id": 402, + "cos": 0.246947 + }, + { + "preset_id": 403, + "cos": 0.207462 + }, + { + "preset_id": 404, + "cos": 0.143125 + }, + { + "preset_id": 405, + "cos": 0.247278 + }, + { + "preset_id": 406, + "cos": 0.26587 + } + ] + }, + { + "query": "양갱", + "cos": [ + { + "preset_id": 101, + "cos": 0.192969 + }, + { + "preset_id": 102, + "cos": 0.183559 + }, + { + "preset_id": 103, + "cos": 0.220376 + }, + { + "preset_id": 104, + "cos": 0.194081 + }, + { + "preset_id": 105, + "cos": 0.170907 + }, + { + "preset_id": 106, + "cos": 0.188957 + }, + { + "preset_id": 201, + "cos": 0.159035 + }, + { + "preset_id": 202, + "cos": 0.135781 + }, + { + "preset_id": 203, + "cos": 0.176655 + }, + { + "preset_id": 204, + "cos": 0.149965 + }, + { + "preset_id": 205, + "cos": 0.195663 + }, + { + "preset_id": 206, + "cos": 0.14935 + }, + { + "preset_id": 207, + "cos": 0.213324 + }, + { + "preset_id": 208, + "cos": 0.1956 + }, + { + "preset_id": 301, + "cos": 0.153354 + }, + { + "preset_id": 302, + "cos": 0.137545 + }, + { + "preset_id": 303, + "cos": 0.123654 + }, + { + "preset_id": 304, + "cos": 0.157689 + }, + { + "preset_id": 305, + "cos": 0.171596 + }, + { + "preset_id": 306, + "cos": 0.131909 + }, + { + "preset_id": 307, + "cos": 0.122157 + }, + { + "preset_id": 401, + "cos": 0.118931 + }, + { + "preset_id": 402, + "cos": 0.194965 + }, + { + "preset_id": 403, + "cos": 0.192769 + }, + { + "preset_id": 404, + "cos": 0.124262 + }, + { + "preset_id": 405, + "cos": 0.20555 + }, + { + "preset_id": 406, + "cos": 0.266388 + } + ] + }, + { + "query": "우주", + "cos": [ + { + "preset_id": 101, + "cos": 0.250384 + }, + { + "preset_id": 102, + "cos": 0.112905 + }, + { + "preset_id": 103, + "cos": 0.175295 + }, + { + "preset_id": 104, + "cos": 0.220649 + }, + { + "preset_id": 105, + "cos": 0.144339 + }, + { + "preset_id": 106, + "cos": 0.219509 + }, + { + "preset_id": 201, + "cos": 0.144828 + }, + { + "preset_id": 202, + "cos": 0.161743 + }, + { + "preset_id": 203, + "cos": 0.305071 + }, + { + "preset_id": 204, + "cos": 0.132221 + }, + { + "preset_id": 205, + "cos": 0.126089 + }, + { + "preset_id": 206, + "cos": 0.160856 + }, + { + "preset_id": 207, + "cos": 0.171796 + }, + { + "preset_id": 208, + "cos": 0.222938 + }, + { + "preset_id": 301, + "cos": 0.217528 + }, + { + "preset_id": 302, + "cos": 0.234384 + }, + { + "preset_id": 303, + "cos": 0.201346 + }, + { + "preset_id": 304, + "cos": 0.209402 + }, + { + "preset_id": 305, + "cos": 0.25359 + }, + { + "preset_id": 306, + "cos": 0.145416 + }, + { + "preset_id": 307, + "cos": 0.166595 + }, + { + "preset_id": 401, + "cos": 0.173671 + }, + { + "preset_id": 402, + "cos": 0.196999 + }, + { + "preset_id": 403, + "cos": 0.262909 + }, + { + "preset_id": 404, + "cos": 0.208235 + }, + { + "preset_id": 405, + "cos": 0.207234 + }, + { + "preset_id": 406, + "cos": 0.239088 + } + ] + }, + { + "query": "소파", + "cos": [ + { + "preset_id": 101, + "cos": 0.152437 + }, + { + "preset_id": 102, + "cos": 0.147102 + }, + { + "preset_id": 103, + "cos": 0.086094 + }, + { + "preset_id": 104, + "cos": 0.113115 + }, + { + "preset_id": 105, + "cos": 0.101504 + }, + { + "preset_id": 106, + "cos": 0.059653 + }, + { + "preset_id": 201, + "cos": 0.156578 + }, + { + "preset_id": 202, + "cos": 0.148016 + }, + { + "preset_id": 203, + "cos": 0.160862 + }, + { + "preset_id": 204, + "cos": 0.188343 + }, + { + "preset_id": 205, + "cos": 0.144965 + }, + { + "preset_id": 206, + "cos": 0.105338 + }, + { + "preset_id": 207, + "cos": 0.155651 + }, + { + "preset_id": 208, + "cos": 0.157615 + }, + { + "preset_id": 301, + "cos": 0.1172 + }, + { + "preset_id": 302, + "cos": 0.302039 + }, + { + "preset_id": 303, + "cos": 0.17034 + }, + { + "preset_id": 304, + "cos": 0.101222 + }, + { + "preset_id": 305, + "cos": 0.114864 + }, + { + "preset_id": 306, + "cos": 0.10531 + }, + { + "preset_id": 307, + "cos": 0.166254 + }, + { + "preset_id": 401, + "cos": 0.105912 + }, + { + "preset_id": 402, + "cos": 0.114073 + }, + { + "preset_id": 403, + "cos": 0.082172 + }, + { + "preset_id": 404, + "cos": 0.056609 + }, + { + "preset_id": 405, + "cos": 0.095647 + }, + { + "preset_id": 406, + "cos": 0.087209 + } + ] + }, + { + "query": "보쌈", + "cos": [ + { + "preset_id": 101, + "cos": 0.154689 + }, + { + "preset_id": 102, + "cos": 0.218253 + }, + { + "preset_id": 103, + "cos": 0.162944 + }, + { + "preset_id": 104, + "cos": 0.184218 + }, + { + "preset_id": 105, + "cos": 0.162635 + }, + { + "preset_id": 106, + "cos": 0.184909 + }, + { + "preset_id": 201, + "cos": 0.153295 + }, + { + "preset_id": 202, + "cos": 0.18878 + }, + { + "preset_id": 203, + "cos": 0.168266 + }, + { + "preset_id": 204, + "cos": 0.205166 + }, + { + "preset_id": 205, + "cos": 0.257017 + }, + { + "preset_id": 206, + "cos": 0.308092 + }, + { + "preset_id": 207, + "cos": 0.297909 + }, + { + "preset_id": 208, + "cos": 0.316394 + }, + { + "preset_id": 301, + "cos": 0.123036 + }, + { + "preset_id": 302, + "cos": 0.161592 + }, + { + "preset_id": 303, + "cos": 0.193799 + }, + { + "preset_id": 304, + "cos": 0.203471 + }, + { + "preset_id": 305, + "cos": 0.14885 + }, + { + "preset_id": 306, + "cos": 0.200783 + }, + { + "preset_id": 307, + "cos": 0.241845 + }, + { + "preset_id": 401, + "cos": 0.100139 + }, + { + "preset_id": 402, + "cos": 0.197429 + }, + { + "preset_id": 403, + "cos": 0.217419 + }, + { + "preset_id": 404, + "cos": 0.122619 + }, + { + "preset_id": 405, + "cos": 0.258729 + }, + { + "preset_id": 406, + "cos": 0.222163 + } + ] + }, + { + "query": "난반", + "cos": [ + { + "preset_id": 101, + "cos": 0.233098 + }, + { + "preset_id": 102, + "cos": 0.183959 + }, + { + "preset_id": 103, + "cos": 0.218371 + }, + { + "preset_id": 104, + "cos": 0.200165 + }, + { + "preset_id": 105, + "cos": 0.237265 + }, + { + "preset_id": 106, + "cos": 0.246515 + }, + { + "preset_id": 201, + "cos": 0.16673 + }, + { + "preset_id": 202, + "cos": 0.200072 + }, + { + "preset_id": 203, + "cos": 0.239211 + }, + { + "preset_id": 204, + "cos": 0.145341 + }, + { + "preset_id": 205, + "cos": 0.29465 + }, + { + "preset_id": 206, + "cos": 0.21668 + }, + { + "preset_id": 207, + "cos": 0.217554 + }, + { + "preset_id": 208, + "cos": 0.289375 + }, + { + "preset_id": 301, + "cos": 0.190917 + }, + { + "preset_id": 302, + "cos": 0.165161 + }, + { + "preset_id": 303, + "cos": 0.158936 + }, + { + "preset_id": 304, + "cos": 0.267281 + }, + { + "preset_id": 305, + "cos": 0.215 + }, + { + "preset_id": 306, + "cos": 0.199728 + }, + { + "preset_id": 307, + "cos": 0.217677 + }, + { + "preset_id": 401, + "cos": 0.154109 + }, + { + "preset_id": 402, + "cos": 0.23852 + }, + { + "preset_id": 403, + "cos": 0.193764 + }, + { + "preset_id": 404, + "cos": 0.22777 + }, + { + "preset_id": 405, + "cos": 0.27302 + }, + { + "preset_id": 406, + "cos": 0.260147 + } + ] + }, + { + "query": "연어", + "cos": [ + { + "preset_id": 101, + "cos": 0.256146 + }, + { + "preset_id": 102, + "cos": 0.256056 + }, + { + "preset_id": 103, + "cos": 0.232879 + }, + { + "preset_id": 104, + "cos": 0.23805 + }, + { + "preset_id": 105, + "cos": 0.173095 + }, + { + "preset_id": 106, + "cos": 0.221432 + }, + { + "preset_id": 201, + "cos": 0.232948 + }, + { + "preset_id": 202, + "cos": 0.241824 + }, + { + "preset_id": 203, + "cos": 0.178863 + }, + { + "preset_id": 204, + "cos": 0.141555 + }, + { + "preset_id": 205, + "cos": 0.183337 + }, + { + "preset_id": 206, + "cos": 0.195478 + }, + { + "preset_id": 207, + "cos": 0.186482 + }, + { + "preset_id": 208, + "cos": 0.21117 + }, + { + "preset_id": 301, + "cos": 0.294357 + }, + { + "preset_id": 302, + "cos": 0.192645 + }, + { + "preset_id": 303, + "cos": 0.192031 + }, + { + "preset_id": 304, + "cos": 0.22792 + }, + { + "preset_id": 305, + "cos": 0.222208 + }, + { + "preset_id": 306, + "cos": 0.179762 + }, + { + "preset_id": 307, + "cos": 0.149073 + }, + { + "preset_id": 401, + "cos": 0.256472 + }, + { + "preset_id": 402, + "cos": 0.208627 + }, + { + "preset_id": 403, + "cos": 0.244061 + }, + { + "preset_id": 404, + "cos": 0.147572 + }, + { + "preset_id": 405, + "cos": 0.201739 + }, + { + "preset_id": 406, + "cos": 0.292683 + } + ] + }, + { + "query": "야경", + "cos": [ + { + "preset_id": 101, + "cos": 0.20097 + }, + { + "preset_id": 102, + "cos": 0.159502 + }, + { + "preset_id": 103, + "cos": 0.166441 + }, + { + "preset_id": 104, + "cos": 0.148031 + }, + { + "preset_id": 105, + "cos": 0.219612 + }, + { + "preset_id": 106, + "cos": 0.214012 + }, + { + "preset_id": 201, + "cos": 0.199363 + }, + { + "preset_id": 202, + "cos": 0.145385 + }, + { + "preset_id": 203, + "cos": 0.183681 + }, + { + "preset_id": 204, + "cos": 0.149459 + }, + { + "preset_id": 205, + "cos": 0.191146 + }, + { + "preset_id": 206, + "cos": 0.198875 + }, + { + "preset_id": 207, + "cos": 0.171418 + }, + { + "preset_id": 208, + "cos": 0.17783 + }, + { + "preset_id": 301, + "cos": 0.164992 + }, + { + "preset_id": 302, + "cos": 0.136853 + }, + { + "preset_id": 303, + "cos": 0.21307 + }, + { + "preset_id": 304, + "cos": 0.237569 + }, + { + "preset_id": 305, + "cos": 0.196751 + }, + { + "preset_id": 306, + "cos": 0.303894 + }, + { + "preset_id": 307, + "cos": 0.219032 + }, + { + "preset_id": 401, + "cos": 0.173453 + }, + { + "preset_id": 402, + "cos": 0.220806 + }, + { + "preset_id": 403, + "cos": 0.194623 + }, + { + "preset_id": 404, + "cos": 0.175337 + }, + { + "preset_id": 405, + "cos": 0.189272 + }, + { + "preset_id": 406, + "cos": 0.204089 + } + ] + }, + { + "query": "우산", + "cos": [ + { + "preset_id": 101, + "cos": 0.198288 + }, + { + "preset_id": 102, + "cos": 0.186361 + }, + { + "preset_id": 103, + "cos": 0.159878 + }, + { + "preset_id": 104, + "cos": 0.181181 + }, + { + "preset_id": 105, + "cos": 0.15867 + }, + { + "preset_id": 106, + "cos": 0.177677 + }, + { + "preset_id": 201, + "cos": 0.176865 + }, + { + "preset_id": 202, + "cos": 0.1134 + }, + { + "preset_id": 203, + "cos": 0.182072 + }, + { + "preset_id": 204, + "cos": 0.160464 + }, + { + "preset_id": 205, + "cos": 0.212712 + }, + { + "preset_id": 206, + "cos": 0.166918 + }, + { + "preset_id": 207, + "cos": 0.219219 + }, + { + "preset_id": 208, + "cos": 0.22595 + }, + { + "preset_id": 301, + "cos": 0.179266 + }, + { + "preset_id": 302, + "cos": 0.150892 + }, + { + "preset_id": 303, + "cos": 0.172922 + }, + { + "preset_id": 304, + "cos": 0.248755 + }, + { + "preset_id": 305, + "cos": 0.227041 + }, + { + "preset_id": 306, + "cos": 0.123815 + }, + { + "preset_id": 307, + "cos": 0.150401 + }, + { + "preset_id": 401, + "cos": 0.136773 + }, + { + "preset_id": 402, + "cos": 0.140636 + }, + { + "preset_id": 403, + "cos": 0.194457 + }, + { + "preset_id": 404, + "cos": 0.15515 + }, + { + "preset_id": 405, + "cos": 0.19475 + }, + { + "preset_id": 406, + "cos": 0.212101 + } + ] + }, + { + "query": "수다", + "cos": [ + { + "preset_id": 101, + "cos": 0.290658 + }, + { + "preset_id": 102, + "cos": 0.212813 + }, + { + "preset_id": 103, + "cos": 0.219215 + }, + { + "preset_id": 104, + "cos": 0.219247 + }, + { + "preset_id": 105, + "cos": 0.179164 + }, + { + "preset_id": 106, + "cos": 0.272254 + }, + { + "preset_id": 201, + "cos": 0.236968 + }, + { + "preset_id": 202, + "cos": 0.163431 + }, + { + "preset_id": 203, + "cos": 0.248123 + }, + { + "preset_id": 204, + "cos": 0.185439 + }, + { + "preset_id": 205, + "cos": 0.260899 + }, + { + "preset_id": 206, + "cos": 0.258692 + }, + { + "preset_id": 207, + "cos": 0.262592 + }, + { + "preset_id": 208, + "cos": 0.180585 + }, + { + "preset_id": 301, + "cos": 0.183066 + }, + { + "preset_id": 302, + "cos": 0.191453 + }, + { + "preset_id": 303, + "cos": 0.162851 + }, + { + "preset_id": 304, + "cos": 0.214666 + }, + { + "preset_id": 305, + "cos": 0.253348 + }, + { + "preset_id": 306, + "cos": 0.184102 + }, + { + "preset_id": 307, + "cos": 0.196347 + }, + { + "preset_id": 401, + "cos": 0.145296 + }, + { + "preset_id": 402, + "cos": 0.130638 + }, + { + "preset_id": 403, + "cos": 0.183039 + }, + { + "preset_id": 404, + "cos": 0.13858 + }, + { + "preset_id": 405, + "cos": 0.282626 + }, + { + "preset_id": 406, + "cos": 0.279851 + } + ] + }, + { + "query": "국밥", + "cos": [ + { + "preset_id": 101, + "cos": 0.245018 + }, + { + "preset_id": 102, + "cos": 0.245572 + }, + { + "preset_id": 103, + "cos": 0.266369 + }, + { + "preset_id": 104, + "cos": 0.265594 + }, + { + "preset_id": 105, + "cos": 0.15946 + }, + { + "preset_id": 106, + "cos": 0.287192 + }, + { + "preset_id": 201, + "cos": 0.20631 + }, + { + "preset_id": 202, + "cos": 0.344228 + }, + { + "preset_id": 203, + "cos": 0.237071 + }, + { + "preset_id": 204, + "cos": 0.26944 + }, + { + "preset_id": 205, + "cos": 0.261016 + }, + { + "preset_id": 206, + "cos": 0.279005 + }, + { + "preset_id": 207, + "cos": 0.182225 + }, + { + "preset_id": 208, + "cos": 0.249228 + }, + { + "preset_id": 301, + "cos": 0.17885 + }, + { + "preset_id": 302, + "cos": 0.161274 + }, + { + "preset_id": 303, + "cos": 0.178865 + }, + { + "preset_id": 304, + "cos": 0.22764 + }, + { + "preset_id": 305, + "cos": 0.193414 + }, + { + "preset_id": 306, + "cos": 0.265804 + }, + { + "preset_id": 307, + "cos": 0.153488 + }, + { + "preset_id": 401, + "cos": 0.225219 + }, + { + "preset_id": 402, + "cos": 0.278633 + }, + { + "preset_id": 403, + "cos": 0.267615 + }, + { + "preset_id": 404, + "cos": 0.197435 + }, + { + "preset_id": 405, + "cos": 0.299428 + }, + { + "preset_id": 406, + "cos": 0.239475 + } + ] + }, + { + "query": "공원", + "cos": [ + { + "preset_id": 101, + "cos": 0.137606 + }, + { + "preset_id": 102, + "cos": 0.161147 + }, + { + "preset_id": 103, + "cos": 0.137104 + }, + { + "preset_id": 104, + "cos": 0.170837 + }, + { + "preset_id": 105, + "cos": 0.112791 + }, + { + "preset_id": 106, + "cos": 0.142228 + }, + { + "preset_id": 201, + "cos": 0.162522 + }, + { + "preset_id": 202, + "cos": 0.139133 + }, + { + "preset_id": 203, + "cos": 0.166835 + }, + { + "preset_id": 204, + "cos": 0.168189 + }, + { + "preset_id": 205, + "cos": 0.183777 + }, + { + "preset_id": 206, + "cos": 0.186073 + }, + { + "preset_id": 207, + "cos": 0.23915 + }, + { + "preset_id": 208, + "cos": 0.289664 + }, + { + "preset_id": 301, + "cos": 0.121861 + }, + { + "preset_id": 302, + "cos": 0.142784 + }, + { + "preset_id": 303, + "cos": 0.192864 + }, + { + "preset_id": 304, + "cos": 0.14271 + }, + { + "preset_id": 305, + "cos": 0.186031 + }, + { + "preset_id": 306, + "cos": 0.249481 + }, + { + "preset_id": 307, + "cos": 0.125644 + }, + { + "preset_id": 401, + "cos": 0.19668 + }, + { + "preset_id": 402, + "cos": 0.226638 + }, + { + "preset_id": 403, + "cos": 0.167398 + }, + { + "preset_id": 404, + "cos": 0.134799 + }, + { + "preset_id": 405, + "cos": 0.1686 + }, + { + "preset_id": 406, + "cos": 0.197105 + } + ] + }, + { + "query": "맛집", + "cos": [ + { + "preset_id": 101, + "cos": 0.250743 + }, + { + "preset_id": 102, + "cos": 0.237355 + }, + { + "preset_id": 103, + "cos": 0.30188 + }, + { + "preset_id": 104, + "cos": 0.200596 + }, + { + "preset_id": 105, + "cos": 0.147936 + }, + { + "preset_id": 106, + "cos": 0.180212 + }, + { + "preset_id": 201, + "cos": 0.304327 + }, + { + "preset_id": 202, + "cos": 0.345571 + }, + { + "preset_id": 203, + "cos": 0.2898 + }, + { + "preset_id": 204, + "cos": 0.338038 + }, + { + "preset_id": 205, + "cos": 0.205649 + }, + { + "preset_id": 206, + "cos": 0.281756 + }, + { + "preset_id": 207, + "cos": 0.198334 + }, + { + "preset_id": 208, + "cos": 0.195913 + }, + { + "preset_id": 301, + "cos": 0.230178 + }, + { + "preset_id": 302, + "cos": 0.233419 + }, + { + "preset_id": 303, + "cos": 0.244916 + }, + { + "preset_id": 304, + "cos": 0.192576 + }, + { + "preset_id": 305, + "cos": 0.218957 + }, + { + "preset_id": 306, + "cos": 0.440227 + }, + { + "preset_id": 307, + "cos": 0.172154 + }, + { + "preset_id": 401, + "cos": 0.283375 + }, + { + "preset_id": 402, + "cos": 0.2704 + }, + { + "preset_id": 403, + "cos": 0.23343 + }, + { + "preset_id": 404, + "cos": 0.182015 + }, + { + "preset_id": 405, + "cos": 0.251358 + }, + { + "preset_id": 406, + "cos": 0.207471 + } + ] + }, + { + "query": "두부", + "cos": [ + { + "preset_id": 101, + "cos": 0.266803 + }, + { + "preset_id": 102, + "cos": 0.211517 + }, + { + "preset_id": 103, + "cos": 0.279727 + }, + { + "preset_id": 104, + "cos": 0.215495 + }, + { + "preset_id": 105, + "cos": 0.277728 + }, + { + "preset_id": 106, + "cos": 0.201115 + }, + { + "preset_id": 201, + "cos": 0.269975 + }, + { + "preset_id": 202, + "cos": 0.244641 + }, + { + "preset_id": 203, + "cos": 0.257197 + }, + { + "preset_id": 204, + "cos": 0.2583 + }, + { + "preset_id": 205, + "cos": 0.237372 + }, + { + "preset_id": 206, + "cos": 0.235965 + }, + { + "preset_id": 207, + "cos": 0.229806 + }, + { + "preset_id": 208, + "cos": 0.222429 + }, + { + "preset_id": 301, + "cos": 0.178643 + }, + { + "preset_id": 302, + "cos": 0.193369 + }, + { + "preset_id": 303, + "cos": 0.220138 + }, + { + "preset_id": 304, + "cos": 0.202706 + }, + { + "preset_id": 305, + "cos": 0.245863 + }, + { + "preset_id": 306, + "cos": 0.174581 + }, + { + "preset_id": 307, + "cos": 0.189266 + }, + { + "preset_id": 401, + "cos": 0.183269 + }, + { + "preset_id": 402, + "cos": 0.176907 + }, + { + "preset_id": 403, + "cos": 0.256754 + }, + { + "preset_id": 404, + "cos": 0.20072 + }, + { + "preset_id": 405, + "cos": 0.279638 + }, + { + "preset_id": 406, + "cos": 0.198846 + } + ] + }, + { + "query": "언덕", + "cos": [ + { + "preset_id": 101, + "cos": 0.206282 + }, + { + "preset_id": 102, + "cos": 0.204562 + }, + { + "preset_id": 103, + "cos": 0.227772 + }, + { + "preset_id": 104, + "cos": 0.21958 + }, + { + "preset_id": 105, + "cos": 0.15034 + }, + { + "preset_id": 106, + "cos": 0.286593 + }, + { + "preset_id": 201, + "cos": 0.208329 + }, + { + "preset_id": 202, + "cos": 0.244772 + }, + { + "preset_id": 203, + "cos": 0.180308 + }, + { + "preset_id": 204, + "cos": 0.223264 + }, + { + "preset_id": 205, + "cos": 0.226284 + }, + { + "preset_id": 206, + "cos": 0.240962 + }, + { + "preset_id": 207, + "cos": 0.177267 + }, + { + "preset_id": 208, + "cos": 0.162405 + }, + { + "preset_id": 301, + "cos": 0.195328 + }, + { + "preset_id": 302, + "cos": 0.237567 + }, + { + "preset_id": 303, + "cos": 0.211694 + }, + { + "preset_id": 304, + "cos": 0.227969 + }, + { + "preset_id": 305, + "cos": 0.254113 + }, + { + "preset_id": 306, + "cos": 0.239078 + }, + { + "preset_id": 307, + "cos": 0.230887 + }, + { + "preset_id": 401, + "cos": 0.202051 + }, + { + "preset_id": 402, + "cos": 0.230214 + }, + { + "preset_id": 403, + "cos": 0.230339 + }, + { + "preset_id": 404, + "cos": 0.181997 + }, + { + "preset_id": 405, + "cos": 0.217841 + }, + { + "preset_id": 406, + "cos": 0.308015 + } + ] + }, + { + "query": "카페", + "cos": [ + { + "preset_id": 101, + "cos": 0.171571 + }, + { + "preset_id": 102, + "cos": 0.179175 + }, + { + "preset_id": 103, + "cos": 0.160337 + }, + { + "preset_id": 104, + "cos": 0.193873 + }, + { + "preset_id": 105, + "cos": 0.125301 + }, + { + "preset_id": 106, + "cos": 0.159216 + }, + { + "preset_id": 201, + "cos": 0.318617 + }, + { + "preset_id": 202, + "cos": 0.173089 + }, + { + "preset_id": 203, + "cos": 0.219712 + }, + { + "preset_id": 204, + "cos": 0.21654 + }, + { + "preset_id": 205, + "cos": 0.267792 + }, + { + "preset_id": 206, + "cos": 0.193805 + }, + { + "preset_id": 207, + "cos": 0.206384 + }, + { + "preset_id": 208, + "cos": 0.206853 + }, + { + "preset_id": 301, + "cos": 0.192588 + }, + { + "preset_id": 302, + "cos": 0.169044 + }, + { + "preset_id": 303, + "cos": 0.225824 + }, + { + "preset_id": 304, + "cos": 0.123689 + }, + { + "preset_id": 305, + "cos": 0.254367 + }, + { + "preset_id": 306, + "cos": 0.236778 + }, + { + "preset_id": 307, + "cos": 0.181993 + }, + { + "preset_id": 401, + "cos": 0.222742 + }, + { + "preset_id": 402, + "cos": 0.200514 + }, + { + "preset_id": 403, + "cos": 0.153091 + }, + { + "preset_id": 404, + "cos": 0.110393 + }, + { + "preset_id": 405, + "cos": 0.234658 + }, + { + "preset_id": 406, + "cos": 0.191694 + } + ] + }, + { + "query": "만두", + "cos": [ + { + "preset_id": 101, + "cos": 0.366643 + }, + { + "preset_id": 102, + "cos": 0.182111 + }, + { + "preset_id": 103, + "cos": 0.287195 + }, + { + "preset_id": 104, + "cos": 0.292028 + }, + { + "preset_id": 105, + "cos": 0.233994 + }, + { + "preset_id": 106, + "cos": 0.270581 + }, + { + "preset_id": 201, + "cos": 0.292746 + }, + { + "preset_id": 202, + "cos": 0.250885 + }, + { + "preset_id": 203, + "cos": 0.297836 + }, + { + "preset_id": 204, + "cos": 0.23314 + }, + { + "preset_id": 205, + "cos": 0.224217 + }, + { + "preset_id": 206, + "cos": 0.259309 + }, + { + "preset_id": 207, + "cos": 0.267971 + }, + { + "preset_id": 208, + "cos": 0.259701 + }, + { + "preset_id": 301, + "cos": 0.251616 + }, + { + "preset_id": 302, + "cos": 0.217526 + }, + { + "preset_id": 303, + "cos": 0.199525 + }, + { + "preset_id": 304, + "cos": 0.221036 + }, + { + "preset_id": 305, + "cos": 0.273879 + }, + { + "preset_id": 306, + "cos": 0.20813 + }, + { + "preset_id": 307, + "cos": 0.158513 + }, + { + "preset_id": 401, + "cos": 0.212193 + }, + { + "preset_id": 402, + "cos": 0.177028 + }, + { + "preset_id": 403, + "cos": 0.377301 + }, + { + "preset_id": 404, + "cos": 0.165464 + }, + { + "preset_id": 405, + "cos": 0.265448 + }, + { + "preset_id": 406, + "cos": 0.279663 + } + ] + }, + { + "query": "피자", + "cos": [ + { + "preset_id": 101, + "cos": 0.230724 + }, + { + "preset_id": 102, + "cos": 0.144923 + }, + { + "preset_id": 103, + "cos": 0.212797 + }, + { + "preset_id": 104, + "cos": 0.221024 + }, + { + "preset_id": 105, + "cos": 0.181446 + }, + { + "preset_id": 106, + "cos": 0.165993 + }, + { + "preset_id": 201, + "cos": 0.228863 + }, + { + "preset_id": 202, + "cos": 0.244952 + }, + { + "preset_id": 203, + "cos": 0.342759 + }, + { + "preset_id": 204, + "cos": 0.286287 + }, + { + "preset_id": 205, + "cos": 0.185719 + }, + { + "preset_id": 206, + "cos": 0.140994 + }, + { + "preset_id": 207, + "cos": 0.13586 + }, + { + "preset_id": 208, + "cos": 0.040506 + }, + { + "preset_id": 301, + "cos": 0.128706 + }, + { + "preset_id": 302, + "cos": 0.154106 + }, + { + "preset_id": 303, + "cos": 0.149135 + }, + { + "preset_id": 304, + "cos": 0.198847 + }, + { + "preset_id": 305, + "cos": 0.200137 + }, + { + "preset_id": 306, + "cos": 0.146216 + }, + { + "preset_id": 307, + "cos": 0.178676 + }, + { + "preset_id": 401, + "cos": 0.145845 + }, + { + "preset_id": 402, + "cos": 0.119409 + }, + { + "preset_id": 403, + "cos": 0.181784 + }, + { + "preset_id": 404, + "cos": 0.092458 + }, + { + "preset_id": 405, + "cos": 0.223381 + }, + { + "preset_id": 406, + "cos": 0.159251 + } + ] + }, + { + "query": "그네팟", + "cos": [ + { + "preset_id": 101, + "cos": 0.177083 + }, + { + "preset_id": 102, + "cos": 0.180504 + }, + { + "preset_id": 103, + "cos": 0.14324 + }, + { + "preset_id": 104, + "cos": 0.222104 + }, + { + "preset_id": 105, + "cos": 0.196967 + }, + { + "preset_id": 106, + "cos": 0.177662 + }, + { + "preset_id": 201, + "cos": 0.235678 + }, + { + "preset_id": 202, + "cos": 0.194594 + }, + { + "preset_id": 203, + "cos": 0.243666 + }, + { + "preset_id": 204, + "cos": 0.198472 + }, + { + "preset_id": 205, + "cos": 0.183105 + }, + { + "preset_id": 206, + "cos": 0.242916 + }, + { + "preset_id": 207, + "cos": 0.159236 + }, + { + "preset_id": 208, + "cos": 0.259768 + }, + { + "preset_id": 301, + "cos": 0.192853 + }, + { + "preset_id": 302, + "cos": 0.161829 + }, + { + "preset_id": 303, + "cos": 0.224948 + }, + { + "preset_id": 304, + "cos": 0.220108 + }, + { + "preset_id": 305, + "cos": 0.205713 + }, + { + "preset_id": 306, + "cos": 0.219479 + }, + { + "preset_id": 307, + "cos": 0.20787 + }, + { + "preset_id": 401, + "cos": 0.145014 + }, + { + "preset_id": 402, + "cos": 0.195253 + }, + { + "preset_id": 403, + "cos": 0.203568 + }, + { + "preset_id": 404, + "cos": 0.162548 + }, + { + "preset_id": 405, + "cos": 0.277262 + }, + { + "preset_id": 406, + "cos": 0.191165 + } + ] + }, + { + "query": "돈카츠", + "cos": [ + { + "preset_id": 101, + "cos": 0.17489 + }, + { + "preset_id": 102, + "cos": 0.219755 + }, + { + "preset_id": 103, + "cos": 0.122713 + }, + { + "preset_id": 104, + "cos": 0.217667 + }, + { + "preset_id": 105, + "cos": 0.1068 + }, + { + "preset_id": 106, + "cos": 0.13632 + }, + { + "preset_id": 201, + "cos": 0.232484 + }, + { + "preset_id": 202, + "cos": 0.145018 + }, + { + "preset_id": 203, + "cos": 0.21363 + }, + { + "preset_id": 204, + "cos": 0.233631 + }, + { + "preset_id": 205, + "cos": 0.238269 + }, + { + "preset_id": 206, + "cos": 0.218393 + }, + { + "preset_id": 207, + "cos": 0.16494 + }, + { + "preset_id": 208, + "cos": 0.158822 + }, + { + "preset_id": 301, + "cos": 0.093563 + }, + { + "preset_id": 302, + "cos": 0.132636 + }, + { + "preset_id": 303, + "cos": 0.12759 + }, + { + "preset_id": 304, + "cos": 0.093089 + }, + { + "preset_id": 305, + "cos": 0.182248 + }, + { + "preset_id": 306, + "cos": 0.102071 + }, + { + "preset_id": 307, + "cos": 0.185059 + }, + { + "preset_id": 401, + "cos": 0.171053 + }, + { + "preset_id": 402, + "cos": 0.167438 + }, + { + "preset_id": 403, + "cos": 0.147513 + }, + { + "preset_id": 404, + "cos": 0.064781 + }, + { + "preset_id": 405, + "cos": 0.153072 + }, + { + "preset_id": 406, + "cos": 0.202536 + } + ] + }, + { + "query": "미슐랭", + "cos": [ + { + "preset_id": 101, + "cos": 0.125373 + }, + { + "preset_id": 102, + "cos": 0.131314 + }, + { + "preset_id": 103, + "cos": 0.122206 + }, + { + "preset_id": 104, + "cos": 0.096307 + }, + { + "preset_id": 105, + "cos": 0.139886 + }, + { + "preset_id": 106, + "cos": 0.179581 + }, + { + "preset_id": 201, + "cos": 0.162289 + }, + { + "preset_id": 202, + "cos": 0.149946 + }, + { + "preset_id": 203, + "cos": 0.184684 + }, + { + "preset_id": 204, + "cos": 0.221113 + }, + { + "preset_id": 205, + "cos": 0.187557 + }, + { + "preset_id": 206, + "cos": 0.166667 + }, + { + "preset_id": 207, + "cos": 0.181896 + }, + { + "preset_id": 208, + "cos": 0.184974 + }, + { + "preset_id": 301, + "cos": 0.12965 + }, + { + "preset_id": 302, + "cos": 0.080915 + }, + { + "preset_id": 303, + "cos": 0.161052 + }, + { + "preset_id": 304, + "cos": 0.165075 + }, + { + "preset_id": 305, + "cos": 0.113534 + }, + { + "preset_id": 306, + "cos": 0.160468 + }, + { + "preset_id": 307, + "cos": 0.125226 + }, + { + "preset_id": 401, + "cos": 0.143837 + }, + { + "preset_id": 402, + "cos": 0.16418 + }, + { + "preset_id": 403, + "cos": 0.171036 + }, + { + "preset_id": 404, + "cos": 0.094744 + }, + { + "preset_id": 405, + "cos": 0.163146 + }, + { + "preset_id": 406, + "cos": 0.169912 + } + ] + }, + { + "query": "차슈밥", + "cos": [ + { + "preset_id": 101, + "cos": 0.151651 + }, + { + "preset_id": 102, + "cos": 0.135151 + }, + { + "preset_id": 103, + "cos": 0.179112 + }, + { + "preset_id": 104, + "cos": 0.16655 + }, + { + "preset_id": 105, + "cos": 0.124361 + }, + { + "preset_id": 106, + "cos": 0.239651 + }, + { + "preset_id": 201, + "cos": 0.234978 + }, + { + "preset_id": 202, + "cos": 0.251962 + }, + { + "preset_id": 203, + "cos": 0.255745 + }, + { + "preset_id": 204, + "cos": 0.255249 + }, + { + "preset_id": 205, + "cos": 0.266802 + }, + { + "preset_id": 206, + "cos": 0.301409 + }, + { + "preset_id": 207, + "cos": 0.197587 + }, + { + "preset_id": 208, + "cos": 0.202013 + }, + { + "preset_id": 301, + "cos": 0.236006 + }, + { + "preset_id": 302, + "cos": 0.151565 + }, + { + "preset_id": 303, + "cos": 0.202225 + }, + { + "preset_id": 304, + "cos": 0.272037 + }, + { + "preset_id": 305, + "cos": 0.212621 + }, + { + "preset_id": 306, + "cos": 0.282619 + }, + { + "preset_id": 307, + "cos": 0.254628 + }, + { + "preset_id": 401, + "cos": 0.140105 + }, + { + "preset_id": 402, + "cos": 0.185967 + }, + { + "preset_id": 403, + "cos": 0.177966 + }, + { + "preset_id": 404, + "cos": 0.102222 + }, + { + "preset_id": 405, + "cos": 0.231714 + }, + { + "preset_id": 406, + "cos": 0.23713 + } + ] + }, + { + "query": "칼국수", + "cos": [ + { + "preset_id": 101, + "cos": 0.187018 + }, + { + "preset_id": 102, + "cos": 0.178246 + }, + { + "preset_id": 103, + "cos": 0.21039 + }, + { + "preset_id": 104, + "cos": 0.135979 + }, + { + "preset_id": 105, + "cos": 0.132232 + }, + { + "preset_id": 106, + "cos": 0.176486 + }, + { + "preset_id": 201, + "cos": 0.181572 + }, + { + "preset_id": 202, + "cos": 0.148817 + }, + { + "preset_id": 203, + "cos": 0.152634 + }, + { + "preset_id": 204, + "cos": 0.204586 + }, + { + "preset_id": 205, + "cos": 0.273468 + }, + { + "preset_id": 206, + "cos": 0.186986 + }, + { + "preset_id": 207, + "cos": 0.185082 + }, + { + "preset_id": 208, + "cos": 0.158871 + }, + { + "preset_id": 301, + "cos": 0.165253 + }, + { + "preset_id": 302, + "cos": 0.144127 + }, + { + "preset_id": 303, + "cos": 0.13244 + }, + { + "preset_id": 304, + "cos": 0.194809 + }, + { + "preset_id": 305, + "cos": 0.166549 + }, + { + "preset_id": 306, + "cos": 0.171295 + }, + { + "preset_id": 307, + "cos": 0.09682 + }, + { + "preset_id": 401, + "cos": 0.130304 + }, + { + "preset_id": 402, + "cos": 0.172428 + }, + { + "preset_id": 403, + "cos": 0.159884 + }, + { + "preset_id": 404, + "cos": 0.054925 + }, + { + "preset_id": 405, + "cos": 0.172002 + }, + { + "preset_id": 406, + "cos": 0.198496 + } + ] + }, + { + "query": "아부라", + "cos": [ + { + "preset_id": 101, + "cos": 0.290835 + }, + { + "preset_id": 102, + "cos": 0.218921 + }, + { + "preset_id": 103, + "cos": 0.320631 + }, + { + "preset_id": 104, + "cos": 0.151653 + }, + { + "preset_id": 105, + "cos": 0.356451 + }, + { + "preset_id": 106, + "cos": 0.245945 + }, + { + "preset_id": 201, + "cos": 0.251733 + }, + { + "preset_id": 202, + "cos": 0.193428 + }, + { + "preset_id": 203, + "cos": 0.23119 + }, + { + "preset_id": 204, + "cos": 0.213473 + }, + { + "preset_id": 205, + "cos": 0.23001 + }, + { + "preset_id": 206, + "cos": 0.239592 + }, + { + "preset_id": 207, + "cos": 0.250438 + }, + { + "preset_id": 208, + "cos": 0.188607 + }, + { + "preset_id": 301, + "cos": 0.188807 + }, + { + "preset_id": 302, + "cos": 0.210896 + }, + { + "preset_id": 303, + "cos": 0.19916 + }, + { + "preset_id": 304, + "cos": 0.201254 + }, + { + "preset_id": 305, + "cos": 0.210522 + }, + { + "preset_id": 306, + "cos": 0.183807 + }, + { + "preset_id": 307, + "cos": 0.190217 + }, + { + "preset_id": 401, + "cos": 0.218107 + }, + { + "preset_id": 402, + "cos": 0.169952 + }, + { + "preset_id": 403, + "cos": 0.227738 + }, + { + "preset_id": 404, + "cos": 0.225503 + }, + { + "preset_id": 405, + "cos": 0.259492 + }, + { + "preset_id": 406, + "cos": 0.209442 + } + ] + }, + { + "query": "브런치", + "cos": [ + { + "preset_id": 101, + "cos": 0.189681 + }, + { + "preset_id": 102, + "cos": 0.218525 + }, + { + "preset_id": 103, + "cos": 0.112167 + }, + { + "preset_id": 104, + "cos": 0.171735 + }, + { + "preset_id": 105, + "cos": 0.165599 + }, + { + "preset_id": 106, + "cos": 0.144396 + }, + { + "preset_id": 201, + "cos": 0.229263 + }, + { + "preset_id": 202, + "cos": 0.223051 + }, + { + "preset_id": 203, + "cos": 0.218986 + }, + { + "preset_id": 204, + "cos": 0.237287 + }, + { + "preset_id": 205, + "cos": 0.296582 + }, + { + "preset_id": 206, + "cos": 0.239744 + }, + { + "preset_id": 207, + "cos": 0.159611 + }, + { + "preset_id": 208, + "cos": 0.22332 + }, + { + "preset_id": 301, + "cos": 0.151577 + }, + { + "preset_id": 302, + "cos": 0.174757 + }, + { + "preset_id": 303, + "cos": 0.203239 + }, + { + "preset_id": 304, + "cos": 0.253966 + }, + { + "preset_id": 305, + "cos": 0.20443 + }, + { + "preset_id": 306, + "cos": 0.19472 + }, + { + "preset_id": 307, + "cos": 0.196359 + }, + { + "preset_id": 401, + "cos": 0.195946 + }, + { + "preset_id": 402, + "cos": 0.199266 + }, + { + "preset_id": 403, + "cos": 0.241437 + }, + { + "preset_id": 404, + "cos": 0.185944 + }, + { + "preset_id": 405, + "cos": 0.210227 + }, + { + "preset_id": 406, + "cos": 0.160669 + } + ] + }, + { + "query": "디저트", + "cos": [ + { + "preset_id": 101, + "cos": 0.253216 + }, + { + "preset_id": 102, + "cos": 0.198418 + }, + { + "preset_id": 103, + "cos": 0.176433 + }, + { + "preset_id": 104, + "cos": 0.254799 + }, + { + "preset_id": 105, + "cos": 0.191557 + }, + { + "preset_id": 106, + "cos": 0.178612 + }, + { + "preset_id": 201, + "cos": 0.287117 + }, + { + "preset_id": 202, + "cos": 0.214458 + }, + { + "preset_id": 203, + "cos": 0.150637 + }, + { + "preset_id": 204, + "cos": 0.460136 + }, + { + "preset_id": 205, + "cos": 0.164267 + }, + { + "preset_id": 206, + "cos": 0.173678 + }, + { + "preset_id": 207, + "cos": 0.224401 + }, + { + "preset_id": 208, + "cos": 0.21586 + }, + { + "preset_id": 301, + "cos": 0.16525 + }, + { + "preset_id": 302, + "cos": 0.130415 + }, + { + "preset_id": 303, + "cos": 0.261832 + }, + { + "preset_id": 304, + "cos": 0.083548 + }, + { + "preset_id": 305, + "cos": 0.282809 + }, + { + "preset_id": 306, + "cos": 0.244442 + }, + { + "preset_id": 307, + "cos": 0.205556 + }, + { + "preset_id": 401, + "cos": 0.270631 + }, + { + "preset_id": 402, + "cos": 0.171515 + }, + { + "preset_id": 403, + "cos": 0.286087 + }, + { + "preset_id": 404, + "cos": 0.169961 + }, + { + "preset_id": 405, + "cos": 0.276535 + }, + { + "preset_id": 406, + "cos": 0.232843 + } + ] + }, + { + "query": "노트북", + "cos": [ + { + "preset_id": 101, + "cos": 0.122891 + }, + { + "preset_id": 102, + "cos": 0.154414 + }, + { + "preset_id": 103, + "cos": 0.131772 + }, + { + "preset_id": 104, + "cos": 0.172957 + }, + { + "preset_id": 105, + "cos": 0.172064 + }, + { + "preset_id": 106, + "cos": 0.1884 + }, + { + "preset_id": 201, + "cos": 0.184561 + }, + { + "preset_id": 202, + "cos": 0.191222 + }, + { + "preset_id": 203, + "cos": 0.11586 + }, + { + "preset_id": 204, + "cos": 0.195908 + }, + { + "preset_id": 205, + "cos": 0.369481 + }, + { + "preset_id": 206, + "cos": 0.200472 + }, + { + "preset_id": 207, + "cos": 0.16188 + }, + { + "preset_id": 208, + "cos": 0.185149 + }, + { + "preset_id": 301, + "cos": 0.138104 + }, + { + "preset_id": 302, + "cos": 0.18486 + }, + { + "preset_id": 303, + "cos": 0.237042 + }, + { + "preset_id": 304, + "cos": 0.204121 + }, + { + "preset_id": 305, + "cos": 0.18526 + }, + { + "preset_id": 306, + "cos": 0.179819 + }, + { + "preset_id": 307, + "cos": 0.315723 + }, + { + "preset_id": 401, + "cos": 0.165621 + }, + { + "preset_id": 402, + "cos": 0.162915 + }, + { + "preset_id": 403, + "cos": 0.169175 + }, + { + "preset_id": 404, + "cos": 0.188932 + }, + { + "preset_id": 405, + "cos": 0.225321 + }, + { + "preset_id": 406, + "cos": 0.177633 + } + ] + }, + { + "query": "콘센트", + "cos": [ + { + "preset_id": 101, + "cos": 0.177428 + }, + { + "preset_id": 102, + "cos": 0.172111 + }, + { + "preset_id": 103, + "cos": 0.144244 + }, + { + "preset_id": 104, + "cos": 0.220919 + }, + { + "preset_id": 105, + "cos": 0.141695 + }, + { + "preset_id": 106, + "cos": 0.211015 + }, + { + "preset_id": 201, + "cos": 0.209462 + }, + { + "preset_id": 202, + "cos": 0.196356 + }, + { + "preset_id": 203, + "cos": 0.212813 + }, + { + "preset_id": 204, + "cos": 0.250391 + }, + { + "preset_id": 205, + "cos": 0.263935 + }, + { + "preset_id": 206, + "cos": 0.197496 + }, + { + "preset_id": 207, + "cos": 0.215876 + }, + { + "preset_id": 208, + "cos": 0.137658 + }, + { + "preset_id": 301, + "cos": 0.237993 + }, + { + "preset_id": 302, + "cos": 0.207883 + }, + { + "preset_id": 303, + "cos": 0.249477 + }, + { + "preset_id": 304, + "cos": 0.204396 + }, + { + "preset_id": 305, + "cos": 0.219156 + }, + { + "preset_id": 306, + "cos": 0.244378 + }, + { + "preset_id": 307, + "cos": 0.258433 + }, + { + "preset_id": 401, + "cos": 0.220784 + }, + { + "preset_id": 402, + "cos": 0.191428 + }, + { + "preset_id": 403, + "cos": 0.286771 + }, + { + "preset_id": 404, + "cos": 0.063274 + }, + { + "preset_id": 405, + "cos": 0.173312 + }, + { + "preset_id": 406, + "cos": 0.248856 + } + ] + }, + { + "query": "기념일", + "cos": [ + { + "preset_id": 101, + "cos": 0.165614 + }, + { + "preset_id": 102, + "cos": 0.283021 + }, + { + "preset_id": 103, + "cos": 0.182748 + }, + { + "preset_id": 104, + "cos": 0.241028 + }, + { + "preset_id": 105, + "cos": 0.167474 + }, + { + "preset_id": 106, + "cos": 0.12369 + }, + { + "preset_id": 201, + "cos": 0.177581 + }, + { + "preset_id": 202, + "cos": 0.21897 + }, + { + "preset_id": 203, + "cos": 0.19488 + }, + { + "preset_id": 204, + "cos": 0.187891 + }, + { + "preset_id": 205, + "cos": 0.221828 + }, + { + "preset_id": 206, + "cos": 0.233336 + }, + { + "preset_id": 207, + "cos": 0.171101 + }, + { + "preset_id": 208, + "cos": 0.333702 + }, + { + "preset_id": 301, + "cos": 0.136907 + }, + { + "preset_id": 302, + "cos": 0.136089 + }, + { + "preset_id": 303, + "cos": 0.233349 + }, + { + "preset_id": 304, + "cos": 0.205558 + }, + { + "preset_id": 305, + "cos": 0.096578 + }, + { + "preset_id": 306, + "cos": 0.232118 + }, + { + "preset_id": 307, + "cos": 0.209435 + }, + { + "preset_id": 401, + "cos": 0.206115 + }, + { + "preset_id": 402, + "cos": 0.515742 + }, + { + "preset_id": 403, + "cos": 0.263655 + }, + { + "preset_id": 404, + "cos": 0.206433 + }, + { + "preset_id": 405, + "cos": 0.280306 + }, + { + "preset_id": 406, + "cos": 0.204111 + } + ] + }, + { + "query": "부모님", + "cos": [ + { + "preset_id": 101, + "cos": 0.278862 + }, + { + "preset_id": 102, + "cos": 0.159199 + }, + { + "preset_id": 103, + "cos": 0.320158 + }, + { + "preset_id": 104, + "cos": 0.219516 + }, + { + "preset_id": 105, + "cos": 0.268307 + }, + { + "preset_id": 106, + "cos": 0.204333 + }, + { + "preset_id": 201, + "cos": 0.131942 + }, + { + "preset_id": 202, + "cos": 0.157415 + }, + { + "preset_id": 203, + "cos": 0.210593 + }, + { + "preset_id": 204, + "cos": 0.126364 + }, + { + "preset_id": 205, + "cos": 0.219135 + }, + { + "preset_id": 206, + "cos": 0.188061 + }, + { + "preset_id": 207, + "cos": 0.182809 + }, + { + "preset_id": 208, + "cos": 0.221958 + }, + { + "preset_id": 301, + "cos": 0.171428 + }, + { + "preset_id": 302, + "cos": 0.196099 + }, + { + "preset_id": 303, + "cos": 0.150507 + }, + { + "preset_id": 304, + "cos": 0.149793 + }, + { + "preset_id": 305, + "cos": 0.189009 + }, + { + "preset_id": 306, + "cos": 0.149866 + }, + { + "preset_id": 307, + "cos": 0.165771 + }, + { + "preset_id": 401, + "cos": 0.176701 + }, + { + "preset_id": 402, + "cos": 0.206245 + }, + { + "preset_id": 403, + "cos": 0.249236 + }, + { + "preset_id": 404, + "cos": 0.190756 + }, + { + "preset_id": 405, + "cos": 0.209187 + }, + { + "preset_id": 406, + "cos": 0.291548 + } + ] + }, + { + "query": "빗소리", + "cos": [ + { + "preset_id": 101, + "cos": 0.260531 + }, + { + "preset_id": 102, + "cos": 0.131674 + }, + { + "preset_id": 103, + "cos": 0.193217 + }, + { + "preset_id": 104, + "cos": 0.16726 + }, + { + "preset_id": 105, + "cos": 0.201566 + }, + { + "preset_id": 106, + "cos": 0.194965 + }, + { + "preset_id": 201, + "cos": 0.249791 + }, + { + "preset_id": 202, + "cos": 0.330081 + }, + { + "preset_id": 203, + "cos": 0.276169 + }, + { + "preset_id": 204, + "cos": 0.203846 + }, + { + "preset_id": 205, + "cos": 0.134705 + }, + { + "preset_id": 206, + "cos": 0.249854 + }, + { + "preset_id": 207, + "cos": 0.22595 + }, + { + "preset_id": 208, + "cos": 0.20972 + }, + { + "preset_id": 301, + "cos": 0.281206 + }, + { + "preset_id": 302, + "cos": 0.322623 + }, + { + "preset_id": 303, + "cos": 0.231302 + }, + { + "preset_id": 304, + "cos": 0.242837 + }, + { + "preset_id": 305, + "cos": 0.220439 + }, + { + "preset_id": 306, + "cos": 0.206323 + }, + { + "preset_id": 307, + "cos": 0.283763 + }, + { + "preset_id": 401, + "cos": 0.143057 + }, + { + "preset_id": 402, + "cos": 0.182055 + }, + { + "preset_id": 403, + "cos": 0.365881 + }, + { + "preset_id": 404, + "cos": 0.157 + }, + { + "preset_id": 405, + "cos": 0.219416 + }, + { + "preset_id": 406, + "cos": 0.166942 + } + ] + }, + { + "query": "6개월", + "cos": [ + { + "preset_id": 101, + "cos": 0.213037 + }, + { + "preset_id": 102, + "cos": 0.172946 + }, + { + "preset_id": 103, + "cos": 0.212202 + }, + { + "preset_id": 104, + "cos": 0.162253 + }, + { + "preset_id": 105, + "cos": 0.187325 + }, + { + "preset_id": 106, + "cos": 0.187325 + }, + { + "preset_id": 201, + "cos": 0.186397 + }, + { + "preset_id": 202, + "cos": 0.131494 + }, + { + "preset_id": 203, + "cos": 0.181032 + }, + { + "preset_id": 204, + "cos": 0.133429 + }, + { + "preset_id": 205, + "cos": 0.238532 + }, + { + "preset_id": 206, + "cos": 0.189027 + }, + { + "preset_id": 207, + "cos": 0.164841 + }, + { + "preset_id": 208, + "cos": 0.21262 + }, + { + "preset_id": 301, + "cos": 0.148829 + }, + { + "preset_id": 302, + "cos": 0.155003 + }, + { + "preset_id": 303, + "cos": 0.149356 + }, + { + "preset_id": 304, + "cos": 0.120173 + }, + { + "preset_id": 305, + "cos": 0.154202 + }, + { + "preset_id": 306, + "cos": 0.139094 + }, + { + "preset_id": 307, + "cos": 0.172793 + }, + { + "preset_id": 401, + "cos": 0.147032 + }, + { + "preset_id": 402, + "cos": 0.220797 + }, + { + "preset_id": 403, + "cos": 0.185938 + }, + { + "preset_id": 404, + "cos": 0.158574 + }, + { + "preset_id": 405, + "cos": 0.241337 + }, + { + "preset_id": 406, + "cos": 0.212775 + } + ] + }, + { + "query": "감자전", + "cos": [ + { + "preset_id": 101, + "cos": 0.171766 + }, + { + "preset_id": 102, + "cos": 0.165281 + }, + { + "preset_id": 103, + "cos": 0.194176 + }, + { + "preset_id": 104, + "cos": 0.206695 + }, + { + "preset_id": 105, + "cos": 0.159268 + }, + { + "preset_id": 106, + "cos": 0.201527 + }, + { + "preset_id": 201, + "cos": 0.163931 + }, + { + "preset_id": 202, + "cos": 0.123075 + }, + { + "preset_id": 203, + "cos": 0.215994 + }, + { + "preset_id": 204, + "cos": 0.202552 + }, + { + "preset_id": 205, + "cos": 0.266739 + }, + { + "preset_id": 206, + "cos": 0.234795 + }, + { + "preset_id": 207, + "cos": 0.177884 + }, + { + "preset_id": 208, + "cos": 0.274305 + }, + { + "preset_id": 301, + "cos": 0.17825 + }, + { + "preset_id": 302, + "cos": 0.152133 + }, + { + "preset_id": 303, + "cos": 0.248618 + }, + { + "preset_id": 304, + "cos": 0.219161 + }, + { + "preset_id": 305, + "cos": 0.252263 + }, + { + "preset_id": 306, + "cos": 0.115698 + }, + { + "preset_id": 307, + "cos": 0.147215 + }, + { + "preset_id": 401, + "cos": 0.092294 + }, + { + "preset_id": 402, + "cos": 0.15496 + }, + { + "preset_id": 403, + "cos": 0.234966 + }, + { + "preset_id": 404, + "cos": 0.096249 + }, + { + "preset_id": 405, + "cos": 0.323696 + }, + { + "preset_id": 406, + "cos": 0.253098 + } + ] + }, + { + "query": "일식집", + "cos": [ + { + "preset_id": 101, + "cos": 0.252003 + }, + { + "preset_id": 102, + "cos": 0.24992 + }, + { + "preset_id": 103, + "cos": 0.281594 + }, + { + "preset_id": 104, + "cos": 0.341045 + }, + { + "preset_id": 105, + "cos": 0.246805 + }, + { + "preset_id": 106, + "cos": 0.245398 + }, + { + "preset_id": 201, + "cos": 0.216972 + }, + { + "preset_id": 202, + "cos": 0.303423 + }, + { + "preset_id": 203, + "cos": 0.245605 + }, + { + "preset_id": 204, + "cos": 0.32873 + }, + { + "preset_id": 205, + "cos": 0.280859 + }, + { + "preset_id": 206, + "cos": 0.223596 + }, + { + "preset_id": 207, + "cos": 0.220433 + }, + { + "preset_id": 208, + "cos": 0.193007 + }, + { + "preset_id": 301, + "cos": 0.238354 + }, + { + "preset_id": 302, + "cos": 0.20449 + }, + { + "preset_id": 303, + "cos": 0.268435 + }, + { + "preset_id": 304, + "cos": 0.164927 + }, + { + "preset_id": 305, + "cos": 0.222462 + }, + { + "preset_id": 306, + "cos": 0.278775 + }, + { + "preset_id": 307, + "cos": 0.112056 + }, + { + "preset_id": 401, + "cos": 0.249179 + }, + { + "preset_id": 402, + "cos": 0.296199 + }, + { + "preset_id": 403, + "cos": 0.267657 + }, + { + "preset_id": 404, + "cos": 0.210053 + }, + { + "preset_id": 405, + "cos": 0.289017 + }, + { + "preset_id": 406, + "cos": 0.221037 + } + ] + }, + { + "query": "부트캠프", + "cos": [ + { + "preset_id": 101, + "cos": 0.226207 + }, + { + "preset_id": 102, + "cos": 0.268243 + }, + { + "preset_id": 103, + "cos": 0.175336 + }, + { + "preset_id": 104, + "cos": 0.269793 + }, + { + "preset_id": 105, + "cos": 0.151965 + }, + { + "preset_id": 106, + "cos": 0.192654 + }, + { + "preset_id": 201, + "cos": 0.229388 + }, + { + "preset_id": 202, + "cos": 0.188574 + }, + { + "preset_id": 203, + "cos": 0.248021 + }, + { + "preset_id": 204, + "cos": 0.292819 + }, + { + "preset_id": 205, + "cos": 0.319966 + }, + { + "preset_id": 206, + "cos": 0.236398 + }, + { + "preset_id": 207, + "cos": 0.261567 + }, + { + "preset_id": 208, + "cos": 0.273072 + }, + { + "preset_id": 301, + "cos": 0.12538 + }, + { + "preset_id": 302, + "cos": 0.125594 + }, + { + "preset_id": 303, + "cos": 0.23058 + }, + { + "preset_id": 304, + "cos": 0.19041 + }, + { + "preset_id": 305, + "cos": 0.210742 + }, + { + "preset_id": 306, + "cos": 0.228526 + }, + { + "preset_id": 307, + "cos": 0.235323 + }, + { + "preset_id": 401, + "cos": 0.221816 + }, + { + "preset_id": 402, + "cos": 0.2483 + }, + { + "preset_id": 403, + "cos": 0.311219 + }, + { + "preset_id": 404, + "cos": 0.14649 + }, + { + "preset_id": 405, + "cos": 0.287064 + }, + { + "preset_id": 406, + "cos": 0.278969 + } + ] + }, + { + "query": "두부찌개", + "cos": [ + { + "preset_id": 101, + "cos": 0.246508 + }, + { + "preset_id": 102, + "cos": 0.168869 + }, + { + "preset_id": 103, + "cos": 0.248462 + }, + { + "preset_id": 104, + "cos": 0.197181 + }, + { + "preset_id": 105, + "cos": 0.223456 + }, + { + "preset_id": 106, + "cos": 0.147667 + }, + { + "preset_id": 201, + "cos": 0.275024 + }, + { + "preset_id": 202, + "cos": 0.230768 + }, + { + "preset_id": 203, + "cos": 0.234533 + }, + { + "preset_id": 204, + "cos": 0.299407 + }, + { + "preset_id": 205, + "cos": 0.217504 + }, + { + "preset_id": 206, + "cos": 0.242951 + }, + { + "preset_id": 207, + "cos": 0.221054 + }, + { + "preset_id": 208, + "cos": 0.224986 + }, + { + "preset_id": 301, + "cos": 0.15314 + }, + { + "preset_id": 302, + "cos": 0.186948 + }, + { + "preset_id": 303, + "cos": 0.178479 + }, + { + "preset_id": 304, + "cos": 0.211794 + }, + { + "preset_id": 305, + "cos": 0.21683 + }, + { + "preset_id": 306, + "cos": 0.230646 + }, + { + "preset_id": 307, + "cos": 0.157605 + }, + { + "preset_id": 401, + "cos": 0.176206 + }, + { + "preset_id": 402, + "cos": 0.171459 + }, + { + "preset_id": 403, + "cos": 0.238771 + }, + { + "preset_id": 404, + "cos": 0.15932 + }, + { + "preset_id": 405, + "cos": 0.249814 + }, + { + "preset_id": 406, + "cos": 0.184565 + } + ] + }, + { + "query": "무한도전", + "cos": [ + { + "preset_id": 101, + "cos": 0.182895 + }, + { + "preset_id": 102, + "cos": 0.055403 + }, + { + "preset_id": 103, + "cos": 0.112384 + }, + { + "preset_id": 104, + "cos": 0.199898 + }, + { + "preset_id": 105, + "cos": 0.098553 + }, + { + "preset_id": 106, + "cos": 0.122058 + }, + { + "preset_id": 201, + "cos": 0.196145 + }, + { + "preset_id": 202, + "cos": 0.096121 + }, + { + "preset_id": 203, + "cos": 0.111922 + }, + { + "preset_id": 204, + "cos": 0.099013 + }, + { + "preset_id": 205, + "cos": 0.173764 + }, + { + "preset_id": 206, + "cos": 0.120942 + }, + { + "preset_id": 207, + "cos": 0.108992 + }, + { + "preset_id": 208, + "cos": 0.163789 + }, + { + "preset_id": 301, + "cos": 0.086357 + }, + { + "preset_id": 302, + "cos": 0.015947 + }, + { + "preset_id": 303, + "cos": 0.112901 + }, + { + "preset_id": 304, + "cos": 0.064256 + }, + { + "preset_id": 305, + "cos": 0.149597 + }, + { + "preset_id": 306, + "cos": 0.0724 + }, + { + "preset_id": 307, + "cos": 0.075393 + }, + { + "preset_id": 401, + "cos": 0.013906 + }, + { + "preset_id": 402, + "cos": 0.099962 + }, + { + "preset_id": 403, + "cos": 0.159137 + }, + { + "preset_id": 404, + "cos": 0.045125 + }, + { + "preset_id": 405, + "cos": 0.202393 + }, + { + "preset_id": 406, + "cos": 0.196323 + } + ] + }, + { + "query": "치킨버거", + "cos": [ + { + "preset_id": 101, + "cos": 0.1646 + }, + { + "preset_id": 102, + "cos": 0.147466 + }, + { + "preset_id": 103, + "cos": 0.178073 + }, + { + "preset_id": 104, + "cos": 0.165062 + }, + { + "preset_id": 105, + "cos": 0.237236 + }, + { + "preset_id": 106, + "cos": 0.149467 + }, + { + "preset_id": 201, + "cos": 0.261039 + }, + { + "preset_id": 202, + "cos": 0.219519 + }, + { + "preset_id": 203, + "cos": 0.16418 + }, + { + "preset_id": 204, + "cos": 0.351428 + }, + { + "preset_id": 205, + "cos": 0.288713 + }, + { + "preset_id": 206, + "cos": 0.318754 + }, + { + "preset_id": 207, + "cos": 0.26592 + }, + { + "preset_id": 208, + "cos": 0.235859 + }, + { + "preset_id": 301, + "cos": 0.106452 + }, + { + "preset_id": 302, + "cos": 0.096251 + }, + { + "preset_id": 303, + "cos": 0.162664 + }, + { + "preset_id": 304, + "cos": 0.180497 + }, + { + "preset_id": 305, + "cos": 0.19608 + }, + { + "preset_id": 306, + "cos": 0.236193 + }, + { + "preset_id": 307, + "cos": 0.187525 + }, + { + "preset_id": 401, + "cos": 0.165613 + }, + { + "preset_id": 402, + "cos": 0.204042 + }, + { + "preset_id": 403, + "cos": 0.218277 + }, + { + "preset_id": 404, + "cos": 0.19501 + }, + { + "preset_id": 405, + "cos": 0.31159 + }, + { + "preset_id": 406, + "cos": 0.228544 + } + ] + }, + { + "query": "샌드위치", + "cos": [ + { + "preset_id": 101, + "cos": 0.176838 + }, + { + "preset_id": 102, + "cos": 0.200907 + }, + { + "preset_id": 103, + "cos": 0.088218 + }, + { + "preset_id": 104, + "cos": 0.147571 + }, + { + "preset_id": 105, + "cos": 0.115221 + }, + { + "preset_id": 106, + "cos": 0.116611 + }, + { + "preset_id": 201, + "cos": 0.16287 + }, + { + "preset_id": 202, + "cos": 0.125751 + }, + { + "preset_id": 203, + "cos": 0.090373 + }, + { + "preset_id": 204, + "cos": 0.233885 + }, + { + "preset_id": 205, + "cos": 0.20409 + }, + { + "preset_id": 206, + "cos": 0.129709 + }, + { + "preset_id": 207, + "cos": 0.164623 + }, + { + "preset_id": 208, + "cos": 0.086239 + }, + { + "preset_id": 301, + "cos": 0.132832 + }, + { + "preset_id": 302, + "cos": 0.175771 + }, + { + "preset_id": 303, + "cos": 0.246703 + }, + { + "preset_id": 304, + "cos": 0.179104 + }, + { + "preset_id": 305, + "cos": 0.206155 + }, + { + "preset_id": 306, + "cos": 0.18704 + }, + { + "preset_id": 307, + "cos": 0.167582 + }, + { + "preset_id": 401, + "cos": 0.202852 + }, + { + "preset_id": 402, + "cos": 0.123937 + }, + { + "preset_id": 403, + "cos": 0.227671 + }, + { + "preset_id": 404, + "cos": 0.115386 + }, + { + "preset_id": 405, + "cos": 0.149944 + }, + { + "preset_id": 406, + "cos": 0.153426 + } + ] + }, + { + "query": "화덕피자", + "cos": [ + { + "preset_id": 101, + "cos": 0.199552 + }, + { + "preset_id": 102, + "cos": 0.136659 + }, + { + "preset_id": 103, + "cos": 0.182989 + }, + { + "preset_id": 104, + "cos": 0.228536 + }, + { + "preset_id": 105, + "cos": 0.13684 + }, + { + "preset_id": 106, + "cos": 0.163099 + }, + { + "preset_id": 201, + "cos": 0.210584 + }, + { + "preset_id": 202, + "cos": 0.257134 + }, + { + "preset_id": 203, + "cos": 0.294619 + }, + { + "preset_id": 204, + "cos": 0.267837 + }, + { + "preset_id": 205, + "cos": 0.135262 + }, + { + "preset_id": 206, + "cos": 0.180674 + }, + { + "preset_id": 207, + "cos": 0.13003 + }, + { + "preset_id": 208, + "cos": 0.111104 + }, + { + "preset_id": 301, + "cos": 0.108305 + }, + { + "preset_id": 302, + "cos": 0.123276 + }, + { + "preset_id": 303, + "cos": 0.164734 + }, + { + "preset_id": 304, + "cos": 0.237431 + }, + { + "preset_id": 305, + "cos": 0.170366 + }, + { + "preset_id": 306, + "cos": 0.158978 + }, + { + "preset_id": 307, + "cos": 0.208054 + }, + { + "preset_id": 401, + "cos": 0.123098 + }, + { + "preset_id": 402, + "cos": 0.178473 + }, + { + "preset_id": 403, + "cos": 0.170972 + }, + { + "preset_id": 404, + "cos": 0.104278 + }, + { + "preset_id": 405, + "cos": 0.22552 + }, + { + "preset_id": 406, + "cos": 0.208253 + } + ] + }, + { + "query": "낙지전골", + "cos": [ + { + "preset_id": 101, + "cos": 0.169005 + }, + { + "preset_id": 102, + "cos": 0.227269 + }, + { + "preset_id": 103, + "cos": 0.253732 + }, + { + "preset_id": 104, + "cos": 0.196521 + }, + { + "preset_id": 105, + "cos": 0.210768 + }, + { + "preset_id": 106, + "cos": 0.213172 + }, + { + "preset_id": 201, + "cos": 0.132042 + }, + { + "preset_id": 202, + "cos": 0.238824 + }, + { + "preset_id": 203, + "cos": 0.159031 + }, + { + "preset_id": 204, + "cos": 0.135124 + }, + { + "preset_id": 205, + "cos": 0.184753 + }, + { + "preset_id": 206, + "cos": 0.196529 + }, + { + "preset_id": 207, + "cos": 0.167081 + }, + { + "preset_id": 208, + "cos": 0.219528 + }, + { + "preset_id": 301, + "cos": 0.167819 + }, + { + "preset_id": 302, + "cos": 0.159254 + }, + { + "preset_id": 303, + "cos": 0.133652 + }, + { + "preset_id": 304, + "cos": 0.169786 + }, + { + "preset_id": 305, + "cos": 0.110573 + }, + { + "preset_id": 306, + "cos": 0.17639 + }, + { + "preset_id": 307, + "cos": 0.163624 + }, + { + "preset_id": 401, + "cos": 0.18171 + }, + { + "preset_id": 402, + "cos": 0.199581 + }, + { + "preset_id": 403, + "cos": 0.18037 + }, + { + "preset_id": 404, + "cos": 0.183378 + }, + { + "preset_id": 405, + "cos": 0.265722 + }, + { + "preset_id": 406, + "cos": 0.181209 + } + ] + }, + { + "query": "가지튀김", + "cos": [ + { + "preset_id": 101, + "cos": 0.217985 + }, + { + "preset_id": 102, + "cos": 0.164446 + }, + { + "preset_id": 103, + "cos": 0.192873 + }, + { + "preset_id": 104, + "cos": 0.236261 + }, + { + "preset_id": 105, + "cos": 0.185412 + }, + { + "preset_id": 106, + "cos": 0.196412 + }, + { + "preset_id": 201, + "cos": 0.228696 + }, + { + "preset_id": 202, + "cos": 0.239597 + }, + { + "preset_id": 203, + "cos": 0.325941 + }, + { + "preset_id": 204, + "cos": 0.253967 + }, + { + "preset_id": 205, + "cos": 0.178144 + }, + { + "preset_id": 206, + "cos": 0.211825 + }, + { + "preset_id": 207, + "cos": 0.205369 + }, + { + "preset_id": 208, + "cos": 0.213193 + }, + { + "preset_id": 301, + "cos": 0.167 + }, + { + "preset_id": 302, + "cos": 0.117371 + }, + { + "preset_id": 303, + "cos": 0.181631 + }, + { + "preset_id": 304, + "cos": 0.172914 + }, + { + "preset_id": 305, + "cos": 0.221203 + }, + { + "preset_id": 306, + "cos": 0.210045 + }, + { + "preset_id": 307, + "cos": 0.128647 + }, + { + "preset_id": 401, + "cos": 0.159008 + }, + { + "preset_id": 402, + "cos": 0.216157 + }, + { + "preset_id": 403, + "cos": 0.280251 + }, + { + "preset_id": 404, + "cos": 0.161724 + }, + { + "preset_id": 405, + "cos": 0.26519 + }, + { + "preset_id": 406, + "cos": 0.220289 + } + ] + }, + { + "query": "라따뚜이", + "cos": [ + { + "preset_id": 101, + "cos": 0.204164 + }, + { + "preset_id": 102, + "cos": 0.14954 + }, + { + "preset_id": 103, + "cos": 0.155973 + }, + { + "preset_id": 104, + "cos": 0.179693 + }, + { + "preset_id": 105, + "cos": 0.20767 + }, + { + "preset_id": 106, + "cos": 0.139883 + }, + { + "preset_id": 201, + "cos": 0.17709 + }, + { + "preset_id": 202, + "cos": 0.230593 + }, + { + "preset_id": 203, + "cos": 0.19844 + }, + { + "preset_id": 204, + "cos": 0.239645 + }, + { + "preset_id": 205, + "cos": 0.164101 + }, + { + "preset_id": 206, + "cos": 0.259513 + }, + { + "preset_id": 207, + "cos": 0.227811 + }, + { + "preset_id": 208, + "cos": 0.207222 + }, + { + "preset_id": 301, + "cos": 0.137574 + }, + { + "preset_id": 302, + "cos": 0.188905 + }, + { + "preset_id": 303, + "cos": 0.215931 + }, + { + "preset_id": 304, + "cos": 0.193127 + }, + { + "preset_id": 305, + "cos": 0.209695 + }, + { + "preset_id": 306, + "cos": 0.225284 + }, + { + "preset_id": 307, + "cos": 0.228685 + }, + { + "preset_id": 401, + "cos": 0.173612 + }, + { + "preset_id": 402, + "cos": 0.226263 + }, + { + "preset_id": 403, + "cos": 0.274679 + }, + { + "preset_id": 404, + "cos": 0.168055 + }, + { + "preset_id": 405, + "cos": 0.193618 + }, + { + "preset_id": 406, + "cos": 0.245527 + } + ] + }, + { + "query": "돼지국밥", + "cos": [ + { + "preset_id": 101, + "cos": 0.229962 + }, + { + "preset_id": 102, + "cos": 0.184002 + }, + { + "preset_id": 103, + "cos": 0.238964 + }, + { + "preset_id": 104, + "cos": 0.239901 + }, + { + "preset_id": 105, + "cos": 0.17602 + }, + { + "preset_id": 106, + "cos": 0.266038 + }, + { + "preset_id": 201, + "cos": 0.265983 + }, + { + "preset_id": 202, + "cos": 0.322238 + }, + { + "preset_id": 203, + "cos": 0.278889 + }, + { + "preset_id": 204, + "cos": 0.344741 + }, + { + "preset_id": 205, + "cos": 0.208525 + }, + { + "preset_id": 206, + "cos": 0.213493 + }, + { + "preset_id": 207, + "cos": 0.141433 + }, + { + "preset_id": 208, + "cos": 0.119768 + }, + { + "preset_id": 301, + "cos": 0.153312 + }, + { + "preset_id": 302, + "cos": 0.14172 + }, + { + "preset_id": 303, + "cos": 0.132046 + }, + { + "preset_id": 304, + "cos": 0.209033 + }, + { + "preset_id": 305, + "cos": 0.203326 + }, + { + "preset_id": 306, + "cos": 0.235756 + }, + { + "preset_id": 307, + "cos": 0.081918 + }, + { + "preset_id": 401, + "cos": 0.211164 + }, + { + "preset_id": 402, + "cos": 0.199371 + }, + { + "preset_id": 403, + "cos": 0.220813 + }, + { + "preset_id": 404, + "cos": 0.181208 + }, + { + "preset_id": 405, + "cos": 0.328711 + }, + { + "preset_id": 406, + "cos": 0.166183 + } + ] + }, + { + "query": "인테리어", + "cos": [ + { + "preset_id": 101, + "cos": 0.176492 + }, + { + "preset_id": 102, + "cos": 0.173571 + }, + { + "preset_id": 103, + "cos": 0.134786 + }, + { + "preset_id": 104, + "cos": 0.165515 + }, + { + "preset_id": 105, + "cos": 0.197037 + }, + { + "preset_id": 106, + "cos": 0.162511 + }, + { + "preset_id": 201, + "cos": 0.189146 + }, + { + "preset_id": 202, + "cos": 0.18351 + }, + { + "preset_id": 203, + "cos": 0.149947 + }, + { + "preset_id": 204, + "cos": 0.226573 + }, + { + "preset_id": 205, + "cos": 0.147894 + }, + { + "preset_id": 206, + "cos": 0.100376 + }, + { + "preset_id": 207, + "cos": 0.231531 + }, + { + "preset_id": 208, + "cos": 0.221477 + }, + { + "preset_id": 301, + "cos": 0.203254 + }, + { + "preset_id": 302, + "cos": 0.147701 + }, + { + "preset_id": 303, + "cos": 0.421694 + }, + { + "preset_id": 304, + "cos": 0.235539 + }, + { + "preset_id": 305, + "cos": 0.315131 + }, + { + "preset_id": 306, + "cos": 0.339493 + }, + { + "preset_id": 307, + "cos": 0.180609 + }, + { + "preset_id": 401, + "cos": 0.226466 + }, + { + "preset_id": 402, + "cos": 0.187274 + }, + { + "preset_id": 403, + "cos": 0.183227 + }, + { + "preset_id": 404, + "cos": 0.146253 + }, + { + "preset_id": 405, + "cos": 0.247627 + }, + { + "preset_id": 406, + "cos": 0.154103 + } + ] + }, + { + "query": "아이스크림", + "cos": [ + { + "preset_id": 101, + "cos": 0.220909 + }, + { + "preset_id": 102, + "cos": 0.172612 + }, + { + "preset_id": 103, + "cos": 0.169787 + }, + { + "preset_id": 104, + "cos": 0.147563 + }, + { + "preset_id": 105, + "cos": 0.322042 + }, + { + "preset_id": 106, + "cos": 0.125758 + }, + { + "preset_id": 201, + "cos": 0.162406 + }, + { + "preset_id": 202, + "cos": 0.1814 + }, + { + "preset_id": 203, + "cos": 0.176061 + }, + { + "preset_id": 204, + "cos": 0.30386 + }, + { + "preset_id": 205, + "cos": 0.197206 + }, + { + "preset_id": 206, + "cos": 0.162016 + }, + { + "preset_id": 207, + "cos": 0.222455 + }, + { + "preset_id": 208, + "cos": 0.108874 + }, + { + "preset_id": 301, + "cos": 0.099172 + }, + { + "preset_id": 302, + "cos": 0.100273 + }, + { + "preset_id": 303, + "cos": 0.193213 + }, + { + "preset_id": 304, + "cos": 0.149813 + }, + { + "preset_id": 305, + "cos": 0.148206 + }, + { + "preset_id": 306, + "cos": 0.171263 + }, + { + "preset_id": 307, + "cos": 0.173652 + }, + { + "preset_id": 401, + "cos": 0.233973 + }, + { + "preset_id": 402, + "cos": 0.115312 + }, + { + "preset_id": 403, + "cos": 0.151127 + }, + { + "preset_id": 404, + "cos": 0.095359 + }, + { + "preset_id": 405, + "cos": 0.248831 + }, + { + "preset_id": 406, + "cos": 0.159514 + } + ] + }, + { + "query": "치과", + "cos": [ + { + "preset_id": 101, + "cos": 0.199035 + }, + { + "preset_id": 102, + "cos": 0.176198 + }, + { + "preset_id": 103, + "cos": 0.202326 + }, + { + "preset_id": 104, + "cos": 0.286512 + }, + { + "preset_id": 105, + "cos": 0.203613 + }, + { + "preset_id": 106, + "cos": 0.163334 + }, + { + "preset_id": 201, + "cos": 0.201264 + }, + { + "preset_id": 202, + "cos": 0.24846 + }, + { + "preset_id": 203, + "cos": 0.139586 + }, + { + "preset_id": 204, + "cos": 0.204478 + }, + { + "preset_id": 205, + "cos": 0.268714 + }, + { + "preset_id": 206, + "cos": 0.219244 + }, + { + "preset_id": 207, + "cos": 0.185301 + }, + { + "preset_id": 208, + "cos": 0.208757 + }, + { + "preset_id": 301, + "cos": 0.142499 + }, + { + "preset_id": 302, + "cos": 0.073014 + }, + { + "preset_id": 303, + "cos": 0.139047 + }, + { + "preset_id": 304, + "cos": 0.157278 + }, + { + "preset_id": 305, + "cos": 0.20213 + }, + { + "preset_id": 306, + "cos": 0.2046 + }, + { + "preset_id": 307, + "cos": 0.086711 + }, + { + "preset_id": 401, + "cos": 0.142645 + }, + { + "preset_id": 402, + "cos": 0.253034 + }, + { + "preset_id": 403, + "cos": 0.210856 + }, + { + "preset_id": 404, + "cos": 0.146558 + }, + { + "preset_id": 405, + "cos": 0.252947 + }, + { + "preset_id": 406, + "cos": 0.266999 + } + ] + }, + { + "query": "액정", + "cos": [ + { + "preset_id": 101, + "cos": 0.207335 + }, + { + "preset_id": 102, + "cos": 0.114581 + }, + { + "preset_id": 103, + "cos": 0.182039 + }, + { + "preset_id": 104, + "cos": 0.19419 + }, + { + "preset_id": 105, + "cos": 0.17052 + }, + { + "preset_id": 106, + "cos": 0.141384 + }, + { + "preset_id": 201, + "cos": 0.12073 + }, + { + "preset_id": 202, + "cos": 0.127709 + }, + { + "preset_id": 203, + "cos": 0.221386 + }, + { + "preset_id": 204, + "cos": 0.152453 + }, + { + "preset_id": 205, + "cos": 0.171257 + }, + { + "preset_id": 206, + "cos": 0.09957 + }, + { + "preset_id": 207, + "cos": 0.142601 + }, + { + "preset_id": 208, + "cos": 0.156307 + }, + { + "preset_id": 301, + "cos": 0.141507 + }, + { + "preset_id": 302, + "cos": 0.082635 + }, + { + "preset_id": 303, + "cos": 0.170484 + }, + { + "preset_id": 304, + "cos": 0.175863 + }, + { + "preset_id": 305, + "cos": 0.284053 + }, + { + "preset_id": 306, + "cos": 0.145772 + }, + { + "preset_id": 307, + "cos": 0.143298 + }, + { + "preset_id": 401, + "cos": 0.030859 + }, + { + "preset_id": 402, + "cos": 0.133791 + }, + { + "preset_id": 403, + "cos": 0.189408 + }, + { + "preset_id": 404, + "cos": 0.102704 + }, + { + "preset_id": 405, + "cos": 0.227743 + }, + { + "preset_id": 406, + "cos": 0.240637 + } + ] + }, + { + "query": "약국", + "cos": [ + { + "preset_id": 101, + "cos": 0.078695 + }, + { + "preset_id": 102, + "cos": 0.089751 + }, + { + "preset_id": 103, + "cos": 0.166198 + }, + { + "preset_id": 104, + "cos": 0.138683 + }, + { + "preset_id": 105, + "cos": 0.147261 + }, + { + "preset_id": 106, + "cos": 0.152022 + }, + { + "preset_id": 201, + "cos": 0.141612 + }, + { + "preset_id": 202, + "cos": 0.188288 + }, + { + "preset_id": 203, + "cos": 0.120048 + }, + { + "preset_id": 204, + "cos": 0.178634 + }, + { + "preset_id": 205, + "cos": 0.241718 + }, + { + "preset_id": 206, + "cos": 0.152094 + }, + { + "preset_id": 207, + "cos": 0.182062 + }, + { + "preset_id": 208, + "cos": 0.178117 + }, + { + "preset_id": 301, + "cos": 0.122087 + }, + { + "preset_id": 302, + "cos": 0.094218 + }, + { + "preset_id": 303, + "cos": 0.11896 + }, + { + "preset_id": 304, + "cos": 0.110962 + }, + { + "preset_id": 305, + "cos": 0.093872 + }, + { + "preset_id": 306, + "cos": 0.202086 + }, + { + "preset_id": 307, + "cos": 0.094854 + }, + { + "preset_id": 401, + "cos": 0.07124 + }, + { + "preset_id": 402, + "cos": 0.17847 + }, + { + "preset_id": 403, + "cos": 0.156457 + }, + { + "preset_id": 404, + "cos": 0.127944 + }, + { + "preset_id": 405, + "cos": 0.189754 + }, + { + "preset_id": 406, + "cos": 0.215727 + } + ] + }, + { + "query": "보험", + "cos": [ + { + "preset_id": 101, + "cos": 0.081997 + }, + { + "preset_id": 102, + "cos": 0.109158 + }, + { + "preset_id": 103, + "cos": 0.118451 + }, + { + "preset_id": 104, + "cos": 0.131343 + }, + { + "preset_id": 105, + "cos": 0.169212 + }, + { + "preset_id": 106, + "cos": 0.116211 + }, + { + "preset_id": 201, + "cos": 0.059769 + }, + { + "preset_id": 202, + "cos": 0.149513 + }, + { + "preset_id": 203, + "cos": 0.11074 + }, + { + "preset_id": 204, + "cos": 0.134859 + }, + { + "preset_id": 205, + "cos": 0.187709 + }, + { + "preset_id": 206, + "cos": 0.226079 + }, + { + "preset_id": 207, + "cos": 0.186486 + }, + { + "preset_id": 208, + "cos": 0.20241 + }, + { + "preset_id": 301, + "cos": 0.141932 + }, + { + "preset_id": 302, + "cos": 0.115416 + }, + { + "preset_id": 303, + "cos": 0.168116 + }, + { + "preset_id": 304, + "cos": 0.156026 + }, + { + "preset_id": 305, + "cos": 0.120954 + }, + { + "preset_id": 306, + "cos": 0.139177 + }, + { + "preset_id": 307, + "cos": 0.148682 + }, + { + "preset_id": 401, + "cos": 0.113103 + }, + { + "preset_id": 402, + "cos": 0.19702 + }, + { + "preset_id": 403, + "cos": 0.187798 + }, + { + "preset_id": 404, + "cos": 0.131991 + }, + { + "preset_id": 405, + "cos": 0.162285 + }, + { + "preset_id": 406, + "cos": 0.262323 + } + ] + }, + { + "query": "세탁", + "cos": [ + { + "preset_id": 101, + "cos": 0.188399 + }, + { + "preset_id": 102, + "cos": 0.167368 + }, + { + "preset_id": 103, + "cos": 0.185848 + }, + { + "preset_id": 104, + "cos": 0.20678 + }, + { + "preset_id": 105, + "cos": 0.162324 + }, + { + "preset_id": 106, + "cos": 0.19931 + }, + { + "preset_id": 201, + "cos": 0.157309 + }, + { + "preset_id": 202, + "cos": 0.156126 + }, + { + "preset_id": 203, + "cos": 0.144757 + }, + { + "preset_id": 204, + "cos": 0.145562 + }, + { + "preset_id": 205, + "cos": 0.226625 + }, + { + "preset_id": 206, + "cos": 0.176638 + }, + { + "preset_id": 207, + "cos": 0.40721 + }, + { + "preset_id": 208, + "cos": 0.192197 + }, + { + "preset_id": 301, + "cos": 0.118775 + }, + { + "preset_id": 302, + "cos": 0.125834 + }, + { + "preset_id": 303, + "cos": 0.165445 + }, + { + "preset_id": 304, + "cos": 0.168979 + }, + { + "preset_id": 305, + "cos": 0.176994 + }, + { + "preset_id": 306, + "cos": 0.135811 + }, + { + "preset_id": 307, + "cos": 0.098006 + }, + { + "preset_id": 401, + "cos": 0.104251 + }, + { + "preset_id": 402, + "cos": 0.173544 + }, + { + "preset_id": 403, + "cos": 0.175009 + }, + { + "preset_id": 404, + "cos": 0.112108 + }, + { + "preset_id": 405, + "cos": 0.236264 + }, + { + "preset_id": 406, + "cos": 0.225642 + } + ] + }, + { + "query": "스키장", + "cos": [ + { + "preset_id": 101, + "cos": 0.168167 + }, + { + "preset_id": 102, + "cos": 0.176605 + }, + { + "preset_id": 103, + "cos": 0.143688 + }, + { + "preset_id": 104, + "cos": 0.208394 + }, + { + "preset_id": 105, + "cos": 0.210041 + }, + { + "preset_id": 106, + "cos": 0.200283 + }, + { + "preset_id": 201, + "cos": 0.227409 + }, + { + "preset_id": 202, + "cos": 0.19816 + }, + { + "preset_id": 203, + "cos": 0.259178 + }, + { + "preset_id": 204, + "cos": 0.324089 + }, + { + "preset_id": 205, + "cos": 0.245738 + }, + { + "preset_id": 206, + "cos": 0.186348 + }, + { + "preset_id": 207, + "cos": 0.306694 + }, + { + "preset_id": 208, + "cos": 0.214333 + }, + { + "preset_id": 301, + "cos": 0.175423 + }, + { + "preset_id": 302, + "cos": 0.144483 + }, + { + "preset_id": 303, + "cos": 0.227786 + }, + { + "preset_id": 304, + "cos": 0.121702 + }, + { + "preset_id": 305, + "cos": 0.206186 + }, + { + "preset_id": 306, + "cos": 0.276267 + }, + { + "preset_id": 307, + "cos": 0.194034 + }, + { + "preset_id": 401, + "cos": 0.219357 + }, + { + "preset_id": 402, + "cos": 0.18975 + }, + { + "preset_id": 403, + "cos": 0.197388 + }, + { + "preset_id": 404, + "cos": 0.077157 + }, + { + "preset_id": 405, + "cos": 0.252181 + }, + { + "preset_id": 406, + "cos": 0.222691 + } + ] + }, + { + "query": "정비소", + "cos": [ + { + "preset_id": 101, + "cos": 0.161807 + }, + { + "preset_id": 102, + "cos": 0.161361 + }, + { + "preset_id": 103, + "cos": 0.153532 + }, + { + "preset_id": 104, + "cos": 0.227239 + }, + { + "preset_id": 105, + "cos": 0.242318 + }, + { + "preset_id": 106, + "cos": 0.202308 + }, + { + "preset_id": 201, + "cos": 0.163105 + }, + { + "preset_id": 202, + "cos": 0.263435 + }, + { + "preset_id": 203, + "cos": 0.238852 + }, + { + "preset_id": 204, + "cos": 0.196593 + }, + { + "preset_id": 205, + "cos": 0.263374 + }, + { + "preset_id": 206, + "cos": 0.234497 + }, + { + "preset_id": 207, + "cos": 0.221927 + }, + { + "preset_id": 208, + "cos": 0.264592 + }, + { + "preset_id": 301, + "cos": 0.229081 + }, + { + "preset_id": 302, + "cos": 0.209958 + }, + { + "preset_id": 303, + "cos": 0.200943 + }, + { + "preset_id": 304, + "cos": 0.261093 + }, + { + "preset_id": 305, + "cos": 0.223214 + }, + { + "preset_id": 306, + "cos": 0.236436 + }, + { + "preset_id": 307, + "cos": 0.189561 + }, + { + "preset_id": 401, + "cos": 0.165491 + }, + { + "preset_id": 402, + "cos": 0.197432 + }, + { + "preset_id": 403, + "cos": 0.263898 + }, + { + "preset_id": 404, + "cos": 0.260295 + }, + { + "preset_id": 405, + "cos": 0.193023 + }, + { + "preset_id": 406, + "cos": 0.23725 + } + ] + }, + { + "query": "리프트", + "cos": [ + { + "preset_id": 101, + "cos": 0.150528 + }, + { + "preset_id": 102, + "cos": 0.122324 + }, + { + "preset_id": 103, + "cos": 0.128998 + }, + { + "preset_id": 104, + "cos": 0.152394 + }, + { + "preset_id": 105, + "cos": 0.154361 + }, + { + "preset_id": 106, + "cos": 0.114436 + }, + { + "preset_id": 201, + "cos": 0.136677 + }, + { + "preset_id": 202, + "cos": 0.160935 + }, + { + "preset_id": 203, + "cos": 0.14209 + }, + { + "preset_id": 204, + "cos": 0.197156 + }, + { + "preset_id": 205, + "cos": 0.132583 + }, + { + "preset_id": 206, + "cos": 0.152034 + }, + { + "preset_id": 207, + "cos": 0.211222 + }, + { + "preset_id": 208, + "cos": 0.159334 + }, + { + "preset_id": 301, + "cos": 0.132599 + }, + { + "preset_id": 302, + "cos": 0.138162 + }, + { + "preset_id": 303, + "cos": 0.212808 + }, + { + "preset_id": 304, + "cos": 0.167192 + }, + { + "preset_id": 305, + "cos": 0.202401 + }, + { + "preset_id": 306, + "cos": 0.18502 + }, + { + "preset_id": 307, + "cos": 0.286381 + }, + { + "preset_id": 401, + "cos": 0.178729 + }, + { + "preset_id": 402, + "cos": 0.146321 + }, + { + "preset_id": 403, + "cos": 0.195831 + }, + { + "preset_id": 404, + "cos": 0.075108 + }, + { + "preset_id": 405, + "cos": 0.161061 + }, + { + "preset_id": 406, + "cos": 0.223821 + } + ] + }, + { + "query": "헬스장", + "cos": [ + { + "preset_id": 101, + "cos": 0.176761 + }, + { + "preset_id": 102, + "cos": 0.144988 + }, + { + "preset_id": 103, + "cos": 0.141686 + }, + { + "preset_id": 104, + "cos": 0.216333 + }, + { + "preset_id": 105, + "cos": 0.138451 + }, + { + "preset_id": 106, + "cos": 0.228505 + }, + { + "preset_id": 201, + "cos": 0.208103 + }, + { + "preset_id": 202, + "cos": 0.1681 + }, + { + "preset_id": 203, + "cos": 0.188507 + }, + { + "preset_id": 204, + "cos": 0.271286 + }, + { + "preset_id": 205, + "cos": 0.240474 + }, + { + "preset_id": 206, + "cos": 0.204992 + }, + { + "preset_id": 207, + "cos": 0.252048 + }, + { + "preset_id": 208, + "cos": 0.233554 + }, + { + "preset_id": 301, + "cos": 0.197642 + }, + { + "preset_id": 302, + "cos": 0.095179 + }, + { + "preset_id": 303, + "cos": 0.207263 + }, + { + "preset_id": 304, + "cos": 0.198365 + }, + { + "preset_id": 305, + "cos": 0.265209 + }, + { + "preset_id": 306, + "cos": 0.255372 + }, + { + "preset_id": 307, + "cos": 0.190413 + }, + { + "preset_id": 401, + "cos": 0.241364 + }, + { + "preset_id": 402, + "cos": 0.169935 + }, + { + "preset_id": 403, + "cos": 0.241871 + }, + { + "preset_id": 404, + "cos": 0.12775 + }, + { + "preset_id": 405, + "cos": 0.234961 + }, + { + "preset_id": 406, + "cos": 0.210486 + } + ] + }, + { + "query": "주유소", + "cos": [ + { + "preset_id": 101, + "cos": 0.157995 + }, + { + "preset_id": 102, + "cos": 0.115835 + }, + { + "preset_id": 103, + "cos": 0.169191 + }, + { + "preset_id": 104, + "cos": 0.202518 + }, + { + "preset_id": 105, + "cos": 0.225162 + }, + { + "preset_id": 106, + "cos": 0.21738 + }, + { + "preset_id": 201, + "cos": 0.195368 + }, + { + "preset_id": 202, + "cos": 0.141717 + }, + { + "preset_id": 203, + "cos": 0.23998 + }, + { + "preset_id": 204, + "cos": 0.190895 + }, + { + "preset_id": 205, + "cos": 0.1998 + }, + { + "preset_id": 206, + "cos": 0.169017 + }, + { + "preset_id": 207, + "cos": 0.191175 + }, + { + "preset_id": 208, + "cos": 0.222396 + }, + { + "preset_id": 301, + "cos": 0.202351 + }, + { + "preset_id": 302, + "cos": 0.179481 + }, + { + "preset_id": 303, + "cos": 0.241476 + }, + { + "preset_id": 304, + "cos": 0.170514 + }, + { + "preset_id": 305, + "cos": 0.196275 + }, + { + "preset_id": 306, + "cos": 0.230252 + }, + { + "preset_id": 307, + "cos": 0.16462 + }, + { + "preset_id": 401, + "cos": 0.219509 + }, + { + "preset_id": 402, + "cos": 0.198427 + }, + { + "preset_id": 403, + "cos": 0.202265 + }, + { + "preset_id": 404, + "cos": 0.22959 + }, + { + "preset_id": 405, + "cos": 0.140329 + }, + { + "preset_id": 406, + "cos": 0.140036 + } + ] + }, + { + "query": "안경점", + "cos": [ + { + "preset_id": 101, + "cos": 0.10633 + }, + { + "preset_id": 102, + "cos": 0.178943 + }, + { + "preset_id": 103, + "cos": 0.130539 + }, + { + "preset_id": 104, + "cos": 0.163161 + }, + { + "preset_id": 105, + "cos": 0.199202 + }, + { + "preset_id": 106, + "cos": 0.179873 + }, + { + "preset_id": 201, + "cos": 0.273211 + }, + { + "preset_id": 202, + "cos": 0.20002 + }, + { + "preset_id": 203, + "cos": 0.115652 + }, + { + "preset_id": 204, + "cos": 0.185604 + }, + { + "preset_id": 205, + "cos": 0.203857 + }, + { + "preset_id": 206, + "cos": 0.188614 + }, + { + "preset_id": 207, + "cos": 0.239726 + }, + { + "preset_id": 208, + "cos": 0.288735 + }, + { + "preset_id": 301, + "cos": 0.178681 + }, + { + "preset_id": 302, + "cos": 0.135361 + }, + { + "preset_id": 303, + "cos": 0.261527 + }, + { + "preset_id": 304, + "cos": 0.233895 + }, + { + "preset_id": 305, + "cos": 0.210479 + }, + { + "preset_id": 306, + "cos": 0.453859 + }, + { + "preset_id": 307, + "cos": 0.218555 + }, + { + "preset_id": 401, + "cos": 0.252917 + }, + { + "preset_id": 402, + "cos": 0.256 + }, + { + "preset_id": 403, + "cos": 0.181013 + }, + { + "preset_id": 404, + "cos": 0.201832 + }, + { + "preset_id": 405, + "cos": 0.248753 + }, + { + "preset_id": 406, + "cos": 0.21017 + } + ] + }, + { + "query": "엔진오일", + "cos": [ + { + "preset_id": 101, + "cos": 0.155328 + }, + { + "preset_id": 102, + "cos": 0.175027 + }, + { + "preset_id": 103, + "cos": 0.161681 + }, + { + "preset_id": 104, + "cos": 0.150439 + }, + { + "preset_id": 105, + "cos": 0.078104 + }, + { + "preset_id": 106, + "cos": 0.158893 + }, + { + "preset_id": 201, + "cos": 0.142656 + }, + { + "preset_id": 202, + "cos": 0.1136 + }, + { + "preset_id": 203, + "cos": 0.13007 + }, + { + "preset_id": 204, + "cos": 0.132917 + }, + { + "preset_id": 205, + "cos": 0.126053 + }, + { + "preset_id": 206, + "cos": 0.152012 + }, + { + "preset_id": 207, + "cos": 0.054747 + }, + { + "preset_id": 208, + "cos": 0.092494 + }, + { + "preset_id": 301, + "cos": 0.106625 + }, + { + "preset_id": 302, + "cos": 0.123543 + }, + { + "preset_id": 303, + "cos": 0.130409 + }, + { + "preset_id": 304, + "cos": 0.210935 + }, + { + "preset_id": 305, + "cos": 0.142756 + }, + { + "preset_id": 306, + "cos": 0.077571 + }, + { + "preset_id": 307, + "cos": 0.147666 + }, + { + "preset_id": 401, + "cos": 0.107548 + }, + { + "preset_id": 402, + "cos": 0.112263 + }, + { + "preset_id": 403, + "cos": 0.142415 + }, + { + "preset_id": 404, + "cos": 0.183157 + }, + { + "preset_id": 405, + "cos": 0.12898 + }, + { + "preset_id": 406, + "cos": 0.152319 + } + ] + }, + { + "query": "임플란트", + "cos": [ + { + "preset_id": 101, + "cos": 0.211009 + }, + { + "preset_id": 102, + "cos": 0.201667 + }, + { + "preset_id": 103, + "cos": 0.166126 + }, + { + "preset_id": 104, + "cos": 0.213964 + }, + { + "preset_id": 105, + "cos": 0.197038 + }, + { + "preset_id": 106, + "cos": 0.143975 + }, + { + "preset_id": 201, + "cos": 0.315121 + }, + { + "preset_id": 202, + "cos": 0.246551 + }, + { + "preset_id": 203, + "cos": 0.187605 + }, + { + "preset_id": 204, + "cos": 0.240056 + }, + { + "preset_id": 205, + "cos": 0.253626 + }, + { + "preset_id": 206, + "cos": 0.193488 + }, + { + "preset_id": 207, + "cos": 0.136941 + }, + { + "preset_id": 208, + "cos": 0.248574 + }, + { + "preset_id": 301, + "cos": 0.207137 + }, + { + "preset_id": 302, + "cos": 0.169416 + }, + { + "preset_id": 303, + "cos": 0.251629 + }, + { + "preset_id": 304, + "cos": 0.235005 + }, + { + "preset_id": 305, + "cos": 0.239384 + }, + { + "preset_id": 306, + "cos": 0.196234 + }, + { + "preset_id": 307, + "cos": 0.272363 + }, + { + "preset_id": 401, + "cos": 0.167085 + }, + { + "preset_id": 402, + "cos": 0.152204 + }, + { + "preset_id": 403, + "cos": 0.225718 + }, + { + "preset_id": 404, + "cos": 0.171122 + }, + { + "preset_id": 405, + "cos": 0.270598 + }, + { + "preset_id": 406, + "cos": 0.166876 + } + ] + }, + { + "query": "예방접종", + "cos": [ + { + "preset_id": 101, + "cos": 0.101694 + }, + { + "preset_id": 102, + "cos": 0.144359 + }, + { + "preset_id": 103, + "cos": 0.188433 + }, + { + "preset_id": 104, + "cos": 0.161121 + }, + { + "preset_id": 105, + "cos": 0.226186 + }, + { + "preset_id": 106, + "cos": 0.234226 + }, + { + "preset_id": 201, + "cos": 0.107111 + }, + { + "preset_id": 202, + "cos": 0.189904 + }, + { + "preset_id": 203, + "cos": 0.121199 + }, + { + "preset_id": 204, + "cos": 0.129905 + }, + { + "preset_id": 205, + "cos": 0.283032 + }, + { + "preset_id": 206, + "cos": 0.173501 + }, + { + "preset_id": 207, + "cos": 0.229485 + }, + { + "preset_id": 208, + "cos": 0.195372 + }, + { + "preset_id": 301, + "cos": 0.142954 + }, + { + "preset_id": 302, + "cos": 0.114519 + }, + { + "preset_id": 303, + "cos": 0.12105 + }, + { + "preset_id": 304, + "cos": 0.144868 + }, + { + "preset_id": 305, + "cos": 0.167764 + }, + { + "preset_id": 306, + "cos": 0.184881 + }, + { + "preset_id": 307, + "cos": 0.14303 + }, + { + "preset_id": 401, + "cos": 0.114822 + }, + { + "preset_id": 402, + "cos": 0.151575 + }, + { + "preset_id": 403, + "cos": 0.137898 + }, + { + "preset_id": 404, + "cos": 0.082221 + }, + { + "preset_id": 405, + "cos": 0.232853 + }, + { + "preset_id": 406, + "cos": 0.257852 + } + ] + }, + { + "query": "동물병원", + "cos": [ + { + "preset_id": 101, + "cos": 0.207174 + }, + { + "preset_id": 102, + "cos": 0.155084 + }, + { + "preset_id": 103, + "cos": 0.179608 + }, + { + "preset_id": 104, + "cos": 0.292542 + }, + { + "preset_id": 105, + "cos": 0.29282 + }, + { + "preset_id": 106, + "cos": 0.17935 + }, + { + "preset_id": 201, + "cos": 0.146168 + }, + { + "preset_id": 202, + "cos": 0.212186 + }, + { + "preset_id": 203, + "cos": 0.117954 + }, + { + "preset_id": 204, + "cos": 0.175615 + }, + { + "preset_id": 205, + "cos": 0.16258 + }, + { + "preset_id": 206, + "cos": 0.207 + }, + { + "preset_id": 207, + "cos": 0.221214 + }, + { + "preset_id": 208, + "cos": 0.214278 + }, + { + "preset_id": 301, + "cos": 0.222927 + }, + { + "preset_id": 302, + "cos": 0.152241 + }, + { + "preset_id": 303, + "cos": 0.182919 + }, + { + "preset_id": 304, + "cos": 0.198853 + }, + { + "preset_id": 305, + "cos": 0.18703 + }, + { + "preset_id": 306, + "cos": 0.19181 + }, + { + "preset_id": 307, + "cos": 0.093162 + }, + { + "preset_id": 401, + "cos": 0.223062 + }, + { + "preset_id": 402, + "cos": 0.193602 + }, + { + "preset_id": 403, + "cos": 0.256338 + }, + { + "preset_id": 404, + "cos": 0.204937 + }, + { + "preset_id": 405, + "cos": 0.192087 + }, + { + "preset_id": 406, + "cos": 0.205407 + } + ] + }, + { + "query": "친구들", + "cos": [ + { + "preset_id": 101, + "cos": 0.475195 + }, + { + "preset_id": 102, + "cos": 0.328264 + }, + { + "preset_id": 103, + "cos": 0.291837 + }, + { + "preset_id": 104, + "cos": 0.320942 + }, + { + "preset_id": 105, + "cos": 0.198666 + }, + { + "preset_id": 106, + "cos": 0.217298 + }, + { + "preset_id": 201, + "cos": 0.237003 + }, + { + "preset_id": 202, + "cos": 0.191462 + }, + { + "preset_id": 203, + "cos": 0.220122 + }, + { + "preset_id": 204, + "cos": 0.209809 + }, + { + "preset_id": 205, + "cos": 0.282261 + }, + { + "preset_id": 206, + "cos": 0.202869 + }, + { + "preset_id": 207, + "cos": 0.182638 + }, + { + "preset_id": 208, + "cos": 0.231607 + }, + { + "preset_id": 301, + "cos": 0.202595 + }, + { + "preset_id": 302, + "cos": 0.162704 + }, + { + "preset_id": 303, + "cos": 0.158092 + }, + { + "preset_id": 304, + "cos": 0.179733 + }, + { + "preset_id": 305, + "cos": 0.167581 + }, + { + "preset_id": 306, + "cos": 0.210971 + }, + { + "preset_id": 307, + "cos": 0.124753 + }, + { + "preset_id": 401, + "cos": 0.214432 + }, + { + "preset_id": 402, + "cos": 0.238569 + }, + { + "preset_id": 403, + "cos": 0.236485 + }, + { + "preset_id": 404, + "cos": 0.185619 + }, + { + "preset_id": 405, + "cos": 0.214384 + }, + { + "preset_id": 406, + "cos": 0.285974 + } + ] + }, + { + "query": "그네 공원", + "cos": [ + { + "preset_id": 101, + "cos": 0.193745 + }, + { + "preset_id": 102, + "cos": 0.170108 + }, + { + "preset_id": 103, + "cos": 0.149315 + }, + { + "preset_id": 104, + "cos": 0.149105 + }, + { + "preset_id": 105, + "cos": 0.186241 + }, + { + "preset_id": 106, + "cos": 0.150918 + }, + { + "preset_id": 201, + "cos": 0.164634 + }, + { + "preset_id": 202, + "cos": 0.131199 + }, + { + "preset_id": 203, + "cos": 0.13295 + }, + { + "preset_id": 204, + "cos": 0.15348 + }, + { + "preset_id": 205, + "cos": 0.169275 + }, + { + "preset_id": 206, + "cos": 0.205116 + }, + { + "preset_id": 207, + "cos": 0.220885 + }, + { + "preset_id": 208, + "cos": 0.239656 + }, + { + "preset_id": 301, + "cos": 0.188033 + }, + { + "preset_id": 302, + "cos": 0.19733 + }, + { + "preset_id": 303, + "cos": 0.286798 + }, + { + "preset_id": 304, + "cos": 0.174456 + }, + { + "preset_id": 305, + "cos": 0.207215 + }, + { + "preset_id": 306, + "cos": 0.285843 + }, + { + "preset_id": 307, + "cos": 0.175273 + }, + { + "preset_id": 401, + "cos": 0.189535 + }, + { + "preset_id": 402, + "cos": 0.227571 + }, + { + "preset_id": 403, + "cos": 0.169743 + }, + { + "preset_id": 404, + "cos": 0.152136 + }, + { + "preset_id": 405, + "cos": 0.146344 + }, + { + "preset_id": 406, + "cos": 0.188592 + } + ] + }, + { + "query": "신한 부트캠프", + "cos": [ + { + "preset_id": 101, + "cos": 0.167607 + }, + { + "preset_id": 102, + "cos": 0.228247 + }, + { + "preset_id": 103, + "cos": 0.138761 + }, + { + "preset_id": 104, + "cos": 0.230434 + }, + { + "preset_id": 105, + "cos": 0.136971 + }, + { + "preset_id": 106, + "cos": 0.163259 + }, + { + "preset_id": 201, + "cos": 0.156752 + }, + { + "preset_id": 202, + "cos": 0.223087 + }, + { + "preset_id": 203, + "cos": 0.185543 + }, + { + "preset_id": 204, + "cos": 0.228701 + }, + { + "preset_id": 205, + "cos": 0.272915 + }, + { + "preset_id": 206, + "cos": 0.214388 + }, + { + "preset_id": 207, + "cos": 0.191461 + }, + { + "preset_id": 208, + "cos": 0.279727 + }, + { + "preset_id": 301, + "cos": 0.105745 + }, + { + "preset_id": 302, + "cos": 0.105601 + }, + { + "preset_id": 303, + "cos": 0.174089 + }, + { + "preset_id": 304, + "cos": 0.180368 + }, + { + "preset_id": 305, + "cos": 0.131921 + }, + { + "preset_id": 306, + "cos": 0.179068 + }, + { + "preset_id": 307, + "cos": 0.273443 + }, + { + "preset_id": 401, + "cos": 0.11812 + }, + { + "preset_id": 402, + "cos": 0.224805 + }, + { + "preset_id": 403, + "cos": 0.220724 + }, + { + "preset_id": 404, + "cos": 0.067813 + }, + { + "preset_id": 405, + "cos": 0.216305 + }, + { + "preset_id": 406, + "cos": 0.192919 + } + ] + }, + { + "query": "신한 부캠", + "cos": [ + { + "preset_id": 101, + "cos": 0.149513 + }, + { + "preset_id": 102, + "cos": 0.25728 + }, + { + "preset_id": 103, + "cos": 0.153528 + }, + { + "preset_id": 104, + "cos": 0.165853 + }, + { + "preset_id": 105, + "cos": 0.14518 + }, + { + "preset_id": 106, + "cos": 0.228403 + }, + { + "preset_id": 201, + "cos": 0.16843 + }, + { + "preset_id": 202, + "cos": 0.264906 + }, + { + "preset_id": 203, + "cos": 0.251679 + }, + { + "preset_id": 204, + "cos": 0.217403 + }, + { + "preset_id": 205, + "cos": 0.309081 + }, + { + "preset_id": 206, + "cos": 0.273382 + }, + { + "preset_id": 207, + "cos": 0.241632 + }, + { + "preset_id": 208, + "cos": 0.248925 + }, + { + "preset_id": 301, + "cos": 0.178009 + }, + { + "preset_id": 302, + "cos": 0.183583 + }, + { + "preset_id": 303, + "cos": 0.194846 + }, + { + "preset_id": 304, + "cos": 0.241799 + }, + { + "preset_id": 305, + "cos": 0.14846 + }, + { + "preset_id": 306, + "cos": 0.209955 + }, + { + "preset_id": 307, + "cos": 0.232988 + }, + { + "preset_id": 401, + "cos": 0.120059 + }, + { + "preset_id": 402, + "cos": 0.257353 + }, + { + "preset_id": 403, + "cos": 0.180012 + }, + { + "preset_id": 404, + "cos": 0.11588 + }, + { + "preset_id": 405, + "cos": 0.22934 + }, + { + "preset_id": 406, + "cos": 0.180714 + } + ] + }, + { + "query": "그네팟 스팟", + "cos": [ + { + "preset_id": 101, + "cos": 0.162441 + }, + { + "preset_id": 102, + "cos": 0.1477 + }, + { + "preset_id": 103, + "cos": 0.113842 + }, + { + "preset_id": 104, + "cos": 0.21767 + }, + { + "preset_id": 105, + "cos": 0.203777 + }, + { + "preset_id": 106, + "cos": 0.140094 + }, + { + "preset_id": 201, + "cos": 0.205817 + }, + { + "preset_id": 202, + "cos": 0.181606 + }, + { + "preset_id": 203, + "cos": 0.261129 + }, + { + "preset_id": 204, + "cos": 0.199181 + }, + { + "preset_id": 205, + "cos": 0.175361 + }, + { + "preset_id": 206, + "cos": 0.270689 + }, + { + "preset_id": 207, + "cos": 0.151434 + }, + { + "preset_id": 208, + "cos": 0.239631 + }, + { + "preset_id": 301, + "cos": 0.163569 + }, + { + "preset_id": 302, + "cos": 0.127344 + }, + { + "preset_id": 303, + "cos": 0.227602 + }, + { + "preset_id": 304, + "cos": 0.185255 + }, + { + "preset_id": 305, + "cos": 0.175562 + }, + { + "preset_id": 306, + "cos": 0.217454 + }, + { + "preset_id": 307, + "cos": 0.200958 + }, + { + "preset_id": 401, + "cos": 0.113216 + }, + { + "preset_id": 402, + "cos": 0.168064 + }, + { + "preset_id": 403, + "cos": 0.160321 + }, + { + "preset_id": 404, + "cos": 0.135083 + }, + { + "preset_id": 405, + "cos": 0.239474 + }, + { + "preset_id": 406, + "cos": 0.181035 + } + ] + }, + { + "query": "신한 부트캠프 친구들과 자주 먹었던 돈카츠 집", + "cos": [ + { + "preset_id": 101, + "cos": 0.405896 + }, + { + "preset_id": 102, + "cos": 0.377996 + }, + { + "preset_id": 103, + "cos": 0.350849 + }, + { + "preset_id": 104, + "cos": 0.320143 + }, + { + "preset_id": 105, + "cos": 0.210123 + }, + { + "preset_id": 106, + "cos": 0.232411 + }, + { + "preset_id": 201, + "cos": 0.355783 + }, + { + "preset_id": 202, + "cos": 0.358129 + }, + { + "preset_id": 203, + "cos": 0.333091 + }, + { + "preset_id": 204, + "cos": 0.442065 + }, + { + "preset_id": 205, + "cos": 0.345734 + }, + { + "preset_id": 206, + "cos": 0.294084 + }, + { + "preset_id": 207, + "cos": 0.216811 + }, + { + "preset_id": 208, + "cos": 0.271592 + }, + { + "preset_id": 301, + "cos": 0.193589 + }, + { + "preset_id": 302, + "cos": 0.180381 + }, + { + "preset_id": 303, + "cos": 0.193642 + }, + { + "preset_id": 304, + "cos": 0.158019 + }, + { + "preset_id": 305, + "cos": 0.159706 + }, + { + "preset_id": 306, + "cos": 0.263341 + }, + { + "preset_id": 307, + "cos": 0.190735 + }, + { + "preset_id": 401, + "cos": 0.258814 + }, + { + "preset_id": 402, + "cos": 0.265215 + }, + { + "preset_id": 403, + "cos": 0.229716 + }, + { + "preset_id": 404, + "cos": 0.192945 + }, + { + "preset_id": 405, + "cos": 0.311727 + }, + { + "preset_id": 406, + "cos": 0.198289 + } + ] + } + ] +} \ No newline at end of file diff --git a/.search/recall_probe.json b/.search/recall_probe.json index 3995a09..2eb49d8 100644 --- a/.search/recall_probe.json +++ b/.search/recall_probe.json @@ -7,6 +7,8 @@ "owned_records": 17, "cut": { "tau_abs": 0.3, + "tau_abs_word": 0.24, + "word_max_chars": 5, "ratio": 0.6, "limit": 20 }, @@ -18,115 +20,133 @@ "note": "본문에 그대로 · 나온다고 보고", "expect": "동교어린이공원", "rank": 1, - "sim": 0.518439, + "sim": 0.518366, "top1_name": "동교어린이공원", - "top1_sim": 0.518439, + "top1_sim": 0.518366, "kept": true, "kept_count": 1, "candidate_count": 17, - "ratio_floor": 0.311063, + "ratio_floor": 0.31102, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.518439 + "sim": 0.518366 }, { "rank": 2, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.275336 + "sim": 0.275439 }, { "rank": 3, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.263217 + "sim": 0.263349 }, { "rank": 4, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.253091 + "sim": 0.253293 }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.216017 + "sim": 0.216101 }, { "rank": 6, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.203826 + "sim": 0.203799 }, { "rank": 7, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.197145 + "sim": 0.197146 }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.191621 + "sim": 0.191642 }, { "rank": 9, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.189377 + "sim": 0.18947 }, { "rank": 10, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.185931 + "sim": 0.18601 }, { "rank": 11, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.175737 + "sim": 0.175753 }, { "rank": 12, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.170391 + "sim": 0.17035 }, { "rank": 13, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.159526 + "sim": 0.159479 }, { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.157997 + "sim": 0.158092 }, { "rank": 15, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.155965 + "sim": 0.155925 }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.150823 + "sim": 0.150763 }, { "rank": 17, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.137873 + "sim": 0.137982 } ], "kept_names": [ @@ -142,120 +162,145 @@ "note": "부분 문자열", "expect": "동교어린이공원", "rank": 3, - "sim": 0.26708, + "sim": 0.267107, "top1_name": "치킨버거 이스트사이드", - "top1_sim": 0.287107, - "kept": false, - "kept_count": 0, + "top1_sim": 0.287139, + "kept": true, + "kept_count": 6, "candidate_count": 17, - "ratio_floor": 0.172264, + "ratio_floor": 0.172283, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.287107 + "sim": 0.287139 }, { "rank": 2, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.267661 + "sim": 0.267631 }, { "rank": 3, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.26708 + "sim": 0.267107 }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.265941 + "sim": 0.266037 }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.242292 + "sim": 0.242341 }, { "rank": 6, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.241571 + "sim": 0.241596 }, { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.209279 + "sim": 0.209344 }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.201573 + "sim": 0.201626 }, { "rank": 9, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.172662 + "sim": 0.172755 }, { "rank": 10, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.167893 + "sim": 0.167882 }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.153549 + "sim": 0.15354 }, { "rank": 12, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.152254 + "sim": 0.152228 }, { "rank": 13, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.148226 + "sim": 0.148162 }, { "rank": 14, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.147366 + "sim": 0.147487 }, { "rank": 15, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.146874 + "sim": 0.146833 }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.138782 + "sim": 0.138839 }, { "rank": 17, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.134181 + "sim": 0.134226 } ], - "kept_names": [], - "cut_verdict": "컷(τ_abs)", - "cause": "① 컷이 잘랐다(풀면 3위)" + "kept_names": [ + "치킨버거 이스트사이드", + "힉스커피", + "동교어린이공원", + "저스트텐동 연남본점", + "키친갈매기", + "모던아시안누들서비스" + ], + "cut_verdict": "통과", + "cause": "— 통과" }, { "query": "신한", @@ -264,121 +309,140 @@ "note": "완전 일치 단어", "expect": "카츠요", "rank": 6, - "sim": 0.230149, + "sim": 0.230176, "top1_name": "플랜트 연남점", - "top1_sim": 0.388904, + "top1_sim": 0.388875, "kept": false, - "kept_count": 3, + "kept_count": 4, "candidate_count": 17, - "ratio_floor": 0.233342, + "ratio_floor": 0.233325, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.388904 + "sim": 0.388875 }, { "rank": 2, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.386753 + "sim": 0.386694 }, { "rank": 3, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.330748 + "sim": 0.33071 }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.241013 + "sim": 0.241061 }, { "rank": 5, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.232709 + "sim": 0.232779 }, { "rank": 6, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.230149 + "sim": 0.230176 }, { "rank": 7, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.210751 + "sim": 0.210818 }, { "rank": 8, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.197504 + "sim": 0.197658 }, { "rank": 9, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.197419 + "sim": 0.197486 }, { "rank": 10, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.195277 + "sim": 0.195403 }, { "rank": 11, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.189584 + "sim": 0.189728 }, { "rank": 12, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.180767 + "sim": 0.180887 }, { "rank": 13, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.179101 + "sim": 0.179187 }, { "rank": 14, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.175377 + "sim": 0.175371 }, { "rank": 15, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.162519 + "sim": 0.162565 }, { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.161438 + "sim": 0.161399 }, { "rank": 17, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.153979 + "sim": 0.154214 } ], "kept_names": [ "플랜트 연남점", "쿠로코식당 연남점", - "진우네 초밥" + "진우네 초밥", + "저스트텐동 연남본점" ], "cut_verdict": "컷(둘 다)", "cause": "③ 컷을 풀어도 6위" @@ -390,115 +454,133 @@ "note": "약어", "expect": "카츠요", "rank": 8, - "sim": 0.210469, + "sim": 0.210499, "top1_name": "쿠로코식당 연남점", - "top1_sim": 0.410226, + "top1_sim": 0.410231, "kept": false, "kept_count": 3, "candidate_count": 17, - "ratio_floor": 0.246136, + "ratio_floor": 0.246139, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.410226 + "sim": 0.410231 }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.330375 + "sim": 0.330408 }, { "rank": 3, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.319498 + "sim": 0.319453 }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.223479 + "sim": 0.223565 }, { "rank": 5, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.222399 + "sim": 0.222423 }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.21289 + "sim": 0.212918 }, { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.21064 + "sim": 0.210636 }, { "rank": 8, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.210469 + "sim": 0.210499 }, { "rank": 9, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.207915 + "sim": 0.207929 }, { "rank": 10, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.193027 + "sim": 0.193025 }, { "rank": 11, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.189066 + "sim": 0.189089 }, { "rank": 12, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.186522 + "sim": 0.186534 }, { "rank": 13, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.181867 + "sim": 0.181799 }, { "rank": 14, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.178887 + "sim": 0.178894 }, { "rank": 15, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.173942 + "sim": 0.174036 }, { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.159926 + "sim": 0.159945 }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.138751 + "sim": 0.138688 } ], "kept_names": [ @@ -516,123 +598,143 @@ "note": "2자 · 본문 A 에 단독", "expect": "동교어린이공원", "rank": 3, - "sim": 0.281763, + "sim": 0.281779, "top1_name": "쿠로코식당 연남점", - "top1_sim": 0.393353, - "kept": false, - "kept_count": 2, + "top1_sim": 0.393463, + "kept": true, + "kept_count": 4, "candidate_count": 17, - "ratio_floor": 0.236012, + "ratio_floor": 0.236078, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.393353 + "sim": 0.393463 }, { "rank": 2, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.384798 + "sim": 0.384874 }, { "rank": 3, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.281763 + "sim": 0.281779 }, { "rank": 4, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.27659 + "sim": 0.276593 }, { "rank": 5, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.225764 + "sim": 0.225785 }, { "rank": 6, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.201276 + "sim": 0.201326 }, { "rank": 7, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.195646 + "sim": 0.195698 }, { "rank": 8, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.195433 + "sim": 0.195424 }, { "rank": 9, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.182309 + "sim": 0.182261 }, { "rank": 10, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.161136 + "sim": 0.161161 }, { "rank": 11, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.15375 + "sim": 0.153768 }, { "rank": 12, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.153431 + "sim": 0.153417 }, { "rank": 13, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.140357 + "sim": 0.140325 }, { "rank": 14, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.126401 + "sim": 0.126412 }, { "rank": 15, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.122811 + "sim": 0.122899 }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.113581 + "sim": 0.113585 }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.097583 + "sim": 0.097599 } ], "kept_names": [ "쿠로코식당 연남점", - "오케이어 맨션" + "오케이어 맨션", + "동교어린이공원", + "사루카메" ], - "cut_verdict": "컷(τ_abs)", - "cause": "① 컷이 잘랐다(풀면 3위)" + "cut_verdict": "통과", + "cause": "— 통과" }, { "query": "라멘", @@ -641,122 +743,142 @@ "note": "2자 · 다른 Record 본문에 단독", "expect": "사루카메", "rank": 2, - "sim": 0.276447, + "sim": 0.276594, "top1_name": "쿠로코식당 연남점", - "top1_sim": 0.321108, - "kept": false, - "kept_count": 1, + "top1_sim": 0.321138, + "kept": true, + "kept_count": 3, "candidate_count": 17, - "ratio_floor": 0.192665, + "ratio_floor": 0.192683, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.321108 + "sim": 0.321138 }, { "rank": 2, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.276447 + "sim": 0.276594 }, { "rank": 3, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.249275 + "sim": 0.249356 }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.222579 + "sim": 0.22265 }, { "rank": 5, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.216744 + "sim": 0.216829 }, { "rank": 6, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.210131 + "sim": 0.210187 }, { "rank": 7, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.195855 + "sim": 0.195827 }, { "rank": 8, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.190465 + "sim": 0.190504 }, { "rank": 9, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.184495 + "sim": 0.184612 }, { "rank": 10, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.174959 + "sim": 0.175055 }, { "rank": 11, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.161523 + "sim": 0.161609 }, { "rank": 12, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.157883 + "sim": 0.157937 }, { "rank": 13, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.155402 + "sim": 0.155443 }, { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.148854 + "sim": 0.148997 }, { "rank": 15, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.133649 + "sim": 0.133679 }, { "rank": 16, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.125145 + "sim": 0.125225 }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.061085 + "sim": 0.061193 } ], "kept_names": [ - "쿠로코식당 연남점" + "쿠로코식당 연남점", + "사루카메", + "키친갈매기" ], - "cut_verdict": "컷(τ_abs)", - "cause": "① 컷이 잘랐다(풀면 2위)" + "cut_verdict": "통과", + "cause": "— 통과" }, { "query": "스팟", @@ -765,120 +887,141 @@ "note": "2자 · 본문 A 에 단독", "expect": "동교어린이공원", "rank": 1, - "sim": 0.243822, + "sim": 0.243831, "top1_name": "동교어린이공원", - "top1_sim": 0.243822, - "kept": false, - "kept_count": 0, + "top1_sim": 0.243831, + "kept": true, + "kept_count": 2, "candidate_count": 17, - "ratio_floor": 0.146293, + "ratio_floor": 0.146299, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.243822 + "sim": 0.243831 }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.240736 + "sim": 0.240629 }, { "rank": 3, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.194379 + "sim": 0.194391 }, { "rank": 4, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.17665 + "sim": 0.176665 }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.174412 + "sim": 0.17441 }, { "rank": 6, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.156894 + "sim": 0.156897 }, { "rank": 7, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.152153 + "sim": 0.152135 }, { "rank": 8, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.152059 + "sim": 0.152119 }, { "rank": 9, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.132726 + "sim": 0.132772 }, { "rank": 10, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.12948 + "sim": 0.129516 }, { "rank": 11, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.12651 + "sim": 0.126495 }, { "rank": 12, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.122988 + "sim": 0.123037 }, { "rank": 13, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.118972 + "sim": 0.119001 }, { "rank": 14, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.10862 + "sim": 0.108523 }, { "rank": 15, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.107377 + "sim": 0.10738 }, { "rank": 16, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.081024 + "sim": 0.081106 }, { "rank": 17, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.078541 + "sim": 0.078537 } ], - "kept_names": [], - "cut_verdict": "컷(τ_abs)", - "cause": "① 컷이 잘랐다(풀면 1위)" + "kept_names": [ + "동교어린이공원", + "플랜트 연남점" + ], + "cut_verdict": "통과", + "cause": "— 통과" }, { "query": "돈카츠", @@ -887,119 +1030,138 @@ "note": "3자 · 본문 B 에 단독", "expect": "카츠요", "rank": 1, - "sim": 0.443795, + "sim": 0.44391, "top1_name": "카츠요", - "top1_sim": 0.443795, + "top1_sim": 0.44391, "kept": true, - "kept_count": 1, + "kept_count": 2, "candidate_count": 17, - "ratio_floor": 0.266277, + "ratio_floor": 0.266346, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.443795 + "sim": 0.44391 }, { "rank": 2, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.277703 + "sim": 0.277722 }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.259834 + "sim": 0.259971 }, { "rank": 4, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.253657 + "sim": 0.253691 }, { "rank": 5, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.229907 + "sim": 0.229899 }, { "rank": 6, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.211316 + "sim": 0.21126 }, { "rank": 7, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.207394 + "sim": 0.207522 }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.205868 + "sim": 0.205965 }, { "rank": 9, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.193514 + "sim": 0.193526 }, { "rank": 10, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.175421 + "sim": 0.175475 }, { "rank": 11, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.174835 + "sim": 0.17488 }, { "rank": 12, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.170547 + "sim": 0.170635 }, { "rank": 13, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.152309 + "sim": 0.152472 }, { "rank": 14, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.143201 + "sim": 0.143281 }, { "rank": 15, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.140876 + "sim": 0.140908 }, { "rank": 16, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.131172 + "sim": 0.131291 }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.084685 + "sim": 0.084707 } ], "kept_names": [ - "카츠요" + "카츠요", + "키친갈매기" ], "cut_verdict": "통과", "cause": "— 통과" @@ -1011,120 +1173,139 @@ "note": "3자 · 본문 B 에 단독", "expect": "카츠요", "rank": 1, - "sim": 0.375264, + "sim": 0.375242, "top1_name": "카츠요", - "top1_sim": 0.375264, + "top1_sim": 0.375242, "kept": true, - "kept_count": 2, + "kept_count": 3, "candidate_count": 17, - "ratio_floor": 0.225158, + "ratio_floor": 0.225145, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.375264 + "sim": 0.375242 }, { "rank": 2, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.336703 + "sim": 0.336663 }, { "rank": 3, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.245331 + "sim": 0.245361 }, { "rank": 4, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.226943 + "sim": 0.226885 }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.223631 + "sim": 0.223615 }, { "rank": 6, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.222089 + "sim": 0.222069 }, { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.212343 + "sim": 0.212335 }, { "rank": 8, "record_id": 290, + "context_id": 290, "name": "힉스커피", "sim": 0.212002 }, { "rank": 9, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", "sim": 0.193262 }, { "rank": 10, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.187486 + "sim": 0.187521 }, { "rank": 11, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.181066 + "sim": 0.181108 }, { "rank": 12, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.169606 + "sim": 0.169681 }, { "rank": 13, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.165562 + "sim": 0.16558 }, { "rank": 14, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.154257 + "sim": 0.154351 }, { "rank": 15, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.151961 + "sim": 0.152089 }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.139373 + "sim": 0.139352 }, { "rank": 17, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.130622 + "sim": 0.130688 } ], "kept_names": [ "카츠요", - "뉴오더클럽 연남" + "뉴오더클럽 연남", + "치킨버거 이스트사이드" ], "cut_verdict": "통과", "cause": "— 통과" @@ -1136,119 +1317,138 @@ "note": "5자 · 본문 A 에 단독", "expect": "동교어린이공원", "rank": 1, - "sim": 0.342187, + "sim": 0.339589, "top1_name": "동교어린이공원", - "top1_sim": 0.342187, + "top1_sim": 0.339589, "kept": true, - "kept_count": 1, + "kept_count": 2, "candidate_count": 17, - "ratio_floor": 0.205312, + "ratio_floor": 0.203753, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.342187 + "sim": 0.339589 }, { "rank": 2, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.245025 + "sim": 0.241467 }, { "rank": 3, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.242246 + "sim": 0.23959 }, { "rank": 4, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.235859 + "sim": 0.232512 }, { "rank": 5, - "record_id": 291, - "name": "오케이어 맨션", - "sim": 0.222988 + "record_id": 289, + "context_id": 289, + "name": "모던아시안누들서비스", + "sim": 0.219638 }, { "rank": 6, - "record_id": 289, - "name": "모던아시안누들서비스", - "sim": 0.222486 + "record_id": 291, + "context_id": 291, + "name": "오케이어 맨션", + "sim": 0.219105 }, { "rank": 7, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.191894 + "sim": 0.193686 }, { "rank": 8, - "record_id": 290, - "name": "힉스커피", - "sim": 0.182889 + "record_id": 286, + "context_id": 286, + "name": "저스트텐동 연남본점", + "sim": 0.18207 }, { "rank": 9, - "record_id": 286, - "name": "저스트텐동 연남본점", - "sim": 0.182065 + "record_id": 290, + "context_id": 290, + "name": "힉스커피", + "sim": 0.181017 }, { "rank": 10, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.172796 + "sim": 0.173496 }, { "rank": 11, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.170898 + "sim": 0.171214 }, { "rank": 12, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.152429 + "sim": 0.155709 }, { "rank": 13, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.135215 + "sim": 0.134749 }, { "rank": 14, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.131674 + "sim": 0.134447 }, { "rank": 15, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.126704 + "sim": 0.123564 }, { "rank": 16, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.116219 + "sim": 0.115038 }, { "rank": 17, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.114089 + "sim": 0.114384 } ], "kept_names": [ - "동교어린이공원" + "동교어린이공원", + "치킨버거 이스트사이드" ], "cut_verdict": "통과", "cause": "— 통과" @@ -1262,116 +1462,140 @@ "rank": null, "sim": null, "top1_name": "치킨버거 이스트사이드", - "top1_sim": 0.295281, + "top1_sim": 0.295225, "kept": false, - "kept_count": 0, + "kept_count": 5, "candidate_count": 17, - "ratio_floor": 0.177169, + "ratio_floor": 0.177135, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.295281 + "sim": 0.295225 }, { "rank": 2, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.284907 + "sim": 0.284854 }, { "rank": 3, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.273617 + "sim": 0.273532 }, { "rank": 4, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.260661 + "sim": 0.260587 }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.248265 + "sim": 0.24823 }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.217304 + "sim": 0.217237 }, { "rank": 7, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.199778 + "sim": 0.199764 }, { "rank": 8, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.198077 + "sim": 0.197963 }, { "rank": 9, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.197108 + "sim": 0.197076 }, { "rank": 10, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.18782 + "sim": 0.187812 }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.182941 + "sim": 0.182875 }, { "rank": 12, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.182706 + "sim": 0.182728 }, { "rank": 13, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.16648 + "sim": 0.166418 }, { "rank": 14, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.163072 + "sim": 0.163051 }, { "rank": 15, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.159116 + "sim": 0.159125 }, { "rank": 16, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.151638 + "sim": 0.151635 }, { "rank": 17, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.1379 + "sim": 0.137863 } ], - "kept_names": [], + "kept_names": [ + "치킨버거 이스트사이드", + "연남칼국수", + "모던아시안누들서비스", + "감나무집기사식당", + "키친갈매기" + ], "cut_verdict": "미검색", "cause": "— 통제" }, @@ -1382,119 +1606,138 @@ "note": "2자 · 힉스커피 본문에 단독", "expect": "힉스커피", "rank": 1, - "sim": 0.41993, + "sim": 0.419934, "top1_name": "힉스커피", - "top1_sim": 0.41993, + "top1_sim": 0.419934, "kept": true, - "kept_count": 1, + "kept_count": 2, "candidate_count": 17, - "ratio_floor": 0.251958, + "ratio_floor": 0.25196, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.41993 + "sim": 0.419934 }, { "rank": 2, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.289945 + "sim": 0.289927 }, { "rank": 3, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.245254 + "sim": 0.245161 }, { "rank": 4, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.228157 + "sim": 0.228032 }, { "rank": 5, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.22753 + "sim": 0.227422 }, { "rank": 6, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.226075 + "sim": 0.226067 }, { "rank": 7, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.220138 + "sim": 0.220078 }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.212119 + "sim": 0.212182 }, { "rank": 9, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.210757 + "sim": 0.210655 }, { "rank": 10, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.20286 + "sim": 0.202864 }, { "rank": 11, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.189936 + "sim": 0.189841 }, { "rank": 12, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.169477 + "sim": 0.169445 }, { "rank": 13, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.148175 + "sim": 0.148122 }, { "rank": 14, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.14244 + "sim": 0.142374 }, { "rank": 15, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.130579 + "sim": 0.130509 }, { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.104281 + "sim": 0.104256 }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.101369 + "sim": 0.101296 } ], "kept_names": [ - "힉스커피" + "힉스커피", + "감나무집기사식당" ], "cut_verdict": "통과", "cause": "— 통과" @@ -1506,119 +1749,138 @@ "note": "2자 · 치킨버거 본문에 단독", "expect": "치킨버거 이스트사이드", "rank": 1, - "sim": 0.367011, + "sim": 0.367066, "top1_name": "치킨버거 이스트사이드", - "top1_sim": 0.367011, + "top1_sim": 0.367066, "kept": true, - "kept_count": 1, + "kept_count": 2, "candidate_count": 17, - "ratio_floor": 0.220207, + "ratio_floor": 0.22024, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.367011 + "sim": 0.367066 }, { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.272325 + "sim": 0.272398 }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.23608 + "sim": 0.236032 }, { "rank": 4, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.230047 + "sim": 0.23001 }, { "rank": 5, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.224481 + "sim": 0.22447 }, { "rank": 6, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.212398 + "sim": 0.212373 }, { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.200807 + "sim": 0.200824 }, { "rank": 8, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.189545 + "sim": 0.189519 }, { "rank": 9, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.187503 + "sim": 0.187459 }, { "rank": 10, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.183803 + "sim": 0.183813 }, { "rank": 11, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.173646 + "sim": 0.173637 }, { "rank": 12, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.164511 + "sim": 0.164528 }, { "rank": 13, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.146749 + "sim": 0.146744 }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.137321 + "sim": 0.137302 }, { "rank": 15, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.095445 + "sim": 0.095479 }, { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.087043 + "sim": 0.087097 }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.050132 + "sim": 0.050094 } ], "kept_names": [ - "치킨버거 이스트사이드" + "치킨버거 이스트사이드", + "저스트텐동 연남본점" ], "cut_verdict": "통과", "cause": "— 통과" @@ -1630,115 +1892,133 @@ "note": "본문 그대로(치킨버거)", "expect": "치킨버거 이스트사이드", "rank": 1, - "sim": 0.474253, + "sim": 0.474249, "top1_name": "치킨버거 이스트사이드", - "top1_sim": 0.474253, + "top1_sim": 0.474249, "kept": true, "kept_count": 1, "candidate_count": 17, - "ratio_floor": 0.284552, + "ratio_floor": 0.284549, + "floor": 0.3, "results": [ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.474253 + "sim": 0.474249 }, { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.269137 + "sim": 0.269057 }, { "rank": 3, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.246075 + "sim": 0.24599 }, { "rank": 4, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.234735 + "sim": 0.234701 }, { "rank": 5, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.233878 + "sim": 0.233863 }, { "rank": 6, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.226007 + "sim": 0.225926 }, { "rank": 7, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.197308 + "sim": 0.197326 }, { "rank": 8, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.174599 + "sim": 0.174452 }, { "rank": 9, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.173309 + "sim": 0.173305 }, { "rank": 10, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.171637 + "sim": 0.17156 }, { "rank": 11, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.159811 + "sim": 0.159791 }, { "rank": 12, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.144405 + "sim": 0.144386 }, { "rank": 13, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.14002 + "sim": 0.140047 }, { "rank": 14, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.137223 + "sim": 0.137262 }, { "rank": 15, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.116719 + "sim": 0.11674 }, { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.093716 + "sim": 0.093668 }, { "rank": 17, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.068593 + "sim": 0.068492 } ], "kept_names": [ @@ -1754,119 +2034,142 @@ "note": "본문 B 에 그대로", "expect": "카츠요", "rank": 1, - "sim": 0.325435, + "sim": 0.325307, "top1_name": "카츠요", - "top1_sim": 0.325435, + "top1_sim": 0.325307, "kept": true, - "kept_count": 1, + "kept_count": 6, "candidate_count": 17, - "ratio_floor": 0.195261, + "ratio_floor": 0.195184, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.325435 + "sim": 0.325307 }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.287485 + "sim": 0.28746 }, { "rank": 3, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.261349 + "sim": 0.261366 }, { "rank": 4, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.25895 + "sim": 0.258853 }, { "rank": 5, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.245482 + "sim": 0.245435 }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.24355 + "sim": 0.243534 }, { "rank": 7, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.234582 + "sim": 0.2347 }, { "rank": 8, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.233781 + "sim": 0.233792 }, { "rank": 9, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.215772 + "sim": 0.215681 }, { "rank": 10, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.214062 + "sim": 0.213997 }, { "rank": 11, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.174088 + "sim": 0.173997 }, { "rank": 12, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.171644 + "sim": 0.171562 }, { "rank": 13, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.170266 + "sim": 0.1702 }, { "rank": 14, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.169794 + "sim": 0.169819 }, { "rank": 15, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.161766 + "sim": 0.161692 }, { "rank": 16, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.155392 + "sim": 0.155318 }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.130895 + "sim": 0.130798 } ], "kept_names": [ - "카츠요" + "카츠요", + "플랜트 연남점", + "쿠로코식당 연남점", + "치킨버거 이스트사이드", + "동교어린이공원", + "힉스커피" ], "cut_verdict": "통과", "cause": "— 통과" @@ -1878,115 +2181,133 @@ "note": "본문 B 에 연속으로 그대로", "expect": "카츠요", "rank": 3, - "sim": 0.37927, + "sim": 0.379262, "top1_name": "쿠로코식당 연남점", - "top1_sim": 0.454174, + "top1_sim": 0.454168, "kept": true, "kept_count": 3, "candidate_count": 17, - "ratio_floor": 0.272504, + "ratio_floor": 0.272501, + "floor": 0.3, "results": [ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.454174 + "sim": 0.454168 }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.416174 + "sim": 0.416156 }, { "rank": 3, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.37927 + "sim": 0.379262 }, { "rank": 4, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.260483 + "sim": 0.260614 }, { "rank": 5, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.251664 + "sim": 0.251749 }, { "rank": 6, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.247981 + "sim": 0.248102 }, { "rank": 7, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.228715 + "sim": 0.228832 }, { "rank": 8, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.211685 + "sim": 0.211612 }, { "rank": 9, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.202599 + "sim": 0.202612 }, { "rank": 10, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.193027 + "sim": 0.193034 }, { "rank": 11, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.192191 + "sim": 0.192271 }, { "rank": 12, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.177074 + "sim": 0.177125 }, { "rank": 13, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.161884 + "sim": 0.16181 }, { "rank": 14, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.161427 + "sim": 0.161476 }, { "rank": 15, "record_id": 292, + "context_id": 292, "name": "적당", "sim": 0.14198 }, { "rank": 16, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.135791 + "sim": 0.135892 }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.128264 + "sim": 0.128311 } ], "kept_names": [ @@ -2004,115 +2325,133 @@ "note": "본문 B 에 없는 표현 · 다른 4건에 그대로", "expect": "카츠요", "rank": 4, - "sim": 0.318867, + "sim": 0.318866, "top1_name": "쿠로코식당 연남점", - "top1_sim": 0.586755, + "top1_sim": 0.58669, "kept": false, "kept_count": 3, "candidate_count": 17, - "ratio_floor": 0.352053, + "ratio_floor": 0.352014, + "floor": 0.3, "results": [ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.586755 + "sim": 0.58669 }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.515795 + "sim": 0.515836 }, { "rank": 3, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.367848 + "sim": 0.367863 }, { "rank": 4, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.318867 + "sim": 0.318866 }, { "rank": 5, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.267343 + "sim": 0.267281 }, { "rank": 6, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.264453 + "sim": 0.264347 }, { "rank": 7, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.236133 + "sim": 0.236065 }, { "rank": 8, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.234614 + "sim": 0.234511 }, { "rank": 9, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.229984 + "sim": 0.229862 }, { "rank": 10, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.223242 + "sim": 0.223233 }, { "rank": 11, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.222843 + "sim": 0.222794 }, { "rank": 12, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.2114 + "sim": 0.211243 }, { "rank": 13, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.207201 + "sim": 0.207227 }, { "rank": 14, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.206858 + "sim": 0.206913 }, { "rank": 15, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.196478 + "sim": 0.196385 }, { "rank": 16, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.169809 + "sim": 0.169715 }, { "rank": 17, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.138537 + "sim": 0.138412 } ], "kept_names": [ @@ -2130,115 +2469,133 @@ "note": "3자 · 본문 B 문두에 단독", "expect": "카츠요", "rank": 1, - "sim": 0.446585, + "sim": 0.446599, "top1_name": "카츠요", - "top1_sim": 0.446585, + "top1_sim": 0.446599, "kept": true, "kept_count": 1, "candidate_count": 17, - "ratio_floor": 0.267951, + "ratio_floor": 0.267959, + "floor": 0.24, "results": [ { "rank": 1, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.446585 + "sim": 0.446599 }, { "rank": 2, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.25779 + "sim": 0.257768 }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.220279 + "sim": 0.220357 }, { "rank": 4, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.207829 + "sim": 0.207816 }, { "rank": 5, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.184905 + "sim": 0.184934 }, { "rank": 6, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.175154 + "sim": 0.175206 }, { "rank": 7, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.174074 + "sim": 0.174118 }, { "rank": 8, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.156339 + "sim": 0.156351 }, { "rank": 9, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.147767 + "sim": 0.147701 }, { "rank": 10, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.141228 + "sim": 0.141239 }, { "rank": 11, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.140464 + "sim": 0.140453 }, { "rank": 12, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.137232 + "sim": 0.137231 }, { "rank": 13, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.131651 + "sim": 0.13165 }, { "rank": 14, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.131525 + "sim": 0.131526 }, { "rank": 15, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.124839 + "sim": 0.124872 }, { "rank": 16, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.124541 + "sim": 0.124543 }, { "rank": 17, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.099627 + "sim": 0.099685 } ], "kept_names": [ @@ -2254,115 +2611,133 @@ "note": "본문 A 첫 구절 그대로", "expect": "동교어린이공원", "rank": 1, - "sim": 0.564011, + "sim": 0.56402, "top1_name": "동교어린이공원", - "top1_sim": 0.564011, + "top1_sim": 0.56402, "kept": true, "kept_count": 1, "candidate_count": 17, - "ratio_floor": 0.338407, + "ratio_floor": 0.338412, + "floor": 0.3, "results": [ { "rank": 1, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.564011 + "sim": 0.56402 }, { "rank": 2, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.287328 + "sim": 0.287436 }, { "rank": 3, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.276898 + "sim": 0.276948 }, { "rank": 4, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.234744 + "sim": 0.234722 }, { "rank": 5, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.220467 + "sim": 0.220483 }, { "rank": 6, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.215136 + "sim": 0.215108 }, { "rank": 7, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.206873 + "sim": 0.206834 }, { "rank": 8, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.198594 + "sim": 0.198568 }, { "rank": 9, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.196397 + "sim": 0.196525 }, { "rank": 10, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.192638 + "sim": 0.192712 }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.184089 + "sim": 0.184095 }, { "rank": 12, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.164586 + "sim": 0.164546 }, { "rank": 13, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.154958 + "sim": 0.154913 }, { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.147514 + "sim": 0.147582 }, { "rank": 15, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.143557 + "sim": 0.143551 }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.141302 + "sim": 0.141288 }, { "rank": 17, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.104244 + "sim": 0.104275 } ], "kept_names": [ @@ -2378,115 +2753,133 @@ "note": "본문 B 거의 전문", "expect": "카츠요", "rank": 1, - "sim": 0.86021, + "sim": 0.8602, "top1_name": "카츠요", - "top1_sim": 0.86021, + "top1_sim": 0.8602, "kept": true, "kept_count": 1, "candidate_count": 17, - "ratio_floor": 0.516126, + "ratio_floor": 0.51612, + "floor": 0.3, "results": [ { "rank": 1, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.86021 + "sim": 0.8602 }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.469401 + "sim": 0.469432 }, { "rank": 3, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.392578 + "sim": 0.392557 }, { "rank": 4, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.391717 + "sim": 0.391795 }, { "rank": 5, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.348168 + "sim": 0.34824 }, { "rank": 6, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.344055 + "sim": 0.344151 }, { "rank": 7, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.321953 + "sim": 0.321943 }, { "rank": 8, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.313329 + "sim": 0.313372 }, { "rank": 9, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.309488 + "sim": 0.309509 }, { "rank": 10, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.300923 + "sim": 0.300971 }, { "rank": 11, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.293157 + "sim": 0.293204 }, { "rank": 12, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.292875 + "sim": 0.29295 }, { "rank": 13, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.281779 + "sim": 0.281754 }, { "rank": 14, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.261717 + "sim": 0.261697 }, { "rank": 15, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.227253 + "sim": 0.227237 }, { "rank": 16, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.172974 + "sim": 0.172969 }, { "rank": 17, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.150073 + "sim": 0.150074 } ], "kept_names": [ @@ -2502,115 +2895,133 @@ "note": "demo_queries 5번", "expect": "카츠요", "rank": 1, - "sim": 0.606892, + "sim": 0.606873, "top1_name": "카츠요", - "top1_sim": 0.606892, + "top1_sim": 0.606873, "kept": true, "kept_count": 8, "candidate_count": 17, - "ratio_floor": 0.364135, + "ratio_floor": 0.364124, + "floor": 0.3, "results": [ { "rank": 1, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.606892 + "sim": 0.606873 }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.48257 + "sim": 0.482538 }, { "rank": 3, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.410748 + "sim": 0.410761 }, { "rank": 4, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.40541 + "sim": 0.405442 }, { "rank": 5, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.388574 + "sim": 0.388606 }, { "rank": 6, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.380924 + "sim": 0.380971 }, { "rank": 7, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.376026 + "sim": 0.376046 }, { "rank": 8, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.365358 + "sim": 0.365434 }, { "rank": 9, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.356763 + "sim": 0.356822 }, { "rank": 10, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.352983 + "sim": 0.352971 }, { "rank": 11, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.334565 + "sim": 0.334577 }, { "rank": 12, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.311071 + "sim": 0.311044 }, { "rank": 13, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.280351 + "sim": 0.280407 }, { "rank": 14, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.277022 + "sim": 0.276986 }, { "rank": 15, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.264799 + "sim": 0.264836 }, { "rank": 16, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.219405 + "sim": 0.21949 }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.100926 + "sim": 0.100903 } ], "kept_names": [ @@ -2633,115 +3044,133 @@ "note": "demo_queries 8번", "expect": "동교어린이공원", "rank": 2, - "sim": 0.457292, + "sim": 0.45781, "top1_name": "치킨버거 이스트사이드", - "top1_sim": 0.5264, + "top1_sim": 0.525771, "kept": true, "kept_count": 5, "candidate_count": 17, - "ratio_floor": 0.31584, + "ratio_floor": 0.315463, + "floor": 0.3, "results": [ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.5264 + "sim": 0.525771 }, { "rank": 2, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.457292 + "sim": 0.45781 }, { "rank": 3, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.399592 + "sim": 0.398954 }, { "rank": 4, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.333748 + "sim": 0.333058 }, { "rank": 5, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.325634 + "sim": 0.325608 }, { "rank": 6, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.311601 + "sim": 0.309727 }, { "rank": 7, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.288293 + "sim": 0.289302 }, { "rank": 8, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.288237 + "sim": 0.286293 }, { "rank": 9, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.269999 + "sim": 0.268234 }, { "rank": 10, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.248143 + "sim": 0.247238 }, { "rank": 11, - "record_id": 289, - "name": "모던아시안누들서비스", - "sim": 0.233325 + "record_id": 279, + "context_id": 279, + "name": "사루카메", + "sim": 0.233539 }, { "rank": 12, - "record_id": 279, - "name": "사루카메", - "sim": 0.233207 + "record_id": 289, + "context_id": 289, + "name": "모던아시안누들서비스", + "sim": 0.23325 }, { "rank": 13, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.232672 + "sim": 0.230075 }, { "rank": 14, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.220666 + "sim": 0.217538 }, { "rank": 15, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.213381 + "sim": 0.212521 }, { "rank": 16, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.193601 + "sim": 0.192411 }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.119771 + "sim": 0.119515 } ], "kept_names": [ diff --git a/.search/word_grid.json b/.search/word_grid.json index 92dec68..a1abe88 100644 --- a/.search/word_grid.json +++ b/.search/word_grid.json @@ -31,120 +31,137 @@ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.287139, + "sim": 0.287137, "is_expected": true }, { "rank": 2, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.267631, + "sim": 0.267647, "is_expected": false }, { "rank": 3, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.267107, + "sim": 0.26709, "is_expected": true }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.266037, + "sim": 0.266027, "is_expected": false }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.242341, + "sim": 0.24234, "is_expected": false }, { "rank": 6, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.241596, + "sim": 0.241562, "is_expected": false }, { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.209344, + "sim": 0.209288, "is_expected": false }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.201626, + "sim": 0.201526, "is_expected": false }, { "rank": 9, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.172755, + "sim": 0.172728, "is_expected": false }, { "rank": 10, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.167882, + "sim": 0.167857, "is_expected": false }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.15354, + "sim": 0.153478, "is_expected": false }, { "rank": 12, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.152228, + "sim": 0.152198, "is_expected": false }, { "rank": 13, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.148162, + "sim": 0.148136, "is_expected": false }, { "rank": 14, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.147487, + "sim": 0.147413, "is_expected": false }, { "rank": 15, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.146833, + "sim": 0.146826, "is_expected": false }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.138839, + "sim": 0.138756, "is_expected": false }, { "rank": 17, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.134226, + "sim": 0.134225, "is_expected": false } ] @@ -168,120 +185,137 @@ { "rank": 1, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.388851, + "sim": 0.388866, "is_expected": true }, { "rank": 2, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.38671, + "sim": 0.386671, "is_expected": true }, { "rank": 3, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.330726, + "sim": 0.330764, "is_expected": true }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.241002, + "sim": 0.240941, "is_expected": false }, { "rank": 5, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.232704, + "sim": 0.232646, "is_expected": true }, { "rank": 6, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.230086, + "sim": 0.230075, "is_expected": true }, { "rank": 7, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.210772, + "sim": 0.210756, "is_expected": false }, { "rank": 8, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.197619, + "sim": 0.197543, "is_expected": false }, { "rank": 9, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.197409, + "sim": 0.197391, "is_expected": true }, { "rank": 10, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.195327, + "sim": 0.195282, "is_expected": false }, { "rank": 11, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.189711, + "sim": 0.189621, "is_expected": false }, { "rank": 12, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.18086, + "sim": 0.180815, "is_expected": false }, { "rank": 13, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.179146, + "sim": 0.179125, "is_expected": false }, { "rank": 14, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.175333, + "sim": 0.175362, "is_expected": false }, { "rank": 15, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.162588, + "sim": 0.162554, "is_expected": false }, { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.161433, + "sim": 0.161335, "is_expected": false }, { "rank": 17, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.154022, + "sim": 0.154059, "is_expected": false } ] @@ -303,120 +337,137 @@ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.410226, + "sim": 0.410243, "is_expected": true }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.330375, + "sim": 0.330424, "is_expected": true }, { "rank": 3, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.319498, + "sim": 0.319484, "is_expected": false }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.223479, + "sim": 0.223542, "is_expected": false }, { "rank": 5, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.222399, + "sim": 0.222504, "is_expected": false }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.21289, + "sim": 0.212943, "is_expected": false }, { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.21064, + "sim": 0.210717, "is_expected": false }, { "rank": 8, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.210469, + "sim": 0.210522, "is_expected": false }, { "rank": 9, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.207915, + "sim": 0.207883, "is_expected": true }, { "rank": 10, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.193027, + "sim": 0.193054, "is_expected": true }, { "rank": 11, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.189066, + "sim": 0.189041, "is_expected": false }, { "rank": 12, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.186522, + "sim": 0.186546, "is_expected": false }, { "rank": 13, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.181867, + "sim": 0.181854, "is_expected": false }, { "rank": 14, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.178887, + "sim": 0.178871, "is_expected": false }, { "rank": 15, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.173942, + "sim": 0.173955, "is_expected": false }, { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.159926, + "sim": 0.159919, "is_expected": false }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.138751, + "sim": 0.138729, "is_expected": false } ] @@ -435,120 +486,137 @@ { "rank": 1, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.243826, + "sim": 0.243903, "is_expected": true }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.240693, + "sim": 0.240667, "is_expected": false }, { "rank": 3, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.194348, + "sim": 0.194504, "is_expected": false }, { "rank": 4, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.176664, + "sim": 0.176695, "is_expected": false }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.17438, + "sim": 0.174536, "is_expected": false }, { "rank": 6, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.156823, + "sim": 0.156909, "is_expected": false }, { "rank": 7, - "record_id": 282, - "name": "치킨버거 이스트사이드", - "sim": 0.152122, + "record_id": 286, + "context_id": 286, + "name": "저스트텐동 연남본점", + "sim": 0.15225, "is_expected": false }, { "rank": 8, - "record_id": 286, - "name": "저스트텐동 연남본점", - "sim": 0.152052, + "record_id": 282, + "context_id": 282, + "name": "치킨버거 이스트사이드", + "sim": 0.152089, "is_expected": false }, { "rank": 9, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.132749, + "sim": 0.132881, "is_expected": false }, { "rank": 10, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.129579, + "sim": 0.129551, "is_expected": false }, { "rank": 11, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.126545, + "sim": 0.126544, "is_expected": false }, { "rank": 12, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.123028, + "sim": 0.123041, "is_expected": false }, { "rank": 13, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.119062, + "sim": 0.11917, "is_expected": false }, { "rank": 14, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.108556, + "sim": 0.108522, "is_expected": false }, { "rank": 15, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.107412, + "sim": 0.10744, "is_expected": false }, { "rank": 16, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.08104, + "sim": 0.081186, "is_expected": false }, { "rank": 17, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.078521, + "sim": 0.078604, "is_expected": false } ] @@ -567,120 +635,137 @@ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.393353, + "sim": 0.393447, "is_expected": false }, { "rank": 2, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.384798, + "sim": 0.384856, "is_expected": false }, { "rank": 3, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.281763, + "sim": 0.281828, "is_expected": true }, { "rank": 4, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.27659, + "sim": 0.276678, "is_expected": false }, { "rank": 5, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.225764, + "sim": 0.225825, "is_expected": false }, { "rank": 6, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.201276, + "sim": 0.201365, "is_expected": false }, { "rank": 7, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.195646, + "sim": 0.195711, "is_expected": false }, { "rank": 8, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.195433, + "sim": 0.195453, "is_expected": false }, { "rank": 9, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.182309, + "sim": 0.182339, "is_expected": false }, { "rank": 10, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.161136, + "sim": 0.16126, "is_expected": false }, { "rank": 11, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.15375, + "sim": 0.153872, "is_expected": false }, { "rank": 12, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.153431, + "sim": 0.15346, "is_expected": false }, { "rank": 13, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.140357, + "sim": 0.140443, "is_expected": false }, { "rank": 14, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.126401, + "sim": 0.126441, "is_expected": false }, { "rank": 15, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.122811, + "sim": 0.122954, "is_expected": false }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.113581, + "sim": 0.113641, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.097583, + "sim": 0.097651, "is_expected": false } ] @@ -700,120 +785,137 @@ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.321108, + "sim": 0.321099, "is_expected": true }, { "rank": 2, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.276447, + "sim": 0.276534, "is_expected": true }, { "rank": 3, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.249275, + "sim": 0.249319, "is_expected": false }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.222579, + "sim": 0.222659, "is_expected": false }, { "rank": 5, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.216744, + "sim": 0.216771, "is_expected": false }, { "rank": 6, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.210131, + "sim": 0.21025, "is_expected": false }, { "rank": 7, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.195855, + "sim": 0.195828, "is_expected": false }, { "rank": 8, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.190465, + "sim": 0.190481, "is_expected": false }, { "rank": 9, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.184495, + "sim": 0.184559, "is_expected": false }, { "rank": 10, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.174959, + "sim": 0.175048, "is_expected": false }, { "rank": 11, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.161523, + "sim": 0.161588, "is_expected": false }, { "rank": 12, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.157883, + "sim": 0.157916, "is_expected": false }, { "rank": 13, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.155402, + "sim": 0.155519, "is_expected": false }, { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.148854, + "sim": 0.148957, "is_expected": false }, { "rank": 15, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.133649, + "sim": 0.13375, "is_expected": false }, { "rank": 16, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.125145, + "sim": 0.125198, "is_expected": false }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.061085, + "sim": 0.061133, "is_expected": false } ] @@ -832,120 +934,137 @@ { "rank": 1, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.390209, + "sim": 0.390297, "is_expected": true }, { "rank": 2, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.225732, + "sim": 0.225789, "is_expected": false }, { "rank": 3, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.2067, + "sim": 0.206834, "is_expected": false }, { "rank": 4, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.197819, + "sim": 0.197867, "is_expected": false }, { "rank": 5, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.191, + "sim": 0.191097, "is_expected": false }, { "rank": 6, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.18597, + "sim": 0.186129, "is_expected": false }, { "rank": 7, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.185608, + "sim": 0.185747, "is_expected": false }, { "rank": 8, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.183852, + "sim": 0.183959, "is_expected": false }, { "rank": 9, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.179015, + "sim": 0.179194, "is_expected": false }, { "rank": 10, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.175145, + "sim": 0.175198, "is_expected": false }, { "rank": 11, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.166158, + "sim": 0.166262, "is_expected": false }, { "rank": 12, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.152664, + "sim": 0.152763, "is_expected": false }, { "rank": 13, - "record_id": 292, - "name": "적당", - "sim": 0.14949, + "record_id": 285, + "context_id": 285, + "name": "월강부산돼지국밥", + "sim": 0.149624, "is_expected": false }, { "rank": 14, - "record_id": 285, - "name": "월강부산돼지국밥", - "sim": 0.149421, + "record_id": 292, + "context_id": 292, + "name": "적당", + "sim": 0.149562, "is_expected": false }, { "rank": 15, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.135254, + "sim": 0.135326, "is_expected": false }, { "rank": 16, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.11456, + "sim": 0.114669, "is_expected": false }, { "rank": 17, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.107635, + "sim": 0.107764, "is_expected": false } ] @@ -964,120 +1083,137 @@ { "rank": 1, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.258275, + "sim": 0.25833, "is_expected": true }, { "rank": 2, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.238093, + "sim": 0.238153, "is_expected": false }, { "rank": 3, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.222771, + "sim": 0.222795, "is_expected": false }, { "rank": 4, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.200953, + "sim": 0.201095, "is_expected": false }, { "rank": 5, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.199207, + "sim": 0.199268, "is_expected": false }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.17742, + "sim": 0.177424, "is_expected": false }, { "rank": 7, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.173397, + "sim": 0.173411, "is_expected": false }, { "rank": 8, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.17227, + "sim": 0.172249, "is_expected": false }, { "rank": 9, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.16695, + "sim": 0.166952, "is_expected": false }, { "rank": 10, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.163426, + "sim": 0.163472, "is_expected": false }, { "rank": 11, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.155672, + "sim": 0.155725, "is_expected": false }, { "rank": 12, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.145475, + "sim": 0.145487, "is_expected": false }, { "rank": 13, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.136926, + "sim": 0.136942, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.135834, + "sim": 0.135764, "is_expected": false }, { "rank": 15, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.132072, + "sim": 0.132134, "is_expected": false }, { "rank": 16, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.124414, + "sim": 0.124402, "is_expected": false }, { "rank": 17, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.080956, + "sim": 0.080964, "is_expected": false } ] @@ -1096,78 +1232,89 @@ { "rank": 1, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.362036, + "sim": 0.362031, "is_expected": true }, { "rank": 2, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.295885, + "sim": 0.295945, "is_expected": false }, { "rank": 3, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.264404, + "sim": 0.264386, "is_expected": false }, { "rank": 4, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.236332, + "sim": 0.236454, "is_expected": false }, { "rank": 5, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.207604, + "sim": 0.207656, "is_expected": false }, { "rank": 6, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.197949, + "sim": 0.197974, "is_expected": false }, { "rank": 7, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.185791, + "sim": 0.185869, "is_expected": false }, { "rank": 8, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.183049, + "sim": 0.183046, "is_expected": false }, { "rank": 9, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.165033, + "sim": 0.165072, "is_expected": false }, { "rank": 10, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.162832, + "sim": 0.162844, "is_expected": false }, { "rank": 11, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.148458, + "sim": 0.148487, "is_expected": false } ] @@ -1186,120 +1333,137 @@ { "rank": 1, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.36201, + "sim": 0.362005, "is_expected": true }, { "rank": 2, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.295889, + "sim": 0.295949, "is_expected": false }, { "rank": 3, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.285286, + "sim": 0.285326, "is_expected": false }, { "rank": 4, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.264412, + "sim": 0.264395, "is_expected": false }, { "rank": 5, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.246304, + "sim": 0.246362, "is_expected": false }, { "rank": 6, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.220002, + "sim": 0.219994, "is_expected": false }, { "rank": 7, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.20228, + "sim": 0.202284, "is_expected": false }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.194965, + "sim": 0.194955, "is_expected": false }, { "rank": 9, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.188011, + "sim": 0.188013, "is_expected": false }, { "rank": 10, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.183098, + "sim": 0.183095, "is_expected": false }, { "rank": 11, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.175061, + "sim": 0.175029, "is_expected": false }, { "rank": 12, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.165033, + "sim": 0.165072, "is_expected": false }, { "rank": 13, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.160441, + "sim": 0.160387, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.157325, + "sim": 0.157324, "is_expected": false }, { "rank": 15, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.155259, + "sim": 0.155222, "is_expected": false }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.154654, + "sim": 0.154698, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.094116, + "sim": 0.094227, "is_expected": false } ] @@ -1318,78 +1482,89 @@ { "rank": 1, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.485047, + "sim": 0.485062, "is_expected": true }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.269448, + "sim": 0.269526, "is_expected": false }, { "rank": 3, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.22591, + "sim": 0.226015, "is_expected": false }, { "rank": 4, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.209016, + "sim": 0.209105, "is_expected": false }, { "rank": 5, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.203341, + "sim": 0.203367, "is_expected": false }, { "rank": 6, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.173369, + "sim": 0.173463, "is_expected": false }, { "rank": 7, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.147466, + "sim": 0.14754, "is_expected": false }, { "rank": 8, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.131499, + "sim": 0.131601, "is_expected": false }, { "rank": 9, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.130688, + "sim": 0.130779, "is_expected": false }, { "rank": 10, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.086994, + "sim": 0.08707, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.077021, + "sim": 0.077113, "is_expected": false } ] @@ -1408,120 +1583,137 @@ { "rank": 1, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.484828, + "sim": 0.484843, "is_expected": true }, { "rank": 2, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.265753, + "sim": 0.265762, "is_expected": false }, { "rank": 3, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.243354, + "sim": 0.243371, "is_expected": false }, { "rank": 4, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.235887, + "sim": 0.235896, "is_expected": false }, { "rank": 5, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.223987, + "sim": 0.224053, "is_expected": false }, { "rank": 6, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.20328, + "sim": 0.203306, "is_expected": false }, { "rank": 7, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.178349, + "sim": 0.178438, "is_expected": false }, { "rank": 8, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.173369, + "sim": 0.173463, "is_expected": false }, { "rank": 9, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.164497, + "sim": 0.164525, "is_expected": false }, { "rank": 10, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.160228, + "sim": 0.160308, "is_expected": false }, { "rank": 11, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.155521, + "sim": 0.155611, "is_expected": false }, { "rank": 12, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.147516, + "sim": 0.14759, "is_expected": false }, { "rank": 13, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.137268, + "sim": 0.137305, "is_expected": false }, { "rank": 14, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.130879, + "sim": 0.13097, "is_expected": false }, { "rank": 15, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.12894, + "sim": 0.128954, "is_expected": false }, { "rank": 16, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.122821, + "sim": 0.122839, "is_expected": false }, { "rank": 17, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.113522, + "sim": 0.113587, "is_expected": false } ] @@ -1540,78 +1732,89 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.419928, + "sim": 0.419979, "is_expected": true }, { "rank": 2, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.228157, + "sim": 0.227993, "is_expected": false }, { "rank": 3, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.227873, + "sim": 0.227839, "is_expected": false }, { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.227506, + "sim": 0.227409, "is_expected": false }, { "rank": 5, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.224405, + "sim": 0.224396, "is_expected": false }, { "rank": 6, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.210755, + "sim": 0.2106, "is_expected": false }, { "rank": 7, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.189934, + "sim": 0.189856, "is_expected": false }, { "rank": 8, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.185215, + "sim": 0.185152, "is_expected": false }, { "rank": 9, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.175298, + "sim": 0.175219, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.175194, + "sim": 0.175076, "is_expected": false }, { "rank": 11, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.1231, + "sim": 0.123124, "is_expected": false } ] @@ -1630,120 +1833,137 @@ { "rank": 1, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.41993, + "sim": 0.41998, "is_expected": true }, { "rank": 2, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.289945, + "sim": 0.289858, "is_expected": false }, { "rank": 3, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.245254, + "sim": 0.245212, "is_expected": false }, { "rank": 4, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.228157, + "sim": 0.227993, "is_expected": false }, { "rank": 5, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.22753, + "sim": 0.227433, "is_expected": false }, { "rank": 6, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.226075, + "sim": 0.226033, "is_expected": false }, { "rank": 7, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.220138, + "sim": 0.220094, "is_expected": false }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.212119, + "sim": 0.212029, "is_expected": false }, { "rank": 9, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.210757, + "sim": 0.210602, "is_expected": false }, { "rank": 10, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.20286, + "sim": 0.202876, "is_expected": false }, { "rank": 11, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.189936, + "sim": 0.189857, "is_expected": false }, { "rank": 12, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.169477, + "sim": 0.169434, "is_expected": false }, { "rank": 13, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.148175, + "sim": 0.148169, "is_expected": false }, { "rank": 14, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.14244, + "sim": 0.142411, "is_expected": false }, { "rank": 15, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.130579, + "sim": 0.130524, "is_expected": false }, { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.104281, + "sim": 0.104202, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.101369, + "sim": 0.101309, "is_expected": false } ] @@ -1762,78 +1982,89 @@ { "rank": 1, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.544439, + "sim": 0.544441, "is_expected": true }, { "rank": 2, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.193247, + "sim": 0.193218, "is_expected": false }, { "rank": 3, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.187314, + "sim": 0.187259, "is_expected": false }, { "rank": 4, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.153359, + "sim": 0.153343, "is_expected": false }, { "rank": 5, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.142745, + "sim": 0.142675, "is_expected": false }, { "rank": 6, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.135488, + "sim": 0.135462, "is_expected": false }, { "rank": 7, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.117993, + "sim": 0.117941, "is_expected": false }, { "rank": 8, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.1139, + "sim": 0.113842, "is_expected": false }, { "rank": 9, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.111255, + "sim": 0.111248, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.09691, + "sim": 0.096887, "is_expected": false }, { "rank": 11, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.095436, + "sim": 0.095388, "is_expected": false } ] @@ -1852,55 +2083,63 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.24651, + "sim": 0.246351, "is_expected": false }, { "rank": 2, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.211706, + "sim": 0.211668, "is_expected": false }, { "rank": 3, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.203202, + "sim": 0.202998, "is_expected": false }, { "rank": 4, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.202677, + "sim": 0.202606, "is_expected": false }, { "rank": 5, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.195826, + "sim": 0.19571, "is_expected": true }, { "rank": 6, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.192275, + "sim": 0.192097, "is_expected": false }, { "rank": 7, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.189366, + "sim": 0.189355, "is_expected": false }, { "rank": 8, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", "sim": 0.178147, "is_expected": false @@ -1908,22 +2147,25 @@ { "rank": 9, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.13882, + "sim": 0.138768, "is_expected": false }, { "rank": 10, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.120195, + "sim": 0.120141, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.096957, + "sim": 0.096844, "is_expected": false } ] @@ -1942,78 +2184,89 @@ { "rank": 1, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.284287, + "sim": 0.28434, "is_expected": true }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.236456, + "sim": 0.236477, "is_expected": false }, { "rank": 3, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.210455, + "sim": 0.210546, "is_expected": false }, { "rank": 4, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.206252, + "sim": 0.206142, "is_expected": false }, { "rank": 5, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.19091, + "sim": 0.190955, "is_expected": false }, { "rank": 6, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.188981, + "sim": 0.189006, "is_expected": false }, { "rank": 7, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.169537, + "sim": 0.169562, "is_expected": false }, { "rank": 8, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.153237, + "sim": 0.153272, "is_expected": false }, { "rank": 9, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.128567, + "sim": 0.128624, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.125668, + "sim": 0.125691, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.115859, + "sim": 0.115807, "is_expected": false } ] @@ -2032,120 +2285,137 @@ { "rank": 1, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.289514, + "sim": 0.28955, "is_expected": false }, { "rank": 2, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.284265, + "sim": 0.284318, "is_expected": true }, { "rank": 3, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.231729, + "sim": 0.231786, "is_expected": false }, { "rank": 4, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.230242, + "sim": 0.230288, "is_expected": false }, { "rank": 5, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.207456, + "sim": 0.207518, "is_expected": false }, { "rank": 6, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.206252, + "sim": 0.206142, "is_expected": false }, { "rank": 7, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.205203, + "sim": 0.20533, "is_expected": false }, { "rank": 8, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.201426, + "sim": 0.201434, "is_expected": false }, { "rank": 9, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.19466, + "sim": 0.194692, "is_expected": false }, { "rank": 10, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.190938, + "sim": 0.190983, "is_expected": false }, { "rank": 11, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.18898, + "sim": 0.189006, "is_expected": false }, { "rank": 12, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.173663, + "sim": 0.173616, "is_expected": false }, { "rank": 13, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.169589, + "sim": 0.169615, "is_expected": false }, { "rank": 14, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.167067, + "sim": 0.167045, "is_expected": false }, { "rank": 15, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.16568, + "sim": 0.165705, "is_expected": false }, { "rank": 16, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.157385, + "sim": 0.157427, "is_expected": false }, { "rank": 17, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.144074, + "sim": 0.144108, "is_expected": false } ] @@ -2164,27 +2434,31 @@ { "rank": 1, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.445865, + "sim": 0.445887, "is_expected": true }, { "rank": 2, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.230013, + "sim": 0.22998, "is_expected": false }, { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.226123, + "sim": 0.226097, "is_expected": false }, { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", "sim": 0.201075, "is_expected": false @@ -2192,50 +2466,57 @@ { "rank": 5, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.195536, + "sim": 0.19552, "is_expected": false }, { "rank": 6, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.17658, + "sim": 0.176582, "is_expected": false }, { "rank": 7, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.164085, + "sim": 0.164086, "is_expected": false }, { "rank": 8, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.157509, + "sim": 0.157492, "is_expected": false }, { "rank": 9, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.141066, + "sim": 0.141055, "is_expected": false }, { "rank": 10, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.140086, + "sim": 0.140077, "is_expected": false }, { "rank": 11, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.131598, + "sim": 0.131579, "is_expected": false } ] @@ -2255,55 +2536,63 @@ { "rank": 1, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.445915, + "sim": 0.445937, "is_expected": true }, { "rank": 2, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.365971, + "sim": 0.365919, "is_expected": true }, { "rank": 3, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.267432, + "sim": 0.267439, "is_expected": false }, { "rank": 4, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.230047, + "sim": 0.230014, "is_expected": false }, { "rank": 5, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.228811, + "sim": 0.228839, "is_expected": false }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.226299, + "sim": 0.226272, "is_expected": false }, { "rank": 7, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.22153, + "sim": 0.221554, "is_expected": false }, { "rank": 8, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", "sim": 0.201144, "is_expected": false @@ -2311,64 +2600,73 @@ { "rank": 9, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.183382, + "sim": 0.18333, "is_expected": false }, { "rank": 10, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.183081, + "sim": 0.183061, "is_expected": false }, { "rank": 11, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.179477, + "sim": 0.179496, "is_expected": false }, { "rank": 12, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.166183, + "sim": 0.166177, "is_expected": false }, { "rank": 13, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.158058, + "sim": 0.158069, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.137482, + "sim": 0.137537, "is_expected": false }, { "rank": 15, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.131598, + "sim": 0.131579, "is_expected": false }, { "rank": 16, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.128573, + "sim": 0.12858, "is_expected": false }, { "rank": 17, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.114902, + "sim": 0.114838, "is_expected": false } ] @@ -2387,43 +2685,49 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.255232, + "sim": 0.255218, "is_expected": true }, { "rank": 2, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.237923, + "sim": 0.237884, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.221201, + "sim": 0.221217, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.179466, + "sim": 0.179396, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.150547, + "sim": 0.150524, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.126093, + "sim": 0.126, "is_expected": false } ] @@ -2442,6 +2746,7 @@ { "rank": 1, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", "sim": 0.365509, "is_expected": true @@ -2449,6 +2754,7 @@ { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", "sim": 0.222095, "is_expected": false @@ -2456,6 +2762,7 @@ { "rank": 3, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", "sim": 0.211421, "is_expected": false @@ -2463,6 +2770,7 @@ { "rank": 4, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", "sim": 0.169501, "is_expected": false @@ -2470,6 +2778,7 @@ { "rank": 5, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", "sim": 0.156255, "is_expected": false @@ -2477,6 +2786,7 @@ { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", "sim": 0.145939, "is_expected": false @@ -2497,6 +2807,7 @@ { "rank": 1, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", "sim": 0.237432, "is_expected": false @@ -2504,6 +2815,7 @@ { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", "sim": 0.217107, "is_expected": false @@ -2511,6 +2823,7 @@ { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", "sim": 0.211047, "is_expected": false @@ -2518,6 +2831,7 @@ { "rank": 4, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", "sim": 0.204176, "is_expected": false @@ -2525,6 +2839,7 @@ { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", "sim": 0.186994, "is_expected": true @@ -2532,6 +2847,7 @@ { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", "sim": 0.157931, "is_expected": false @@ -2552,120 +2868,137 @@ { "rank": 1, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.439656, + "sim": 0.439695, "is_expected": false }, { "rank": 2, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.406111, + "sim": 0.406044, "is_expected": true }, { "rank": 3, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.348858, + "sim": 0.348864, "is_expected": false }, { "rank": 4, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.301194, + "sim": 0.301207, "is_expected": false }, { "rank": 5, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.300999, + "sim": 0.301102, "is_expected": false }, { "rank": 6, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.285805, + "sim": 0.285876, "is_expected": false }, { "rank": 7, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.272944, + "sim": 0.273001, "is_expected": false }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.272126, + "sim": 0.272102, "is_expected": false }, { "rank": 9, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.257424, + "sim": 0.257462, "is_expected": false }, { "rank": 10, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.247144, + "sim": 0.247151, "is_expected": false }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.245939, + "sim": 0.246005, "is_expected": false }, { "rank": 12, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.239823, + "sim": 0.239778, "is_expected": false }, { "rank": 13, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.239012, + "sim": 0.238988, "is_expected": false }, { "rank": 14, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.211888, + "sim": 0.211804, "is_expected": false }, { "rank": 15, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.194213, + "sim": 0.194254, "is_expected": false }, { "rank": 16, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.163563, + "sim": 0.163579, "is_expected": false }, { "rank": 17, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.161922, + "sim": 0.161957, "is_expected": false } ] @@ -2684,120 +3017,137 @@ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.367151, + "sim": 0.367066, "is_expected": true }, { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.272408, + "sim": 0.272398, "is_expected": false }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.236122, + "sim": 0.236032, "is_expected": false }, { "rank": 4, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.230116, + "sim": 0.23001, "is_expected": false }, { "rank": 5, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.224535, + "sim": 0.22447, "is_expected": false }, { "rank": 6, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.212449, + "sim": 0.212373, "is_expected": false }, { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.200883, + "sim": 0.200824, "is_expected": false }, { "rank": 8, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.189561, + "sim": 0.189519, "is_expected": false }, { "rank": 9, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.187558, + "sim": 0.187459, "is_expected": false }, { "rank": 10, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.183927, + "sim": 0.183813, "is_expected": false }, { "rank": 11, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.173765, + "sim": 0.173637, "is_expected": false }, { "rank": 12, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.164566, + "sim": 0.164528, "is_expected": false }, { "rank": 13, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.146854, + "sim": 0.146744, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.137379, + "sim": 0.137302, "is_expected": false }, { "rank": 15, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.095541, + "sim": 0.095479, "is_expected": false }, { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.087151, + "sim": 0.087097, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.050124, + "sim": 0.050094, "is_expected": false } ] @@ -2816,120 +3166,137 @@ { "rank": 1, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.385055, + "sim": 0.385048, "is_expected": false }, { "rank": 2, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.384455, + "sim": 0.384467, "is_expected": false }, { "rank": 3, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.379954, + "sim": 0.379934, "is_expected": true }, { "rank": 4, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.354045, + "sim": 0.354119, "is_expected": false }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.328707, + "sim": 0.328663, "is_expected": false }, { "rank": 6, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.327335, + "sim": 0.327306, "is_expected": false }, { "rank": 7, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.326603, + "sim": 0.326634, "is_expected": false }, { "rank": 8, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.306185, + "sim": 0.306189, "is_expected": false }, { "rank": 9, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.304548, + "sim": 0.304613, "is_expected": false }, { "rank": 10, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.292774, + "sim": 0.292735, "is_expected": false }, { "rank": 11, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.277203, + "sim": 0.277216, "is_expected": false }, { "rank": 12, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.264424, + "sim": 0.264403, "is_expected": false }, { "rank": 13, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.224941, + "sim": 0.224927, "is_expected": false }, { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.214907, + "sim": 0.2149, "is_expected": false }, { "rank": 15, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.192063, + "sim": 0.191987, "is_expected": false }, { "rank": 16, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.184696, + "sim": 0.184633, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.096989, + "sim": 0.096987, "is_expected": false } ] @@ -2948,120 +3315,137 @@ { "rank": 1, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.36962, + "sim": 0.369574, "is_expected": true }, { "rank": 2, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.357615, + "sim": 0.357611, "is_expected": false }, { "rank": 3, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.288755, + "sim": 0.288778, "is_expected": false }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.286774, + "sim": 0.286812, "is_expected": false }, { "rank": 5, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.281731, + "sim": 0.28167, "is_expected": false }, { "rank": 6, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.28118, + "sim": 0.281147, "is_expected": false }, { "rank": 7, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.251906, + "sim": 0.251862, "is_expected": false }, { "rank": 8, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.237268, + "sim": 0.237277, "is_expected": false }, { "rank": 9, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.23185, + "sim": 0.231857, "is_expected": false }, { "rank": 10, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.231845, + "sim": 0.231836, "is_expected": false }, { "rank": 11, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.226131, + "sim": 0.226098, "is_expected": false }, { "rank": 12, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.215629, + "sim": 0.215651, "is_expected": false }, { "rank": 13, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.196646, + "sim": 0.196614, "is_expected": false }, { "rank": 14, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.169746, + "sim": 0.1697, "is_expected": false }, { "rank": 15, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.162391, + "sim": 0.162371, "is_expected": false }, { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.142545, + "sim": 0.14254, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.123192, + "sim": 0.123144, "is_expected": false } ] @@ -3080,78 +3464,89 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.342094, + "sim": 0.342377, "is_expected": true }, { "rank": 2, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.26533, + "sim": 0.265462, "is_expected": false }, { "rank": 3, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.233671, + "sim": 0.233793, "is_expected": false }, { "rank": 4, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.221655, + "sim": 0.221691, "is_expected": false }, { "rank": 5, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.212153, + "sim": 0.212302, "is_expected": false }, { "rank": 6, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.190476, + "sim": 0.190518, "is_expected": false }, { "rank": 7, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.189795, + "sim": 0.189954, "is_expected": false }, { "rank": 8, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.145655, + "sim": 0.14562, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.144353, + "sim": 0.144437, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.142736, + "sim": 0.142877, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.105605, + "sim": 0.105648, "is_expected": false } ] @@ -3170,62 +3565,71 @@ { "rank": 1, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.342311, + "sim": 0.342594, "is_expected": true }, { "rank": 2, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.265372, + "sim": 0.265503, "is_expected": false }, { "rank": 3, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.255056, + "sim": 0.255287, "is_expected": false }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.241981, + "sim": 0.242101, "is_expected": false }, { "rank": 5, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.226169, + "sim": 0.226217, "is_expected": false }, { "rank": 6, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.212245, + "sim": 0.212394, "is_expected": false }, { "rank": 7, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.207551, + "sim": 0.207655, "is_expected": false }, { "rank": 8, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.202542, + "sim": 0.202653, "is_expected": false }, { "rank": 9, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", "sim": 0.192175, "is_expected": false @@ -3233,57 +3637,65 @@ { "rank": 10, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.18607, + "sim": 0.186054, "is_expected": false }, { "rank": 11, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.182795, + "sim": 0.182792, "is_expected": false }, { "rank": 12, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.181362, + "sim": 0.181422, "is_expected": false }, { "rank": 13, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.168241, + "sim": 0.168221, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.167907, + "sim": 0.167986, "is_expected": false }, { "rank": 15, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.149517, + "sim": 0.149556, "is_expected": false }, { "rank": 16, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.145655, + "sim": 0.14562, "is_expected": false }, { "rank": 17, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.142743, + "sim": 0.142884, "is_expected": false } ] @@ -3305,78 +3717,89 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.442727, + "sim": 0.442678, "is_expected": true }, { "rank": 2, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.440324, + "sim": 0.440308, "is_expected": true }, { "rank": 3, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.397023, + "sim": 0.39703, "is_expected": true }, { "rank": 4, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.396384, + "sim": 0.39631, "is_expected": true }, { "rank": 5, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.301863, + "sim": 0.301785, "is_expected": false }, { "rank": 6, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.281986, + "sim": 0.281904, "is_expected": false }, { "rank": 7, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.249968, + "sim": 0.249898, "is_expected": false }, { "rank": 8, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.204993, + "sim": 0.20496, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.15253, + "sim": 0.152521, "is_expected": false }, { "rank": 10, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.150335, + "sim": 0.150369, "is_expected": false }, { "rank": 11, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.131631, + "sim": 0.131655, "is_expected": false } ] @@ -3396,120 +3819,137 @@ { "rank": 1, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.442507, + "sim": 0.442458, "is_expected": true }, { "rank": 2, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.44033, + "sim": 0.440314, "is_expected": true }, { "rank": 3, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.282045, + "sim": 0.281963, "is_expected": false }, { "rank": 4, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.264743, + "sim": 0.264754, "is_expected": false }, { "rank": 5, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.249936, + "sim": 0.249866, "is_expected": false }, { "rank": 6, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.218003, + "sim": 0.218105, "is_expected": false }, { "rank": 7, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.212199, + "sim": 0.212184, "is_expected": false }, { "rank": 8, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.190104, + "sim": 0.190157, "is_expected": false }, { "rank": 9, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.181685, + "sim": 0.181611, "is_expected": false }, { "rank": 10, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.172299, + "sim": 0.17227, "is_expected": false }, { "rank": 11, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.157844, + "sim": 0.157819, "is_expected": false }, { "rank": 12, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.155511, + "sim": 0.155485, "is_expected": false }, { "rank": 13, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.131631, + "sim": 0.131655, "is_expected": false }, { "rank": 14, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.125345, + "sim": 0.125475, "is_expected": false }, { "rank": 15, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.122223, + "sim": 0.122229, "is_expected": false }, { "rank": 16, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.11062, + "sim": 0.110558, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.066726, + "sim": 0.066784, "is_expected": false } ] @@ -3528,78 +3968,89 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.329599, + "sim": 0.32959, "is_expected": false }, { "rank": 2, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.310755, + "sim": 0.310736, "is_expected": false }, { "rank": 3, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.308882, + "sim": 0.308929, "is_expected": false }, { "rank": 4, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.30797, + "sim": 0.307992, "is_expected": true }, { "rank": 5, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.25379, + "sim": 0.253803, "is_expected": false }, { "rank": 6, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.247871, + "sim": 0.247844, "is_expected": false }, { "rank": 7, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.243387, + "sim": 0.243477, "is_expected": false }, { "rank": 8, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.241306, + "sim": 0.241374, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.241057, + "sim": 0.241035, "is_expected": false }, { "rank": 10, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.239018, + "sim": 0.239044, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.207274, + "sim": 0.2073, "is_expected": false } ] @@ -3618,78 +4069,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.303307, + "sim": 0.303341, "is_expected": false }, { "rank": 2, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.284175, + "sim": 0.284304, "is_expected": false }, { "rank": 3, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.265396, + "sim": 0.265397, "is_expected": true }, { "rank": 4, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.243602, + "sim": 0.243775, "is_expected": false }, { "rank": 5, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.219783, + "sim": 0.219821, "is_expected": false }, { "rank": 6, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.191125, + "sim": 0.191152, "is_expected": false }, { "rank": 7, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.191032, + "sim": 0.191035, "is_expected": false }, { "rank": 8, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.185763, + "sim": 0.185975, "is_expected": false }, { "rank": 9, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.157118, + "sim": 0.157335, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.141401, + "sim": 0.141373, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.116735, + "sim": 0.116753, "is_expected": false } ] @@ -3708,120 +4170,137 @@ { "rank": 1, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.518366, + "sim": 0.518379, "is_expected": true }, { "rank": 2, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.275439, + "sim": 0.275306, "is_expected": false }, { "rank": 3, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.263349, + "sim": 0.263209, "is_expected": false }, { "rank": 4, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.253293, + "sim": 0.253154, "is_expected": false }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.216101, + "sim": 0.216016, "is_expected": false }, { "rank": 6, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.203799, + "sim": 0.203719, "is_expected": false }, { "rank": 7, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.197146, + "sim": 0.197072, "is_expected": false }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.191642, + "sim": 0.191519, "is_expected": false }, { "rank": 9, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.18947, + "sim": 0.189398, "is_expected": false }, { "rank": 10, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.18601, + "sim": 0.185931, "is_expected": false }, { "rank": 11, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.175753, + "sim": 0.175589, "is_expected": false }, { "rank": 12, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.17035, + "sim": 0.170331, "is_expected": false }, { "rank": 13, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.159479, + "sim": 0.159452, "is_expected": false }, { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.158092, + "sim": 0.158027, "is_expected": false }, { "rank": 15, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.155925, + "sim": 0.155928, "is_expected": false }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.150763, + "sim": 0.150631, "is_expected": false }, { "rank": 17, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.137982, + "sim": 0.137877, "is_expected": false } ] @@ -3840,120 +4319,137 @@ { "rank": 1, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.443842, + "sim": 0.44391, "is_expected": true }, { "rank": 2, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.277637, + "sim": 0.277722, "is_expected": false }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.259905, + "sim": 0.259971, "is_expected": false }, { "rank": 4, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.2536, + "sim": 0.253691, "is_expected": false }, { "rank": 5, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.229895, + "sim": 0.229899, "is_expected": false }, { "rank": 6, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.211242, + "sim": 0.21126, "is_expected": false }, { "rank": 7, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.207465, + "sim": 0.207522, "is_expected": false }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.205932, + "sim": 0.205965, "is_expected": false }, { "rank": 9, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.193542, + "sim": 0.193526, "is_expected": false }, { "rank": 10, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.175415, + "sim": 0.175475, "is_expected": false }, { "rank": 11, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.174824, + "sim": 0.17488, "is_expected": false }, { "rank": 12, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.170527, + "sim": 0.170635, "is_expected": false }, { "rank": 13, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.152332, + "sim": 0.152472, "is_expected": false }, { "rank": 14, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.143181, + "sim": 0.143281, "is_expected": false }, { "rank": 15, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.140841, + "sim": 0.140908, "is_expected": false }, { "rank": 16, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.131228, + "sim": 0.131291, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.084702, + "sim": 0.084707, "is_expected": false } ] @@ -3972,120 +4468,137 @@ { "rank": 1, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.429769, + "sim": 0.42978, "is_expected": true }, { "rank": 2, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.199854, + "sim": 0.199758, "is_expected": false }, { "rank": 3, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.183776, + "sim": 0.183761, "is_expected": false }, { "rank": 4, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.178566, + "sim": 0.178473, "is_expected": false }, { "rank": 5, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.17511, + "sim": 0.175031, "is_expected": false }, { "rank": 6, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.172157, + "sim": 0.172096, "is_expected": false }, { "rank": 7, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.16673, + "sim": 0.166667, "is_expected": false }, { "rank": 8, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.16625, + "sim": 0.166172, "is_expected": false }, { "rank": 9, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.153815, + "sim": 0.153752, "is_expected": false }, { "rank": 10, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.151544, + "sim": 0.151562, "is_expected": false }, { "rank": 11, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.149784, + "sim": 0.149717, "is_expected": false }, { "rank": 12, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.145772, + "sim": 0.145664, "is_expected": false }, { "rank": 13, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.125163, + "sim": 0.125089, "is_expected": false }, { "rank": 14, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.120481, + "sim": 0.120506, "is_expected": false }, { "rank": 15, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.118721, + "sim": 0.118647, "is_expected": false }, { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.088618, + "sim": 0.08858, "is_expected": false }, { "rank": 17, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.07049, + "sim": 0.07042, "is_expected": false } ] @@ -4104,120 +4617,137 @@ { "rank": 1, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.389679, + "sim": 0.389678, "is_expected": true }, { "rank": 2, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.293706, + "sim": 0.29365, "is_expected": false }, { "rank": 3, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.289074, + "sim": 0.288936, "is_expected": false }, { "rank": 4, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.286433, + "sim": 0.286407, "is_expected": false }, { "rank": 5, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.276036, + "sim": 0.275912, "is_expected": false }, { "rank": 6, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.274203, + "sim": 0.274142, "is_expected": false }, { "rank": 7, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.252971, + "sim": 0.25297, "is_expected": false }, { "rank": 8, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.248311, + "sim": 0.24821, "is_expected": false }, { "rank": 9, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.246125, + "sim": 0.246014, "is_expected": false }, { "rank": 10, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.238948, + "sim": 0.238956, "is_expected": false }, { "rank": 11, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.235789, + "sim": 0.235692, "is_expected": false }, { "rank": 12, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.23064, + "sim": 0.230606, "is_expected": false }, { "rank": 13, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.201406, + "sim": 0.201296, "is_expected": false }, { "rank": 14, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.198362, + "sim": 0.19835, "is_expected": false }, { "rank": 15, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.194655, + "sim": 0.194628, "is_expected": false }, { "rank": 16, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.18959, + "sim": 0.189667, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.170274, + "sim": 0.170237, "is_expected": false } ] @@ -4236,78 +4766,89 @@ { "rank": 1, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.35094, + "sim": 0.35105, "is_expected": true }, { "rank": 2, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.274907, + "sim": 0.275, "is_expected": false }, { "rank": 3, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.270553, + "sim": 0.270601, "is_expected": false }, { "rank": 4, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.236942, + "sim": 0.236928, "is_expected": false }, { "rank": 5, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.236188, + "sim": 0.236283, "is_expected": false }, { "rank": 6, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.228086, + "sim": 0.228133, "is_expected": false }, { "rank": 7, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.189661, + "sim": 0.189748, "is_expected": false }, { "rank": 8, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.186004, + "sim": 0.185955, "is_expected": false }, { "rank": 9, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.18167, + "sim": 0.181636, "is_expected": false }, { "rank": 10, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.172318, + "sim": 0.172424, "is_expected": false }, { "rank": 11, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.158987, + "sim": 0.158998, "is_expected": false } ] @@ -4326,120 +4867,137 @@ { "rank": 1, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.366474, + "sim": 0.366508, "is_expected": true }, { "rank": 2, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.324122, + "sim": 0.324131, "is_expected": false }, { "rank": 3, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.276662, + "sim": 0.27677, "is_expected": false }, { "rank": 4, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.275894, + "sim": 0.275974, "is_expected": false }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.274859, + "sim": 0.274951, "is_expected": false }, { "rank": 6, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.261129, + "sim": 0.261184, "is_expected": false }, { "rank": 7, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.258124, + "sim": 0.258269, "is_expected": false }, { "rank": 8, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.236801, + "sim": 0.236787, "is_expected": false }, { "rank": 9, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.236188, + "sim": 0.236283, "is_expected": false }, { "rank": 10, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.23238, + "sim": 0.232475, "is_expected": false }, { "rank": 11, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.223768, + "sim": 0.223862, "is_expected": false }, { "rank": 12, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.181777, + "sim": 0.181743, "is_expected": false }, { "rank": 13, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.18039, + "sim": 0.180421, "is_expected": false }, { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.159078, + "sim": 0.159089, "is_expected": false }, { "rank": 15, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.132846, + "sim": 0.132955, "is_expected": false }, { "rank": 16, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.108552, + "sim": 0.108642, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.108295, + "sim": 0.108293, "is_expected": false } ] @@ -4458,104 +5016,119 @@ { "rank": 1, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.563278, + "sim": 0.563294, "is_expected": true }, { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.282307, + "sim": 0.282361, "is_expected": false }, { "rank": 3, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.269247, + "sim": 0.269273, "is_expected": false }, { "rank": 4, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.2469, + "sim": 0.246861, "is_expected": false }, { "rank": 5, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.246464, + "sim": 0.246463, "is_expected": false }, { "rank": 6, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.237586, + "sim": 0.237606, "is_expected": false }, { "rank": 7, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.234842, + "sim": 0.234851, "is_expected": false }, { "rank": 8, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.228176, + "sim": 0.228183, "is_expected": false }, { "rank": 9, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.22566, + "sim": 0.225636, "is_expected": false }, { "rank": 10, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.19653, + "sim": 0.196512, "is_expected": false }, { "rank": 11, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.193573, + "sim": 0.193563, "is_expected": false }, { "rank": 12, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.193413, + "sim": 0.193441, "is_expected": false }, { "rank": 13, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.190427, + "sim": 0.190472, "is_expected": false }, { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.167007, + "sim": 0.166978, "is_expected": false }, { "rank": 15, "record_id": 277, + "context_id": 277, "name": "카츠요", "sim": 0.16257, "is_expected": false @@ -4563,15 +5136,17 @@ { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.147784, + "sim": 0.147819, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.137626, + "sim": 0.137644, "is_expected": false } ] @@ -4590,78 +5165,89 @@ { "rank": 1, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.526386, + "sim": 0.526397, "is_expected": true }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.297858, + "sim": 0.297743, "is_expected": false }, { "rank": 3, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.224971, + "sim": 0.224818, "is_expected": false }, { "rank": 4, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.208002, + "sim": 0.207902, "is_expected": false }, { "rank": 5, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.196346, + "sim": 0.196203, "is_expected": false }, { "rank": 6, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.175175, + "sim": 0.175151, "is_expected": false }, { "rank": 7, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.159886, + "sim": 0.159831, "is_expected": false }, { "rank": 8, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.137398, + "sim": 0.137378, "is_expected": false }, { "rank": 9, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.13698, + "sim": 0.136881, "is_expected": false }, { "rank": 10, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.126784, + "sim": 0.126739, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.121576, + "sim": 0.12157, "is_expected": false } ] @@ -4680,78 +5266,89 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.367055, + "sim": 0.366904, "is_expected": true }, { "rank": 2, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.301748, + "sim": 0.301689, "is_expected": false }, { "rank": 3, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.296895, + "sim": 0.296789, "is_expected": false }, { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.254093, + "sim": 0.25403, "is_expected": false }, { "rank": 5, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.237546, + "sim": 0.237524, "is_expected": false }, { "rank": 6, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.223872, + "sim": 0.223839, "is_expected": false }, { "rank": 7, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.206919, + "sim": 0.206826, "is_expected": false }, { "rank": 8, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.161545, + "sim": 0.16148, "is_expected": false }, { "rank": 9, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.138068, + "sim": 0.138053, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.135156, + "sim": 0.135129, "is_expected": false }, { "rank": 11, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.083183, + "sim": 0.08319, "is_expected": false } ] @@ -4770,120 +5367,137 @@ { "rank": 1, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.367307, + "sim": 0.367155, "is_expected": true }, { "rank": 2, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.295161, + "sim": 0.295081, "is_expected": false }, { "rank": 3, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.255947, + "sim": 0.255932, "is_expected": false }, { "rank": 4, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.254098, + "sim": 0.254035, "is_expected": false }, { "rank": 5, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.239454, + "sim": 0.239445, "is_expected": false }, { "rank": 6, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.237551, + "sim": 0.237529, "is_expected": false }, { "rank": 7, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.223872, + "sim": 0.223839, "is_expected": false }, { "rank": 8, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.196636, + "sim": 0.196679, "is_expected": false }, { "rank": 9, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.190725, + "sim": 0.190585, "is_expected": false }, { "rank": 10, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.174103, + "sim": 0.174106, "is_expected": false }, { "rank": 11, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.16887, + "sim": 0.16891, "is_expected": false }, { "rank": 12, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.166211, + "sim": 0.166208, "is_expected": false }, { "rank": 13, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.161535, + "sim": 0.16147, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.160635, + "sim": 0.160677, "is_expected": false }, { "rank": 15, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.147578, + "sim": 0.147499, "is_expected": false }, { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.136453, + "sim": 0.136436, "is_expected": false }, { "rank": 17, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.12265, + "sim": 0.122634, "is_expected": false } ] @@ -4902,43 +5516,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.416471, + "sim": 0.416535, "is_expected": true }, { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.21386, + "sim": 0.213825, "is_expected": false }, { "rank": 3, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.192648, + "sim": 0.192662, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.145906, + "sim": 0.145956, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.135131, + "sim": 0.13521, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.107079, + "sim": 0.107102, "is_expected": false } ] @@ -4957,43 +5577,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.347677, + "sim": 0.34767, "is_expected": true }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.171439, + "sim": 0.171476, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.155716, + "sim": 0.155685, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.155141, + "sim": 0.15518, "is_expected": false }, { "rank": 5, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.138103, + "sim": 0.138154, "is_expected": false }, { "rank": 6, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.130801, + "sim": 0.130839, "is_expected": false } ] @@ -5012,43 +5638,49 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.336321, + "sim": 0.336069, "is_expected": true }, { "rank": 2, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.235611, + "sim": 0.235569, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.227791, + "sim": 0.227617, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.165764, + "sim": 0.165596, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.158802, + "sim": 0.158777, "is_expected": false }, { "rank": 6, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.117653, + "sim": 0.117678, "is_expected": false } ] @@ -5067,43 +5699,49 @@ { "rank": 1, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.402614, + "sim": 0.4026, "is_expected": true }, { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.193112, + "sim": 0.193149, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.170917, + "sim": 0.171002, "is_expected": false }, { "rank": 4, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.156483, + "sim": 0.156586, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.145182, + "sim": 0.145271, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.140291, + "sim": 0.140349, "is_expected": false } ] @@ -5122,43 +5760,49 @@ { "rank": 1, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.422386, + "sim": 0.422392, "is_expected": true }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.299567, + "sim": 0.299528, "is_expected": false }, { "rank": 3, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.267844, + "sim": 0.267823, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.253063, + "sim": 0.253023, "is_expected": false }, { "rank": 5, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.202055, + "sim": 0.202023, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.124036, + "sim": 0.124015, "is_expected": false } ] @@ -5177,120 +5821,137 @@ { "rank": 1, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.446585, + "sim": 0.446569, "is_expected": true }, { "rank": 2, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.25779, + "sim": 0.257828, "is_expected": false }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.220279, + "sim": 0.220218, "is_expected": false }, { "rank": 4, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.207829, + "sim": 0.207807, "is_expected": false }, { "rank": 5, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.184905, + "sim": 0.18486, "is_expected": false }, { "rank": 6, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.175154, + "sim": 0.175138, "is_expected": false }, { "rank": 7, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.174074, + "sim": 0.174063, "is_expected": false }, { "rank": 8, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.156339, + "sim": 0.15632, "is_expected": false }, { "rank": 9, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.147767, + "sim": 0.14775, "is_expected": false }, { "rank": 10, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.141228, + "sim": 0.141203, "is_expected": false }, { "rank": 11, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.140464, + "sim": 0.140416, "is_expected": false }, { "rank": 12, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.137232, + "sim": 0.137226, "is_expected": false }, { "rank": 13, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.131651, + "sim": 0.131622, "is_expected": false }, { "rank": 14, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.131525, + "sim": 0.1315, "is_expected": false }, { "rank": 15, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.124839, + "sim": 0.124838, "is_expected": false }, { "rank": 16, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.124541, + "sim": 0.124545, "is_expected": false }, { "rank": 17, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.099627, + "sim": 0.099612, "is_expected": false } ] @@ -5309,78 +5970,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.492343, + "sim": 0.492275, "is_expected": true }, { "rank": 2, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.251024, + "sim": 0.251099, "is_expected": false }, { "rank": 3, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.212285, + "sim": 0.212378, "is_expected": false }, { "rank": 4, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.196189, + "sim": 0.196186, "is_expected": false }, { "rank": 5, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.191507, + "sim": 0.191673, "is_expected": false }, { "rank": 6, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.18636, + "sim": 0.186488, "is_expected": false }, { "rank": 7, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.172816, + "sim": 0.172969, "is_expected": false }, { "rank": 8, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.170556, + "sim": 0.170583, "is_expected": false }, { "rank": 9, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.151624, + "sim": 0.151791, "is_expected": false }, { "rank": 10, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.137905, + "sim": 0.138009, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.114109, + "sim": 0.114164, "is_expected": false } ] @@ -5399,118 +6071,135 @@ { "rank": 1, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.339581, + "sim": 0.339761, "is_expected": false }, { "rank": 2, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.315833, + "sim": 0.315951, "is_expected": false }, { "rank": 3, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.294544, + "sim": 0.294657, "is_expected": false }, { "rank": 4, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.2854, + "sim": 0.285548, "is_expected": true }, { "rank": 5, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.279264, + "sim": 0.279445, "is_expected": false }, { "rank": 6, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.263266, + "sim": 0.263453, "is_expected": false }, { "rank": 7, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.259473, + "sim": 0.259586, "is_expected": false }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.252907, + "sim": 0.252994, "is_expected": false }, { "rank": 9, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.250267, + "sim": 0.25041, "is_expected": false }, { "rank": 10, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.228772, + "sim": 0.228874, "is_expected": false }, { "rank": 11, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.228117, + "sim": 0.228175, "is_expected": false }, { "rank": 12, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.224595, + "sim": 0.224642, "is_expected": false }, { "rank": 13, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.222145, + "sim": 0.222243, "is_expected": false }, { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.208433, + "sim": 0.208531, "is_expected": false }, { "rank": 15, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.207253, + "sim": 0.207334, "is_expected": false }, { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.199164, + "sim": 0.199261, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", "sim": 0.1276, "is_expected": false @@ -5531,120 +6220,137 @@ { "rank": 1, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.325469, + "sim": 0.325402, "is_expected": true }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.287491, + "sim": 0.287368, "is_expected": false }, { "rank": 3, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.261359, + "sim": 0.26132, "is_expected": false }, { "rank": 4, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.258856, + "sim": 0.258757, "is_expected": false }, { "rank": 5, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.245503, + "sim": 0.245406, "is_expected": false }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.243548, + "sim": 0.243467, "is_expected": false }, { "rank": 7, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.234609, + "sim": 0.23456, "is_expected": false }, { "rank": 8, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.233764, + "sim": 0.233702, "is_expected": false }, { "rank": 9, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.215734, + "sim": 0.215593, "is_expected": false }, { "rank": 10, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.214005, + "sim": 0.21396, "is_expected": false }, { "rank": 11, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.174035, + "sim": 0.173999, "is_expected": false }, { "rank": 12, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.171497, + "sim": 0.171505, "is_expected": false }, { "rank": 13, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.170158, + "sim": 0.170107, "is_expected": false }, { "rank": 14, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.169816, + "sim": 0.169754, "is_expected": false }, { "rank": 15, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.161673, + "sim": 0.161636, "is_expected": false }, { "rank": 16, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.155349, + "sim": 0.155222, "is_expected": false }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.130771, + "sim": 0.130801, "is_expected": false } ] @@ -5663,120 +6369,137 @@ { "rank": 1, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.512088, + "sim": 0.512008, "is_expected": true }, { "rank": 2, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.352435, + "sim": 0.352418, "is_expected": false }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.302758, + "sim": 0.302815, "is_expected": false }, { "rank": 4, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.295433, + "sim": 0.295467, "is_expected": false }, { "rank": 5, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.287519, + "sim": 0.287548, "is_expected": false }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.252871, + "sim": 0.252927, "is_expected": false }, { "rank": 7, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.248764, + "sim": 0.248825, "is_expected": false }, { "rank": 8, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.248338, + "sim": 0.248411, "is_expected": false }, { "rank": 9, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.242457, + "sim": 0.242476, "is_expected": false }, { "rank": 10, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.233994, + "sim": 0.234014, "is_expected": false }, { "rank": 11, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.224032, + "sim": 0.224067, "is_expected": false }, { "rank": 12, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.218056, + "sim": 0.218041, "is_expected": false }, { "rank": 13, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.2167, + "sim": 0.216768, "is_expected": false }, { "rank": 14, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.214968, + "sim": 0.214997, "is_expected": false }, { "rank": 15, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.203747, + "sim": 0.203725, "is_expected": false }, { "rank": 16, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.203575, + "sim": 0.203565, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.122467, + "sim": 0.122542, "is_expected": false } ] @@ -5795,120 +6518,137 @@ { "rank": 1, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.318721, + "sim": 0.318772, "is_expected": true }, { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.173842, + "sim": 0.173884, "is_expected": false }, { "rank": 3, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.158737, + "sim": 0.15878, "is_expected": false }, { "rank": 4, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.158249, + "sim": 0.158301, "is_expected": false }, { "rank": 5, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.156482, + "sim": 0.156529, "is_expected": false }, { "rank": 6, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.147137, + "sim": 0.147208, "is_expected": false }, { "rank": 7, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.147123, + "sim": 0.147168, "is_expected": false }, { "rank": 8, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.146641, + "sim": 0.146721, "is_expected": false }, { "rank": 9, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.132289, + "sim": 0.132369, "is_expected": false }, { "rank": 10, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.11021, + "sim": 0.110286, "is_expected": false }, { "rank": 11, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.087858, + "sim": 0.087962, "is_expected": false }, { "rank": 12, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.086878, + "sim": 0.086863, "is_expected": false }, { "rank": 13, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.08289, + "sim": 0.082955, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.081009, + "sim": 0.081087, "is_expected": false }, { "rank": 15, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.077132, + "sim": 0.077213, "is_expected": false }, { "rank": 16, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.058358, + "sim": 0.058345, "is_expected": false }, { "rank": 17, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.041162, + "sim": 0.0412, "is_expected": false } ] @@ -5927,120 +6667,137 @@ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.582145, + "sim": 0.582043, "is_expected": true }, { "rank": 2, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.328742, + "sim": 0.328604, "is_expected": false }, { "rank": 3, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.273396, + "sim": 0.273345, "is_expected": false }, { "rank": 4, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.266299, + "sim": 0.266177, "is_expected": false }, { "rank": 5, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.256219, + "sim": 0.256054, "is_expected": false }, { "rank": 6, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.23763, + "sim": 0.237651, "is_expected": false }, { "rank": 7, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.234278, + "sim": 0.234057, "is_expected": false }, { "rank": 8, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.230887, + "sim": 0.230795, "is_expected": false }, { "rank": 9, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.213081, + "sim": 0.212831, "is_expected": false }, { "rank": 10, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.205899, + "sim": 0.205785, "is_expected": false }, { "rank": 11, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.20388, + "sim": 0.203931, "is_expected": false }, { "rank": 12, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.197558, + "sim": 0.197466, "is_expected": false }, { "rank": 13, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.185319, + "sim": 0.18515, "is_expected": false }, { "rank": 14, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.158358, + "sim": 0.158208, "is_expected": false }, { "rank": 15, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.158013, + "sim": 0.158088, "is_expected": false }, { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.157771, + "sim": 0.157766, "is_expected": false }, { "rank": 17, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.151154, + "sim": 0.151342, "is_expected": false } ] @@ -6059,78 +6816,89 @@ { "rank": 1, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.460215, + "sim": 0.460137, "is_expected": true }, { "rank": 2, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.238322, + "sim": 0.238372, "is_expected": false }, { "rank": 3, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.204525, + "sim": 0.204524, "is_expected": false }, { "rank": 4, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.202754, + "sim": 0.202718, "is_expected": false }, { "rank": 5, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.189317, + "sim": 0.189292, "is_expected": false }, { "rank": 6, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.187603, + "sim": 0.187618, "is_expected": false }, { "rank": 7, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.160139, + "sim": 0.160182, "is_expected": false }, { "rank": 8, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.151523, + "sim": 0.15157, "is_expected": false }, { "rank": 9, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.148102, + "sim": 0.148077, "is_expected": false }, { "rank": 10, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.115578, + "sim": 0.115571, "is_expected": false }, { "rank": 11, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.097893, + "sim": 0.097962, "is_expected": false } ] @@ -6149,120 +6917,137 @@ { "rank": 1, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.367678, + "sim": 0.367674, "is_expected": true }, { "rank": 2, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.238474, + "sim": 0.238523, "is_expected": false }, { "rank": 3, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.235241, + "sim": 0.235343, "is_expected": false }, { "rank": 4, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.234824, + "sim": 0.234804, "is_expected": false }, { "rank": 5, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.189364, + "sim": 0.18934, "is_expected": false }, { "rank": 6, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.189252, + "sim": 0.189194, "is_expected": false }, { "rank": 7, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.181652, + "sim": 0.181642, "is_expected": false }, { "rank": 8, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.173482, + "sim": 0.173511, "is_expected": false }, { "rank": 9, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.160137, + "sim": 0.160179, "is_expected": false }, { "rank": 10, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.15155, + "sim": 0.151597, "is_expected": false }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.147559, + "sim": 0.147636, "is_expected": false }, { "rank": 12, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.14646, + "sim": 0.146569, "is_expected": false }, { "rank": 13, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.13956, + "sim": 0.139618, "is_expected": false }, { "rank": 14, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.131285, + "sim": 0.131315, "is_expected": false }, { "rank": 15, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.116321, + "sim": 0.116291, "is_expected": false }, { "rank": 16, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.115578, + "sim": 0.115571, "is_expected": false }, { "rank": 17, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.086247, + "sim": 0.086294, "is_expected": false } ] @@ -6281,78 +7066,89 @@ { "rank": 1, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.37561, + "sim": 0.375539, "is_expected": true }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.311448, + "sim": 0.311493, "is_expected": false }, { "rank": 3, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.271272, + "sim": 0.271278, "is_expected": false }, { "rank": 4, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.265706, + "sim": 0.265843, "is_expected": false }, { "rank": 5, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.237152, + "sim": 0.23716, "is_expected": false }, { "rank": 6, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.202821, + "sim": 0.202948, "is_expected": false }, { "rank": 7, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.19392, + "sim": 0.193958, "is_expected": false }, { "rank": 8, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.175482, + "sim": 0.17558, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.160994, + "sim": 0.161028, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.143968, + "sim": 0.144087, "is_expected": false }, { "rank": 11, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.110459, + "sim": 0.11047, "is_expected": false } ] @@ -6371,78 +7167,89 @@ { "rank": 1, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.39725, + "sim": 0.397375, "is_expected": true }, { "rank": 2, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.238533, + "sim": 0.238404, "is_expected": false }, { "rank": 3, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.223812, + "sim": 0.223783, "is_expected": false }, { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.198149, + "sim": 0.19804, "is_expected": false }, { "rank": 5, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.18192, + "sim": 0.181907, "is_expected": false }, { "rank": 6, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.174772, + "sim": 0.174852, "is_expected": false }, { "rank": 7, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.159205, + "sim": 0.15919, "is_expected": false }, { "rank": 8, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.149761, + "sim": 0.149742, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.075594, + "sim": 0.075591, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.07103, + "sim": 0.071012, "is_expected": false }, { "rank": 11, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.05847, + "sim": 0.058415, "is_expected": false } ] @@ -6461,78 +7268,89 @@ { "rank": 1, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.79707, + "sim": 0.796989, "is_expected": true }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.310082, + "sim": 0.309991, "is_expected": false }, { "rank": 3, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.276451, + "sim": 0.276419, "is_expected": false }, { "rank": 4, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.274258, + "sim": 0.274243, "is_expected": false }, { "rank": 5, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.263459, + "sim": 0.263481, "is_expected": false }, { "rank": 6, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.249966, + "sim": 0.249969, "is_expected": false }, { "rank": 7, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.241795, + "sim": 0.241711, "is_expected": false }, { "rank": 8, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.206084, + "sim": 0.206039, "is_expected": false }, { "rank": 9, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.175205, + "sim": 0.175181, "is_expected": false }, { "rank": 10, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.171889, + "sim": 0.171883, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.156796, + "sim": 0.156749, "is_expected": false } ] @@ -6551,120 +7369,137 @@ { "rank": 1, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.79707, + "sim": 0.796989, "is_expected": true }, { "rank": 2, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.306969, + "sim": 0.306923, "is_expected": false }, { "rank": 3, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.294484, + "sim": 0.294398, "is_expected": false }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.291255, + "sim": 0.29118, "is_expected": false }, { "rank": 5, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.282617, + "sim": 0.282587, "is_expected": false }, { "rank": 6, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.276499, + "sim": 0.276467, "is_expected": false }, { "rank": 7, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.273637, + "sim": 0.273615, "is_expected": false }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.266731, + "sim": 0.266643, "is_expected": false }, { "rank": 9, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.263426, + "sim": 0.263447, "is_expected": false }, { "rank": 10, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.249924, + "sim": 0.249927, "is_expected": false }, { "rank": 11, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.219448, + "sim": 0.219454, "is_expected": false }, { "rank": 12, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.206196, + "sim": 0.206155, "is_expected": false }, { "rank": 13, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.197804, + "sim": 0.197773, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.189804, + "sim": 0.189775, "is_expected": false }, { "rank": 15, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.177817, + "sim": 0.177876, "is_expected": false }, { "rank": 16, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.171848, + "sim": 0.171842, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.159607, + "sim": 0.159581, "is_expected": false } ] @@ -6683,78 +7518,89 @@ { "rank": 1, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.344206, + "sim": 0.344092, "is_expected": true }, { "rank": 2, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.245946, + "sim": 0.246041, "is_expected": false }, { "rank": 3, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.230184, + "sim": 0.230186, "is_expected": false }, { "rank": 4, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.223204, + "sim": 0.223202, "is_expected": false }, { "rank": 5, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.222446, + "sim": 0.222563, "is_expected": false }, { "rank": 6, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.203546, + "sim": 0.203603, "is_expected": false }, { "rank": 7, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.194812, + "sim": 0.194892, "is_expected": false }, { "rank": 8, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.182088, + "sim": 0.182234, "is_expected": false }, { "rank": 9, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.181646, + "sim": 0.181789, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.144106, + "sim": 0.144077, "is_expected": false }, { "rank": 11, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.131703, + "sim": 0.131762, "is_expected": false } ] @@ -6773,69 +7619,79 @@ { "rank": 1, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.592074, + "sim": 0.592231, "is_expected": true }, { "rank": 2, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.422939, + "sim": 0.423085, "is_expected": false }, { "rank": 3, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.311374, + "sim": 0.311325, "is_expected": false }, { "rank": 4, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.308054, + "sim": 0.308083, "is_expected": false }, { "rank": 5, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.303001, + "sim": 0.302907, "is_expected": false }, { "rank": 6, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.285619, + "sim": 0.285677, "is_expected": false }, { "rank": 7, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.280641, + "sim": 0.280649, "is_expected": false }, { "rank": 8, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.276738, + "sim": 0.276803, "is_expected": false }, { "rank": 9, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.250459, + "sim": 0.250488, "is_expected": false }, { "rank": 10, "record_id": 290, + "context_id": 290, "name": "힉스커피", "sim": 0.247868, "is_expected": false @@ -6843,50 +7699,57 @@ { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.245305, + "sim": 0.245327, "is_expected": false }, { "rank": 12, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.226196, + "sim": 0.226229, "is_expected": false }, { "rank": 13, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.2181, + "sim": 0.218094, "is_expected": false }, { "rank": 14, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.208239, + "sim": 0.208214, "is_expected": false }, { "rank": 15, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.206661, + "sim": 0.206667, "is_expected": false }, { "rank": 16, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.158491, + "sim": 0.158523, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.149897, + "sim": 0.14996, "is_expected": false } ] @@ -6905,78 +7768,89 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.37469, + "sim": 0.374681, "is_expected": true }, { "rank": 2, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.222832, + "sim": 0.222774, "is_expected": false }, { "rank": 3, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.222644, + "sim": 0.2227, "is_expected": false }, { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.216409, + "sim": 0.216384, "is_expected": false }, { "rank": 5, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.215822, + "sim": 0.215756, "is_expected": false }, { "rank": 6, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.193764, + "sim": 0.193765, "is_expected": false }, { "rank": 7, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.16937, + "sim": 0.169317, "is_expected": false }, { "rank": 8, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.163612, + "sim": 0.163548, "is_expected": false }, { "rank": 9, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.1515, + "sim": 0.151432, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.112253, + "sim": 0.112232, "is_expected": false }, { "rank": 11, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.06729, + "sim": 0.067242, "is_expected": false } ] @@ -6995,120 +7869,137 @@ { "rank": 1, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.374662, + "sim": 0.374652, "is_expected": true }, { "rank": 2, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.222656, + "sim": 0.222712, "is_expected": false }, { "rank": 3, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.216405, + "sim": 0.216379, "is_expected": false }, { "rank": 4, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.216294, + "sim": 0.216246, "is_expected": false }, { "rank": 5, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.215861, + "sim": 0.215794, "is_expected": false }, { "rank": 6, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.201893, + "sim": 0.20184, "is_expected": false }, { "rank": 7, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.198415, + "sim": 0.198376, "is_expected": false }, { "rank": 8, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.196365, + "sim": 0.19645, "is_expected": false }, { "rank": 9, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.193764, + "sim": 0.193765, "is_expected": false }, { "rank": 10, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.172496, + "sim": 0.172509, "is_expected": false }, { "rank": 11, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.164313, + "sim": 0.164327, "is_expected": false }, { "rank": 12, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.157248, + "sim": 0.157206, "is_expected": false }, { "rank": 13, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.150371, + "sim": 0.150329, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.119217, + "sim": 0.119108, "is_expected": false }, { "rank": 15, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.116276, + "sim": 0.116177, "is_expected": false }, { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.081577, + "sim": 0.081487, "is_expected": false }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.042522, + "sim": 0.042475, "is_expected": false } ] @@ -7127,120 +8018,137 @@ { "rank": 1, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.342273, + "sim": 0.339589, "is_expected": true }, { "rank": 2, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.245076, + "sim": 0.241467, "is_expected": false }, { "rank": 3, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.242253, + "sim": 0.23959, "is_expected": false }, { "rank": 4, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.23595, + "sim": 0.232512, "is_expected": false }, { "rank": 5, - "record_id": 291, - "name": "오케이어 맨션", - "sim": 0.222993, + "record_id": 289, + "context_id": 289, + "name": "모던아시안누들서비스", + "sim": 0.219638, "is_expected": false }, { "rank": 6, - "record_id": 289, - "name": "모던아시안누들서비스", - "sim": 0.222565, + "record_id": 291, + "context_id": 291, + "name": "오케이어 맨션", + "sim": 0.219105, "is_expected": false }, { "rank": 7, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.192052, + "sim": 0.193686, "is_expected": false }, { "rank": 8, - "record_id": 290, - "name": "힉스커피", - "sim": 0.183049, + "record_id": 286, + "context_id": 286, + "name": "저스트텐동 연남본점", + "sim": 0.18207, "is_expected": false }, { "rank": 9, - "record_id": 286, - "name": "저스트텐동 연남본점", - "sim": 0.182101, + "record_id": 290, + "context_id": 290, + "name": "힉스커피", + "sim": 0.181017, "is_expected": false }, { "rank": 10, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.172901, + "sim": 0.173496, "is_expected": false }, { "rank": 11, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.170997, + "sim": 0.171214, "is_expected": false }, { "rank": 12, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.15252, + "sim": 0.155709, "is_expected": false }, { "rank": 13, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.135313, + "sim": 0.134749, "is_expected": false }, { "rank": 14, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.131744, + "sim": 0.134447, "is_expected": false }, { "rank": 15, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.126739, + "sim": 0.123564, "is_expected": false }, { "rank": 16, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.116244, + "sim": 0.115038, "is_expected": false }, { "rank": 17, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.114032, + "sim": 0.114384, "is_expected": false } ] @@ -7259,43 +8167,49 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.198217, + "sim": 0.198188, "is_expected": false }, { "rank": 2, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.197817, + "sim": 0.197712, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.175827, + "sim": 0.175783, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.170948, + "sim": 0.170873, "is_expected": false }, { "rank": 5, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.170513, + "sim": 0.170372, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.165519, + "sim": 0.165512, "is_expected": false } ] @@ -7312,13 +8226,15 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.267655, + "sim": 0.267671, "is_expected": false }, { "rank": 2, "record_id": 268, + "context_id": 268, "name": "키친갈매기", "sim": 0.242326, "is_expected": false @@ -7326,64 +8242,73 @@ { "rank": 3, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.241596, + "sim": 0.241562, "is_expected": false }, { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.20928, + "sim": 0.209223, "is_expected": false }, { "rank": 5, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.202069, + "sim": 0.202019, "is_expected": false }, { "rank": 6, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.172736, + "sim": 0.172709, "is_expected": false }, { "rank": 7, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.167091, + "sim": 0.16705, "is_expected": false }, { "rank": 8, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.158875, + "sim": 0.158939, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.151762, + "sim": 0.151746, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.148349, + "sim": 0.148335, "is_expected": false }, { "rank": 11, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.128699, + "sim": 0.128715, "is_expected": false } ] @@ -7400,20 +8325,23 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.249768, + "sim": 0.24974, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.235628, + "sim": 0.2356, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", "sim": 0.212034, "is_expected": false @@ -7421,22 +8349,25 @@ { "rank": 4, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.202092, + "sim": 0.202089, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.193141, + "sim": 0.193154, "is_expected": false }, { "rank": 6, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.170104, + "sim": 0.170055, "is_expected": false } ] @@ -7453,78 +8384,89 @@ { "rank": 1, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.215834, + "sim": 0.215769, "is_expected": false }, { "rank": 2, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.210743, + "sim": 0.210726, "is_expected": false }, { "rank": 3, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.197621, + "sim": 0.197546, "is_expected": false }, { "rank": 4, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.195327, + "sim": 0.195282, "is_expected": false }, { "rank": 5, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.189618, + "sim": 0.189528, "is_expected": false }, { "rank": 6, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.183973, + "sim": 0.183806, "is_expected": false }, { "rank": 7, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.169638, + "sim": 0.169679, "is_expected": false }, { "rank": 8, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.153986, + "sim": 0.154024, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.143791, + "sim": 0.143767, "is_expected": false }, { "rank": 10, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.118786, + "sim": 0.11879, "is_expected": false }, { "rank": 11, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.056463, + "sim": 0.05647, "is_expected": false } ] @@ -7541,43 +8483,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.272963, + "sim": 0.273023, "is_expected": false }, { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.219154, + "sim": 0.219098, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.215035, + "sim": 0.214965, "is_expected": false }, { "rank": 4, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.200793, + "sim": 0.200827, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.196934, + "sim": 0.197005, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.163362, + "sim": 0.163369, "is_expected": false } ] @@ -7594,78 +8542,89 @@ { "rank": 1, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.222949, + "sim": 0.222966, "is_expected": false }, { "rank": 2, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.222377, + "sim": 0.222482, "is_expected": false }, { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.212826, + "sim": 0.21288, "is_expected": false }, { "rank": 4, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.21143, + "sim": 0.211386, "is_expected": false }, { "rank": 5, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.210542, + "sim": 0.210619, "is_expected": false }, { "rank": 6, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.202226, + "sim": 0.202213, "is_expected": false }, { "rank": 7, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.201497, + "sim": 0.201415, "is_expected": false }, { "rank": 8, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.189066, + "sim": 0.189041, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.184952, + "sim": 0.184862, "is_expected": false }, { "rank": 10, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.178931, + "sim": 0.178915, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.124885, + "sim": 0.124941, "is_expected": false } ] @@ -7682,43 +8641,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.157422, + "sim": 0.157513, "is_expected": false }, { "rank": 2, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.140023, + "sim": 0.140103, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.127748, + "sim": 0.127767, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.077637, + "sim": 0.07766, "is_expected": false }, { "rank": 5, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.054247, + "sim": 0.054342, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.052944, + "sim": 0.053057, "is_expected": false } ] @@ -7735,78 +8700,89 @@ { "rank": 1, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.2968, + "sim": 0.296808, "is_expected": false }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.237861, + "sim": 0.237882, "is_expected": false }, { "rank": 3, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.179367, + "sim": 0.179386, "is_expected": false }, { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.174412, + "sim": 0.174567, "is_expected": false }, { "rank": 5, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.142656, + "sim": 0.142773, "is_expected": false }, { "rank": 6, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.140868, + "sim": 0.140926, "is_expected": false }, { "rank": 7, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.138599, + "sim": 0.138575, "is_expected": false }, { "rank": 8, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.13272, + "sim": 0.132853, "is_expected": false }, { "rank": 9, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.119062, + "sim": 0.11917, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.080998, + "sim": 0.081144, "is_expected": false }, { "rank": 11, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.07856, + "sim": 0.078644, "is_expected": false } ] @@ -7823,43 +8799,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.200769, + "sim": 0.200811, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.176684, + "sim": 0.176728, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.142444, + "sim": 0.142431, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.120894, + "sim": 0.120951, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.113435, + "sim": 0.113445, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.112595, + "sim": 0.112634, "is_expected": false } ] @@ -7876,78 +8858,89 @@ { "rank": 1, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.384777, + "sim": 0.384834, "is_expected": false }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.208055, + "sim": 0.208241, "is_expected": false }, { "rank": 3, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.201326, + "sim": 0.201415, "is_expected": false }, { "rank": 4, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.198455, + "sim": 0.198558, "is_expected": false }, { "rank": 5, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.170103, + "sim": 0.170151, "is_expected": false }, { "rank": 6, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.164383, + "sim": 0.164472, "is_expected": false }, { "rank": 7, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.161136, + "sim": 0.16126, "is_expected": false }, { "rank": 8, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.15366, + "sim": 0.153782, "is_expected": false }, { "rank": 9, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.15345, + "sim": 0.153479, "is_expected": false }, { "rank": 10, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.148053, + "sim": 0.148173, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.144999, + "sim": 0.145017, "is_expected": false } ] @@ -7964,43 +8957,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.200869, + "sim": 0.200935, "is_expected": false }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.180991, + "sim": 0.181058, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.17498, + "sim": 0.174984, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.162661, + "sim": 0.162735, "is_expected": false }, { "rank": 5, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.15222, + "sim": 0.152273, "is_expected": false }, { "rank": 6, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.123104, + "sim": 0.123095, "is_expected": false } ] @@ -8017,78 +9016,89 @@ { "rank": 1, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.24923, + "sim": 0.249274, "is_expected": false }, { "rank": 2, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.229906, + "sim": 0.229869, "is_expected": false }, { "rank": 3, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.221577, + "sim": 0.221445, "is_expected": false }, { "rank": 4, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.216637, + "sim": 0.216664, "is_expected": false }, { "rank": 5, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.210131, + "sim": 0.21025, "is_expected": false }, { "rank": 6, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.200098, + "sim": 0.200155, "is_expected": false }, { "rank": 7, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.191927, + "sim": 0.191999, "is_expected": false }, { "rank": 8, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.190228, + "sim": 0.190268, "is_expected": false }, { "rank": 9, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.161448, + "sim": 0.161513, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.148873, + "sim": 0.148976, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.114122, + "sim": 0.114191, "is_expected": false } ] @@ -8105,43 +9115,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.203244, + "sim": 0.203357, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.180682, + "sim": 0.180576, "is_expected": false }, { "rank": 3, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.168761, + "sim": 0.168882, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.161053, + "sim": 0.16119, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.146017, + "sim": 0.14607, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.07564, + "sim": 0.075709, "is_expected": false } ] @@ -8158,78 +9174,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.248601, + "sim": 0.248683, "is_expected": false }, { "rank": 2, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.225736, + "sim": 0.225793, "is_expected": false }, { "rank": 3, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.192787, + "sim": 0.192837, "is_expected": false }, { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.185589, + "sim": 0.185728, "is_expected": false }, { "rank": 5, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.183852, + "sim": 0.183959, "is_expected": false }, { "rank": 6, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.172717, + "sim": 0.172752, "is_expected": false }, { "rank": 7, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.170608, + "sim": 0.170766, "is_expected": false }, { "rank": 8, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.153552, + "sim": 0.153706, "is_expected": false }, { "rank": 9, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.14948, + "sim": 0.149552, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.114526, + "sim": 0.114635, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.107507, + "sim": 0.107564, "is_expected": false } ] @@ -8246,43 +9273,49 @@ { "rank": 1, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.300869, + "sim": 0.300913, "is_expected": false }, { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.217833, + "sim": 0.21787, "is_expected": false }, { "rank": 3, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.215261, + "sim": 0.21523, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.183355, + "sim": 0.183356, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.182735, + "sim": 0.1827, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.14441, + "sim": 0.144408, "is_expected": false } ] @@ -8299,78 +9332,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.235393, + "sim": 0.235491, "is_expected": false }, { "rank": 2, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.200953, + "sim": 0.201095, "is_expected": false }, { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.17736, + "sim": 0.177364, "is_expected": false }, { "rank": 4, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.153963, + "sim": 0.153956, "is_expected": false }, { "rank": 5, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.151998, + "sim": 0.15209, "is_expected": false }, { "rank": 6, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.137395, + "sim": 0.137447, "is_expected": false }, { "rank": 7, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.136999, + "sim": 0.137016, "is_expected": false }, { "rank": 8, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.136513, + "sim": 0.13658, "is_expected": false }, { "rank": 9, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.132124, + "sim": 0.132186, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.12441, + "sim": 0.124398, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.123046, + "sim": 0.123073, "is_expected": false } ] @@ -8387,43 +9431,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.228779, + "sim": 0.228745, "is_expected": false }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.197581, + "sim": 0.197647, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.194737, + "sim": 0.194822, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.169165, + "sim": 0.169194, "is_expected": false }, { "rank": 5, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.159348, + "sim": 0.159417, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.137128, + "sim": 0.13715, "is_expected": false } ] @@ -8440,43 +9490,49 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.200458, + "sim": 0.200461, "is_expected": false }, { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.189162, + "sim": 0.189245, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.164343, + "sim": 0.164384, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.157951, + "sim": 0.158054, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.124035, + "sim": 0.124084, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.110881, + "sim": 0.110916, "is_expected": false } ] @@ -8493,43 +9549,49 @@ { "rank": 1, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.285461, + "sim": 0.285484, "is_expected": false }, { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.21075, + "sim": 0.210741, "is_expected": false }, { "rank": 3, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.179293, + "sim": 0.179211, "is_expected": false }, { "rank": 4, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.178911, + "sim": 0.178913, "is_expected": false }, { "rank": 5, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.176747, + "sim": 0.176809, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.15895, + "sim": 0.158874, "is_expected": false } ] @@ -8546,43 +9608,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.122422, + "sim": 0.122381, "is_expected": false }, { "rank": 2, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.117531, + "sim": 0.117479, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.117381, + "sim": 0.117319, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.100787, + "sim": 0.100702, "is_expected": false }, { "rank": 5, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.071102, + "sim": 0.071089, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.039268, + "sim": 0.039253, "is_expected": false } ] @@ -8599,90 +9667,103 @@ { "rank": 1, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.228763, + "sim": 0.228749, "is_expected": false }, { "rank": 2, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.205613, + "sim": 0.205588, "is_expected": false }, { "rank": 3, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.198235, + "sim": 0.198211, "is_expected": false }, { "rank": 4, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.195634, + "sim": 0.195654, "is_expected": false }, { "rank": 5, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.157485, + "sim": 0.157363, "is_expected": false }, { "rank": 6, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.146551, + "sim": 0.14651, "is_expected": false }, { "rank": 7, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.139499, + "sim": 0.13943, "is_expected": false }, { "rank": 8, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.137013, + "sim": 0.136946, "is_expected": false }, { "rank": 9, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.135516, + "sim": 0.13549, "is_expected": false }, { "rank": 10, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.113883, + "sim": 0.113826, "is_expected": false }, { "rank": 11, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.111255, + "sim": 0.111248, "is_expected": false }, { "rank": 12, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.109971, + "sim": 0.109925, "is_expected": false }, { "rank": 13, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", "sim": 0.099133, "is_expected": false @@ -8690,29 +9771,33 @@ { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.096902, + "sim": 0.096879, "is_expected": false }, { "rank": 15, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.095449, + "sim": 0.095401, "is_expected": false }, { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.053708, + "sim": 0.053695, "is_expected": false }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": -0.006965, + "sim": -0.007043, "is_expected": false } ] @@ -8729,43 +9814,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.240518, + "sim": 0.240648, "is_expected": false }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.186638, + "sim": 0.18676, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.165102, + "sim": 0.165044, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.160428, + "sim": 0.160498, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.1591, + "sim": 0.159086, "is_expected": false }, { "rank": 6, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.144541, + "sim": 0.144472, "is_expected": false } ] @@ -8782,83 +9873,95 @@ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.234771, + "sim": 0.234711, "is_expected": false }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.22422, + "sim": 0.224064, "is_expected": false }, { "rank": 3, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.211706, + "sim": 0.211668, "is_expected": false }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.209183, + "sim": 0.209007, "is_expected": false }, { "rank": 5, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.201601, + "sim": 0.201561, "is_expected": false }, { "rank": 6, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.197555, + "sim": 0.197512, "is_expected": false }, { "rank": 7, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.196995, + "sim": 0.196992, "is_expected": false }, { "rank": 8, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.196845, + "sim": 0.196816, "is_expected": false }, { "rank": 9, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.192259, + "sim": 0.192081, "is_expected": false }, { "rank": 10, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.189342, + "sim": 0.18933, "is_expected": false }, { "rank": 11, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.184519, + "sim": 0.18453, "is_expected": false }, { "rank": 12, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", "sim": 0.178205, "is_expected": false @@ -8866,36 +9969,41 @@ { "rank": 13, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.136519, + "sim": 0.136548, "is_expected": false }, { "rank": 14, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.120191, + "sim": 0.120137, "is_expected": false }, { "rank": 15, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.118828, + "sim": 0.118821, "is_expected": false }, { "rank": 16, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.110353, + "sim": 0.110282, "is_expected": false }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.101934, + "sim": 0.101845, "is_expected": false } ] @@ -8912,43 +10020,49 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.243688, + "sim": 0.243735, "is_expected": false }, { "rank": 2, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.21889, + "sim": 0.218863, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.20872, + "sim": 0.208735, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.189425, + "sim": 0.189452, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.172462, + "sim": 0.172433, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.168528, + "sim": 0.168513, "is_expected": false } ] @@ -8965,43 +10079,49 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.284038, + "sim": 0.283984, "is_expected": false }, { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.272823, + "sim": 0.272853, "is_expected": false }, { "rank": 3, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.24848, + "sim": 0.248508, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.217073, + "sim": 0.217076, "is_expected": false }, { "rank": 5, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.185197, + "sim": 0.185102, "is_expected": false }, { "rank": 6, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.111425, + "sim": 0.11141, "is_expected": false } ] @@ -9018,78 +10138,89 @@ { "rank": 1, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.258778, + "sim": 0.25867, "is_expected": false }, { "rank": 2, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.25539, + "sim": 0.255356, "is_expected": false }, { "rank": 3, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.243777, + "sim": 0.24373, "is_expected": false }, { "rank": 4, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.214851, + "sim": 0.214807, "is_expected": false }, { "rank": 5, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.214416, + "sim": 0.214365, "is_expected": false }, { "rank": 6, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.201794, + "sim": 0.201817, "is_expected": false }, { "rank": 7, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.195149, + "sim": 0.195169, "is_expected": false }, { "rank": 8, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.192425, + "sim": 0.19235, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.190987, + "sim": 0.190942, "is_expected": false }, { "rank": 10, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.151515, + "sim": 0.1515, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.138141, + "sim": 0.13804, "is_expected": false } ] @@ -9106,104 +10237,119 @@ { "rank": 1, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.258739, + "sim": 0.258631, "is_expected": false }, { "rank": 2, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.256442, + "sim": 0.256475, "is_expected": false }, { "rank": 3, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.255459, + "sim": 0.255425, "is_expected": false }, { "rank": 4, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.248395, + "sim": 0.248383, "is_expected": false }, { "rank": 5, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.245352, + "sim": 0.24532, "is_expected": false }, { "rank": 6, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.243817, + "sim": 0.243771, "is_expected": false }, { "rank": 7, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.229719, + "sim": 0.229712, "is_expected": false }, { "rank": 8, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.214851, + "sim": 0.214807, "is_expected": false }, { "rank": 9, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.192481, + "sim": 0.192406, "is_expected": false }, { "rank": 10, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.187629, + "sim": 0.187628, "is_expected": false }, { "rank": 11, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.186949, + "sim": 0.186974, "is_expected": false }, { "rank": 12, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.179285, + "sim": 0.179287, "is_expected": false }, { "rank": 13, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.172424, + "sim": 0.172453, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.171774, + "sim": 0.171787, "is_expected": false }, { "rank": 15, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", "sim": 0.167669, "is_expected": false @@ -9211,15 +10357,17 @@ { "rank": 16, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.155296, + "sim": 0.155287, "is_expected": false }, { "rank": 17, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.098134, + "sim": 0.098082, "is_expected": false } ] @@ -9236,6 +10384,7 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", "sim": 0.290744, "is_expected": false @@ -9243,6 +10392,7 @@ { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", "sim": 0.221815, "is_expected": false @@ -9250,6 +10400,7 @@ { "rank": 3, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", "sim": 0.204773, "is_expected": false @@ -9257,6 +10408,7 @@ { "rank": 4, "record_id": 273, + "context_id": 273, "name": "오향절면", "sim": 0.201875, "is_expected": false @@ -9264,6 +10416,7 @@ { "rank": 5, "record_id": 267, + "context_id": 267, "name": "애플하우스", "sim": 0.185133, "is_expected": false @@ -9271,6 +10424,7 @@ { "rank": 6, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", "sim": 0.171592, "is_expected": false @@ -9278,6 +10432,7 @@ { "rank": 7, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", "sim": 0.165317, "is_expected": false @@ -9285,6 +10440,7 @@ { "rank": 8, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", "sim": 0.149781, "is_expected": false @@ -9292,6 +10448,7 @@ { "rank": 9, "record_id": 270, + "context_id": 270, "name": "적당", "sim": 0.146985, "is_expected": false @@ -9299,6 +10456,7 @@ { "rank": 10, "record_id": 268, + "context_id": 268, "name": "키친갈매기", "sim": 0.146236, "is_expected": false @@ -9306,6 +10464,7 @@ { "rank": 11, "record_id": 266, + "context_id": 266, "name": "도피", "sim": 0.08299, "is_expected": false @@ -9324,6 +10483,7 @@ { "rank": 1, "record_id": 290, + "context_id": 290, "name": "힉스커피", "sim": 0.29083, "is_expected": false @@ -9331,6 +10491,7 @@ { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", "sim": 0.237436, "is_expected": false @@ -9338,6 +10499,7 @@ { "rank": 3, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", "sim": 0.229416, "is_expected": false @@ -9345,6 +10507,7 @@ { "rank": 4, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", "sim": 0.217607, "is_expected": false @@ -9352,6 +10515,7 @@ { "rank": 5, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", "sim": 0.211919, "is_expected": false @@ -9359,6 +10523,7 @@ { "rank": 6, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", "sim": 0.179457, "is_expected": false @@ -9366,6 +10531,7 @@ { "rank": 7, "record_id": 288, + "context_id": 288, "name": "연남칼국수", "sim": 0.174586, "is_expected": false @@ -9373,6 +10539,7 @@ { "rank": 8, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", "sim": 0.1714, "is_expected": false @@ -9380,6 +10547,7 @@ { "rank": 9, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", "sim": 0.168582, "is_expected": false @@ -9387,6 +10555,7 @@ { "rank": 10, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", "sim": 0.165416, "is_expected": false @@ -9394,6 +10563,7 @@ { "rank": 11, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", "sim": 0.149781, "is_expected": false @@ -9401,6 +10571,7 @@ { "rank": 12, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", "sim": 0.148799, "is_expected": false @@ -9408,6 +10579,7 @@ { "rank": 13, "record_id": 292, + "context_id": 292, "name": "적당", "sim": 0.146982, "is_expected": false @@ -9415,6 +10587,7 @@ { "rank": 14, "record_id": 293, + "context_id": 293, "name": "키친갈매기", "sim": 0.146283, "is_expected": false @@ -9422,6 +10595,7 @@ { "rank": 15, "record_id": 277, + "context_id": 277, "name": "카츠요", "sim": 0.120823, "is_expected": false @@ -9429,6 +10603,7 @@ { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", "sim": 0.104371, "is_expected": false @@ -9436,6 +10611,7 @@ { "rank": 17, "record_id": 279, + "context_id": 279, "name": "사루카메", "sim": 0.09751, "is_expected": false @@ -9454,6 +10630,7 @@ { "rank": 1, "record_id": 267, + "context_id": 267, "name": "애플하우스", "sim": 0.253395, "is_expected": false @@ -9461,6 +10638,7 @@ { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", "sim": 0.244218, "is_expected": false @@ -9468,6 +10646,7 @@ { "rank": 3, "record_id": 273, + "context_id": 273, "name": "오향절면", "sim": 0.24099, "is_expected": false @@ -9475,6 +10654,7 @@ { "rank": 4, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", "sim": 0.237917, "is_expected": false @@ -9482,6 +10662,7 @@ { "rank": 5, "record_id": 275, + "context_id": 275, "name": "힉스커피", "sim": 0.229994, "is_expected": false @@ -9489,6 +10670,7 @@ { "rank": 6, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", "sim": 0.209424, "is_expected": false @@ -9496,6 +10678,7 @@ { "rank": 7, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", "sim": 0.195589, "is_expected": false @@ -9503,6 +10686,7 @@ { "rank": 8, "record_id": 268, + "context_id": 268, "name": "키친갈매기", "sim": 0.192998, "is_expected": false @@ -9510,6 +10694,7 @@ { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", "sim": 0.188952, "is_expected": false @@ -9517,6 +10702,7 @@ { "rank": 10, "record_id": 270, + "context_id": 270, "name": "적당", "sim": 0.187143, "is_expected": false @@ -9524,6 +10710,7 @@ { "rank": 11, "record_id": 266, + "context_id": 266, "name": "도피", "sim": 0.13962, "is_expected": false @@ -9542,6 +10729,7 @@ { "rank": 1, "record_id": 288, + "context_id": 288, "name": "연남칼국수", "sim": 0.258533, "is_expected": false @@ -9549,6 +10737,7 @@ { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", "sim": 0.25786, "is_expected": false @@ -9556,6 +10745,7 @@ { "rank": 3, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", "sim": 0.247367, "is_expected": false @@ -9563,6 +10753,7 @@ { "rank": 4, "record_id": 290, + "context_id": 290, "name": "힉스커피", "sim": 0.230069, "is_expected": false @@ -9570,6 +10761,7 @@ { "rank": 5, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", "sim": 0.228702, "is_expected": false @@ -9577,6 +10769,7 @@ { "rank": 6, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", "sim": 0.228658, "is_expected": false @@ -9584,6 +10777,7 @@ { "rank": 7, "record_id": 279, + "context_id": 279, "name": "사루카메", "sim": 0.224059, "is_expected": false @@ -9591,6 +10785,7 @@ { "rank": 8, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", "sim": 0.222416, "is_expected": false @@ -9598,6 +10793,7 @@ { "rank": 9, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", "sim": 0.214126, "is_expected": false @@ -9605,6 +10801,7 @@ { "rank": 10, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", "sim": 0.209424, "is_expected": false @@ -9612,6 +10809,7 @@ { "rank": 11, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", "sim": 0.195627, "is_expected": false @@ -9619,6 +10817,7 @@ { "rank": 12, "record_id": 293, + "context_id": 293, "name": "키친갈매기", "sim": 0.192967, "is_expected": false @@ -9626,6 +10825,7 @@ { "rank": 13, "record_id": 292, + "context_id": 292, "name": "적당", "sim": 0.187204, "is_expected": false @@ -9633,6 +10833,7 @@ { "rank": 14, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", "sim": 0.169416, "is_expected": false @@ -9640,6 +10841,7 @@ { "rank": 15, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", "sim": 0.165309, "is_expected": false @@ -9647,6 +10849,7 @@ { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", "sim": 0.144137, "is_expected": false @@ -9654,6 +10857,7 @@ { "rank": 17, "record_id": 277, + "context_id": 277, "name": "카츠요", "sim": 0.142001, "is_expected": false @@ -9672,43 +10876,49 @@ { "rank": 1, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.330393, + "sim": 0.330308, "is_expected": false }, { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.304196, + "sim": 0.304158, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.252521, + "sim": 0.252448, "is_expected": false }, { "rank": 4, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.236958, + "sim": 0.237008, "is_expected": false }, { "rank": 5, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.208011, + "sim": 0.208084, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.188677, + "sim": 0.18868, "is_expected": false } ] @@ -9725,78 +10935,89 @@ { "rank": 1, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.32342, + "sim": 0.323463, "is_expected": false }, { "rank": 2, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.315762, + "sim": 0.315706, "is_expected": false }, { "rank": 3, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.285847, + "sim": 0.285919, "is_expected": false }, { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.272961, + "sim": 0.273018, "is_expected": false }, { "rank": 5, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.260221, + "sim": 0.260253, "is_expected": false }, { "rank": 6, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.257424, + "sim": 0.257462, "is_expected": false }, { "rank": 7, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.23895, + "sim": 0.238925, "is_expected": false }, { "rank": 8, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.232671, + "sim": 0.232697, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.206627, + "sim": 0.206692, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.163478, + "sim": 0.163493, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.12765, + "sim": 0.127713, "is_expected": false } ] @@ -9813,43 +11034,49 @@ { "rank": 1, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.254083, + "sim": 0.254041, "is_expected": false }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.230958, + "sim": 0.230912, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.218429, + "sim": 0.218308, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.152084, + "sim": 0.15199, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.092094, + "sim": 0.092041, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.086573, + "sim": 0.086524, "is_expected": false } ] @@ -9866,78 +11093,89 @@ { "rank": 1, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.250424, + "sim": 0.250365, "is_expected": false }, { "rank": 2, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.244031, + "sim": 0.243961, "is_expected": false }, { "rank": 3, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.230113, + "sim": 0.230007, "is_expected": false }, { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.200868, + "sim": 0.200809, "is_expected": false }, { "rank": 5, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.189419, + "sim": 0.189377, "is_expected": false }, { "rank": 6, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.17302, + "sim": 0.172972, "is_expected": false }, { "rank": 7, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.164566, + "sim": 0.164528, "is_expected": false }, { "rank": 8, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.163362, + "sim": 0.163383, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.148528, + "sim": 0.1484, "is_expected": false }, { "rank": 10, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.109616, + "sim": 0.109513, "is_expected": false }, { "rank": 11, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.095545, + "sim": 0.095482, "is_expected": false } ] @@ -9954,43 +11192,49 @@ { "rank": 1, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.287332, + "sim": 0.287351, "is_expected": false }, { "rank": 2, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.281226, + "sim": 0.281229, "is_expected": false }, { "rank": 3, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.248739, + "sim": 0.248661, "is_expected": false }, { "rank": 4, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.247837, + "sim": 0.247826, "is_expected": false }, { "rank": 5, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.167308, + "sim": 0.167364, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.136064, + "sim": 0.136004, "is_expected": false } ] @@ -10007,78 +11251,89 @@ { "rank": 1, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.388929, + "sim": 0.38893, "is_expected": false }, { "rank": 2, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.384374, + "sim": 0.384384, "is_expected": false }, { "rank": 3, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.32871, + "sim": 0.328665, "is_expected": false }, { "rank": 4, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.326758, + "sim": 0.326625, "is_expected": false }, { "rank": 5, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.319847, + "sim": 0.319877, "is_expected": false }, { "rank": 6, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.310582, + "sim": 0.310577, "is_expected": false }, { "rank": 7, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.304562, + "sim": 0.304626, "is_expected": false }, { "rank": 8, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.27569, + "sim": 0.275702, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.237129, + "sim": 0.237145, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.214921, + "sim": 0.214914, "is_expected": false }, { "rank": 11, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.192063, + "sim": 0.191987, "is_expected": false } ] @@ -10095,43 +11350,49 @@ { "rank": 1, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.258135, + "sim": 0.258162, "is_expected": false }, { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.254532, + "sim": 0.25445, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.204365, + "sim": 0.204362, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.203519, + "sim": 0.203489, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.196245, + "sim": 0.196203, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.118142, + "sim": 0.118104, "is_expected": false } ] @@ -10148,78 +11409,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.315674, + "sim": 0.315688, "is_expected": false }, { "rank": 2, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.288755, + "sim": 0.288778, "is_expected": false }, { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.281844, + "sim": 0.281784, "is_expected": false }, { "rank": 4, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.265549, + "sim": 0.265528, "is_expected": false }, { "rank": 5, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.227613, + "sim": 0.227563, "is_expected": false }, { "rank": 6, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.226107, + "sim": 0.226074, "is_expected": false }, { "rank": 7, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.22591, + "sim": 0.225852, "is_expected": false }, { "rank": 8, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.1852, + "sim": 0.185186, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.177128, + "sim": 0.177064, "is_expected": false }, { "rank": 10, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.169745, + "sim": 0.169699, "is_expected": false }, { "rank": 11, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.162374, + "sim": 0.162355, "is_expected": false } ] @@ -10236,43 +11508,49 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.219237, + "sim": 0.219408, "is_expected": false }, { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.218235, + "sim": 0.218458, "is_expected": false }, { "rank": 3, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.198233, + "sim": 0.198273, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.193177, + "sim": 0.193314, "is_expected": false }, { "rank": 5, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.191704, + "sim": 0.191767, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.180721, + "sim": 0.18079, "is_expected": false } ] @@ -10289,43 +11567,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.224231, + "sim": 0.224222, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.170143, + "sim": 0.170079, "is_expected": false }, { "rank": 3, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.166715, + "sim": 0.166598, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.147537, + "sim": 0.1476, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.121575, + "sim": 0.121502, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.088235, + "sim": 0.088173, "is_expected": false } ] @@ -10342,43 +11626,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.263213, + "sim": 0.263181, "is_expected": false }, { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.231737, + "sim": 0.231746, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.228153, + "sim": 0.228207, "is_expected": false }, { "rank": 4, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.225875, + "sim": 0.225871, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.222071, + "sim": 0.22212, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.159132, + "sim": 0.159133, "is_expected": false } ] @@ -10395,120 +11685,137 @@ { "rank": 1, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.329649, + "sim": 0.32964, "is_expected": false }, { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.319071, + "sim": 0.319098, "is_expected": false }, { "rank": 3, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.310755, + "sim": 0.310736, "is_expected": false }, { "rank": 4, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.279099, + "sim": 0.279155, "is_expected": false }, { "rank": 5, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.268735, + "sim": 0.268746, "is_expected": false }, { "rank": 6, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.262641, + "sim": 0.262578, "is_expected": false }, { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.247889, + "sim": 0.247863, "is_expected": false }, { "rank": 8, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.243331, + "sim": 0.243421, "is_expected": false }, { "rank": 9, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.240158, + "sim": 0.240178, "is_expected": false }, { "rank": 10, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.240077, + "sim": 0.240063, "is_expected": false }, { "rank": 11, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.239048, + "sim": 0.239074, "is_expected": false }, { "rank": 12, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.225866, + "sim": 0.225856, "is_expected": false }, { "rank": 13, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.221616, + "sim": 0.221601, "is_expected": false }, { "rank": 14, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.21495, + "sim": 0.214972, "is_expected": false }, { "rank": 15, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.207352, + "sim": 0.207385, "is_expected": false }, { "rank": 16, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.205808, + "sim": 0.205826, "is_expected": false }, { "rank": 17, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.205484, + "sim": 0.205495, "is_expected": false } ] @@ -10525,43 +11832,49 @@ { "rank": 1, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.211296, + "sim": 0.211449, "is_expected": false }, { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.178777, + "sim": 0.178852, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.16155, + "sim": 0.161606, "is_expected": false }, { "rank": 4, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.152972, + "sim": 0.153019, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.135793, + "sim": 0.135801, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.075291, + "sim": 0.075311, "is_expected": false } ] @@ -10578,120 +11891,137 @@ { "rank": 1, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.287827, + "sim": 0.28795, "is_expected": false }, { "rank": 2, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.284231, + "sim": 0.28436, "is_expected": false }, { "rank": 3, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.24472, + "sim": 0.244925, "is_expected": false }, { "rank": 4, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.243602, + "sim": 0.243775, "is_expected": false }, { "rank": 5, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.215531, + "sim": 0.21571, "is_expected": false }, { "rank": 6, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.198212, + "sim": 0.198318, "is_expected": false }, { "rank": 7, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.191108, + "sim": 0.19111, "is_expected": false }, { "rank": 8, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.191038, + "sim": 0.191065, "is_expected": false }, { "rank": 9, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.190404, + "sim": 0.190507, "is_expected": false }, { "rank": 10, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.186516, + "sim": 0.186766, "is_expected": false }, { "rank": 11, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.185374, + "sim": 0.185499, "is_expected": false }, { "rank": 12, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.165832, + "sim": 0.165865, "is_expected": false }, { "rank": 13, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.162472, + "sim": 0.162569, "is_expected": false }, { "rank": 14, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.156014, + "sim": 0.156123, "is_expected": false }, { "rank": 15, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.141436, + "sim": 0.141408, "is_expected": false }, { "rank": 16, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.093471, + "sim": 0.093625, "is_expected": false }, { "rank": 17, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.071249, + "sim": 0.071356, "is_expected": false } ] @@ -10708,43 +12038,49 @@ { "rank": 1, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.221338, + "sim": 0.221206, "is_expected": false }, { "rank": 2, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.18273, + "sim": 0.182584, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.175511, + "sim": 0.175397, "is_expected": false }, { "rank": 4, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.175129, + "sim": 0.175037, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.163824, + "sim": 0.163663, "is_expected": false }, { "rank": 6, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.148038, + "sim": 0.147909, "is_expected": false } ] @@ -10761,78 +12097,89 @@ { "rank": 1, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.275439, + "sim": 0.275306, "is_expected": false }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.265207, + "sim": 0.26509, "is_expected": false }, { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.253377, + "sim": 0.253239, "is_expected": false }, { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.216053, + "sim": 0.215968, "is_expected": false }, { "rank": 5, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.184547, + "sim": 0.184463, "is_expected": false }, { "rank": 6, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.181487, + "sim": 0.181438, "is_expected": false }, { "rank": 7, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.178859, + "sim": 0.178727, "is_expected": false }, { "rank": 8, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.158051, + "sim": 0.157987, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.144413, + "sim": 0.144352, "is_expected": false }, { "rank": 10, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.137976, + "sim": 0.137871, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.136172, + "sim": 0.136124, "is_expected": false } ] @@ -10849,43 +12196,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.175758, + "sim": 0.175817, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.130575, + "sim": 0.130578, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.098054, + "sim": 0.098098, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.093083, + "sim": 0.093068, "is_expected": false }, { "rank": 5, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.089737, + "sim": 0.089738, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.068597, + "sim": 0.06863, "is_expected": false } ] @@ -10902,78 +12255,89 @@ { "rank": 1, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.277617, + "sim": 0.277702, "is_expected": false }, { "rank": 2, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.253451, + "sim": 0.253542, "is_expected": false }, { "rank": 3, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.225204, + "sim": 0.225244, "is_expected": false }, { "rank": 4, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.223587, + "sim": 0.223617, "is_expected": false }, { "rank": 5, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.219086, + "sim": 0.219126, "is_expected": false }, { "rank": 6, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.2184, + "sim": 0.218489, "is_expected": false }, { "rank": 7, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.211301, + "sim": 0.211319, "is_expected": false }, { "rank": 8, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.178873, + "sim": 0.178971, "is_expected": false }, { "rank": 9, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.174776, + "sim": 0.174831, "is_expected": false }, { "rank": 10, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.143181, + "sim": 0.143281, "is_expected": false }, { "rank": 11, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.139467, + "sim": 0.139496, "is_expected": false } ] @@ -10990,43 +12354,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.195311, + "sim": 0.195192, "is_expected": false }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.151416, + "sim": 0.151374, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.142738, + "sim": 0.142682, "is_expected": false }, { "rank": 4, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.137125, + "sim": 0.137023, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.10442, + "sim": 0.104317, "is_expected": false }, { "rank": 6, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.090889, + "sim": 0.090807, "is_expected": false } ] @@ -11043,78 +12413,89 @@ { "rank": 1, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.218263, + "sim": 0.218201, "is_expected": false }, { "rank": 2, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.197102, + "sim": 0.197097, "is_expected": false }, { "rank": 3, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.183768, + "sim": 0.183754, "is_expected": false }, { "rank": 4, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.183359, + "sim": 0.18325, "is_expected": false }, { "rank": 5, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.17506, + "sim": 0.175035, "is_expected": false }, { "rank": 6, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.151506, + "sim": 0.151524, "is_expected": false }, { "rank": 7, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.151403, + "sim": 0.151409, "is_expected": false }, { "rank": 8, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.149793, + "sim": 0.149725, "is_expected": false }, { "rank": 9, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.145722, + "sim": 0.145614, "is_expected": false }, { "rank": 10, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.118721, + "sim": 0.118647, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.082427, + "sim": 0.082375, "is_expected": false } ] @@ -11131,43 +12512,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.306463, + "sim": 0.306419, "is_expected": false }, { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.260027, + "sim": 0.259979, "is_expected": false }, { "rank": 3, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.2391, + "sim": 0.239022, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.208734, + "sim": 0.208751, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.186885, + "sim": 0.186873, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.138477, + "sim": 0.138404, "is_expected": false } ] @@ -11184,78 +12571,89 @@ { "rank": 1, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.286425, + "sim": 0.2864, "is_expected": false }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.276009, + "sim": 0.275919, "is_expected": false }, { "rank": 3, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.265369, + "sim": 0.265281, "is_expected": false }, { "rank": 4, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.252843, + "sim": 0.252842, "is_expected": false }, { "rank": 5, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.246228, + "sim": 0.246116, "is_expected": false }, { "rank": 6, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.238906, + "sim": 0.238914, "is_expected": false }, { "rank": 7, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.220439, + "sim": 0.220358, "is_expected": false }, { "rank": 8, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.219672, + "sim": 0.219647, "is_expected": false }, { "rank": 9, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.218891, + "sim": 0.218812, "is_expected": false }, { "rank": 10, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.18959, + "sim": 0.189667, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.17173, + "sim": 0.171675, "is_expected": false } ] @@ -11272,43 +12670,49 @@ { "rank": 1, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.200654, + "sim": 0.200705, "is_expected": false }, { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.192985, + "sim": 0.193093, "is_expected": false }, { "rank": 3, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.188193, + "sim": 0.188257, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.16486, + "sim": 0.164835, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.164148, + "sim": 0.164165, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.114905, + "sim": 0.114925, "is_expected": false } ] @@ -11325,43 +12729,49 @@ { "rank": 1, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.305173, + "sim": 0.30519, "is_expected": false }, { "rank": 2, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.214661, + "sim": 0.214735, "is_expected": false }, { "rank": 3, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.212782, + "sim": 0.212759, "is_expected": false }, { "rank": 4, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.199626, + "sim": 0.19965, "is_expected": false }, { "rank": 5, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.169054, + "sim": 0.169177, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.161005, + "sim": 0.160975, "is_expected": false } ] @@ -11378,20 +12788,23 @@ { "rank": 1, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.246464, + "sim": 0.246463, "is_expected": false }, { "rank": 2, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.234855, + "sim": 0.234864, "is_expected": false }, { "rank": 3, "record_id": 273, + "context_id": 273, "name": "오향절면", "sim": 0.21704, "is_expected": false @@ -11399,57 +12812,65 @@ { "rank": 4, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.20902, + "sim": 0.209084, "is_expected": false }, { "rank": 5, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.205218, + "sim": 0.205302, "is_expected": false }, { "rank": 6, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.196502, + "sim": 0.196484, "is_expected": false }, { "rank": 7, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.193341, + "sim": 0.193369, "is_expected": false }, { "rank": 8, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.178536, + "sim": 0.178532, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.169757, + "sim": 0.169724, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.167003, + "sim": 0.166974, "is_expected": false }, { "rank": 11, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.166642, + "sim": 0.166673, "is_expected": false } ] @@ -11466,43 +12887,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.236097, + "sim": 0.236119, "is_expected": false }, { "rank": 2, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.188103, + "sim": 0.188039, "is_expected": false }, { "rank": 3, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.185736, + "sim": 0.185698, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.172426, + "sim": 0.172359, "is_expected": false }, { "rank": 5, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.154649, + "sim": 0.154547, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.133092, + "sim": 0.133069, "is_expected": false } ] @@ -11519,120 +12946,137 @@ { "rank": 1, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.260997, + "sim": 0.260967, "is_expected": false }, { "rank": 2, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.255387, + "sim": 0.255307, "is_expected": false }, { "rank": 3, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.251118, + "sim": 0.251067, "is_expected": false }, { "rank": 4, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.246834, + "sim": 0.246699, "is_expected": false }, { "rank": 5, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.208718, + "sim": 0.208739, "is_expected": false }, { "rank": 6, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.208001, + "sim": 0.207901, "is_expected": false }, { "rank": 7, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.196397, + "sim": 0.196254, "is_expected": false }, { "rank": 8, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.18742, + "sim": 0.187357, "is_expected": false }, { "rank": 9, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.186296, + "sim": 0.186275, "is_expected": false }, { "rank": 10, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.186221, + "sim": 0.186146, "is_expected": false }, { "rank": 11, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.183899, + "sim": 0.183954, "is_expected": false }, { "rank": 12, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.175143, + "sim": 0.175119, "is_expected": false }, { "rank": 13, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.170367, + "sim": 0.170389, "is_expected": false }, { "rank": 14, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.159886, + "sim": 0.159831, "is_expected": false }, { "rank": 15, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.137044, + "sim": 0.136945, "is_expected": false }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.118996, + "sim": 0.118958, "is_expected": false }, { "rank": 17, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.109973, + "sim": 0.109944, "is_expected": false } ] @@ -11649,43 +13093,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.242788, + "sim": 0.242818, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.216097, + "sim": 0.216052, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.19201, + "sim": 0.191988, "is_expected": false }, { "rank": 4, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.181465, + "sim": 0.181454, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.159149, + "sim": 0.159144, "is_expected": false }, { "rank": 6, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.141989, + "sim": 0.141925, "is_expected": false } ] @@ -11702,78 +13152,89 @@ { "rank": 1, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.229917, + "sim": 0.229879, "is_expected": false }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.215513, + "sim": 0.215569, "is_expected": false }, { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.203233, + "sim": 0.203247, "is_expected": false }, { "rank": 4, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.17949, + "sim": 0.179586, "is_expected": false }, { "rank": 5, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.168903, + "sim": 0.168969, "is_expected": false }, { "rank": 6, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.167745, + "sim": 0.167812, "is_expected": false }, { "rank": 7, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.146516, + "sim": 0.146569, "is_expected": false }, { "rank": 8, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.145864, + "sim": 0.145914, "is_expected": false }, { "rank": 9, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.139974, + "sim": 0.139962, "is_expected": false }, { "rank": 10, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.09857, + "sim": 0.098557, "is_expected": false }, { "rank": 11, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.098197, + "sim": 0.098327, "is_expected": false } ] @@ -11790,120 +13251,137 @@ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.2059, + "sim": 0.2058, "is_expected": false }, { "rank": 2, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.20317, + "sim": 0.203183, "is_expected": false }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.197907, + "sim": 0.197913, "is_expected": false }, { "rank": 4, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.187441, + "sim": 0.187478, "is_expected": false }, { "rank": 5, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.184792, + "sim": 0.184785, "is_expected": false }, { "rank": 6, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.180775, + "sim": 0.180715, "is_expected": false }, { "rank": 7, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.179541, + "sim": 0.179637, "is_expected": false }, { "rank": 8, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.174152, + "sim": 0.174168, "is_expected": false }, { "rank": 9, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.146511, + "sim": 0.146564, "is_expected": false }, { "rank": 10, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.145837, + "sim": 0.145887, "is_expected": false }, { "rank": 11, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.143778, + "sim": 0.143705, "is_expected": false }, { "rank": 12, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.142662, + "sim": 0.142648, "is_expected": false }, { "rank": 13, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.142122, + "sim": 0.142141, "is_expected": false }, { "rank": 14, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.136472, + "sim": 0.13647, "is_expected": false }, { "rank": 15, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.129517, + "sim": 0.12957, "is_expected": false }, { "rank": 16, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.098197, + "sim": 0.098327, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.096627, + "sim": 0.096542, "is_expected": false } ] @@ -11920,78 +13398,89 @@ { "rank": 1, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.277078, + "sim": 0.277026, "is_expected": false }, { "rank": 2, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.269376, + "sim": 0.269368, "is_expected": false }, { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.259374, + "sim": 0.259409, "is_expected": false }, { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.252123, + "sim": 0.252003, "is_expected": false }, { "rank": 5, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.21892, + "sim": 0.218865, "is_expected": false }, { "rank": 6, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.211796, + "sim": 0.211755, "is_expected": false }, { "rank": 7, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.192082, + "sim": 0.192039, "is_expected": false }, { "rank": 8, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.18873, + "sim": 0.188719, "is_expected": false }, { "rank": 9, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.179538, + "sim": 0.179497, "is_expected": false }, { "rank": 10, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.176785, + "sim": 0.176743, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.145144, + "sim": 0.145106, "is_expected": false } ] @@ -12008,120 +13497,137 @@ { "rank": 1, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.293251, + "sim": 0.293279, "is_expected": false }, { "rank": 2, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.277111, + "sim": 0.277059, "is_expected": false }, { "rank": 3, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.265706, + "sim": 0.265804, "is_expected": false }, { "rank": 4, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.259439, + "sim": 0.259475, "is_expected": false }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.252084, + "sim": 0.251964, "is_expected": false }, { "rank": 6, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.243805, + "sim": 0.243748, "is_expected": false }, { "rank": 7, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.215824, + "sim": 0.215819, "is_expected": false }, { "rank": 8, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.213446, + "sim": 0.213464, "is_expected": false }, { "rank": 9, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.200422, + "sim": 0.200426, "is_expected": false }, { "rank": 10, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.199683, + "sim": 0.199695, "is_expected": false }, { "rank": 11, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.192082, + "sim": 0.192039, "is_expected": false }, { "rank": 12, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.18292, + "sim": 0.182898, "is_expected": false }, { "rank": 13, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.176775, + "sim": 0.176733, "is_expected": false }, { "rank": 14, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.15393, + "sim": 0.153963, "is_expected": false }, { "rank": 15, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.147196, + "sim": 0.147161, "is_expected": false }, { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.139113, + "sim": 0.139167, "is_expected": false }, { "rank": 17, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.106457, + "sim": 0.106501, "is_expected": false } ] @@ -12138,78 +13644,89 @@ { "rank": 1, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.186044, + "sim": 0.185943, "is_expected": false }, { "rank": 2, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.177006, + "sim": 0.176913, "is_expected": false }, { "rank": 3, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.17257, + "sim": 0.172557, "is_expected": false }, { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.169076, + "sim": 0.169119, "is_expected": false }, { "rank": 5, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.157784, + "sim": 0.157756, "is_expected": false }, { "rank": 6, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.129431, + "sim": 0.129606, "is_expected": false }, { "rank": 7, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.128404, + "sim": 0.128374, "is_expected": false }, { "rank": 8, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.126207, + "sim": 0.126221, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.113286, + "sim": 0.113277, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.106238, + "sim": 0.106087, "is_expected": false }, { "rank": 11, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.098471, + "sim": 0.098453, "is_expected": false } ] @@ -12226,120 +13743,137 @@ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.341677, + "sim": 0.341718, "is_expected": false }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.279404, + "sim": 0.279392, "is_expected": false }, { "rank": 3, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.217242, + "sim": 0.217188, "is_expected": false }, { "rank": 4, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.213895, + "sim": 0.214018, "is_expected": false }, { "rank": 5, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.201052, + "sim": 0.201057, "is_expected": false }, { "rank": 6, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.186044, + "sim": 0.185943, "is_expected": false }, { "rank": 7, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.184004, + "sim": 0.183947, "is_expected": false }, { "rank": 8, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.177072, + "sim": 0.17698, "is_expected": false }, { "rank": 9, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.175417, + "sim": 0.175341, "is_expected": false }, { "rank": 10, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.169579, + "sim": 0.169576, "is_expected": false }, { "rank": 11, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.169117, + "sim": 0.169161, "is_expected": false }, { "rank": 12, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.164495, + "sim": 0.16455, "is_expected": false }, { "rank": 13, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.161122, + "sim": 0.161022, "is_expected": false }, { "rank": 14, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.157781, + "sim": 0.157752, "is_expected": false }, { "rank": 15, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.136835, + "sim": 0.136711, "is_expected": false }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.12501, + "sim": 0.124907, "is_expected": false }, { "rank": 17, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.106288, + "sim": 0.106137, "is_expected": false } ] @@ -12356,78 +13890,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.22118, + "sim": 0.221176, "is_expected": false }, { "rank": 2, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.219408, + "sim": 0.219381, "is_expected": false }, { "rank": 3, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.200673, + "sim": 0.200668, "is_expected": false }, { "rank": 4, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.177529, + "sim": 0.177542, "is_expected": false }, { "rank": 5, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.177496, + "sim": 0.177506, "is_expected": false }, { "rank": 6, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.152564, + "sim": 0.152612, "is_expected": false }, { "rank": 7, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.134201, + "sim": 0.134194, "is_expected": false }, { "rank": 8, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.128191, + "sim": 0.128173, "is_expected": false }, { "rank": 9, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.10908, + "sim": 0.109078, "is_expected": false }, { "rank": 10, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.108568, + "sim": 0.108512, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.104186, + "sim": 0.104171, "is_expected": false } ] @@ -12444,120 +13989,137 @@ { "rank": 1, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.274178, + "sim": 0.274122, "is_expected": false }, { "rank": 2, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.216757, + "sim": 0.216748, "is_expected": false }, { "rank": 3, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.208158, + "sim": 0.20809, "is_expected": false }, { "rank": 4, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.200699, + "sim": 0.200694, "is_expected": false }, { "rank": 5, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.194417, + "sim": 0.194458, "is_expected": false }, { "rank": 6, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.18834, + "sim": 0.188312, "is_expected": false }, { "rank": 7, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.187777, + "sim": 0.187807, "is_expected": false }, { "rank": 8, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.184157, + "sim": 0.184191, "is_expected": false }, { "rank": 9, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.177566, + "sim": 0.177575, "is_expected": false }, { "rank": 10, - "record_id": 284, - "name": "진우네 초밥", - "sim": 0.177546, + "record_id": 289, + "context_id": 289, + "name": "모던아시안누들서비스", + "sim": 0.177542, "is_expected": false }, { "rank": 11, - "record_id": 289, - "name": "모던아시안누들서비스", - "sim": 0.177529, + "record_id": 284, + "context_id": 284, + "name": "진우네 초밥", + "sim": 0.177525, "is_expected": false }, { "rank": 12, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.161884, + "sim": 0.161877, "is_expected": false }, { "rank": 13, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.152583, + "sim": 0.15263, "is_expected": false }, { "rank": 14, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.135954, + "sim": 0.1359, "is_expected": false }, { "rank": 15, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.113928, + "sim": 0.113877, "is_expected": false }, { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.10869, + "sim": 0.108742, "is_expected": false }, { "rank": 17, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.108589, + "sim": 0.108533, "is_expected": false } ] @@ -12574,78 +14136,89 @@ { "rank": 1, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.3317, + "sim": 0.331681, "is_expected": false }, { "rank": 2, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.304415, + "sim": 0.30441, "is_expected": false }, { "rank": 3, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.291001, + "sim": 0.290993, "is_expected": false }, { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.262091, + "sim": 0.262033, "is_expected": false }, { "rank": 5, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.250097, + "sim": 0.250119, "is_expected": false }, { "rank": 6, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.214272, + "sim": 0.214271, "is_expected": false }, { "rank": 7, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.213848, + "sim": 0.213814, "is_expected": false }, { "rank": 8, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.21243, + "sim": 0.212381, "is_expected": false }, { "rank": 9, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.211062, + "sim": 0.21104, "is_expected": false }, { "rank": 10, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.169112, + "sim": 0.16904, "is_expected": false }, { "rank": 11, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.165432, + "sim": 0.165386, "is_expected": false } ] @@ -12662,120 +14235,137 @@ { "rank": 1, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.347286, + "sim": 0.347276, "is_expected": false }, { "rank": 2, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.3317, + "sim": 0.331681, "is_expected": false }, { "rank": 3, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.26568, + "sim": 0.265592, "is_expected": false }, { "rank": 4, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.263002, + "sim": 0.262953, "is_expected": false }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.262202, + "sim": 0.262143, "is_expected": false }, { "rank": 6, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.247885, + "sim": 0.247892, "is_expected": false }, { "rank": 7, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.246231, + "sim": 0.246174, "is_expected": false }, { "rank": 8, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.225834, + "sim": 0.225778, "is_expected": false }, { "rank": 9, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.218688, + "sim": 0.218676, "is_expected": false }, { "rank": 10, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.214282, + "sim": 0.214281, "is_expected": false }, { "rank": 11, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.213258, + "sim": 0.213183, "is_expected": false }, { "rank": 12, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.212518, + "sim": 0.212468, "is_expected": false }, { "rank": 13, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.204267, + "sim": 0.204257, "is_expected": false }, { "rank": 14, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.200459, + "sim": 0.200449, "is_expected": false }, { "rank": 15, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.177805, + "sim": 0.177811, "is_expected": false }, { "rank": 16, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.165481, + "sim": 0.165435, "is_expected": false }, { "rank": 17, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.147394, + "sim": 0.147363, "is_expected": false } ] @@ -12792,43 +14382,49 @@ { "rank": 1, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.179085, + "sim": 0.179088, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.17874, + "sim": 0.178712, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.168918, + "sim": 0.168892, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.161926, + "sim": 0.161902, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.158514, + "sim": 0.158502, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.128208, + "sim": 0.128182, "is_expected": false } ] @@ -12845,78 +14441,89 @@ { "rank": 1, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.174066, + "sim": 0.174055, "is_expected": false }, { "rank": 2, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.167617, + "sim": 0.167602, "is_expected": false }, { "rank": 3, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.156685, + "sim": 0.15663, "is_expected": false }, { "rank": 4, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.155843, + "sim": 0.155776, "is_expected": false }, { "rank": 5, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.140464, + "sim": 0.140416, "is_expected": false }, { "rank": 6, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.131467, + "sim": 0.131443, "is_expected": false }, { "rank": 7, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.124556, + "sim": 0.12456, "is_expected": false }, { "rank": 8, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.099548, + "sim": 0.099534, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.089517, + "sim": 0.089513, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.087653, + "sim": 0.087659, "is_expected": false }, { "rank": 11, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.075104, + "sim": 0.075083, "is_expected": false } ] @@ -12933,43 +14540,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.212661, + "sim": 0.212656, "is_expected": false }, { "rank": 2, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.186316, + "sim": 0.18622, "is_expected": false }, { "rank": 3, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.167848, + "sim": 0.167841, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.153142, + "sim": 0.153216, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.116654, + "sim": 0.116765, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.112783, + "sim": 0.112714, "is_expected": false } ] @@ -12986,120 +14599,137 @@ { "rank": 1, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.318831, + "sim": 0.318941, "is_expected": false }, { "rank": 2, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.231716, + "sim": 0.231696, "is_expected": false }, { "rank": 3, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.196189, + "sim": 0.196186, "is_expected": false }, { "rank": 4, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.194814, + "sim": 0.19479, "is_expected": false }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.186337, + "sim": 0.186465, "is_expected": false }, { "rank": 6, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.181599, + "sim": 0.181623, "is_expected": false }, { "rank": 7, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.180107, + "sim": 0.180063, "is_expected": false }, { "rank": 8, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.177646, + "sim": 0.177642, "is_expected": false }, { "rank": 9, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.173101, + "sim": 0.1732, "is_expected": false }, { "rank": 10, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.172771, + "sim": 0.172924, "is_expected": false }, { "rank": 11, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.170502, + "sim": 0.170529, "is_expected": false }, { "rank": 12, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.160999, + "sim": 0.161045, "is_expected": false }, { "rank": 13, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.158853, + "sim": 0.158821, "is_expected": false }, { "rank": 14, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.151837, + "sim": 0.151881, "is_expected": false }, { "rank": 15, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.151689, + "sim": 0.151855, "is_expected": false }, { "rank": 16, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.141959, + "sim": 0.142043, "is_expected": false }, { "rank": 17, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.133385, + "sim": 0.13336, "is_expected": false } ] @@ -13116,43 +14746,49 @@ { "rank": 1, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.254014, + "sim": 0.254082, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.2487, + "sim": 0.248738, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.235758, + "sim": 0.23589, "is_expected": false }, { "rank": 4, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.199298, + "sim": 0.19939, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.196183, + "sim": 0.196254, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.115825, + "sim": 0.115842, "is_expected": false } ] @@ -13169,78 +14805,89 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.315851, + "sim": 0.31597, "is_expected": false }, { "rank": 2, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.265971, + "sim": 0.266109, "is_expected": false }, { "rank": 3, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.228765, + "sim": 0.228866, "is_expected": false }, { "rank": 4, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.226501, + "sim": 0.226637, "is_expected": false }, { "rank": 5, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.222169, + "sim": 0.222267, "is_expected": false }, { "rank": 6, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.219588, + "sim": 0.219668, "is_expected": false }, { "rank": 7, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.219369, + "sim": 0.219504, "is_expected": false }, { "rank": 8, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.21711, + "sim": 0.217229, "is_expected": false }, { "rank": 9, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.208415, + "sim": 0.208513, "is_expected": false }, { "rank": 10, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.207253, + "sim": 0.207334, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.171753, + "sim": 0.171859, "is_expected": false } ] @@ -13257,43 +14904,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.228549, + "sim": 0.228378, "is_expected": false }, { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.20178, + "sim": 0.201759, "is_expected": false }, { "rank": 3, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.190817, + "sim": 0.19076, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.160372, + "sim": 0.160299, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.159425, + "sim": 0.159353, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.150316, + "sim": 0.150267, "is_expected": false } ] @@ -13310,78 +14963,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.266834, + "sim": 0.266771, "is_expected": false }, { "rank": 2, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.243495, + "sim": 0.243413, "is_expected": false }, { "rank": 3, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.234607, + "sim": 0.234419, "is_expected": false }, { "rank": 4, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.215694, + "sim": 0.215553, "is_expected": false }, { "rank": 5, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.179256, + "sim": 0.179115, "is_expected": false }, { "rank": 6, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.171503, + "sim": 0.171511, "is_expected": false }, { "rank": 7, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.170239, + "sim": 0.170189, "is_expected": false }, { "rank": 8, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.169816, + "sim": 0.169754, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.165801, + "sim": 0.165727, "is_expected": false }, { "rank": 10, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.159875, + "sim": 0.15981, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.127288, + "sim": 0.127194, "is_expected": false } ] @@ -13398,43 +15062,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.301254, + "sim": 0.301272, "is_expected": false }, { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.238523, + "sim": 0.238514, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.224991, + "sim": 0.225037, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.198625, + "sim": 0.198586, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.17297, + "sim": 0.173033, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.149927, + "sim": 0.14989, "is_expected": false } ] @@ -13451,78 +15121,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.330229, + "sim": 0.330228, "is_expected": false }, { "rank": 2, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.307437, + "sim": 0.307468, "is_expected": false }, { "rank": 3, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.301138, + "sim": 0.301161, "is_expected": false }, { "rank": 4, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.287519, + "sim": 0.287548, "is_expected": false }, { "rank": 5, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.252838, + "sim": 0.252893, "is_expected": false }, { "rank": 6, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.232625, + "sim": 0.232611, "is_expected": false }, { "rank": 7, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.224984, + "sim": 0.224987, "is_expected": false }, { "rank": 8, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.214975, + "sim": 0.215004, "is_expected": false }, { "rank": 9, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.203786, + "sim": 0.203764, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.203508, + "sim": 0.203499, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.190263, + "sim": 0.190212, "is_expected": false } ] @@ -13539,43 +15220,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.141239, + "sim": 0.141404, "is_expected": false }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.105881, + "sim": 0.105944, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.103774, + "sim": 0.103947, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.090019, + "sim": 0.090105, "is_expected": false }, { "rank": 5, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.070862, + "sim": 0.070946, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.062989, + "sim": 0.063085, "is_expected": false } ] @@ -13592,78 +15279,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.270477, + "sim": 0.270539, "is_expected": false }, { "rank": 2, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.158635, + "sim": 0.158678, "is_expected": false }, { "rank": 3, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.156482, + "sim": 0.156529, "is_expected": false }, { "rank": 4, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.120146, + "sim": 0.120171, "is_expected": false }, { "rank": 5, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.114904, + "sim": 0.115062, "is_expected": false }, { "rank": 6, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.084539, + "sim": 0.084553, "is_expected": false }, { "rank": 7, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.082913, + "sim": 0.082977, "is_expected": false }, { "rank": 8, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.082841, + "sim": 0.082921, "is_expected": false }, { "rank": 9, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.058317, + "sim": 0.058304, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.043663, + "sim": 0.043749, "is_expected": false }, { "rank": 11, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.041065, + "sim": 0.041103, "is_expected": false } ] @@ -13680,43 +15378,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.359057, + "sim": 0.35895, "is_expected": false }, { "rank": 2, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.241238, + "sim": 0.241276, "is_expected": false }, { "rank": 3, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.198583, + "sim": 0.198767, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.139647, + "sim": 0.139457, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.139081, + "sim": 0.138978, "is_expected": false }, { "rank": 6, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.137166, + "sim": 0.137109, "is_expected": false } ] @@ -13733,78 +15437,89 @@ { "rank": 1, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.337799, + "sim": 0.337715, "is_expected": false }, { "rank": 2, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.328741, + "sim": 0.328604, "is_expected": false }, { "rank": 3, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.300812, + "sim": 0.300818, "is_expected": false }, { "rank": 4, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.273408, + "sim": 0.273356, "is_expected": false }, { "rank": 5, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.245498, + "sim": 0.245307, "is_expected": false }, { "rank": 6, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.23763, + "sim": 0.237651, "is_expected": false }, { "rank": 7, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.205954, + "sim": 0.205841, "is_expected": false }, { "rank": 8, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.203838, + "sim": 0.203888, "is_expected": false }, { "rank": 9, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.183858, + "sim": 0.183749, "is_expected": false }, { "rank": 10, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.182467, + "sim": 0.182371, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.158331, + "sim": 0.158328, "is_expected": false } ] @@ -13821,43 +15536,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.234118, + "sim": 0.234169, "is_expected": false }, { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.137674, + "sim": 0.137754, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.135688, + "sim": 0.135683, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.129899, + "sim": 0.129862, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.126546, + "sim": 0.126494, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.114485, + "sim": 0.114464, "is_expected": false } ] @@ -13874,43 +15595,49 @@ { "rank": 1, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.193924, + "sim": 0.193983, "is_expected": false }, { "rank": 2, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.191486, + "sim": 0.191524, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.18579, + "sim": 0.185793, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.160075, + "sim": 0.160139, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.127421, + "sim": 0.127501, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.091239, + "sim": 0.09127, "is_expected": false } ] @@ -13927,120 +15654,137 @@ { "rank": 1, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.271368, + "sim": 0.271374, "is_expected": false }, { "rank": 2, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.265622, + "sim": 0.265758, "is_expected": false }, { "rank": 3, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.246609, + "sim": 0.246707, "is_expected": false }, { "rank": 4, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.236525, + "sim": 0.236534, "is_expected": false }, { "rank": 5, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.225585, + "sim": 0.225697, "is_expected": false }, { "rank": 6, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.220865, + "sim": 0.220968, "is_expected": false }, { "rank": 7, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.196735, + "sim": 0.196695, "is_expected": false }, { "rank": 8, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.19392, + "sim": 0.193958, "is_expected": false }, { "rank": 9, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.193069, + "sim": 0.193036, "is_expected": false }, { "rank": 10, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.190098, + "sim": 0.19018, "is_expected": false }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.189673, + "sim": 0.189707, "is_expected": false }, { "rank": 12, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.189403, + "sim": 0.189397, "is_expected": false }, { "rank": 13, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.178443, + "sim": 0.178479, "is_expected": false }, { "rank": 14, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.175604, + "sim": 0.175701, "is_expected": false }, { "rank": 15, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.141969, + "sim": 0.142014, "is_expected": false }, { "rank": 16, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.110491, + "sim": 0.110503, "is_expected": false }, { "rank": 17, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.076944, + "sim": 0.077012, "is_expected": false } ] @@ -14057,43 +15801,49 @@ { "rank": 1, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.208774, + "sim": 0.208768, "is_expected": false }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.203316, + "sim": 0.203317, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.194489, + "sim": 0.194493, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.179752, + "sim": 0.179813, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.178565, + "sim": 0.178551, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.151603, + "sim": 0.151574, "is_expected": false } ] @@ -14110,83 +15860,95 @@ { "rank": 1, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.343543, + "sim": 0.343594, "is_expected": false }, { "rank": 2, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.272335, + "sim": 0.27235, "is_expected": false }, { "rank": 3, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.238533, + "sim": 0.238404, "is_expected": false }, { "rank": 4, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.225783, + "sim": 0.225767, "is_expected": false }, { "rank": 5, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.209621, + "sim": 0.209658, "is_expected": false }, { "rank": 6, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.199238, + "sim": 0.199355, "is_expected": false }, { "rank": 7, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.199073, + "sim": 0.19913, "is_expected": false }, { "rank": 8, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.198205, + "sim": 0.198096, "is_expected": false }, { "rank": 9, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.197298, + "sim": 0.19727, "is_expected": false }, { "rank": 10, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.189482, + "sim": 0.189492, "is_expected": false }, { "rank": 11, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.181909, + "sim": 0.181897, "is_expected": false }, { "rank": 12, "record_id": 288, + "context_id": 288, "name": "연남칼국수", "sim": 0.178453, "is_expected": false @@ -14194,36 +15956,41 @@ { "rank": 13, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.176965, + "sim": 0.176954, "is_expected": false }, { "rank": 14, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.169384, + "sim": 0.169421, "is_expected": false }, { "rank": 15, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.159212, + "sim": 0.159196, "is_expected": false }, { "rank": 16, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.149789, + "sim": 0.14977, "is_expected": false }, { "rank": 17, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.049735, + "sim": 0.049787, "is_expected": false } ] @@ -14240,43 +16007,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.277579, + "sim": 0.277548, "is_expected": false }, { "rank": 2, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.204623, + "sim": 0.204551, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.196264, + "sim": 0.196247, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.16516, + "sim": 0.165095, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.154262, + "sim": 0.154208, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.143371, + "sim": 0.143282, "is_expected": false } ] @@ -14293,43 +16066,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.27489, + "sim": 0.274895, "is_expected": false }, { "rank": 2, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.214437, + "sim": 0.214485, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.193404, + "sim": 0.193511, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.167591, + "sim": 0.167638, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.15145, + "sim": 0.15147, "is_expected": false }, { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.144931, + "sim": 0.144995, "is_expected": false } ] @@ -14346,120 +16125,137 @@ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.277136, + "sim": 0.27725, "is_expected": false }, { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.261678, + "sim": 0.261658, "is_expected": false }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.25073, + "sim": 0.250738, "is_expected": false }, { "rank": 4, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.245973, + "sim": 0.246068, "is_expected": false }, { "rank": 5, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.223204, + "sim": 0.223202, "is_expected": false }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.222515, + "sim": 0.222632, "is_expected": false }, { "rank": 7, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.219114, + "sim": 0.219117, "is_expected": false }, { "rank": 8, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.204162, + "sim": 0.204184, "is_expected": false }, { "rank": 9, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.20257, + "sim": 0.202598, "is_expected": false }, { "rank": 10, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.202322, + "sim": 0.202403, "is_expected": false }, { "rank": 11, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.190739, + "sim": 0.190872, "is_expected": false }, { "rank": 12, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.18586, + "sim": 0.185894, "is_expected": false }, { "rank": 13, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.183215, + "sim": 0.183325, "is_expected": false }, { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.181673, + "sim": 0.181817, "is_expected": false }, { "rank": 15, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.131762, + "sim": 0.131822, "is_expected": false }, { "rank": 16, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.126816, + "sim": 0.126814, "is_expected": false }, { "rank": 17, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.103536, + "sim": 0.103627, "is_expected": false } ] @@ -14476,27 +16272,31 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.269228, + "sim": 0.269336, "is_expected": false }, { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.265823, + "sim": 0.265944, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.243579, + "sim": 0.243611, "is_expected": false }, { "rank": 4, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", "sim": 0.206545, "is_expected": false @@ -14504,15 +16304,17 @@ { "rank": 5, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.190935, + "sim": 0.191007, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.174252, + "sim": 0.174247, "is_expected": false } ] @@ -14529,55 +16331,63 @@ { "rank": 1, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.307827, + "sim": 0.307774, "is_expected": false }, { "rank": 2, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.305319, + "sim": 0.305385, "is_expected": false }, { "rank": 3, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.303001, + "sim": 0.302907, "is_expected": false }, { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.285628, + "sim": 0.285686, "is_expected": false }, { "rank": 5, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.274139, + "sim": 0.274124, "is_expected": false }, { "rank": 6, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.259136, + "sim": 0.259182, "is_expected": false }, { "rank": 7, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.250648, + "sim": 0.250664, "is_expected": false }, { "rank": 8, "record_id": 275, + "context_id": 275, "name": "힉스커피", "sim": 0.247907, "is_expected": false @@ -14585,22 +16395,25 @@ { "rank": 9, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.218093, + "sim": 0.218087, "is_expected": false }, { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.206579, + "sim": 0.206585, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.095009, + "sim": 0.095025, "is_expected": false } ] @@ -14617,43 +16430,49 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.279457, + "sim": 0.27944, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.214619, + "sim": 0.214645, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.186415, + "sim": 0.186583, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.158963, + "sim": 0.159003, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.153878, + "sim": 0.153838, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.14453, + "sim": 0.144537, "is_expected": false } ] @@ -14670,43 +16489,49 @@ { "rank": 1, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.169863, + "sim": 0.168848, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.161994, + "sim": 0.161924, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.157972, + "sim": 0.154814, "is_expected": false }, { "rank": 4, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.145654, + "sim": 0.142783, "is_expected": false }, { "rank": 5, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.139007, + "sim": 0.139107, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.057161, + "sim": 0.058447, "is_expected": false } ] @@ -14723,78 +16548,89 @@ { "rank": 1, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.242229, + "sim": 0.239567, "is_expected": false }, { "rank": 2, - "record_id": 269, - "name": "오케이어 맨션", - "sim": 0.222974, + "record_id": 274, + "context_id": 274, + "name": "모던아시안누들서비스", + "sim": 0.219638, "is_expected": false }, { "rank": 3, - "record_id": 274, - "name": "모던아시안누들서비스", - "sim": 0.222565, + "record_id": 269, + "context_id": 269, + "name": "오케이어 맨션", + "sim": 0.219086, "is_expected": false }, { "rank": 4, - "record_id": 266, - "name": "도피", - "sim": 0.218617, + "record_id": 273, + "context_id": 273, + "name": "오향절면", + "sim": 0.217967, "is_expected": false }, { "rank": 5, - "record_id": 273, - "name": "오향절면", - "sim": 0.214358, + "record_id": 266, + "context_id": 266, + "name": "도피", + "sim": 0.21672, "is_expected": false }, { "rank": 6, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.201631, + "sim": 0.202876, "is_expected": false }, { "rank": 7, - "record_id": 275, - "name": "힉스커피", - "sim": 0.183007, + "record_id": 267, + "context_id": 267, + "name": "애플하우스", + "sim": 0.182071, "is_expected": false }, { "rank": 8, - "record_id": 267, - "name": "애플하우스", - "sim": 0.182331, + "record_id": 275, + "context_id": 275, + "name": "힉스커피", + "sim": 0.180983, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.166031, + "sim": 0.162772, "is_expected": false }, { "rank": 10, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.131698, + "sim": 0.134403, "is_expected": false }, { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.107207, + "sim": 0.107318, "is_expected": false } ] @@ -14813,43 +16649,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.225958, + "sim": 0.225866, "is_expected": false }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.183506, + "sim": 0.183507, "is_expected": false }, { "rank": 3, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.162935, + "sim": 0.162961, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.149919, + "sim": 0.149881, "is_expected": false }, { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.144915, + "sim": 0.144894, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.084067, + "sim": 0.08412, "is_expected": false } ] @@ -14866,78 +16708,89 @@ { "rank": 1, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.273617, + "sim": 0.273532, "is_expected": false }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.265266, + "sim": 0.265255, "is_expected": false }, { "rank": 3, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.248304, + "sim": 0.248269, "is_expected": false }, { "rank": 4, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.245917, + "sim": 0.245853, "is_expected": false }, { "rank": 5, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.234145, + "sim": 0.234085, "is_expected": false }, { "rank": 6, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.217219, + "sim": 0.217151, "is_expected": false }, { "rank": 7, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.207743, + "sim": 0.207731, "is_expected": false }, { "rank": 8, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.197145, + "sim": 0.197114, "is_expected": false }, { "rank": 9, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.187739, + "sim": 0.187731, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.127868, + "sim": 0.127793, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.095987, + "sim": 0.095991, "is_expected": false } ] @@ -14954,120 +16807,137 @@ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.295281, + "sim": 0.295225, "is_expected": false }, { "rank": 2, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.284907, + "sim": 0.284854, "is_expected": false }, { "rank": 3, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.273617, + "sim": 0.273532, "is_expected": false }, { "rank": 4, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.260661, + "sim": 0.260587, "is_expected": false }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.248265, + "sim": 0.24823, "is_expected": false }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.217304, + "sim": 0.217237, "is_expected": false }, { "rank": 7, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.199778, + "sim": 0.199764, "is_expected": false }, { "rank": 8, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.198077, + "sim": 0.197963, "is_expected": false }, { "rank": 9, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.197108, + "sim": 0.197076, "is_expected": false }, { "rank": 10, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.18782, + "sim": 0.187812, "is_expected": false }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.182941, + "sim": 0.182875, "is_expected": false }, { "rank": 12, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.182706, + "sim": 0.182728, "is_expected": false }, { "rank": 13, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.16648, + "sim": 0.166418, "is_expected": false }, { "rank": 14, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.163072, + "sim": 0.163051, "is_expected": false }, { "rank": 15, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.159116, + "sim": 0.159125, "is_expected": false }, { "rank": 16, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.151638, + "sim": 0.151635, "is_expected": false }, { "rank": 17, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.1379, + "sim": 0.137863, "is_expected": false } ] @@ -15084,43 +16954,49 @@ { "rank": 1, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.160101, + "sim": 0.16005, "is_expected": false }, { "rank": 2, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.153356, + "sim": 0.15323, "is_expected": false }, { "rank": 3, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.150193, + "sim": 0.150103, "is_expected": false }, { "rank": 4, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.125159, + "sim": 0.125178, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.116551, + "sim": 0.116532, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.092881, + "sim": 0.092855, "is_expected": false } ] @@ -15137,13 +17013,15 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.29909, + "sim": 0.299132, "is_expected": false }, { "rank": 2, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", "sim": 0.233269, "is_expected": false @@ -15151,64 +17029,73 @@ { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.188163, + "sim": 0.188112, "is_expected": false }, { "rank": 4, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.14128, + "sim": 0.141221, "is_expected": false }, { "rank": 5, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.141126, + "sim": 0.141054, "is_expected": false }, { "rank": 6, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.138299, + "sim": 0.138285, "is_expected": false }, { "rank": 7, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.122716, + "sim": 0.122666, "is_expected": false }, { "rank": 8, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.116725, + "sim": 0.116567, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.105411, + "sim": 0.105331, "is_expected": false }, { "rank": 10, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.089262, + "sim": 0.089264, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.079463, + "sim": 0.0795, "is_expected": false } ] @@ -15225,6 +17112,7 @@ { "rank": 1, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", "sim": 0.233269, "is_expected": false @@ -15232,113 +17120,129 @@ { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.225836, + "sim": 0.225813, "is_expected": false }, { "rank": 3, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.188174, + "sim": 0.188123, "is_expected": false }, { "rank": 4, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.151785, + "sim": 0.151686, "is_expected": false }, { "rank": 5, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.151209, + "sim": 0.151132, "is_expected": false }, { "rank": 6, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.149622, + "sim": 0.149472, "is_expected": false }, { "rank": 7, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.138292, + "sim": 0.138278, "is_expected": false }, { "rank": 8, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.125942, + "sim": 0.125858, "is_expected": false }, { "rank": 9, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.122703, + "sim": 0.122653, "is_expected": false }, { "rank": 10, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.120409, + "sim": 0.120357, "is_expected": false }, { "rank": 11, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.118209, + "sim": 0.118225, "is_expected": false }, { "rank": 12, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.117346, + "sim": 0.117292, "is_expected": false }, { "rank": 13, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.116627, + "sim": 0.116469, "is_expected": false }, { "rank": 14, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.114621, + "sim": 0.114587, "is_expected": false }, { "rank": 15, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.113265, + "sim": 0.113161, "is_expected": false }, { "rank": 16, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.106496, + "sim": 0.106366, "is_expected": false }, { "rank": 17, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.086957, + "sim": 0.086909, "is_expected": false } ] @@ -15355,43 +17259,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.180408, + "sim": 0.180477, "is_expected": false }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.156414, + "sim": 0.156468, "is_expected": false }, { "rank": 3, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.136964, + "sim": 0.137043, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.119639, + "sim": 0.119648, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.100276, + "sim": 0.10035, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.083083, + "sim": 0.083227, "is_expected": false } ] @@ -15408,78 +17318,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.239508, + "sim": 0.239535, "is_expected": false }, { "rank": 2, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.177615, + "sim": 0.177627, "is_expected": false }, { "rank": 3, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.15085, + "sim": 0.150938, "is_expected": false }, { "rank": 4, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.123401, + "sim": 0.123475, "is_expected": false }, { "rank": 5, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.120725, + "sim": 0.120807, "is_expected": false }, { "rank": 6, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.117392, + "sim": 0.117447, "is_expected": false }, { "rank": 7, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.106676, + "sim": 0.106807, "is_expected": false }, { "rank": 8, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.105305, + "sim": 0.105449, "is_expected": false }, { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.101813, + "sim": 0.101897, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.09446, + "sim": 0.094572, "is_expected": false }, { "rank": 11, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.085416, + "sim": 0.085506, "is_expected": false } ] @@ -15496,120 +17417,137 @@ { "rank": 1, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.195777, + "sim": 0.195817, "is_expected": false }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.183769, + "sim": 0.183874, "is_expected": false }, { "rank": 3, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.172872, + "sim": 0.172944, "is_expected": false }, { "rank": 4, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.171508, + "sim": 0.171639, "is_expected": false }, { "rank": 5, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.163985, + "sim": 0.164145, "is_expected": false }, { "rank": 6, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.161603, + "sim": 0.161729, "is_expected": false }, { "rank": 7, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.150811, + "sim": 0.150899, "is_expected": false }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.150652, + "sim": 0.150695, "is_expected": false }, { "rank": 9, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.135515, + "sim": 0.13563, "is_expected": false }, { "rank": 10, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.133187, + "sim": 0.133228, "is_expected": false }, { "rank": 11, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.123401, + "sim": 0.123475, "is_expected": false }, { "rank": 12, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.12076, + "sim": 0.120842, "is_expected": false }, { "rank": 13, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.117297, + "sim": 0.117352, "is_expected": false }, { "rank": 14, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.108765, + "sim": 0.10882, "is_expected": false }, { "rank": 15, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.108089, + "sim": 0.108154, "is_expected": false }, { "rank": 16, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.106598, + "sim": 0.106729, "is_expected": false }, { "rank": 17, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.090824, + "sim": 0.090867, "is_expected": false } ] @@ -15626,43 +17564,49 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.164253, + "sim": 0.164266, "is_expected": false }, { "rank": 2, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.127268, + "sim": 0.127323, "is_expected": false }, { "rank": 3, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.117982, + "sim": 0.118129, "is_expected": false }, { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.114806, + "sim": 0.11482, "is_expected": false }, { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.083094, + "sim": 0.083137, "is_expected": false }, { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.075153, + "sim": 0.07527, "is_expected": false } ] @@ -15679,78 +17623,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.288136, + "sim": 0.288198, "is_expected": false }, { "rank": 2, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.196863, + "sim": 0.196961, "is_expected": false }, { "rank": 3, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.138456, + "sim": 0.138525, "is_expected": false }, { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.132115, + "sim": 0.132183, "is_expected": false }, { "rank": 5, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.131288, + "sim": 0.131341, "is_expected": false }, { "rank": 6, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.124314, + "sim": 0.124327, "is_expected": false }, { "rank": 7, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.122786, + "sim": 0.122767, "is_expected": false }, { "rank": 8, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.110226, + "sim": 0.110309, "is_expected": false }, { "rank": 9, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.095895, + "sim": 0.095989, "is_expected": false }, { "rank": 10, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.080762, + "sim": 0.080747, "is_expected": false }, { "rank": 11, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.048463, + "sim": 0.04856, "is_expected": false } ] @@ -15767,120 +17722,137 @@ { "rank": 1, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.196881, + "sim": 0.196979, "is_expected": false }, { "rank": 2, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.174848, + "sim": 0.1749, "is_expected": false }, { "rank": 3, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.173864, + "sim": 0.173887, "is_expected": false }, { "rank": 4, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.148013, + "sim": 0.148134, "is_expected": false }, { "rank": 5, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.138497, + "sim": 0.138517, "is_expected": false }, { "rank": 6, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.138425, + "sim": 0.138493, "is_expected": false }, { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.132187, + "sim": 0.132254, "is_expected": false }, { "rank": 8, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.122786, + "sim": 0.122767, "is_expected": false }, { "rank": 9, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.120895, + "sim": 0.120948, "is_expected": false }, { "rank": 10, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.120435, + "sim": 0.120486, "is_expected": false }, { "rank": 11, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.115134, + "sim": 0.11522, "is_expected": false }, { "rank": 12, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.110225, + "sim": 0.110347, "is_expected": false }, { "rank": 13, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.100173, + "sim": 0.100232, "is_expected": false }, { "rank": 14, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.097307, + "sim": 0.097372, "is_expected": false }, { "rank": 15, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.095874, + "sim": 0.095968, "is_expected": false }, { "rank": 16, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.095204, + "sim": 0.095291, "is_expected": false }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.053218, + "sim": 0.053237, "is_expected": false } ] @@ -15897,43 +17869,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.147402, + "sim": 0.147417, "is_expected": false }, { "rank": 2, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.141138, + "sim": 0.141106, "is_expected": false }, { "rank": 3, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.118104, + "sim": 0.118087, "is_expected": false }, { "rank": 4, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.101782, + "sim": 0.101764, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.081245, + "sim": 0.081305, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.073843, + "sim": 0.073844, "is_expected": false } ] @@ -15950,78 +17928,89 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.206499, + "sim": 0.206451, "is_expected": false }, { "rank": 2, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.192584, + "sim": 0.192601, "is_expected": false }, { "rank": 3, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.188828, + "sim": 0.188777, "is_expected": false }, { "rank": 4, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.174768, + "sim": 0.174736, "is_expected": false }, { "rank": 5, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.147345, + "sim": 0.147322, "is_expected": false }, { "rank": 6, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.124482, + "sim": 0.124538, "is_expected": false }, { "rank": 7, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.112237, + "sim": 0.112217, "is_expected": false }, { "rank": 8, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.106897, + "sim": 0.106865, "is_expected": false }, { "rank": 9, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.096904, + "sim": 0.096836, "is_expected": false }, { "rank": 10, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.079937, + "sim": 0.0799, "is_expected": false }, { "rank": 11, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.077385, + "sim": 0.077386, "is_expected": false } ] @@ -16038,120 +18027,137 @@ { "rank": 1, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.218957, + "sim": 0.218911, "is_expected": false }, { "rank": 2, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.192584, + "sim": 0.192601, "is_expected": false }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.191253, + "sim": 0.191192, "is_expected": false }, { "rank": 4, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.190796, + "sim": 0.190693, "is_expected": false }, { "rank": 5, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.188763, + "sim": 0.188712, "is_expected": false }, { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.174913, + "sim": 0.174881, "is_expected": false }, { "rank": 7, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.171257, + "sim": 0.171211, "is_expected": false }, { "rank": 8, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.147325, + "sim": 0.147302, "is_expected": false }, { "rank": 9, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.14215, + "sim": 0.142137, "is_expected": false }, { "rank": 10, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.141962, + "sim": 0.1419, "is_expected": false }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.134956, + "sim": 0.13498, "is_expected": false }, { "rank": 12, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.133451, + "sim": 0.133463, "is_expected": false }, { "rank": 13, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.124478, + "sim": 0.124535, "is_expected": false }, { "rank": 14, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.122849, + "sim": 0.122835, "is_expected": false }, { "rank": 15, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.094853, + "sim": 0.094782, "is_expected": false }, { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.089434, + "sim": 0.089465, "is_expected": false }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.084661, + "sim": 0.084648, "is_expected": false } ] @@ -16168,6 +18174,7 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", "sim": 0.247548, "is_expected": false @@ -16175,36 +18182,41 @@ { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.213825, + "sim": 0.213928, "is_expected": false }, { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.159806, + "sim": 0.159812, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.155819, + "sim": 0.155846, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.108894, + "sim": 0.109011, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.097582, + "sim": 0.09767, "is_expected": false } ] @@ -16221,78 +18233,89 @@ { "rank": 1, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.336062, + "sim": 0.336095, "is_expected": false }, { "rank": 2, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.324372, + "sim": 0.324497, "is_expected": false }, { "rank": 3, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.277668, + "sim": 0.277671, "is_expected": false }, { "rank": 4, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.274002, + "sim": 0.274078, "is_expected": false }, { "rank": 5, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.268668, + "sim": 0.268741, "is_expected": false }, { "rank": 6, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.253817, + "sim": 0.253857, "is_expected": false }, { "rank": 7, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.252481, + "sim": 0.252522, "is_expected": false }, { "rank": 8, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.219571, + "sim": 0.219612, "is_expected": false }, { "rank": 9, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.216448, + "sim": 0.216447, "is_expected": false }, { "rank": 10, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.215436, + "sim": 0.21545, "is_expected": false }, { "rank": 11, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.165012, + "sim": 0.165091, "is_expected": false } ] @@ -16309,120 +18332,137 @@ { "rank": 1, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.351065, + "sim": 0.351155, "is_expected": false }, { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.342859, + "sim": 0.342916, "is_expected": false }, { "rank": 3, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.286118, + "sim": 0.286185, "is_expected": false }, { "rank": 4, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.282975, + "sim": 0.283018, "is_expected": false }, { "rank": 5, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.27407, + "sim": 0.274146, "is_expected": false }, { "rank": 6, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.268749, + "sim": 0.268822, "is_expected": false }, { "rank": 7, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.263876, + "sim": 0.264042, "is_expected": false }, { "rank": 8, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.252449, + "sim": 0.252489, "is_expected": false }, { "rank": 9, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.240229, + "sim": 0.240309, "is_expected": false }, { "rank": 10, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.219535, + "sim": 0.219576, "is_expected": false }, { "rank": 11, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.217452, + "sim": 0.217471, "is_expected": false }, { "rank": 12, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.216448, + "sim": 0.216447, "is_expected": false }, { "rank": 13, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.209066, + "sim": 0.209103, "is_expected": false }, { "rank": 14, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.19313, + "sim": 0.193289, "is_expected": false }, { "rank": 15, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.187167, + "sim": 0.187239, "is_expected": false }, { "rank": 16, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.183665, + "sim": 0.1837, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.082193, + "sim": 0.082203, "is_expected": false } ] @@ -16439,43 +18479,49 @@ { "rank": 1, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.252696, + "sim": 0.252656, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.241196, + "sim": 0.241257, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.206874, + "sim": 0.206898, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.196261, + "sim": 0.196313, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.167634, + "sim": 0.167704, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.158038, + "sim": 0.15802, "is_expected": false } ] @@ -16492,78 +18538,89 @@ { "rank": 1, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.282319, + "sim": 0.282331, "is_expected": false }, { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.270714, + "sim": 0.270721, "is_expected": false }, { "rank": 3, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.254512, + "sim": 0.254581, "is_expected": false }, { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.220412, + "sim": 0.220538, "is_expected": false }, { "rank": 5, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.220265, + "sim": 0.220263, "is_expected": false }, { "rank": 6, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.20464, + "sim": 0.204707, "is_expected": false }, { "rank": 7, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.204627, + "sim": 0.204691, "is_expected": false }, { "rank": 8, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.203839, + "sim": 0.203951, "is_expected": false }, { "rank": 9, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.183533, + "sim": 0.183595, "is_expected": false }, { "rank": 10, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.17957, + "sim": 0.179698, "is_expected": false }, { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.154551, + "sim": 0.154658, "is_expected": false } ] @@ -16580,120 +18637,137 @@ { "rank": 1, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.282319, + "sim": 0.282331, "is_expected": false }, { "rank": 2, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.246724, + "sim": 0.246717, "is_expected": false }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.234063, + "sim": 0.234086, "is_expected": false }, { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.226321, + "sim": 0.226303, "is_expected": false }, { "rank": 5, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.220445, + "sim": 0.220571, "is_expected": false }, { "rank": 6, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.215453, + "sim": 0.215427, "is_expected": false }, { "rank": 7, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.213473, + "sim": 0.213523, "is_expected": false }, { "rank": 8, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.213275, + "sim": 0.213274, "is_expected": false }, { "rank": 9, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.207861, + "sim": 0.20785, "is_expected": false }, { "rank": 10, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.204598, + "sim": 0.204665, "is_expected": false }, { "rank": 11, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.192718, + "sim": 0.192784, "is_expected": false }, { "rank": 12, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.183644, + "sim": 0.183706, "is_expected": false }, { "rank": 13, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.179519, + "sim": 0.179646, "is_expected": false }, { "rank": 14, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.175675, + "sim": 0.175721, "is_expected": false }, { "rank": 15, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.172102, + "sim": 0.172082, "is_expected": false }, { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.168493, + "sim": 0.168443, "is_expected": false }, { "rank": 17, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.150978, + "sim": 0.151008, "is_expected": false } ] @@ -16710,43 +18784,49 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.152899, + "sim": 0.152835, "is_expected": false }, { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.143226, + "sim": 0.143261, "is_expected": false }, { "rank": 3, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.121036, + "sim": 0.120957, "is_expected": false }, { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.102636, + "sim": 0.10264, "is_expected": false }, { "rank": 5, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.085292, + "sim": 0.085257, "is_expected": false }, { "rank": 6, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.079241, + "sim": 0.079211, "is_expected": false } ] @@ -16763,78 +18843,89 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.220135, + "sim": 0.220054, "is_expected": false }, { "rank": 2, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.217661, + "sim": 0.217672, "is_expected": false }, { "rank": 3, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.213653, + "sim": 0.213553, "is_expected": false }, { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.154462, + "sim": 0.154361, "is_expected": false }, { "rank": 5, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.152633, + "sim": 0.152594, "is_expected": false }, { "rank": 6, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.140095, + "sim": 0.140054, "is_expected": false }, { "rank": 7, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.130927, + "sim": 0.13088, "is_expected": false }, { "rank": 8, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.126353, + "sim": 0.1263, "is_expected": false }, { "rank": 9, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.093589, + "sim": 0.093593, "is_expected": false }, { "rank": 10, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.091793, + "sim": 0.091787, "is_expected": false }, { "rank": 11, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.083336, + "sim": 0.083244, "is_expected": false } ] @@ -16851,120 +18942,137 @@ { "rank": 1, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.244543, + "sim": 0.244513, "is_expected": false }, { "rank": 2, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.220196, + "sim": 0.220115, "is_expected": false }, { "rank": 3, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.217648, + "sim": 0.217659, "is_expected": false }, { "rank": 4, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.210559, + "sim": 0.210547, "is_expected": false }, { "rank": 5, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.173397, + "sim": 0.173372, "is_expected": false }, { "rank": 6, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.155466, + "sim": 0.155369, "is_expected": false }, { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.154444, + "sim": 0.154342, "is_expected": false }, { "rank": 8, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.152633, + "sim": 0.152594, "is_expected": false }, { "rank": 9, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.140728, + "sim": 0.140656, "is_expected": false }, { "rank": 10, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.126414, + "sim": 0.12636, "is_expected": false }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.123276, + "sim": 0.123256, "is_expected": false }, { "rank": 12, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.123197, + "sim": 0.123162, "is_expected": false }, { "rank": 13, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.111333, + "sim": 0.111257, "is_expected": false }, { "rank": 14, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.0839, + "sim": 0.083796, "is_expected": false }, { "rank": 15, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.071675, + "sim": 0.071601, "is_expected": false }, { "rank": 16, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.052668, + "sim": 0.052642, "is_expected": false }, { "rank": 17, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.048063, + "sim": 0.047996, "is_expected": false } ] @@ -16981,43 +19089,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.251098, + "sim": 0.251073, "is_expected": false }, { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.212276, + "sim": 0.212268, "is_expected": false }, { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.200533, + "sim": 0.200529, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.184558, + "sim": 0.184539, "is_expected": false }, { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.150933, + "sim": 0.150984, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.113584, + "sim": 0.113546, "is_expected": false } ] @@ -17034,78 +19148,89 @@ { "rank": 1, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.3283, + "sim": 0.328385, "is_expected": false }, { "rank": 2, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.311703, + "sim": 0.311727, "is_expected": false }, { "rank": 3, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.304532, + "sim": 0.304535, "is_expected": false }, { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.255248, + "sim": 0.255295, "is_expected": false }, { "rank": 5, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.250785, + "sim": 0.250762, "is_expected": false }, { "rank": 6, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.200328, + "sim": 0.200341, "is_expected": false }, { "rank": 7, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.193586, + "sim": 0.193609, "is_expected": false }, { "rank": 8, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.184625, + "sim": 0.184649, "is_expected": false }, { "rank": 9, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.179317, + "sim": 0.179295, "is_expected": false }, { "rank": 10, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.168709, + "sim": 0.168683, "is_expected": false }, { "rank": 11, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.16803, + "sim": 0.167974, "is_expected": false } ] @@ -17122,120 +19247,137 @@ { "rank": 1, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.328385, + "sim": 0.328469, "is_expected": false }, { "rank": 2, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.286831, + "sim": 0.286961, "is_expected": false }, { "rank": 3, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.255224, + "sim": 0.255271, "is_expected": false }, { "rank": 4, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.244173, + "sim": 0.244248, "is_expected": false }, { "rank": 5, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.230452, + "sim": 0.230407, "is_expected": false }, { "rank": 6, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.22209, + "sim": 0.222145, "is_expected": false }, { "rank": 7, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.205039, + "sim": 0.20506, "is_expected": false }, { "rank": 8, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.200343, + "sim": 0.200356, "is_expected": false }, { "rank": 9, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.199503, + "sim": 0.199551, "is_expected": false }, { "rank": 10, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.186516, + "sim": 0.186568, "is_expected": false }, { "rank": 11, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.185698, + "sim": 0.18576, "is_expected": false }, { "rank": 12, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.181722, + "sim": 0.181773, "is_expected": false }, { "rank": 13, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.179331, + "sim": 0.179309, "is_expected": false }, { "rank": 14, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.168709, + "sim": 0.168683, "is_expected": false }, { "rank": 15, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.168413, + "sim": 0.168348, "is_expected": false }, { "rank": 16, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.167629, + "sim": 0.167633, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.143716, + "sim": 0.143718, "is_expected": false } ] @@ -17252,43 +19394,49 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", - "sim": 0.22479, + "sim": 0.224822, "is_expected": false }, { "rank": 2, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", - "sim": 0.199156, + "sim": 0.199172, "is_expected": false }, { "rank": 3, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", - "sim": 0.183056, + "sim": 0.183233, "is_expected": false }, { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", - "sim": 0.167214, + "sim": 0.167303, "is_expected": false }, { "rank": 5, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", - "sim": 0.160565, + "sim": 0.160659, "is_expected": false }, { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", - "sim": 0.097184, + "sim": 0.0973, "is_expected": false } ] @@ -17305,78 +19453,89 @@ { "rank": 1, "record_id": 270, + "context_id": 270, "name": "적당", - "sim": 0.315469, + "sim": 0.315475, "is_expected": false }, { "rank": 2, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", - "sim": 0.268711, + "sim": 0.268716, "is_expected": false }, { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", - "sim": 0.265682, + "sim": 0.26564, "is_expected": false }, { "rank": 4, "record_id": 267, + "context_id": 267, "name": "애플하우스", - "sim": 0.227099, + "sim": 0.227096, "is_expected": false }, { "rank": 5, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", - "sim": 0.21721, + "sim": 0.217288, "is_expected": false }, { "rank": 6, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", - "sim": 0.198335, + "sim": 0.19828, "is_expected": false }, { "rank": 7, "record_id": 273, + "context_id": 273, "name": "오향절면", - "sim": 0.194427, + "sim": 0.194451, "is_expected": false }, { "rank": 8, "record_id": 266, + "context_id": 266, "name": "도피", - "sim": 0.192496, + "sim": 0.192447, "is_expected": false }, { "rank": 9, "record_id": 276, + "context_id": 276, "name": "메밀집", - "sim": 0.157819, + "sim": 0.157807, "is_expected": false }, { "rank": 10, "record_id": 268, + "context_id": 268, "name": "키친갈매기", - "sim": 0.14863, + "sim": 0.148719, "is_expected": false }, { "rank": 11, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", - "sim": 0.124843, + "sim": 0.124767, "is_expected": false } ] @@ -17393,120 +19552,137 @@ { "rank": 1, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", - "sim": 0.348558, + "sim": 0.348546, "is_expected": false }, { "rank": 2, "record_id": 292, + "context_id": 292, "name": "적당", - "sim": 0.31546, + "sim": 0.315466, "is_expected": false }, { "rank": 3, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", - "sim": 0.287153, + "sim": 0.287198, "is_expected": false }, { "rank": 4, "record_id": 290, + "context_id": 290, "name": "힉스커피", - "sim": 0.265627, + "sim": 0.265585, "is_expected": false }, { "rank": 5, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", - "sim": 0.253861, + "sim": 0.253961, "is_expected": false }, { "rank": 6, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", - "sim": 0.217147, + "sim": 0.217224, "is_expected": false }, { "rank": 7, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", - "sim": 0.212447, + "sim": 0.212461, "is_expected": false }, { "rank": 8, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", - "sim": 0.199109, + "sim": 0.199222, "is_expected": false }, { "rank": 9, "record_id": 288, + "context_id": 288, "name": "연남칼국수", - "sim": 0.195375, + "sim": 0.195366, "is_expected": false }, { "rank": 10, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", - "sim": 0.191264, + "sim": 0.191194, "is_expected": false }, { "rank": 11, "record_id": 277, + "context_id": 277, "name": "카츠요", - "sim": 0.176115, + "sim": 0.176243, "is_expected": false }, { "rank": 12, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", - "sim": 0.175864, + "sim": 0.175845, "is_expected": false }, { "rank": 13, "record_id": 279, + "context_id": 279, "name": "사루카메", - "sim": 0.162704, + "sim": 0.162735, "is_expected": false }, { "rank": 14, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", - "sim": 0.161415, + "sim": 0.161504, "is_expected": false }, { "rank": 15, "record_id": 293, + "context_id": 293, "name": "키친갈매기", - "sim": 0.148651, + "sim": 0.14874, "is_expected": false }, { "rank": 16, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", - "sim": 0.124843, + "sim": 0.124767, "is_expected": false }, { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", - "sim": 0.093764, + "sim": 0.093877, "is_expected": false } ] @@ -17523,6 +19699,7 @@ { "rank": 1, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", "sim": 0.35567, "is_expected": false @@ -17530,6 +19707,7 @@ { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", "sim": 0.292322, "is_expected": false @@ -17537,6 +19715,7 @@ { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", "sim": 0.277131, "is_expected": false @@ -17544,6 +19723,7 @@ { "rank": 4, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", "sim": 0.154344, "is_expected": false @@ -17551,6 +19731,7 @@ { "rank": 5, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", "sim": 0.147184, "is_expected": false @@ -17558,6 +19739,7 @@ { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", "sim": 0.122639, "is_expected": false @@ -17576,6 +19758,7 @@ { "rank": 1, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", "sim": 0.329344, "is_expected": false @@ -17583,6 +19766,7 @@ { "rank": 2, "record_id": 270, + "context_id": 270, "name": "적당", "sim": 0.324161, "is_expected": false @@ -17590,6 +19774,7 @@ { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", "sim": 0.318691, "is_expected": false @@ -17597,6 +19782,7 @@ { "rank": 4, "record_id": 266, + "context_id": 266, "name": "도피", "sim": 0.263364, "is_expected": false @@ -17604,6 +19790,7 @@ { "rank": 5, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", "sim": 0.255762, "is_expected": false @@ -17611,6 +19798,7 @@ { "rank": 6, "record_id": 268, + "context_id": 268, "name": "키친갈매기", "sim": 0.204243, "is_expected": false @@ -17618,6 +19806,7 @@ { "rank": 7, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", "sim": 0.190759, "is_expected": false @@ -17625,6 +19814,7 @@ { "rank": 8, "record_id": 276, + "context_id": 276, "name": "메밀집", "sim": 0.179021, "is_expected": false @@ -17632,6 +19822,7 @@ { "rank": 9, "record_id": 267, + "context_id": 267, "name": "애플하우스", "sim": 0.174623, "is_expected": false @@ -17639,6 +19830,7 @@ { "rank": 10, "record_id": 273, + "context_id": 273, "name": "오향절면", "sim": 0.167454, "is_expected": false @@ -17646,6 +19838,7 @@ { "rank": 11, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", "sim": 0.164302, "is_expected": false @@ -17664,6 +19857,7 @@ { "rank": 1, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", "sim": 0.329371, "is_expected": false @@ -17671,6 +19865,7 @@ { "rank": 2, "record_id": 292, + "context_id": 292, "name": "적당", "sim": 0.324246, "is_expected": false @@ -17678,6 +19873,7 @@ { "rank": 3, "record_id": 290, + "context_id": 290, "name": "힉스커피", "sim": 0.318655, "is_expected": false @@ -17685,6 +19881,7 @@ { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", "sim": 0.291918, "is_expected": false @@ -17692,6 +19889,7 @@ { "rank": 5, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", "sim": 0.242531, "is_expected": false @@ -17699,6 +19897,7 @@ { "rank": 6, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", "sim": 0.230607, "is_expected": false @@ -17706,6 +19905,7 @@ { "rank": 7, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", "sim": 0.211076, "is_expected": false @@ -17713,6 +19913,7 @@ { "rank": 8, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", "sim": 0.205306, "is_expected": false @@ -17720,6 +19921,7 @@ { "rank": 9, "record_id": 293, + "context_id": 293, "name": "키친갈매기", "sim": 0.204306, "is_expected": false @@ -17727,6 +19929,7 @@ { "rank": 10, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", "sim": 0.20415, "is_expected": false @@ -17734,6 +19937,7 @@ { "rank": 11, "record_id": 288, + "context_id": 288, "name": "연남칼국수", "sim": 0.187596, "is_expected": false @@ -17741,6 +19945,7 @@ { "rank": 12, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", "sim": 0.184777, "is_expected": false @@ -17748,6 +19953,7 @@ { "rank": 13, "record_id": 277, + "context_id": 277, "name": "카츠요", "sim": 0.169947, "is_expected": false @@ -17755,6 +19961,7 @@ { "rank": 14, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", "sim": 0.169835, "is_expected": false @@ -17762,6 +19969,7 @@ { "rank": 15, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", "sim": 0.164302, "is_expected": false @@ -17769,6 +19977,7 @@ { "rank": 16, "record_id": 279, + "context_id": 279, "name": "사루카메", "sim": 0.160082, "is_expected": false @@ -17776,6 +19985,7 @@ { "rank": 17, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", "sim": 0.140387, "is_expected": false @@ -17794,6 +20004,7 @@ { "rank": 1, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", "sim": 0.173303, "is_expected": false @@ -17801,6 +20012,7 @@ { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", "sim": 0.13831, "is_expected": false @@ -17808,6 +20020,7 @@ { "rank": 3, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", "sim": 0.132886, "is_expected": false @@ -17815,6 +20028,7 @@ { "rank": 4, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", "sim": 0.119838, "is_expected": false @@ -17822,6 +20036,7 @@ { "rank": 5, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", "sim": 0.078342, "is_expected": false @@ -17829,6 +20044,7 @@ { "rank": 6, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", "sim": 0.075698, "is_expected": false @@ -17847,6 +20063,7 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", "sim": 0.205662, "is_expected": false @@ -17854,6 +20071,7 @@ { "rank": 2, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", "sim": 0.173833, "is_expected": false @@ -17861,6 +20079,7 @@ { "rank": 3, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", "sim": 0.142477, "is_expected": false @@ -17868,6 +20087,7 @@ { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", "sim": 0.137313, "is_expected": false @@ -17875,6 +20095,7 @@ { "rank": 5, "record_id": 270, + "context_id": 270, "name": "적당", "sim": 0.137309, "is_expected": false @@ -17882,6 +20103,7 @@ { "rank": 6, "record_id": 275, + "context_id": 275, "name": "힉스커피", "sim": 0.124812, "is_expected": false @@ -17889,6 +20111,7 @@ { "rank": 7, "record_id": 267, + "context_id": 267, "name": "애플하우스", "sim": 0.107714, "is_expected": false @@ -17896,6 +20119,7 @@ { "rank": 8, "record_id": 273, + "context_id": 273, "name": "오향절면", "sim": 0.103195, "is_expected": false @@ -17903,6 +20127,7 @@ { "rank": 9, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", "sim": 0.066044, "is_expected": false @@ -17910,6 +20135,7 @@ { "rank": 10, "record_id": 266, + "context_id": 266, "name": "도피", "sim": 0.062501, "is_expected": false @@ -17917,6 +20143,7 @@ { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", "sim": 0.019086, "is_expected": false @@ -17935,6 +20162,7 @@ { "rank": 1, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", "sim": 0.178382, "is_expected": false @@ -17942,6 +20170,7 @@ { "rank": 2, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", "sim": 0.173833, "is_expected": false @@ -17949,6 +20178,7 @@ { "rank": 3, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", "sim": 0.166437, "is_expected": false @@ -17956,6 +20186,7 @@ { "rank": 4, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", "sim": 0.142676, "is_expected": false @@ -17963,6 +20194,7 @@ { "rank": 5, "record_id": 292, + "context_id": 292, "name": "적당", "sim": 0.137346, "is_expected": false @@ -17970,6 +20202,7 @@ { "rank": 6, "record_id": 293, + "context_id": 293, "name": "키친갈매기", "sim": 0.137272, "is_expected": false @@ -17977,6 +20210,7 @@ { "rank": 7, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", "sim": 0.128888, "is_expected": false @@ -17984,6 +20218,7 @@ { "rank": 8, "record_id": 290, + "context_id": 290, "name": "힉스커피", "sim": 0.124829, "is_expected": false @@ -17991,6 +20226,7 @@ { "rank": 9, "record_id": 288, + "context_id": 288, "name": "연남칼국수", "sim": 0.12476, "is_expected": false @@ -17998,6 +20234,7 @@ { "rank": 10, "record_id": 279, + "context_id": 279, "name": "사루카메", "sim": 0.122916, "is_expected": false @@ -18005,6 +20242,7 @@ { "rank": 11, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", "sim": 0.101796, "is_expected": false @@ -18012,6 +20250,7 @@ { "rank": 12, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", "sim": 0.100572, "is_expected": false @@ -18019,6 +20258,7 @@ { "rank": 13, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", "sim": 0.10023, "is_expected": false @@ -18026,6 +20266,7 @@ { "rank": 14, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", "sim": 0.0971, "is_expected": false @@ -18033,6 +20274,7 @@ { "rank": 15, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", "sim": 0.066104, "is_expected": false @@ -18040,6 +20282,7 @@ { "rank": 16, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", "sim": 0.065156, "is_expected": false @@ -18047,6 +20290,7 @@ { "rank": 17, "record_id": 277, + "context_id": 277, "name": "카츠요", "sim": 0.046701, "is_expected": false @@ -18065,6 +20309,7 @@ { "rank": 1, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", "sim": 0.244106, "is_expected": false @@ -18072,6 +20317,7 @@ { "rank": 2, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", "sim": 0.213993, "is_expected": false @@ -18079,6 +20325,7 @@ { "rank": 3, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", "sim": 0.179991, "is_expected": false @@ -18086,6 +20333,7 @@ { "rank": 4, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", "sim": 0.171439, "is_expected": false @@ -18093,6 +20341,7 @@ { "rank": 5, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", "sim": 0.152023, "is_expected": false @@ -18100,6 +20349,7 @@ { "rank": 6, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", "sim": 0.146701, "is_expected": false @@ -18118,6 +20368,7 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", "sim": 0.288527, "is_expected": false @@ -18125,6 +20376,7 @@ { "rank": 2, "record_id": 275, + "context_id": 275, "name": "힉스커피", "sim": 0.269423, "is_expected": false @@ -18132,6 +20384,7 @@ { "rank": 3, "record_id": 266, + "context_id": 266, "name": "도피", "sim": 0.259684, "is_expected": false @@ -18139,6 +20392,7 @@ { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", "sim": 0.258017, "is_expected": false @@ -18146,6 +20400,7 @@ { "rank": 5, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", "sim": 0.254538, "is_expected": false @@ -18153,6 +20408,7 @@ { "rank": 6, "record_id": 270, + "context_id": 270, "name": "적당", "sim": 0.216135, "is_expected": false @@ -18160,6 +20416,7 @@ { "rank": 7, "record_id": 267, + "context_id": 267, "name": "애플하우스", "sim": 0.207232, "is_expected": false @@ -18167,6 +20424,7 @@ { "rank": 8, "record_id": 273, + "context_id": 273, "name": "오향절면", "sim": 0.205426, "is_expected": false @@ -18174,6 +20432,7 @@ { "rank": 9, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", "sim": 0.194727, "is_expected": false @@ -18181,6 +20440,7 @@ { "rank": 10, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", "sim": 0.175003, "is_expected": false @@ -18188,6 +20448,7 @@ { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", "sim": 0.150512, "is_expected": false @@ -18206,6 +20467,7 @@ { "rank": 1, "record_id": 290, + "context_id": 290, "name": "힉스커피", "sim": 0.269281, "is_expected": false @@ -18213,6 +20475,7 @@ { "rank": 2, "record_id": 293, + "context_id": 293, "name": "키친갈매기", "sim": 0.258024, "is_expected": false @@ -18220,6 +20483,7 @@ { "rank": 3, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", "sim": 0.254538, "is_expected": false @@ -18227,6 +20491,7 @@ { "rank": 4, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", "sim": 0.251607, "is_expected": false @@ -18234,6 +20499,7 @@ { "rank": 5, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", "sim": 0.251182, "is_expected": false @@ -18241,6 +20507,7 @@ { "rank": 6, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", "sim": 0.235955, "is_expected": false @@ -18248,6 +20515,7 @@ { "rank": 7, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", "sim": 0.225909, "is_expected": false @@ -18255,6 +20523,7 @@ { "rank": 8, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", "sim": 0.219261, "is_expected": false @@ -18262,6 +20531,7 @@ { "rank": 9, "record_id": 292, + "context_id": 292, "name": "적당", "sim": 0.216188, "is_expected": false @@ -18269,6 +20539,7 @@ { "rank": 10, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", "sim": 0.206839, "is_expected": false @@ -18276,6 +20547,7 @@ { "rank": 11, "record_id": 279, + "context_id": 279, "name": "사루카메", "sim": 0.192825, "is_expected": false @@ -18283,6 +20555,7 @@ { "rank": 12, "record_id": 277, + "context_id": 277, "name": "카츠요", "sim": 0.192139, "is_expected": false @@ -18290,6 +20563,7 @@ { "rank": 13, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", "sim": 0.189095, "is_expected": false @@ -18297,6 +20571,7 @@ { "rank": 14, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", "sim": 0.175086, "is_expected": false @@ -18304,6 +20579,7 @@ { "rank": 15, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", "sim": 0.174751, "is_expected": false @@ -18311,6 +20587,7 @@ { "rank": 16, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", "sim": 0.166326, "is_expected": false @@ -18318,6 +20595,7 @@ { "rank": 17, "record_id": 288, + "context_id": 288, "name": "연남칼국수", "sim": 0.164957, "is_expected": false @@ -18336,6 +20614,7 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", "sim": 0.19215, "is_expected": false @@ -18343,6 +20622,7 @@ { "rank": 2, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", "sim": 0.185251, "is_expected": false @@ -18350,6 +20630,7 @@ { "rank": 3, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", "sim": 0.141101, "is_expected": false @@ -18357,6 +20638,7 @@ { "rank": 4, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", "sim": 0.125216, "is_expected": false @@ -18364,6 +20646,7 @@ { "rank": 5, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", "sim": 0.116233, "is_expected": false @@ -18371,6 +20654,7 @@ { "rank": 6, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", "sim": 0.089834, "is_expected": false @@ -18389,6 +20673,7 @@ { "rank": 1, "record_id": 276, + "context_id": 276, "name": "메밀집", "sim": 0.334414, "is_expected": false @@ -18396,6 +20681,7 @@ { "rank": 2, "record_id": 273, + "context_id": 273, "name": "오향절면", "sim": 0.210116, "is_expected": false @@ -18403,6 +20689,7 @@ { "rank": 3, "record_id": 270, + "context_id": 270, "name": "적당", "sim": 0.165268, "is_expected": false @@ -18410,6 +20697,7 @@ { "rank": 4, "record_id": 268, + "context_id": 268, "name": "키친갈매기", "sim": 0.158972, "is_expected": false @@ -18417,6 +20705,7 @@ { "rank": 5, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", "sim": 0.145705, "is_expected": false @@ -18424,6 +20713,7 @@ { "rank": 6, "record_id": 275, + "context_id": 275, "name": "힉스커피", "sim": 0.140919, "is_expected": false @@ -18431,6 +20721,7 @@ { "rank": 7, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", "sim": 0.11027, "is_expected": false @@ -18438,6 +20729,7 @@ { "rank": 8, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", "sim": 0.076831, "is_expected": false @@ -18445,6 +20737,7 @@ { "rank": 9, "record_id": 267, + "context_id": 267, "name": "애플하우스", "sim": 0.07585, "is_expected": false @@ -18452,6 +20745,7 @@ { "rank": 10, "record_id": 266, + "context_id": 266, "name": "도피", "sim": 0.075728, "is_expected": false @@ -18459,6 +20753,7 @@ { "rank": 11, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", "sim": 0.061236, "is_expected": false @@ -18477,6 +20772,7 @@ { "rank": 1, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", "sim": 0.188995, "is_expected": false @@ -18484,6 +20780,7 @@ { "rank": 2, "record_id": 277, + "context_id": 277, "name": "카츠요", "sim": 0.188869, "is_expected": false @@ -18491,6 +20788,7 @@ { "rank": 3, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", "sim": 0.17901, "is_expected": false @@ -18498,6 +20796,7 @@ { "rank": 4, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", "sim": 0.177559, "is_expected": false @@ -18505,6 +20804,7 @@ { "rank": 5, "record_id": 292, + "context_id": 292, "name": "적당", "sim": 0.165269, "is_expected": false @@ -18512,6 +20812,7 @@ { "rank": 6, "record_id": 288, + "context_id": 288, "name": "연남칼국수", "sim": 0.162915, "is_expected": false @@ -18519,6 +20820,7 @@ { "rank": 7, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", "sim": 0.160547, "is_expected": false @@ -18526,6 +20828,7 @@ { "rank": 8, "record_id": 293, + "context_id": 293, "name": "키친갈매기", "sim": 0.15896, "is_expected": false @@ -18533,6 +20836,7 @@ { "rank": 9, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", "sim": 0.154171, "is_expected": false @@ -18540,6 +20844,7 @@ { "rank": 10, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", "sim": 0.145735, "is_expected": false @@ -18547,6 +20852,7 @@ { "rank": 11, "record_id": 290, + "context_id": 290, "name": "힉스커피", "sim": 0.141077, "is_expected": false @@ -18554,6 +20860,7 @@ { "rank": 12, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", "sim": 0.140619, "is_expected": false @@ -18561,6 +20868,7 @@ { "rank": 13, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", "sim": 0.140271, "is_expected": false @@ -18568,6 +20876,7 @@ { "rank": 14, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", "sim": 0.139276, "is_expected": false @@ -18575,6 +20884,7 @@ { "rank": 15, "record_id": 279, + "context_id": 279, "name": "사루카메", "sim": 0.130576, "is_expected": false @@ -18582,6 +20892,7 @@ { "rank": 16, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", "sim": 0.11027, "is_expected": false @@ -18589,6 +20900,7 @@ { "rank": 17, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", "sim": 0.106723, "is_expected": false @@ -18607,6 +20919,7 @@ { "rank": 1, "record_id": 257, + "context_id": 257, "name": "[데모] 강변 산책로 초입", "sim": 0.240327, "is_expected": false @@ -18614,6 +20927,7 @@ { "rank": 2, "record_id": 256, + "context_id": 256, "name": "[데모] 넓은 한상 식당", "sim": 0.210402, "is_expected": false @@ -18621,6 +20935,7 @@ { "rank": 3, "record_id": 252, + "context_id": 252, "name": "[데모] 골목 안 다방", "sim": 0.167871, "is_expected": false @@ -18628,6 +20943,7 @@ { "rank": 4, "record_id": 253, + "context_id": 253, "name": "[데모] 창가 작업실 카페", "sim": 0.166591, "is_expected": false @@ -18635,6 +20951,7 @@ { "rank": 5, "record_id": 254, + "context_id": 254, "name": "[데모] 연남 골목 선술집", "sim": 0.161111, "is_expected": false @@ -18642,6 +20959,7 @@ { "rank": 6, "record_id": 255, + "context_id": 255, "name": "[데모] 언덕 위 야경 식당", "sim": 0.15829, "is_expected": false @@ -18660,6 +20978,7 @@ { "rank": 1, "record_id": 273, + "context_id": 273, "name": "오향절면", "sim": 0.290594, "is_expected": false @@ -18667,6 +20986,7 @@ { "rank": 2, "record_id": 276, + "context_id": 276, "name": "메밀집", "sim": 0.248772, "is_expected": false @@ -18674,6 +20994,7 @@ { "rank": 3, "record_id": 275, + "context_id": 275, "name": "힉스커피", "sim": 0.219591, "is_expected": false @@ -18681,6 +21002,7 @@ { "rank": 4, "record_id": 269, + "context_id": 269, "name": "오케이어 맨션", "sim": 0.213938, "is_expected": false @@ -18688,6 +21010,7 @@ { "rank": 5, "record_id": 270, + "context_id": 270, "name": "적당", "sim": 0.208724, "is_expected": false @@ -18695,6 +21018,7 @@ { "rank": 6, "record_id": 272, + "context_id": 272, "name": "주토피아 서울", "sim": 0.194342, "is_expected": false @@ -18702,6 +21026,7 @@ { "rank": 7, "record_id": 274, + "context_id": 274, "name": "모던아시안누들서비스", "sim": 0.18232, "is_expected": false @@ -18709,6 +21034,7 @@ { "rank": 8, "record_id": 266, + "context_id": 266, "name": "도피", "sim": 0.181179, "is_expected": false @@ -18716,6 +21042,7 @@ { "rank": 9, "record_id": 268, + "context_id": 268, "name": "키친갈매기", "sim": 0.168257, "is_expected": false @@ -18723,6 +21050,7 @@ { "rank": 10, "record_id": 267, + "context_id": 267, "name": "애플하우스", "sim": 0.161401, "is_expected": false @@ -18730,6 +21058,7 @@ { "rank": 11, "record_id": 271, + "context_id": 271, "name": "피넛커피 충북혁신도시점", "sim": 0.142173, "is_expected": false @@ -18748,6 +21077,7 @@ { "rank": 1, "record_id": 282, + "context_id": 282, "name": "치킨버거 이스트사이드", "sim": 0.286909, "is_expected": false @@ -18755,6 +21085,7 @@ { "rank": 2, "record_id": 281, + "context_id": 281, "name": "플랜트 연남점", "sim": 0.260084, "is_expected": false @@ -18762,6 +21093,7 @@ { "rank": 3, "record_id": 278, + "context_id": 278, "name": "감나무집기사식당", "sim": 0.259004, "is_expected": false @@ -18769,6 +21101,7 @@ { "rank": 4, "record_id": 286, + "context_id": 286, "name": "저스트텐동 연남본점", "sim": 0.236053, "is_expected": false @@ -18776,6 +21109,7 @@ { "rank": 5, "record_id": 285, + "context_id": 285, "name": "월강부산돼지국밥", "sim": 0.229376, "is_expected": false @@ -18783,6 +21117,7 @@ { "rank": 6, "record_id": 290, + "context_id": 290, "name": "힉스커피", "sim": 0.219785, "is_expected": false @@ -18790,6 +21125,7 @@ { "rank": 7, "record_id": 291, + "context_id": 291, "name": "오케이어 맨션", "sim": 0.213975, "is_expected": false @@ -18797,6 +21133,7 @@ { "rank": 8, "record_id": 283, + "context_id": 283, "name": "쿠로코식당 연남점", "sim": 0.209091, "is_expected": false @@ -18804,6 +21141,7 @@ { "rank": 9, "record_id": 292, + "context_id": 292, "name": "적당", "sim": 0.208766, "is_expected": false @@ -18811,6 +21149,7 @@ { "rank": 10, "record_id": 288, + "context_id": 288, "name": "연남칼국수", "sim": 0.205297, "is_expected": false @@ -18818,6 +21157,7 @@ { "rank": 11, "record_id": 289, + "context_id": 289, "name": "모던아시안누들서비스", "sim": 0.18232, "is_expected": false @@ -18825,6 +21165,7 @@ { "rank": 12, "record_id": 277, + "context_id": 277, "name": "카츠요", "sim": 0.171747, "is_expected": false @@ -18832,6 +21173,7 @@ { "rank": 13, "record_id": 284, + "context_id": 284, "name": "진우네 초밥", "sim": 0.17092, "is_expected": false @@ -18839,6 +21181,7 @@ { "rank": 14, "record_id": 293, + "context_id": 293, "name": "키친갈매기", "sim": 0.168234, "is_expected": false @@ -18846,6 +21189,7 @@ { "rank": 15, "record_id": 287, + "context_id": 287, "name": "동교어린이공원", "sim": 0.166432, "is_expected": false @@ -18853,6 +21197,7 @@ { "rank": 16, "record_id": 280, + "context_id": 280, "name": "뉴오더클럽 연남", "sim": 0.145267, "is_expected": false @@ -18860,6 +21205,7 @@ { "rank": 17, "record_id": 279, + "context_id": 279, "name": "사루카메", "sim": 0.07744, "is_expected": false From cf07b156856a7ae97e94e53fd7a1ef36a1e37597 Mon Sep 17 00:00:00 2001 From: colosair Date: Wed, 5 Aug 2026 21:58:01 +0900 Subject: [PATCH 08/34] =?UTF-8?q?docs(S15P11A705-336):=20fusion=20?= =?UTF-8?q?=EC=8B=A4=EC=B8=A1=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20I53=C2=B7too?= =?UTF-8?q?ls=20README=20=EC=A0=88=EC=B0=A8=C2=B7CI=20search-upgrade=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측 수치의 보존본을 I53 으로 정식화 — 병합 방식×floor 격자, 채택 기준 대조, 사례 4건, 못 잰 축(-339 몫). tools/search_cut/README 에 신규 4종+테스트 실행 절차. ai-ci 트리거에 search-upgrade 추가 — base 가 통합 브랜치인 PR 이 CI 없이 병합되는 것을 막는다(T36 계열). Co-Authored-By: Claude Fable 5 --- .github/workflows/ai-ci.yml | 6 +- .../2026-08-05-fusion-measurement.md | 135 ++++++++++++++++++ docs/implements/README.md | 2 + tools/search_cut/README.md | 25 +++- 4 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 docs/implements/2026-08-05-fusion-measurement.md diff --git a/.github/workflows/ai-ci.yml b/.github/workflows/ai-ci.yml index 7e346f1..387a9ac 100644 --- a/.github/workflows/ai-ci.yml +++ b/.github/workflows/ai-ci.yml @@ -18,11 +18,13 @@ on: # # types 를 명시하는 순간 기본값 셋이 사라지므로 넷을 다 적는다. # test_ci_image_publish_contract.py 가 이 목록을 계약으로 고정한다. + # search-upgrade 는 검색 고도화 통합 브랜치다(P49 §6) — 작업 PR 의 base 가 dev 가 아니라 + # 이 브랜치라서, 여기 없으면 그 PR 들이 CI 없이 병합된다(T36 계열). pull_request: types: [opened, synchronize, reopened, edited] - branches: [main, dev] + branches: [main, dev, search-upgrade] push: - branches: [main, dev] + branches: [main, dev, search-upgrade] permissions: contents: read diff --git a/docs/implements/2026-08-05-fusion-measurement.md b/docs/implements/2026-08-05-fusion-measurement.md new file mode 100644 index 0000000..297b9ad --- /dev/null +++ b/docs/implements/2026-08-05-fusion-measurement.md @@ -0,0 +1,135 @@ +# Keyword fusion 1단계 실측 — 병합 방식·floor 격자와 채택 기준 대조 + +- **티켓**: `S15P11A705-336` +- **날짜**: 2026-08-05 (실측 17:1x~17:5x KST · 원 실행은 leo 브랜치 코드 대행 실측) +- **하네스**: `tools/search_cut/{rank_score,fusion,fusion_sweep,keyword_matrix}.py` — 실행 절차는 그 README +- **기준 문서**: [P48](../proposals/P48-search-signal-expansion.md) §1·§2·§6.1 · 발견 문제의 기록 [T73~T78](../troubleshooting/2026-08-05-multi-signal-investigation.md) +- **성격**: **재기만 한다.** 앱 런타임 코드를 바꾸지 않는다. 채택(fusion 활성화) 여부는 이 리포트가 정하지 않는다 — 후속 티켓(`S15P11A705-339`)의 판단 재료다. + +## 요약 + +``` +환경 동등성 픽스처 24종 통과 · rank_score 재현 = I52 기록과 일치 + (무관-문장형 무노출 11/15 · 스팟 0.2438 · 진단프로브 hit@1 0.5909) + +핵심 결과 가중합은 정답과 관련 없는 후보를 함께 끌어올린다 — + floor 0.25 에서 신한 복구(컷 후 4위)와 무관-문장형 무노출 11/15→9/15 감소가 + 같은 계산에서 나온다. RRF 는 무노출을 유지하지만 문장형 hit@1 0.8333→0.5833. + +채택 기준 P48 §6.1 네 조건을 전부 통과한 조합은 floor 0.35 · binary w=0.05 하나다. + 그 조건에서 신한·부캠은 회복되지 않는다 — 이 구조의 알려진 한계다. +``` + +## 1. 측정 조건 + +| | | +|---|---| +| 코드 | leo 3커밋(`origin/leo` `fcb397c`) — 이 브랜치에 인수된 것과 동일. 버그 2건(T76·T77)은 실측 시 로컬 패치, 이 브랜치에서 정식 수정 | +| DB | 시연 DB `:15432` (`pinlog-demo-postgres-1`) · Record 42건 · 소유자 3명 | +| Preset | 27건 v1 (PUBLIC 25 · PRIVATE_ONLY 2) · keyword 판정 83건 · confidence NULL 0건 | +| profile | `openai-text-embedding-3-small-1536-cosine-v1` | +| GMS 호출 | `word_matrix` 배치 1회 · `recall_probe` 재생성 · `keyword_matrix` 배치 1회(질의 92건) — 이후 sweep 은 전부 파일만 읽음 | +| 컷 | `tau_abs=0.30` · `tau_word=0.24` · `r=0.60` · `limit=20` (현행 기본값) | + +`word_grid.json`·`recall_probe.json` 은 `context_id` 를 포함해 재생성했다(P48 §4.1 — keyword 조인 요건). 재생성으로 유사도가 기존 커밋본과 10⁻⁴ 규모로 다를 수 있다(T42·T68) — baseline 재현 대조가 그 흔들림이 판정을 바꾸지 않았음을 보인다. + +## 2. Baseline 재현 — 환경이 leo 와 같은 결과를 내는가 + +`rank_score.py` 를 이 환경에서 돌린 값이 leo 의 baseline 리포트([I52](2026-08-05-search-rank-baseline.md)) 기록과 일치했다. + +``` +문장형(정답, 12건) 컷 후 hit@1 0.8333 · hit@3 1.0000 · MRR 0.9028 +단어형(정답, 66건) 컷 후 hit@1 0.8636 · MRR 0.9015 +무관-문장형(15건) 무노출 11/15 (0.7333) ← -213 기록과도 일치 +진단프로브(22건) hit@1 0.5909 · 신한 컷 전 6위 · 부캠 8위 · 스팟 1위(0.2438) +``` + +## 3. 병합 방식 격자 — 기본 축 (floor 0.25 · top-k 3) + +### 3.1 정답 세그먼트 + +| 방식 | 문장형 hit@1 | 문장형 mrr | 단어형 hit@1 | 단어형 mrr | 사례 `신한` | +|---|---|---|---|---|---| +| baseline (신호 없음) | 0.8333 | 0.9028 | 0.8636 | 0.9015 | 컷 후 잘림 | +| binary w=0.05 | 0.8333 | 0.9167 | 0.8636 | 0.9058 | **4위 복구** | +| binary w=0.10 | 0.8333 | 0.9167 | 0.8030 | 0.8750 | 4위 복구 | +| conf w=0.10 | 0.7500 | 0.8750 | 0.8788 | 0.9141 | 4위 복구 | +| conf w=0.25 | 0.7500 | 0.8750 | **0.8939** | **0.9311** | 4위 복구 | +| idf w=0.10 | 0.7500 | 0.8750 | 0.8636 | 0.9051 | 4위 복구 | +| rrf c=0 | **0.5833** | 0.7361 | 0.8030 | 0.8620 | 잘림 | +| rrf c=0.033 | 0.0000 | 0.0000 | 0.0000 | 0.0000 | — (전 세그먼트 0건) | + +`부캠`은 모든 방식에서 잘림 그대로다. RRF cutoff 는 0/0.004/0.008/0.016/0.033 다섯 값을 쟀고 0~0.008 은 결과가 같으며 0.016 부터 반환 수가 줄고 0.033 에서 모든 결과가 제거된다. + +### 3.2 무관 세그먼트 — 무노출 비율 (높을수록 좋다) + +| 방식 | 무관-문장형 (baseline 0.7333 = 11/15) | 무관-단어형 (baseline 0.4222) | +|---|---|---| +| binary w=0.05 | **0.6000 (9/15 로 감소)** | 0.4000 | +| binary w=0.10 | 0.5333 | 0.2667 | +| conf w=0.10 | 0.6667 | 0.4222 | +| conf w=0.25 | 0.5333 | 0.4000 | +| idf w=0.10 | 0.6000 | 0.3333 | +| rrf c=0~0.016 | **0.7333 (유지)** | **0.4222 (유지)** | + +**`신한` 복구와 무관 노출 증가가 같은 계산에서 나온다.** 가중합은 컷을 부스트된 점수에 걸므로, 컷 경계 아래의 정답을 끌어올리는 힘이 경계 아래의 무관 후보에도 똑같이 작용한다. RRF 가 무노출을 지키는 것은 컷을 원래 코사인에 거는 별도 관문(P48 §2.2) 덕이고, 순위 퇴행은 벡터 1위가 키워드 순위와의 합산에서 밀리기 때문이다. + +## 4. floor 격자 — 0.25 / 0.30 / 0.35 / 0.40 + +floor 0.35 에서 무관 무노출이 baseline 으로 완전히 돌아오고, 그 조건의 binary w=0.05 가 유일하게 퇴행 없이 개선만 남는다. + +``` +단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129 + recall@3 0.9205→0.9356 · nDCG@3 0.9007→0.9159 +문장형 hit@3 1.0 유지 · MRR 0.9028→0.9167 · nDCG@3 0.9276→0.9385 +무관 0.7333 / 0.4222 — baseline 과 동일 +``` + +대가가 둘 있다. Preset 후보가 floor 를 넘는 단어형 질의가 66건 중 12건으로 줄고(미반영 54건 — 목록은 artifact 에서 재구성 가능, Preset 개정 `-338` 의 설계 입력), floor 0.25 에서 복구됐던 `신한`이 다시 잘린다. + +## 5. P48 §6.1 채택 기준 대조 + +| 기준 | floor 0.25 가중합 | floor 0.25 RRF | **floor 0.35 binary w=0.05** | +|---|---|---|---| +| Hit@3·Recall@3 퇴행 없음 | 충족 | **위반** (문장형 hit@3 1.0→0.8333) | 충족 | +| 한 세그먼트 이상 MRR/nDCG 개선 | 충족 | 위반 | 충족 (단어형·문장형 모두) | +| 무관 통과율 악화 없음 | **위반** | 충족 | 충족 | +| RRF cutoff=0 단독 채택 금지 | 해당 없음 | 격자 5값 실측 | 해당 없음 | + +**전 조건을 통과한 조합은 floor 0.35 · binary w=0.05 하나다.** 다만 이 값은 개정 전 Preset 기준의 관측이다 — Preset 개정(`-338`) 후 `-339` 가 재측정으로 확정한다. + +## 6. 사례 4건 (컷 후 정답 순위) + +| 조건 | 신한 | 부캠 | 그네 | 스팟 | +|---|---|---|---|---| +| baseline | 잘림 | 잘림 | 3위 | 1위 | +| floor 0.25 가중합 | 4위 | 잘림 | 3위 | 1위 | +| floor 0.35 binary w=0.05 | 잘림 | 잘림 | 3위 | 1위 | + +`신한`·`부캠`은 keyword 신호의 안전값으로 회복되지 않는다 — `부캠`은 질의 재작성(`-337`), `신한`은 문자열 검색(P49 §3, back 연동 후속)의 대상이라는 역할 분담이 이 실측으로 재확인됐다. + +## 7. 검증 — 실행한 것과 못 한 것 + +**실행한 것** + +- 픽스처 27종 통과 (`test_search_fusion.py` 24 + `test_keyword_matrix_parse.py` 3) +- baseline 재현 = I52·`-213` 기록과 일치 (§2) +- 방식 격자(가중합 5조합 + RRF cutoff 5값) × floor 4값 +- 가드 무오발동 — profile 정합·context_id 존재·artifact 신선도·라벨 정합 + +**못 한 것** + +- **재현성 회차를 뜨지 않았다.** 채택값을 확정하지 않으므로 요구하지 않았다 — `-339` 확정 시 `-266` 의 T68 절차(회차 대조)를 적용한다 +- **floor 0.30~0.35 사이를 재지 않았다** — 경계가 이 구간 어딘가에 있다 +- **weight 세밀 격자를 훑지 않았다** — 하네스 내장값(binary 0.05/0.10 · conf 0.10/0.25 · idf 0.10)만 +- **운영 DB 를 재지 않았다** — 시연 DB 만. 선행 리포트들과 같은 한계 +- Record 42건 규모라 **절대값을 일반화하지 않는다** — 퇴행 탐지 근거로만 쓴다 + +## 산출물 + +| 경로 | 수명 | +|---|---| +| `.search/keyword_matrix.json` | **영구(커밋)** — 재생성에 GMS 배치 1회. `.gitignore` 예외 등록 | +| `.search/word_grid.json` · `recall_probe.json` | **영구(커밋 갱신)** — `context_id` 포함 재생성분 | +| 스윕 결과 JSON (`fusion_sweep*.json` · `rank_baseline.json`) | **미커밋** — artifact 에서 재구성 가능(기존 규약). 수치는 이 리포트 표가 보존본 | +| 이 문서 | 영구 | diff --git a/docs/implements/README.md b/docs/implements/README.md index afa317c..a5ff79c 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -54,6 +54,7 @@ | I52 | [2026-08-05-search-rank-baseline.md](2026-08-05-search-rank-baseline.md) | 구현 | 검색 **순위** 지표 baseline `tools/search_cut/rank_score.py` — 기존 지표가 전부 컷 기준이라 순위 변화를 못 센다(P48 1단계 이후는 순위를 바꾸는 변경이라 개선이 0으로 보인다). 기존 행렬만 읽어 Hit·Recall·MRR·nDCG@1/3/5 를 **컷 전/후 · 단어형/문장형 · 정답질의/무관질의**로 갈라 낸다. **재구성 일치 두 건** — 무관-문장형 침묵률 11/15 와 `스팟` 0.2438 이 기존 기록과 같다. **`-255` ① 판정이 이미 닫혀 있음을 확인**(`-266` 의 단어형 분기가 `그네` 를 회복시켰다). `신한`·`부캠` 은 `tau_word` 와 `r·top1` **양쪽 모두 아래**라 컷 조절로 닿지 않는다 (티켓 없음 · 런타임 변경 없음) | | I50 | [2026-08-03-preset-display-name-deploy-chain.md](2026-08-03-preset-display-name-deploy-chain.md) | 검증 | 표시명 개정의 배포 체인 여섯 단계와 소관 — **봉인 값은 판 표기이지 부트스트랩 재실행 조건이 아니다.** 낡은 봉인 값 위에서 새 표시명이 운영 화면까지 온 것이 곧 재실행의 증거다(`hook-delete-policy` 가 같은 이름 Job 을 지우고 다시 만들고, 적재는 멱등이다). 중앙이 「값이 같으면 재실행 안 된다」로 판정했고 독립 검증이 논거를 반증했으나 클러스터 관측 경로가 없어 「확인 불가」로 끝났던 것을 **배포가 닫았다.** 릴리스가 두 번 막힌다 — base 검사(`main` base 는 `release/*`·`hotfix/*` 만)와 strict 상태 검사(BEHIND 거부). 화면 확인이 유일한 검증이라 이르게 보면 「안 됐다」로 오판한다 (S15P11A705-292) | | I51 | [2026-08-05-short-query-boundary.md](2026-08-05-short-query-boundary.md) | 검증 | 짧은 질의의 층별 탈락과 단어형 경계 두 정의 — **`-266` 이후 남은 실패는 셋뿐이고 경계 정의로는 하나도 안 풀린다.** `신한`(③ τ_abs·6위)·`부캠`(③ τ_abs·8위)·`신한 부캠`(**④ r**·4위)이고 실서버 대조 22/22 일치로 층을 확정했다(전처리·폴백은 **존재하지 않는다** — `min_length=1` 뿐이고 0건이면 0건이 그대로 나간다). **「2자가 안 걸린다」는 부정확** — 2자 7건 중 5건은 걸리고 못 걸리는 둘은 컷 전 순위가 6·8위다. `-266` 이 「재지 않았다」고 적은 B(공백·짧다)·C(무공백·길다) 대역을 **본문 인접 어절쌍 전량**(쌍 176종 × spaced/joined × 소유자 3명 = 1,128행)으로 처음 쟀다 — 손실은 C 에 몰려 있고(16/122 → 어절 수 정의 3/122) **그런데 초점 집합 36행에서는 어느 정의에서도 누락이 0** 이라 두 표가 어긋난다(회복되는 것이 기능어 쌍이다). 새 관측 둘: **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385 · 172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 τ 탈락 — `-266` 이 「안전 방향」이라 부른 판단이 이 대역에서는 손해다). 계약이 「이것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하므로 **세 번째다** (S15P11A705-273) | +| I53 | [2026-08-05-fusion-measurement.md](2026-08-05-fusion-measurement.md) | 구현 | Keyword fusion 1단계 실측 — 병합 방식(가중합 5조합·RRF cutoff 5값)×floor 4값 격자. **가중합은 정답과 관련 없는 후보를 함께 끌어올린다** — floor 0.25 에서 `신한` 복구(컷 후 4위)와 무관-문장형 무노출 11/15→9/15 감소가 같은 계산에서 나온다. RRF 는 무노출을 유지하나 문장형 hit@1 0.8333→0.5833 퇴행. **P48 §6.1 네 조건을 전부 통과한 조합은 floor 0.35 · binary w=0.05 하나** — 그 조건에서 단어형·문장형 전 지표 개선·무관 무노출 유지, 대가는 신호 반영 12/66 축소와 `신한` 재제외. `신한`·`부캠`은 이 구조로 안 풀린다(재작성 `-337`·문자열 검색의 대상). 환경 동등성은 baseline 재현 = I52 일치로 확인. 못 잰 축(floor 0.30~0.35·weight 격자·재현성 회차)은 `-339` 몫 (S15P11A705-336) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -111,5 +112,6 @@ | I49 | GMS 이미지 측정 하네스 `tools/gms_image/`(5스크립트) + 회차 기록 `.gms_image/axis-{a,b}.jsonl` — **설계의 핵심은 치수와 바이트를 따로 움직인 것이다.** 실제 사진은 치수가 크면 바이트도 커서 `-253` 의 세 가설(A 게이트웨이 가산 · B base64 를 텍스트로 셈 · C 벤더 자체 규칙)이 전부 같은 곡선을 낸다 — 합성 PNG 의 노이즈 줄 수로 **512×512 를 유지한 채 2 KB↔72 KB** 를 만들어 얽힘을 풀었다. 판정은 두 기계적 검사(바이트 불변 · 치수 반응)로만 하고 **벤더 공개 공식은 판정에서 분리**했다(넣으면 「공식대로니 정상」이라는 순환). **가설 A 기각의 근거는 경로를 가로지른 대조** — Gemini 만 보면 272 로 평탄해 A 처럼 보이지만 같은 게이트웨이의 OpenAI(8,523→25,524)·Anthropic(39→1,407)이 치수에 반응하므로 게이트웨이 상수가 아니다. **비용 손잡이는 벤더 선택이 전부** — 같은 1024×1024 가 `gpt-4o-mini` 25,524 · `haiku-4-5` 1,407 · `gemini-2.5-flash-lite` 272 로 **94배** 차이다. 축 A 는 404·401·403 이 **하나도 안 나왔다** — GMS 는 모든 거부를 400 + 자체 봉투로 내고, 대조군으로 넣은 `GET .../models` 가 세 벤더 모두 「Model not found in request」를 내면서 **게이트웨이가 경로가 아니라 본문의 `model` 로 라우팅한다**는 구조가 드러났다(그래서 모델 목록 조회 자체가 불가능하고, 본문 상한 오진도 같은 뿌리다). 호출 상한을 코드에 박고(축 A 20 · 축 B 30, 양쪽 소진) 응답을 한 건씩 append 한다 — 세션이 죽어도 쓴 쿼터가 남는다. `report.py --replay` 는 GMS 를 안 부른다(판정 규칙 변경과 T27 쿼터 변동이 섞이면 결론이 왜 바뀌었는지 알 수 없다). **못 잰 것** — `detail:"low"`(계획 조건이 전부 본문 상한에 걸렸다) · 상한의 정확한 값(구간까지) · 분당/일일 한도(429 가 0건) · p95(n=5) | [이미지 측정 리포트](2026-08-03-gms-image-probe.md), [tools/gms_image/](../../tools/gms_image/), [비전 재고 I36](2026-08-03-gms-vision-probe.md) | | I50 | 표시명 개정 배포 체인 기록 — **산출물은 문서 하나이고 내용은 판정이다.** ① 체인 여섯 단계의 소관을 갈랐다(우리 `ai#102`·`ai#104` / 자동화 `image-publish` / 인프라 `infra#185` / 클러스터 sync / 화면). `image-publish` 가 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**(전수 근거는 `I48`). ② **미결 판정 하나를 실측으로 닫았다** — 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 반대로 판정했고 독립 검증이 논거만 반증한 채 **클러스터 관측 경로가 없어 「확인 불가」**로 끝나 있었다. **다만 표기가 낡은 것은 기록 정합성 문제로 남는다** — 배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다. ③ 릴리스가 막히는 두 곳을 절차로 적었다 — base 검사는 `release/*`·`hotfix/*` 만 허용하므로 `dev` 를 그대로 열지 않고(`ai#103` 이 그렇게 막혀 닫혔다), strict 는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(그 병합 커밋 `032e391f` 가 `ai#104` 에 남아 있다). **둘 다 CI 를 다시 기다리게 한다.** ④ 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 헛돌았고 갱신을 만든 것은 수동 실행이다 — `success` 두 번의 뜻이 다르다(`T71` 과 같은 성질). ⑤ 확인 수단은 화면뿐이라 **「아직인가 실패인가」가 갈리지 않는다** — 실제로 한 번 오판했다가 재확인에서 뒤집혔다 | [배포 체인 리포트](2026-08-03-preset-display-name-deploy-chain.md), [I48](2026-08-03-dev-deploy-gap.md), [T70~T72](../troubleshooting/2026-08-03-repeat-incidents.md) | | I51 | 짧은 질의 층 프로브 `tools/search_cut/layer_probe.py` + 경계 행렬 `boundary_matrix.py`·`boundary_sweep.py` + `.search/boundary_grid.json`(1,128행)·`layer_probe.json` — **산출물은 하네스 셋과 판정이고 코드는 고치지 않았다.** ① 층을 코드로 확정했다 — 계약이 후보로 준 「질의 전처리」와 「후보 0건 폴백」은 **존재하지 않는다**(`schema/search.py:9` 는 `min_length=1` 뿐이고 정규화가 없어 원문이 그대로 도달하며, `_cut` 이 0건이면 0건이 그대로 응답이다). 자르는 것은 `search_service.py:111-115,122` 둘뿐이고 `LIMIT` 은 이 데이터에서 한 건도 안 자른다(후보 17·11·6건). **실서버 대조 22/22 일치** — 재구성에 `_cut`·`_is_word_query` 를 다시 적었다(구현 import 금지, `-213` 규칙). ② `-266` 이후 남은 실패는 셋뿐이고 **서로 다른 층이다** — `신한`(③ τ_abs 0.2301<0.24·6위)·`부캠`(③ 0.2105·8위)·`신한 부캠`(**④ r** 0.3187<0.3520·4위). 셋째는 τ 를 넘고 `r` 에만 걸리므로 **경계 정의를 어떻게 바꿔도 안 풀린다**(`-266` 이 `r` 을 「참여하지 않는다」로 가르지 않았다). ③ 「2자가 안 걸린다」는 부정확 — 2자 7건 중 5건은 걸리고(`우주` 0.4200 1위·`공원` 0.3672 1위) 못 걸리는 둘은 **컷 전 순위가 6·8위**다. ④ `-266` 이 「둘이 갈리는 질의가 행렬에 없다」고 적은 대역을 만들었다 — 본문 **문장 내 인접 어절쌍 전량**을 `spaced`/`joined` 짝으로 내면 기대 정답이 같아 차이가 전부 「공백 하나」에 귀속된다(쌍 176종 × 2형태 × 소유자 3명 = 1,128행, GMS 376건). 질의를 고르지 않아 `-266` 이 한계로 적은 선정 재량이 없다(남는 재량은 「두 어절 모두 2자 이상」 하나). ⑤ 손실은 **C 대역(무공백·길다)** 에 몰려 있다 — 현행 16/122·1위 손실 4 가 어절 수 정의에서 3/122·1 이 되고 A·B·D 는 안 움직인다. **그런데 내용어 초점 집합 36행에서는 여섯 정의 전부 누락 0** 이라 두 표가 어긋난다 — 회복되는 것이 기능어 쌍(`당시자주` 0.1909·`같은분위기가` 0.2170)이라 사용자 이득이 관측되지 않았다. ⑥ 새 관측 둘 — **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385·중앙값 -0.0279·172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 ④ r 이 아니라 ③ τ 에서 잘린다). 후자는 `-266` 이 「측정이 아니라 안전 방향으로의 판단」이라 적은 대역이고 **그 판단이 여기서는 손해로 나타난다**(3건 표본). ⑦ 처방 후보 여섯을 비용·부작용과 함께 냈고 **「안 바꾸는 안」을 포함**했다 — 고르는 것은 이 티켓의 일이 아니다. ⑧ 계약이 「네가 재는 것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하고 후자는 코드까지 병합돼 dev 배포 tag(`7e72826`)에 들어 있으므로 **세 번째다** | [경계 리포트](2026-08-05-short-query-boundary.md), [I45](2026-08-03-search-recall-probe.md), [I47](2026-08-03-word-query-cut.md), [tools/search_cut/](../../tools/search_cut/) | +| I53 | Keyword fusion 1단계 실측 `tools/search_cut/fusion_sweep.py` 격자 — 방식(binary·conf·idf·RRF)×weight×floor(0.25/0.30/0.35/0.40)×RRF cutoff(0~0.033). **`신한` 복구와 무관 노출 증가가 같은 계산에서 나온다**(가중합은 컷을 부스트 점수에 걸어 경계 아래 정답과 무관 후보를 똑같이 끌어올린다). RRF 는 cosine eligibility gate 로 무노출을 지키지만 벡터 1위가 키워드 순위 합산에 밀려 문장형이 퇴행한다. floor 0.35 에서 무관 무노출이 baseline 복원되고 binary w=0.05 만 P48 §6.1 전 조건 통과 — Preset 후보가 floor 를 넘는 단어형 질의가 12/66 뿐이라는 대가(미반영 54건 = `-338` 설계 입력). 사례 4건 개별 보고(신한·부캠 미회복 확정). 픽스처 27종·가드 무오발동·baseline 재현 일치 | [실측 리포트](2026-08-05-fusion-measurement.md), [T73~T78](../troubleshooting/2026-08-05-multi-signal-investigation.md), [P48](../proposals/P48-search-signal-expansion.md), [P49](../proposals/P49-multi-signal-search.md) | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. diff --git a/tools/search_cut/README.md b/tools/search_cut/README.md index d4aae7a..7ab2a93 100644 --- a/tools/search_cut/README.md +++ b/tools/search_cut/README.md @@ -30,9 +30,13 @@ | `boundary_matrix.py` | 단어형 **경계 정의** 두 가지를 가르는 행렬(`S15P11A705-273`). 본문 인접 어절쌍을 `spaced`/`joined` 짝으로 낸다. **GMS 임베딩 배치 3회** | | `boundary_sweep.py` | `_is_word_query` 정의 6종을 같은 행렬에 걸어 비교한다. **DB 도 GMS 도 부르지 않는다** | | `layer_probe.py` | 질의가 **어느 층에서** 몇 건을 잃는지. 후보·LIMIT·τ·r·**실서버**를 한 줄에 놓는다 | +| `rank_score.py` | 검색 **순위** 지표 baseline(P48 0단계, I52). 컷 기준 지표로는 순위 변화가 안 보여서 따로 둔다. Hit·Recall·MRR·nDCG 를 컷 전/후 · 단어형/문장형 · 정답/무관으로 갈라 낸다. **DB 도 GMS 도 부르지 않는다** | +| `fusion.py` | keyword 신호 fusion **순수 로직**(P48 1단계). DB·GMS·파일을 읽지 않고 인자만 받는다 — `tests/test_search_fusion.py` 가 픽스처로 검증한다 | +| `keyword_matrix.py` | keyword 신호 artifact 생성 — 질의별 전체 활성 Preset 코사인 + Context 별 keyword·confidence·상태. **GMS 임베딩 배치 1회 + DB 읽기** | +| `fusion_sweep.py` | fusion 방식(binary·confidence·idf·RRF)×가중치×floor×RRF cutoff 격자. `keyword_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 GMS 도 부르지 않는다** | -`matrix.json` · `recall_probe.json` · `word_grid.json` 은 **커밋한다.** 다시 뜨려면 GMS 를 -부르고, `tau_grid` 의 것과 달리 Context 본문을 담지 않는다(장소명까지). +`matrix.json` · `recall_probe.json` · `word_grid.json` · `keyword_matrix.json` 은 **커밋한다.** +다시 뜨려면 GMS 를 부르고, `tau_grid` 의 것과 달리 Context 본문을 담지 않는다(장소명까지). ## 단어형 컷 격자 (`S15P11A705-266`) @@ -162,6 +166,23 @@ export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" **worktree 에서 돌린다면 `.env` 를 그쪽에도 둔다.** `get_settings()` 의 `env_file` 은 CWD 기준이고 `.env` 는 gitignore 라 worktree 에 없다 — `GMS_API_KEY` 부터 없어서 죽는다(T41). +### keyword fusion (P48 1단계, `S15P11A705-336` 실측) + +```bash +.venv/Scripts/python.exe -m pytest tests/test_search_fusion.py tests/test_keyword_matrix_parse.py -q + # 픽스처 검증. DB·GMS 0회 +.venv/Scripts/python.exe tools/search_cut/rank_score.py # 순위 baseline. 파일만 읽는다 +.venv/Scripts/python.exe tools/search_cut/keyword_matrix.py # GMS 배치 1회 + DB 읽기 +.venv/Scripts/python.exe tools/search_cut/fusion_sweep.py --rrf-cutoff-grid "0,0.004,0.008,0.016" --floor 0.35 + # 파일만 읽는다 +``` + +**floor 를 반드시 축으로 훑는다** — 기본값(0.25)은 관련 없는 질의의 결과가 노출되는 +대역이라, 그 값만 보면 무관 노출 조건을 잘못 통과시킨다(T73). `word_matrix.py` 와 +`recall_probe.py` 는 행에 `context_id` 를 포함해야 keyword 조인이 성립한다 — 낡은 +행렬이면 `fusion_sweep.py` 의 가드가 재지 않고 멈춘다. 결과 판정 기준은 P48 §6.1, +실측 기록은 [구현 리포트 I53](../../docs/implements/2026-08-05-fusion-measurement.md). + ### 실서버 대조 서버를 **이 브랜치 코드로** 띄워야 한다. 다른 워킹트리의 서버를 재면 컷이 없는 코드를 From 8a7962874506c901c3df40fb142180e3307b6c64 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 09:05:43 +0900 Subject: [PATCH 09/34] =?UTF-8?q?feat:=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=EA=B7=9C=EC=B9=99=20=EC=8B=A4=EC=B8=A1=20?= =?UTF-8?q?=ED=95=98=EB=84=A4=EC=8A=A4=EC=99=80=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=EC=95=88=20=E2=80=94=20P49=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lexical_matrix.py 가 본문 매치를 (질의×소유자×Record)로 굳히고(본문 미저장·스냅샷 DB :25432 가드), lexical_sweep.py 가 게이트 3단×병합 3종 격자를 오프라인으로 훑는다. 측정 결과: 무관·타인소유·문장형에서 매치 0건(노출 증가 없음), 신한 회복(RRF 5위), 경계 게이트는 기대 정답 6건 제외 손해만 관측. 규칙 확정안(단어형 한정·부분일치 + RRF k=60 + 컷·계약 불변 + 실패 시 벡터만)은 I54 리포트에 있다. 티켓 미발급(런타임 무변경 측정) — 티켓 귀속은 중앙 판단. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + .search/lexical_matrix.json | 1073 +++++++++++++++++ .../2026-08-06-lexical-merge-rule.md | 84 ++ docs/implements/README.md | 2 + tools/search_cut/README.md | 15 + tools/search_cut/lexical_matrix.py | 181 +++ tools/search_cut/lexical_sweep.py | 257 ++++ 7 files changed, 1615 insertions(+) create mode 100644 .search/lexical_matrix.json create mode 100644 docs/implements/2026-08-06-lexical-merge-rule.md create mode 100644 tools/search_cut/lexical_matrix.py create mode 100644 tools/search_cut/lexical_sweep.py diff --git a/.gitignore b/.gitignore index defa741..5ea36fc 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,9 @@ tools/keyword_eval/compare_out.txt # `fusion_sweep.py` 가 이 파일과 행렬 셋만 읽으므로(DB·GMS 0회) 유실되면 재측정이 막힌다. # 스윕 결과(`fusion_sweep*.json`·`rank_baseline.json`)는 여기서 재구성되므로 커밋하지 않는다. !.search/keyword_matrix.json +# `lexical_matrix.json` 은 문자열 매치 artifact 다(P49 작업 3) — 본문을 담지 않고 Record id 와 +# 매치 여부만 담는다. 재생성에 스냅샷 DB 가 필요하므로 커밋한다. 스윕 결과는 여기서 재구성된다. +!.search/lexical_matrix.json # `layer_probe.json` 은 층별 잔존 건수와 실서버 대조 결과다(S15P11A705-273). 유사도가 # 아니라 **판정**이라 값이 작고, 다음 사람이 「그때 서버가 무엇을 뱉었나」를 대조할 수 # 있어야 한다. diff --git a/.search/lexical_matrix.json b/.search/lexical_matrix.json new file mode 100644 index 0000000..93f37c3 --- /dev/null +++ b/.search/lexical_matrix.json @@ -0,0 +1,1073 @@ +{ + "stage": "P49-lexical", + "profile": "openai-text-embedding-3-small-1536-cosine-v1", + "generated_at": "2026-08-06T00:01:05.146975+00:00", + "db_port": "25432", + "source": { + "matrices": { + "matrix.json": { + "profile": "openai-text-embedding-3-small-1536-cosine-v1", + "record_count": 42 + }, + "word_grid.json": { + "profile": "openai-text-embedding-3-small-1536-cosine-v1", + "record_count": 42 + }, + "recall_probe.json": { + "profile": "openai-text-embedding-3-small-1536-cosine-v1", + "record_count": null + } + } + }, + "record_pairs": 42, + "queries": [ + { + "query": "비 오는 날 가려고 저장한 곳", + "matches": {} + }, + { + "query": "혼자 조용히 작업하기 좋은 카페", + "matches": {} + }, + { + "query": "친구들이랑 시끌벅적하게 놀 만한 곳", + "matches": {} + }, + { + "query": "기념일에 야경 보면서 식사할 곳", + "matches": {} + }, + { + "query": "돈카츠 먹으러 자주 갔던 곳", + "matches": {} + }, + { + "query": "미슐랭에 오른 라멘집", + "matches": {} + }, + { + "query": "친구들이랑 피자에 맥주 마신 곳", + "matches": {} + }, + { + "query": "밥 먹고 산책하면서 쉬어가는 공원", + "matches": {} + }, + { + "query": "채식 샌드위치 먹던 단골집", + "matches": {} + }, + { + "query": "책 사면 꽃을 주는 서점", + "matches": {} + }, + { + "query": "화덕에 구운 피자집", + "matches": {} + }, + { + "query": "양갱 파는 분위기 좋은 카페", + "matches": {} + }, + { + "query": "자동차 엔진오일 교환 정비소", + "matches": {} + }, + { + "query": "치과 임플란트 상담 받을 곳", + "matches": {} + }, + { + "query": "겨울 스키장 리프트권 파는 데", + "matches": {} + }, + { + "query": "노트북 액정 수리 서비스센터", + "matches": {} + }, + { + "query": "강아지 예방접종 동물병원", + "matches": {} + }, + { + "query": "그네", + "matches": { + "81": [ + { + "record_id": 282, + "substring": true, + "boundary": true + }, + { + "record_id": 287, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "신한", + "matches": { + "81": [ + { + "record_id": 277, + "substring": true, + "boundary": true + }, + { + "record_id": 278, + "substring": true, + "boundary": true + }, + { + "record_id": 280, + "substring": true, + "boundary": true + }, + { + "record_id": 281, + "substring": true, + "boundary": true + }, + { + "record_id": 283, + "substring": true, + "boundary": true + }, + { + "record_id": 284, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "부캠", + "matches": { + "81": [ + { + "record_id": 278, + "substring": true, + "boundary": true + }, + { + "record_id": 281, + "substring": true, + "boundary": true + }, + { + "record_id": 283, + "substring": true, + "boundary": true + }, + { + "record_id": 284, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "스팟", + "matches": { + "81": [ + { + "record_id": 287, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "산책", + "matches": { + "81": [ + { + "record_id": 287, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "라멘", + "matches": { + "81": [ + { + "record_id": 279, + "substring": true, + "boundary": true + }, + { + "record_id": 283, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "피맥", + "matches": { + "81": [ + { + "record_id": 280, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "비건", + "matches": { + "81": [ + { + "record_id": 281, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "서점", + "matches": { + "80": [ + { + "record_id": 269, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 291, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "양갱", + "matches": { + "80": [ + { + "record_id": 270, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 292, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "우주", + "matches": { + "80": [ + { + "record_id": 275, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 290, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "소파", + "matches": { + "80": [ + { + "record_id": 271, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "보쌈", + "matches": { + "80": [ + { + "record_id": 273, + "substring": true, + "boundary": false + } + ] + } + }, + { + "query": "난반", + "matches": { + "80": [ + { + "record_id": 268, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 293, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "연어", + "matches": { + "80": [ + { + "record_id": 268, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 284, + "substring": true, + "boundary": false + }, + { + "record_id": 293, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "야경", + "matches": { + "75": [ + { + "record_id": 255, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "우산", + "matches": { + "75": [ + { + "record_id": 252, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "수다", + "matches": { + "75": [ + { + "record_id": 254, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "국밥", + "matches": { + "81": [ + { + "record_id": 285, + "substring": true, + "boundary": false + } + ] + } + }, + { + "query": "공원", + "matches": { + "81": [ + { + "record_id": 282, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "맛집", + "matches": { + "81": [ + { + "record_id": 278, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "두부", + "matches": { + "81": [ + { + "record_id": 278, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "언덕", + "matches": { + "80": [ + { + "record_id": 275, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 290, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "카페", + "matches": { + "80": [ + { + "record_id": 266, + "substring": true, + "boundary": true + }, + { + "record_id": 270, + "substring": true, + "boundary": true + }, + { + "record_id": 271, + "substring": true, + "boundary": true + }, + { + "record_id": 275, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 290, + "substring": true, + "boundary": true + }, + { + "record_id": 292, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "만두", + "matches": { + "80": [ + { + "record_id": 267, + "substring": true, + "boundary": false + } + ] + } + }, + { + "query": "피자", + "matches": { + "80": [ + { + "record_id": 272, + "substring": true, + "boundary": false + } + ] + } + }, + { + "query": "그네팟", + "matches": { + "81": [ + { + "record_id": 287, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "돈카츠", + "matches": { + "81": [ + { + "record_id": 277, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "미슐랭", + "matches": { + "81": [ + { + "record_id": 279, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "차슈밥", + "matches": { + "81": [ + { + "record_id": 279, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "칼국수", + "matches": { + "80": [ + { + "record_id": 273, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 288, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "아부라", + "matches": { + "81": [ + { + "record_id": 288, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "브런치", + "matches": { + "80": [ + { + "record_id": 266, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "디저트", + "matches": { + "77": [ + { + "record_id": 261, + "substring": true, + "boundary": true + } + ], + "80": [ + { + "record_id": 275, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 290, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "노트북", + "matches": { + "75": [ + { + "record_id": 253, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "콘센트", + "matches": { + "75": [ + { + "record_id": 253, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "기념일", + "matches": { + "75": [ + { + "record_id": 255, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "부모님", + "matches": { + "75": [ + { + "record_id": 256, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "빗소리", + "matches": { + "75": [ + { + "record_id": 252, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "6개월", + "matches": { + "81": [ + { + "record_id": 277, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "감자전", + "matches": { + "80": [ + { + "record_id": 276, + "substring": true, + "boundary": false + } + ] + } + }, + { + "query": "일식집", + "matches": { + "81": [ + { + "record_id": 284, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "부트캠프", + "matches": { + "81": [ + { + "record_id": 277, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "두부찌개", + "matches": { + "81": [ + { + "record_id": 278, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "무한도전", + "matches": { + "81": [ + { + "record_id": 278, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "치킨버거", + "matches": { + "81": [ + { + "record_id": 282, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "샌드위치", + "matches": { + "80": [ + { + "record_id": 266, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 281, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "화덕피자", + "matches": { + "80": [ + { + "record_id": 272, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "낙지전골", + "matches": { + "80": [ + { + "record_id": 273, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "가지튀김", + "matches": { + "80": [ + { + "record_id": 274, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 289, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "라따뚜이", + "matches": { + "80": [ + { + "record_id": 266, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "돼지국밥", + "matches": { + "81": [ + { + "record_id": 285, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "인테리어", + "matches": { + "77": [ + { + "record_id": 261, + "substring": true, + "boundary": true + } + ], + "80": [ + { + "record_id": 275, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 290, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "아이스크림", + "matches": { + "81": [ + { + "record_id": 287, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "치과", + "matches": {} + }, + { + "query": "액정", + "matches": {} + }, + { + "query": "약국", + "matches": {} + }, + { + "query": "보험", + "matches": {} + }, + { + "query": "세탁", + "matches": {} + }, + { + "query": "스키장", + "matches": {} + }, + { + "query": "정비소", + "matches": {} + }, + { + "query": "리프트", + "matches": {} + }, + { + "query": "헬스장", + "matches": {} + }, + { + "query": "주유소", + "matches": {} + }, + { + "query": "안경점", + "matches": {} + }, + { + "query": "엔진오일", + "matches": {} + }, + { + "query": "임플란트", + "matches": {} + }, + { + "query": "예방접종", + "matches": {} + }, + { + "query": "동물병원", + "matches": {} + }, + { + "query": "친구들", + "matches": { + "75": [ + { + "record_id": 254, + "substring": true, + "boundary": true + } + ], + "81": [ + { + "record_id": 277, + "substring": true, + "boundary": true + }, + { + "record_id": 280, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "그네 공원", + "matches": { + "81": [ + { + "record_id": 282, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "신한 부트캠프", + "matches": { + "81": [ + { + "record_id": 277, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "신한 부캠", + "matches": { + "81": [ + { + "record_id": 278, + "substring": true, + "boundary": true + }, + { + "record_id": 281, + "substring": true, + "boundary": true + }, + { + "record_id": 283, + "substring": true, + "boundary": true + }, + { + "record_id": 284, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "그네팟 스팟", + "matches": { + "81": [ + { + "record_id": 287, + "substring": true, + "boundary": true + } + ] + } + }, + { + "query": "신한 부트캠프 친구들과 자주 먹었던 돈카츠 집", + "matches": { + "81": [ + { + "record_id": 277, + "substring": true, + "boundary": true + } + ] + } + } + ] +} diff --git a/docs/implements/2026-08-06-lexical-merge-rule.md b/docs/implements/2026-08-06-lexical-merge-rule.md new file mode 100644 index 0000000..6858c2e --- /dev/null +++ b/docs/implements/2026-08-06-lexical-merge-rule.md @@ -0,0 +1,84 @@ +# 문자열 검색 병합 규칙 실측 — 게이트·병합 방식 격자와 규칙 확정안 + +- **티켓**: 미발급. P49 §8 작업 3이다. back 연동 티켓 발급의 판단 재료로 중앙에 전달한다. +- **날짜**: 2026-08-06 +- **하네스**: `tools/search_cut/lexical_matrix.py`(매치 artifact 생성) · `lexical_sweep.py`(격자 비교) — 실행 절차는 그 README +- **기준 문서**: [P49](../proposals/P49-multi-signal-search.md) §3~§5 · 선행 실측 [I53](2026-08-05-fusion-measurement.md) +- **성격**: 재기만 한다. 앱 런타임 코드를 바꾸지 않는다. 측정은 스냅샷 DB(:25432)에서 했다. + +## 한눈에 보는 결과 + +| 항목 | 확인한 내용 | 현재 판단 | +|---|---|---| +| 안전성 | 관련 없는 질의·타인 소유 질의·문장형 질의에서 문자열 매치가 한 건도 발생하지 않았다 | 어떤 게이트·병합 조합도 무관 결과 노출을 늘리지 않는다 | +| `신한` 회복 | 문자열 후보를 결과에 추가하자 `신한`의 정답이 검색 결과에 포함됐다 | 키워드 점수(I53)로 풀리지 않던 사례가 문자열 검색으로 풀린다 | +| 병합 방식 | 순위 결합(RRF)이 뒤에 추가하는 방식보다 정답 순위를 더 올렸다 | RRF(k=60) 채택을 권고한다 | +| 경계 게이트 | 어절 시작 경계를 요구하면 기대 정답 6건이 제외되고, 방어 이득은 관측되지 않았다 | 부분일치를 채택하되 운영 코퍼스에서 재평가한다 | + +## 배경 + +실패 사례 `신한`은 본문에 문자열이 그대로 있는데도 임베딩 유사도가 낮아 검색되지 않는다. 유사도 컷 조정과 키워드 점수(I53)로는 회복되지 않음이 확인됐다. P49는 문자열 검색을 별도 경로로 두고 Spring이 병합하는 구조를 제안했고, back이 구현을 시작하기 전에 병합 규칙을 오프라인으로 확정하는 것이 이 측정의 목적이다. + +## 측정 조건 + +- 매치 artifact: 질의 92건(기존 행렬 3종에서 수집) × Record 42건(소유자 3명). 본문은 스냅샷 DB의 `core.context.body`에서 읽어 매치 판정에만 쓰고 저장하지 않았다. 매치는 총 94건이다. +- 격자: 게이트 3단 × 병합 규칙 3종. + - 게이트 — G0: 모든 질의·부분일치(대조군) / G1: 단어형 질의만·부분일치 / G2: 단어형 질의만·어절 시작 경계 일치. + - 병합 — M1: 벡터 컷 통과자 뒤에 추가 / M2: RRF(k=60) 순위 결합 / M3: 문자열 후보를 앞에 고정. +- 컷은 현행 그대로 벡터 경로에만 적용했다. 문자열 후보의 자격은 게이트가 정한다. +- 지표는 순위 기준선 도구(`rank_score.py`)의 것을 그대로 썼다. + +## 확인한 결과 — 측정 + +**안전성: 문자열 매치는 관련 있는 곳에서만 발생했다.** 관련 없는 문장형 질의 15건, 관련 없는 단어형 질의 45건, 타인 소유 질의 96건, 그리고 문장형 정답 질의 12건에서 문자열 매치가 0건이었다. 따라서 모든 게이트·병합 조합에서 이 세그먼트들의 지표가 현행과 완전히 같았다. 무노출 비율도 그대로다(문장형 11/15, 단어형 0.4222). + +**단어형 정답 질의 66건에서는 큰 개선이 나왔다.** 부분일치 게이트(G1)에서 매치 81건 중 7건이 벡터 컷 통과자에 없던 정답을 추가했고, 빈 결과가 나오던 질의 1건도 결과를 갖게 됐다(무결과 비율 0.0152→0). 병합 방식별 순위는 다음과 같다. + +| 조합 | hit@1 | hit@3 | MRR | recall@3 | nDCG@3 | +|---|---|---|---|---|---| +| 현행(벡터만) | 0.8636 | 0.9394 | 0.9015 | 0.9205 | 0.9007 | +| G1·M1 (뒤에 추가) | 0.8788 | 0.9697 | 0.9242 | 0.9508 | 0.9254 | +| G1·M2 (RRF) | 1.0000 | 1.0000 | 1.0000 | 0.9848 | 1.0000 | +| G1·M3 (앞에 고정) | 1.0000 | 1.0000 | 1.0000 | 0.9848 | 1.0000 | + +단어형 정답의 라벨 자체가 「본문에 질의 문자열이 있는 Record」로 정의되어 있어, 이 세그먼트에서 문자열 검색이 유리한 것은 구조적이다. 그래서 이 수치는 문자열 검색의 절대 성능이 아니라 **병합 방식 간의 차이**를 읽는 용도다. 순위 결합(M2)과 앞 고정(M3)이 뒤에 추가(M1)보다 정답을 확실히 위로 올린다. + +**경계 게이트(G2)는 이 코퍼스에서 손해만 관측됐다.** 어절 시작 경계를 요구하면 부분일치 기준으로는 매치인 기대 정답 6건이 제외된다(단어형 hit@1 1.0→0.9394). 경계 게이트가 막으려는 유형(「대신한」처럼 어절 중간에서 시작하는 우연한 일치)은 무관·타인 소유 세그먼트에서 한 건도 발생하지 않아, 방어 이득을 관측할 수 없었다. + +**사례 4건.** `신한`은 현행에서 결과에 없다가 모든 조합에서 회복됐다(M1이면 6위, M2·M3이면 5위). `그네`는 3위에서 2위로 올랐다(M2·M3). `스팟`은 1위 유지. `부캠`은 회복되지 않았다 — 본문에 「부캠」 문자열이 없으므로 예상대로이며, 이 사례는 질의 재작성(-337)의 대상이다. + +## 원인 — 측정 결과를 바탕으로 한 해석 + +문자열 매치가 무관 세그먼트에서 0건인 이유는 코퍼스의 성질로 해석한다. 무관 질의는 시연 기록의 범주 밖 표현(치과·엔진오일류)으로 만들어졌고, 타인 소유 질의는 그 소유자의 본문에 없는 것이 라벨 조건이다. 문장형 질의가 본문에 그대로 등장하지 않는 것도 자연스럽다 — 사람이 친 문장이 기록 문장과 글자 단위로 일치할 확률은 낮다. + +M2와 M3의 지표가 같은 것은 이 코퍼스에서 벡터 컷 통과자와 문자열 후보의 겹침·개수가 작아 두 방식의 순서 차이가 상위 3위 안에서 드러나지 않았기 때문이다. + +## 결정 및 대응 — back에 전달할 규칙 확정안 + +아래는 이 측정에 근거한 권고안이다. 채택 판정은 중앙과 사용자의 몫이다. + +1. **게이트: 단어형 질의만, 부분일치(G1).** 단어형 판정은 검색 서비스의 현행 판정과 동일 기준을 쓴다 — 앞뒤 공백 제거 후 내부에 공백(유니코드 전체)이 없고 5자 이하. 어절 시작 경계 요구(G2)는 기대 정답 6건을 잃는 손해가 관측됐고 이득이 관측되지 않아 채택하지 않는다. +2. **병합: RRF, k=60(M2).** 벡터 컷 통과자와 문자열 매치의 합집합을 순위 결합으로 재정렬한다. M3(앞 고정)와 지표가 같았지만, 문자열 매치가 여러 건일 때 단일 신호가 상위를 독점하는 것을 순위 결합이 완화하므로 M2를 권고한다. +3. **컷과 응답 계약은 불변.** 벡터 후보의 포함 여부는 현행 컷(τ·r) 그대로다. 응답의 `similarity`는 원래 코사인 값을 유지한다. 문자열 단독으로 들어온 항목도 코사인 값을 채워 보낸다(null이면 back의 방어 코드가 항목을 버린다 — `RecordSearchService.distinctByRecord` 확인 결과). +4. **실패 시 복귀.** 문자열 검색이 실패하면 벡터 결과만 반환한다. 이 경로의 동작은 현행과 동일하다. + +## 검증 — 실행한 것과 못 한 것 + +**실행한 것** + +- 게이트 3단 × 병합 3종 = 9조합 + 현행 대조를 5개 세그먼트(정답 문장형·단어형, 무관 문장형·단어형, 타인 소유)와 사례 4건에서 측정했다. +- 스냅샷 DB 사용을 도구 가드로 강제했다(포트 25432가 아니면 실행 중단). +- artifact에 본문이 저장되지 않는 것을 산출물에서 확인했다(Record id와 매치 여부만). + +**못 한 것 — 전부 추가 검증 필요** + +- **경계 게이트가 막으려는 유형이 코퍼스에 없다.** 어절 중간 시작 매치·부수적 언급·동형어 사례가 무관 세그먼트에 존재하지 않아, G2와 의미 검증(P49 §5의 3층)의 이득을 잴 수 없었다. 운영 코퍼스 또는 확장 코퍼스에서 재평가해야 한다. G1 채택은 「이 코퍼스에서 손해가 없다」는 근거이지 「어떤 코퍼스에서도 안전하다」는 근거가 아니다. +- **단어형 정답 라벨과 문자열 매치의 순환성.** 라벨 정의가 부분일치라 문자열 검색의 재현율이 구조적으로 유리하다. 독립 라벨(사람 판정)로는 재지 않았다. +- back 측 구현(SQL·인덱스·지연)은 이 측정의 범위 밖이다. 시연 규모(기록 42건)에서는 순차 검색으로 충분하다고 판단하나 측정하지 않았다. + +## 근거 + +- 산출물: `.search/lexical_matrix.json`(매치 artifact, 커밋) · `.search/lexical_sweep.json`(격자 결과 — artifact에서 재구성 가능하므로 미커밋, 수치는 이 리포트 표가 보존본) +- 도구: `tools/search_cut/lexical_matrix.py` · `lexical_sweep.py` +- 선행: [I53](2026-08-05-fusion-measurement.md)(키워드 점수로 신한 미회복 확정) · [`-273` 층 관측](2026-08-05-short-query-boundary.md)(신한 컷 전 6위) · [T73~T78](../troubleshooting/2026-08-05-multi-signal-investigation.md) +- back 소비 코드 확인: `back` `RecordSearchService.java`(순서 유지·값 필터 없음·null만 제외) diff --git a/docs/implements/README.md b/docs/implements/README.md index a5ff79c..a9ff362 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -55,6 +55,7 @@ | I50 | [2026-08-03-preset-display-name-deploy-chain.md](2026-08-03-preset-display-name-deploy-chain.md) | 검증 | 표시명 개정의 배포 체인 여섯 단계와 소관 — **봉인 값은 판 표기이지 부트스트랩 재실행 조건이 아니다.** 낡은 봉인 값 위에서 새 표시명이 운영 화면까지 온 것이 곧 재실행의 증거다(`hook-delete-policy` 가 같은 이름 Job 을 지우고 다시 만들고, 적재는 멱등이다). 중앙이 「값이 같으면 재실행 안 된다」로 판정했고 독립 검증이 논거를 반증했으나 클러스터 관측 경로가 없어 「확인 불가」로 끝났던 것을 **배포가 닫았다.** 릴리스가 두 번 막힌다 — base 검사(`main` base 는 `release/*`·`hotfix/*` 만)와 strict 상태 검사(BEHIND 거부). 화면 확인이 유일한 검증이라 이르게 보면 「안 됐다」로 오판한다 (S15P11A705-292) | | I51 | [2026-08-05-short-query-boundary.md](2026-08-05-short-query-boundary.md) | 검증 | 짧은 질의의 층별 탈락과 단어형 경계 두 정의 — **`-266` 이후 남은 실패는 셋뿐이고 경계 정의로는 하나도 안 풀린다.** `신한`(③ τ_abs·6위)·`부캠`(③ τ_abs·8위)·`신한 부캠`(**④ r**·4위)이고 실서버 대조 22/22 일치로 층을 확정했다(전처리·폴백은 **존재하지 않는다** — `min_length=1` 뿐이고 0건이면 0건이 그대로 나간다). **「2자가 안 걸린다」는 부정확** — 2자 7건 중 5건은 걸리고 못 걸리는 둘은 컷 전 순위가 6·8위다. `-266` 이 「재지 않았다」고 적은 B(공백·짧다)·C(무공백·길다) 대역을 **본문 인접 어절쌍 전량**(쌍 176종 × spaced/joined × 소유자 3명 = 1,128행)으로 처음 쟀다 — 손실은 C 에 몰려 있고(16/122 → 어절 수 정의 3/122) **그런데 초점 집합 36행에서는 어느 정의에서도 누락이 0** 이라 두 표가 어긋난다(회복되는 것이 기능어 쌍이다). 새 관측 둘: **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385 · 172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 τ 탈락 — `-266` 이 「안전 방향」이라 부른 판단이 이 대역에서는 손해다). 계약이 「이것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하므로 **세 번째다** (S15P11A705-273) | | I53 | [2026-08-05-fusion-measurement.md](2026-08-05-fusion-measurement.md) | 구현 | Keyword fusion 1단계 실측 — 병합 방식(가중합 5조합·RRF cutoff 5값)×floor 4값 격자. **가중합은 정답과 관련 없는 후보를 함께 끌어올린다** — floor 0.25 에서 `신한` 복구(컷 후 4위)와 무관-문장형 무노출 11/15→9/15 감소가 같은 계산에서 나온다. RRF 는 무노출을 유지하나 문장형 hit@1 0.8333→0.5833 퇴행. **P48 §6.1 네 조건을 전부 통과한 조합은 floor 0.35 · binary w=0.05 하나** — 그 조건에서 단어형·문장형 전 지표 개선·무관 무노출 유지, 대가는 신호 반영 12/66 축소와 `신한` 재제외. `신한`·`부캠`은 이 구조로 안 풀린다(재작성 `-337`·문자열 검색의 대상). 환경 동등성은 baseline 재현 = I52 일치로 확인. 못 잰 축(floor 0.30~0.35·weight 격자·재현성 회차)은 `-339` 몫 (S15P11A705-336) | +| I54 | [2026-08-06-lexical-merge-rule.md](2026-08-06-lexical-merge-rule.md) | 구현 | 문자열 검색 병합 규칙 실측 — 게이트 3단(전체/단어형 한정/어절 경계) × 병합 3종(뒤 추가/RRF/앞 고정) 격자. **무관·타인소유·문장형 세그먼트에서 문자열 매치 0건** — 어떤 조합도 관련 없는 결과 노출을 늘리지 않는다. `신한` 이 전 조합에서 회복(RRF 5위)되고 `부캠` 은 예상대로 미회복(재작성 -337 대상). 경계 게이트는 기대 정답 6건을 제외하는 손해만 관측돼 부분일치 채택 권고. **규칙 확정안: 단어형 한정·부분일치 게이트 + RRF(k=60) 병합 + 컷·응답 계약 불변 + 실패 시 벡터만** — back 연동 티켓의 판단 재료. 한계: 경계 게이트가 막으려는 유형(어절 중간 시작·부수적 언급·동형어)이 코퍼스에 없어 이득 측정 불가, 운영 코퍼스 재평가 필요 (티켓 없음 · 런타임 변경 없음 · 스냅샷 DB) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -113,5 +114,6 @@ | I50 | 표시명 개정 배포 체인 기록 — **산출물은 문서 하나이고 내용은 판정이다.** ① 체인 여섯 단계의 소관을 갈랐다(우리 `ai#102`·`ai#104` / 자동화 `image-publish` / 인프라 `infra#185` / 클러스터 sync / 화면). `image-publish` 가 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**(전수 근거는 `I48`). ② **미결 판정 하나를 실측으로 닫았다** — 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 반대로 판정했고 독립 검증이 논거만 반증한 채 **클러스터 관측 경로가 없어 「확인 불가」**로 끝나 있었다. **다만 표기가 낡은 것은 기록 정합성 문제로 남는다** — 배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다. ③ 릴리스가 막히는 두 곳을 절차로 적었다 — base 검사는 `release/*`·`hotfix/*` 만 허용하므로 `dev` 를 그대로 열지 않고(`ai#103` 이 그렇게 막혀 닫혔다), strict 는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(그 병합 커밋 `032e391f` 가 `ai#104` 에 남아 있다). **둘 다 CI 를 다시 기다리게 한다.** ④ 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 헛돌았고 갱신을 만든 것은 수동 실행이다 — `success` 두 번의 뜻이 다르다(`T71` 과 같은 성질). ⑤ 확인 수단은 화면뿐이라 **「아직인가 실패인가」가 갈리지 않는다** — 실제로 한 번 오판했다가 재확인에서 뒤집혔다 | [배포 체인 리포트](2026-08-03-preset-display-name-deploy-chain.md), [I48](2026-08-03-dev-deploy-gap.md), [T70~T72](../troubleshooting/2026-08-03-repeat-incidents.md) | | I51 | 짧은 질의 층 프로브 `tools/search_cut/layer_probe.py` + 경계 행렬 `boundary_matrix.py`·`boundary_sweep.py` + `.search/boundary_grid.json`(1,128행)·`layer_probe.json` — **산출물은 하네스 셋과 판정이고 코드는 고치지 않았다.** ① 층을 코드로 확정했다 — 계약이 후보로 준 「질의 전처리」와 「후보 0건 폴백」은 **존재하지 않는다**(`schema/search.py:9` 는 `min_length=1` 뿐이고 정규화가 없어 원문이 그대로 도달하며, `_cut` 이 0건이면 0건이 그대로 응답이다). 자르는 것은 `search_service.py:111-115,122` 둘뿐이고 `LIMIT` 은 이 데이터에서 한 건도 안 자른다(후보 17·11·6건). **실서버 대조 22/22 일치** — 재구성에 `_cut`·`_is_word_query` 를 다시 적었다(구현 import 금지, `-213` 규칙). ② `-266` 이후 남은 실패는 셋뿐이고 **서로 다른 층이다** — `신한`(③ τ_abs 0.2301<0.24·6위)·`부캠`(③ 0.2105·8위)·`신한 부캠`(**④ r** 0.3187<0.3520·4위). 셋째는 τ 를 넘고 `r` 에만 걸리므로 **경계 정의를 어떻게 바꿔도 안 풀린다**(`-266` 이 `r` 을 「참여하지 않는다」로 가르지 않았다). ③ 「2자가 안 걸린다」는 부정확 — 2자 7건 중 5건은 걸리고(`우주` 0.4200 1위·`공원` 0.3672 1위) 못 걸리는 둘은 **컷 전 순위가 6·8위**다. ④ `-266` 이 「둘이 갈리는 질의가 행렬에 없다」고 적은 대역을 만들었다 — 본문 **문장 내 인접 어절쌍 전량**을 `spaced`/`joined` 짝으로 내면 기대 정답이 같아 차이가 전부 「공백 하나」에 귀속된다(쌍 176종 × 2형태 × 소유자 3명 = 1,128행, GMS 376건). 질의를 고르지 않아 `-266` 이 한계로 적은 선정 재량이 없다(남는 재량은 「두 어절 모두 2자 이상」 하나). ⑤ 손실은 **C 대역(무공백·길다)** 에 몰려 있다 — 현행 16/122·1위 손실 4 가 어절 수 정의에서 3/122·1 이 되고 A·B·D 는 안 움직인다. **그런데 내용어 초점 집합 36행에서는 여섯 정의 전부 누락 0** 이라 두 표가 어긋난다 — 회복되는 것이 기능어 쌍(`당시자주` 0.1909·`같은분위기가` 0.2170)이라 사용자 이득이 관측되지 않았다. ⑥ 새 관측 둘 — **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385·중앙값 -0.0279·172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 ④ r 이 아니라 ③ τ 에서 잘린다). 후자는 `-266` 이 「측정이 아니라 안전 방향으로의 판단」이라 적은 대역이고 **그 판단이 여기서는 손해로 나타난다**(3건 표본). ⑦ 처방 후보 여섯을 비용·부작용과 함께 냈고 **「안 바꾸는 안」을 포함**했다 — 고르는 것은 이 티켓의 일이 아니다. ⑧ 계약이 「네가 재는 것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하고 후자는 코드까지 병합돼 dev 배포 tag(`7e72826`)에 들어 있으므로 **세 번째다** | [경계 리포트](2026-08-05-short-query-boundary.md), [I45](2026-08-03-search-recall-probe.md), [I47](2026-08-03-word-query-cut.md), [tools/search_cut/](../../tools/search_cut/) | | I53 | Keyword fusion 1단계 실측 `tools/search_cut/fusion_sweep.py` 격자 — 방식(binary·conf·idf·RRF)×weight×floor(0.25/0.30/0.35/0.40)×RRF cutoff(0~0.033). **`신한` 복구와 무관 노출 증가가 같은 계산에서 나온다**(가중합은 컷을 부스트 점수에 걸어 경계 아래 정답과 무관 후보를 똑같이 끌어올린다). RRF 는 cosine eligibility gate 로 무노출을 지키지만 벡터 1위가 키워드 순위 합산에 밀려 문장형이 퇴행한다. floor 0.35 에서 무관 무노출이 baseline 복원되고 binary w=0.05 만 P48 §6.1 전 조건 통과 — Preset 후보가 floor 를 넘는 단어형 질의가 12/66 뿐이라는 대가(미반영 54건 = `-338` 설계 입력). 사례 4건 개별 보고(신한·부캠 미회복 확정). 픽스처 27종·가드 무오발동·baseline 재현 일치 | [실측 리포트](2026-08-05-fusion-measurement.md), [T73~T78](../troubleshooting/2026-08-05-multi-signal-investigation.md), [P48](../proposals/P48-search-signal-expansion.md), [P49](../proposals/P49-multi-signal-search.md) | +| I54 | 문자열 병합 규칙 하네스 `tools/search_cut/lexical_matrix.py`·`lexical_sweep.py` — 본문 매치를 (질의 × 소유자 × Record) 로 굳히고(본문 미저장·매치 여부만) 게이트×병합 격자를 오프라인으로 훑는다. 스냅샷 DB(:25432) 가드. 측정 결과: 문자열 매치 94건이 전부 정답 세그먼트에만 발생, 단어형 정답 라벨이 부분일치 정의라 재현율은 구조적으로 유리(순환성 명시), 병합 방식 차이는 RRF·앞고정 > 뒤추가. 단독 추가 7건 · 빈 결과 1건 해소 | [규칙 리포트](2026-08-06-lexical-merge-rule.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md) | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. diff --git a/tools/search_cut/README.md b/tools/search_cut/README.md index 7ab2a93..908b7e8 100644 --- a/tools/search_cut/README.md +++ b/tools/search_cut/README.md @@ -34,6 +34,8 @@ | `fusion.py` | keyword 신호 fusion **순수 로직**(P48 1단계). DB·GMS·파일을 읽지 않고 인자만 받는다 — `tests/test_search_fusion.py` 가 픽스처로 검증한다 | | `keyword_matrix.py` | keyword 신호 artifact 생성 — 질의별 전체 활성 Preset 코사인 + Context 별 keyword·confidence·상태. **GMS 임베딩 배치 1회 + DB 읽기** | | `fusion_sweep.py` | fusion 방식(binary·confidence·idf·RRF)×가중치×floor×RRF cutoff 격자. `keyword_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 GMS 도 부르지 않는다** | +| `lexical_matrix.py` | 문자열 매치 artifact — 본문에 질의가 그대로 있는지를 (질의×소유자×Record)로 굳힌다. 본문은 저장하지 않는다. **스냅샷 DB(:25432) 읽기 · GMS 0회** | +| `lexical_sweep.py` | 문자열 병합 규칙 격자 — 게이트 3단×병합 3종. `lexical_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 GMS 도 부르지 않는다** | `matrix.json` · `recall_probe.json` · `word_grid.json` · `keyword_matrix.json` 은 **커밋한다.** 다시 뜨려면 GMS 를 부르고, `tau_grid` 의 것과 달리 Context 본문을 담지 않는다(장소명까지). @@ -183,6 +185,19 @@ CWD 기준이고 `.env` 는 gitignore 라 worktree 에 없다 — `GMS_API_KEY` 행렬이면 `fusion_sweep.py` 의 가드가 재지 않고 멈춘다. 결과 판정 기준은 P48 §6.1, 실측 기록은 [구현 리포트 I53](../../docs/implements/2026-08-05-fusion-measurement.md). +### 문자열 병합 규칙 (P49 작업 3) + +```bash +export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:25432/pinlog" # 스냅샷 DB +.venv/Scripts/python.exe tools/search_cut/lexical_matrix.py # DB 읽기. GMS 0회 +.venv/Scripts/python.exe tools/search_cut/lexical_sweep.py # 파일만 읽는다 +``` + +검색 고도화 트랙의 측정은 시연 DB(:15432)가 아니라 **스냅샷 DB(:25432)** 에서 한다 — +브랜치는 코드만 격리하고 DB 는 격리하지 않는다(P49 §6). `lexical_matrix.py` 가 포트를 +검사해 다르면 멈춘다. 결과 판정과 규칙 확정안은 +[구현 리포트 I54](../../docs/implements/2026-08-06-lexical-merge-rule.md). + ### 실서버 대조 서버를 **이 브랜치 코드로** 띄워야 한다. 다른 워킹트리의 서버를 재면 컷이 없는 코드를 diff --git a/tools/search_cut/lexical_matrix.py b/tools/search_cut/lexical_matrix.py new file mode 100644 index 0000000..cdfb96b --- /dev/null +++ b/tools/search_cut/lexical_matrix.py @@ -0,0 +1,181 @@ +"""문자열 매치 artifact 생성 — 문자열 검색 병합 규칙 실측용 (P49 §8 작업 3). + +기록 본문에 질의 문자열이 그대로 있는지를 (질의 × 소유자 × Record) 단위로 계산해 +`.search/lexical_matrix.json` 으로 굳힌다. 이후 `lexical_sweep.py` 는 이 파일과 기존 +행렬만 읽고 DB 를 부르지 않는다. + + python tools/search_cut/lexical_matrix.py + +## 왜 이 artifact 가 필요한가 + +문자열 검색(P49 §3)의 병합 규칙은 back 이 구현하기 전에 오프라인으로 정해야 한다. +그런데 기존 행렬(`word_grid.json` 등)은 벡터 유사도만 담고 있어 「본문에 문자열이 +있는가」를 재구성할 수 없다. 이 파일이 그 정보를 채운다. + +## 본문은 저장하지 않는다 + +본문(`core.context.body`)은 DB 에서 읽어 매치 판정에만 쓰고 버린다. artifact 에는 +Record id 와 매치 여부(부분일치 · 어절 시작 경계 일치)만 남는다 — 기존 행렬이 +장소명까지만 담는 것과 같은 원칙이다. + +## 측정 도구의 core 접근에 대해 + +런타임 코드(FastAPI)는 공용 계약상 `core.*` 를 읽지 않는다. 측정 도구는 런타임이 +아니며, `tools/tau_grid/matrix.py` 가 같은 JOIN 을 이미 쓴다 — 그 선례를 따른다. + +## 스냅샷 DB 를 쓴다 + +검색 고도화 트랙의 측정은 시연 DB 가 아니라 스냅샷 DB(:25432)에서 한다 — 브랜치는 +코드만 격리하고 DB 는 격리하지 않기 때문이다(P49 §6). 포트가 다르면 재지 않고 멈춘다. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from app.core.config import get_settings # noqa: E402 +from app.core.db import Database # noqa: E402 + +for _s in (sys.stdout, sys.stderr): + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + +SEARCH = ROOT / ".search" + +# 검색 고도화 측정 정본은 스냅샷 DB 다(P49 §6). 시연 DB(:15432)를 직접 재면 +# 측정과 시연이 같은 데이터를 공유해 부수효과가 섞인다. +EXPECT_PORT = "25432" + +MATRICES = ("matrix.json", "word_grid.json", "recall_probe.json") + +# 모집단은 검색 Query 와 같다(personal-search.md §4) — 검색 후보가 아닌 Context 의 +# 본문에 문자열이 있어도 검색은 그 Record 를 반환할 수 없으므로 세지 않는다. +_CONTEXTS = """ +SELECT e.record_id, e.user_id, e.context_id, c.body +FROM ai.context_embedding e +JOIN ai.context_ai_state s ON s.context_id = e.context_id +JOIN core.context c ON c.id = e.context_id +WHERE e.is_deleted = false + AND e.embedding_profile = $1 + AND s.embedding_status = 'COMPLETED' +ORDER BY e.user_id, e.record_id, e.context_id +""" + + +def log(msg: str = "") -> None: + print(msg, flush=True) + + +def collect_queries() -> tuple[list[str], dict]: + """행렬 셋에서 질의를 모은다. 손으로 다시 적으면 행렬과 어긋난다(keyword_matrix 와 동일).""" + queries: list[str] = [] + seen: set[str] = set() + meta: dict = {} + for name in MATRICES: + p = SEARCH / name + if not p.exists(): + raise SystemExit(f"행렬이 없다: {p.relative_to(ROOT)} — 먼저 그것부터 뜬다") + d = json.loads(p.read_text(encoding="utf-8")) + meta[name] = {"profile": d.get("profile"), "record_count": d.get("record_count")} + for section in ("queries", "cross", "offtopic"): + for e in d.get(section, []) or []: + q = e["query"] + if q not in seen: + seen.add(q) + queries.append(q) + profiles = {m["profile"] for m in meta.values()} + if len(profiles) != 1: + raise SystemExit(f"행렬의 Profile 이 어긋난다: {meta} — 재지 않고 멈춘다") + return queries, meta + + +def boundary_match(query: str, body: str) -> bool: + """어절 시작 경계 매치 — 매치 시작 위치의 앞이 문자열 처음이거나 공백이면 참. + + 「신한은행」·「신한에서」(조사·합성)는 통과하고 「대신한」은 차단한다. 교착어 특성상 + 어절 끝 경계는 요구하지 않는다(P49 §5). 공백 판정은 유니코드 전체다 — `_is_word_query` + 가 전각 공백을 공백으로 보는 것과 기준을 맞춘다. + """ + return re.search(r"(?:^|\s)" + re.escape(query), body) is not None + + +async def build() -> dict: + settings = get_settings() + url = settings.database_url + if f":{EXPECT_PORT}/" not in url: + raise SystemExit( + f"DATABASE_URL 이 스냅샷 DB(:{EXPECT_PORT})가 아니다: 측정을 멈춘다.\n" + " 검색 고도화 측정은 스냅샷에서 한다(P49 §6) — 시연 DB 를 직접 재지 않는다." + ) + + queries, meta = collect_queries() + profile = next(iter({m["profile"] for m in meta.values()})) + + db = Database(url) + await db.connect() + try: + async with db.acquire() as conn: + rows = await conn.fetch(_CONTEXTS, profile) + finally: + await db.disconnect() + + if not rows: + raise SystemExit("검색 가능한 Context 가 0건이다 — 스냅샷이 비었다. 재지 않고 멈춘다.") + + # (user, record) -> [body...] 본문은 이 함수 밖으로 나가지 않는다. + bodies: dict[tuple[int, int], list[str]] = {} + for r in rows: + bodies.setdefault((r["user_id"], r["record_id"]), []).append(r["body"]) + + out_queries = [] + for q in queries: + needle = q.strip() + owners: dict[str, list] = {} + for (uid, rid), bs in bodies.items(): + sub = any(needle in b for b in bs) + if not sub: + continue + bound = any(boundary_match(needle, b) for b in bs) + owners.setdefault(str(uid), []).append( + {"record_id": rid, "substring": True, "boundary": bound} + ) + out_queries.append({"query": q, "matches": owners}) + + n_match = sum(len(v) for e in out_queries for v in e["matches"].values()) + log(f" 질의 {len(queries)}건 × Record {len(bodies)}쌍 — 매치 {n_match}건") + + return { + "stage": "P49-lexical", + "profile": profile, + "generated_at": datetime.now(timezone.utc).isoformat(), + "db_port": EXPECT_PORT, + "source": {"matrices": meta}, + "record_pairs": len(bodies), + "queries": out_queries, + } + + +def main() -> int: + ap = argparse.ArgumentParser(description="문자열 매치 artifact (P49 작업 3)") + ap.add_argument("--out", default=str(SEARCH / "lexical_matrix.json")) + args = ap.parse_args() + + data = asyncio.run(build()) + out = Path(args.out) + out.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") + log(f" → {out} ({out.stat().st_size:,} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/search_cut/lexical_sweep.py b/tools/search_cut/lexical_sweep.py new file mode 100644 index 0000000..1f17b47 --- /dev/null +++ b/tools/search_cut/lexical_sweep.py @@ -0,0 +1,257 @@ +"""문자열 검색 병합 규칙 비교 — P49 §8 작업 3. + +**DB 도 GMS 도 부르지 않는다.** `lexical_matrix.py` 가 만든 매치 artifact 와 기존 행렬만 +읽는다. 병합 규칙과 게이트를 바꿔 가며 순위 지표와 무관 질의 노출을 비교하는 것이 목적이다. + + python tools/search_cut/lexical_sweep.py + +## 무엇을 비교하나 + +**게이트 3단** — 문자열 매치를 후보로 인정하는 조건. + + G0 모든 질의 · 부분일치 (게이트 없음 — 대조군) + G1 단어형 질의만 · 부분일치 (문장형의 조사·부사 우연 일치 차단) + G2 단어형 질의만 · 어절 시작 경계 (「대신한」류 문자열 우연 차단) + +**병합 규칙 3종** — 게이트를 통과한 문자열 후보를 결과에 넣는 방법. + + M1 뒤에 추가 벡터 컷 통과자를 그대로 두고, 문자열 후보를 그 뒤에 유사도순으로 추가 + M2 RRF 결합 벡터 컷 통과자와 문자열 후보의 합집합을 순위 결합(k=60)으로 재정렬 + M3 앞에 고정 문자열 후보를 맨 앞에 두고 벡터 컷 통과자를 뒤에 + +지표는 `rank_score.py` 의 것을 그대로 쓴다 — baseline 과 다른 자로 재면 비교가 성립하지 +않는다. 컷은 현행 그대로 벡터 경로에만 적용한다. 문자열 후보는 컷을 거치지 않고 게이트가 +자격을 정한다(P49 §4 — 「어떤 후보도 근거 없이 들어오지 않는다」의 문자열 쪽 근거가 게이트다). +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from rank_score import ( # noqa: E402 + GuardError, SERVICE_LIMIT, aggregate, cut, is_word_query, metrics_for, zero_rate, +) + +ROOT = Path(__file__).resolve().parents[2] +SEARCH = ROOT / ".search" + +CASES = ("신한", "부캠", "그네", "스팟") +RRF_K = 60.0 + +GATES = ("G0", "G1", "G2") +RULES = ("M1", "M2", "M3") + + +def log(msg: str = "") -> None: + print(msg, flush=True) + + +def load(search_dir: Path = SEARCH) -> tuple[dict, dict]: + paths = { + "matrix": search_dir / "matrix.json", + "word_grid": search_dir / "word_grid.json", + "recall_probe": search_dir / "recall_probe.json", + "lexical": search_dir / "lexical_matrix.json", + } + for k, p in paths.items(): + if not p.exists(): + raise GuardError(f"파일이 없다: {p}") + data = {k: json.loads(p.read_text(encoding="utf-8")) for k, p in paths.items()} + profiles = {k: d.get("profile") for k, d in data.items()} + if len(set(profiles.values())) != 1 or None in profiles.values(): + raise GuardError(f"Profile 이 어긋난다: {profiles}") + lex = {e["query"]: e["matches"] for e in data["lexical"]["queries"]} + return data, lex + + +def lexical_candidates(lex: dict, query: str, uid, gate: str) -> list[int]: + """게이트를 통과한 문자열 매치 Record id 목록.""" + if gate in ("G1", "G2") and not is_word_query(query): + return [] + entries = (lex.get(query) or {}).get(str(uid)) or [] + if gate == "G2": + entries = [e for e in entries if e["boundary"]] + return [e["record_id"] for e in entries] + + +def merge(rows: list[dict], kept: list[dict], lex_ids: list[int], + rule: str, limit: int) -> list[dict]: + """컷 통과자(kept)와 문자열 후보(lex_ids)를 병합한다. rows 는 유사도 내림차순 전량이다.""" + by_rid = {} + vrank = {} + for i, r in enumerate(rows, 1): + rid = r["record_id"] + if rid not in by_rid: + by_rid[rid] = r + vrank[rid] = i + kept_ids = [r["record_id"] for r in kept] + lex_rows = [by_rid[rid] for rid in lex_ids if rid in by_rid] + lex_rows.sort(key=lambda r: -float(r["sim"])) + + if rule == "M1": + out = list(kept) + [r for r in lex_rows if r["record_id"] not in kept_ids] + elif rule == "M3": + lex_set = {r["record_id"] for r in lex_rows} + out = lex_rows + [r for r in kept if r["record_id"] not in lex_set] + elif rule == "M2": + lrank = {r["record_id"]: i for i, r in enumerate(lex_rows, 1)} + cand = {r["record_id"] for r in kept} | set(lrank) + scored = [] + for rid in cand: + s = 1.0 / (RRF_K + vrank[rid]) + if rid in lrank: + s += 1.0 / (RRF_K + lrank[rid]) + scored.append((s, vrank[rid], rid)) + scored.sort(key=lambda x: (-x[0], x[1], x[2])) + out = [by_rid[rid] for _, _, rid in scored] + else: + raise GuardError(f"알 수 없는 병합 규칙: {rule}") + return out[:limit] + + +def evaluate(entries: list[dict], *, lex: dict, gate: str, rule: str, + limit: int, default_uid=None) -> tuple[dict, dict]: + """세그먼트 하나를 재고 (지표, 부가 진단)을 돌려준다.""" + per, diag = [], {"lex_hits": 0, "lex_only_added": 0, "gate_dropped_expected": 0} + for e in entries: + q, rows = e["query"], (e.get("results") or []) + uid = e.get("user_id", default_uid) + if uid is None: + raise GuardError(f"질의 `{q}` 에 user_id 가 없다") + n_rel = sum(1 for r in rows if r.get("is_expected")) + kept = cut(rows, q, limit=limit) + lex_ids = lexical_candidates(lex, q, uid, gate) + diag["lex_hits"] += len(lex_ids) + kept_ids = {r["record_id"] for r in kept} + diag["lex_only_added"] += sum(1 for rid in lex_ids if rid not in kept_ids) + # 게이트가 자른 기대 정답 — 부분일치로는 매치인데 이 게이트에선 제외된 것 + all_ids = set(lexical_candidates(lex, q, uid, "G0")) + expected_ids = {r["record_id"] for r in rows if r.get("is_expected")} + diag["gate_dropped_expected"] += len((all_ids - set(lex_ids)) & expected_ids) + final = merge(rows, kept, lex_ids, rule, limit) + per.append(metrics_for(final, n_rel)) + agg = aggregate(per) + if agg: + agg["zero_rate"] = zero_rate(per) + return agg, diag + + +COLS = ("n", "hit@1", "hit@3", "mrr", "recall@3", "ndcg@3", "zero_rate", "returned") + + +def row_str(label: str, m: dict, extra: str = "") -> str: + if not m: + return f" {label:<14} (0건)" + cells = "".join( + (str(m[c]).rjust(9) if c == "n" else f"{m[c]:.4f}".rjust(9)) for c in COLS + ) + return f" {label:<14}{cells} {extra}" + + +def main() -> int: + ap = argparse.ArgumentParser(description="문자열 병합 규칙 비교 (P49 작업 3)") + ap.add_argument("--json") + ap.add_argument("--limit", type=int, default=SERVICE_LIMIT) + args = ap.parse_args() + + try: + data, lex = load() + except GuardError as exc: + print(f"[가드] {exc}", file=sys.stderr) + return 1 + + probe_uid = data["recall_probe"].get("user_id") + segs = { + "문장형(정답)": (data["matrix"]["queries"], None), + "단어형(정답)": (data["word_grid"]["queries"], None), + "무관-문장형": (data["matrix"]["offtopic"], None), + "무관-단어형": (data["word_grid"]["offtopic"], None), + "타인소유-단어형": (data["word_grid"]["cross"], None), + } + + log("=" * 100) + log("문자열 병합 규칙 비교 — P49 작업 3") + log("=" * 100) + log(f" Profile {data['lexical']['profile']}") + log(f" 매치 원본 lexical_matrix.json (질의 {len(lex)}건 · DB {data['lexical']['db_port']})") + log(f" 컷 현행 그대로 벡터 경로에만 · limit={args.limit}") + log(" 호출 DB 0회 · GMS 0회") + + result: dict = {"stage": "P49-lexical-sweep", "grid": {}} + + for seg_name, (entries, default_uid) in segs.items(): + log(f"\n{seg_name}") + log(" " + "게이트·규칙".ljust(12) + "".join(c.rjust(9) for c in COLS) + + " 매치/단독추가/게이트제외") + base, _ = evaluate(entries, lex=lex, gate="G1", rule="M1", limit=args.limit) + # baseline: 문자열 후보 없이 컷 통과자만 — G1·M1 에서 lex 를 비우는 대신 직접 계산 + per = [] + for e in entries: + rows = e.get("results") or [] + n_rel = sum(1 for r in rows if r.get("is_expected")) + per.append(metrics_for(cut(rows, e["query"], limit=args.limit), n_rel)) + base = aggregate(per) + base["zero_rate"] = zero_rate(per) + log(row_str("현행(벡터만)", base)) + result["grid"].setdefault(seg_name, {})["baseline"] = base + for gate in GATES: + for rule in RULES: + m, diag = evaluate(entries, lex=lex, gate=gate, rule=rule, + limit=args.limit, default_uid=default_uid) + extra = f"{diag['lex_hits']}/{diag['lex_only_added']}/{diag['gate_dropped_expected']}" + log(row_str(f"{gate}·{rule}", m, extra)) + result["grid"][seg_name][f"{gate}.{rule}"] = {"metrics": m, "diag": diag} + + # 사례 4건 — recall_probe (소유자 1명) + log("\n사례별 (병합 후 정답 순위 · `—` 는 없음)") + probe = [ + dict(e, user_id=e.get("user_id", probe_uid), + results=[dict(r, is_expected=(r.get("name") == e.get("expect"))) + for r in (e.get("results") or [])]) + for e in data["recall_probe"]["queries"] if e["query"] in CASES + ] + log(" " + "게이트·규칙".ljust(12) + "".join(q.rjust(10) for q in CASES)) + cells = [] + for e in probe: + kept = cut(e["results"], e["query"], limit=args.limit) + hit = next((i for i, r in enumerate(kept, 1) if r["is_expected"]), None) + cells.append((e["query"], hit)) + order = {q: i for i, q in enumerate(CASES)} + cells.sort(key=lambda x: order.get(x[0], 99)) + log(" " + "현행(벡터만)".ljust(12) + + "".join((str(h) if h else "—").rjust(10) for _, h in cells)) + result["cases"] = {"baseline": dict(cells)} + for gate in GATES: + for rule in RULES: + row = {} + for e in probe: + rows_, q, uid = e["results"], e["query"], e["user_id"] + kept = cut(rows_, q, limit=args.limit) + final = merge(rows_, kept, lexical_candidates(lex, q, uid, gate), + rule, args.limit) + row[q] = next((i for i, r in enumerate(final, 1) if r["is_expected"]), None) + log(" " + f"{gate}·{rule}".ljust(12) + + "".join((str(row.get(q)) if row.get(q) else "—").rjust(10) for q in CASES)) + result["cases"][f"{gate}.{rule}"] = row + + log("\n읽는 법") + log(" · 「매치/단독추가/게이트제외」 = 게이트 통과 매치 총수 / 벡터 컷 통과자에 없어서") + log(" 문자열이 단독으로 추가한 수 / 부분일치인데 이 게이트가 제외한 기대 정답 수.") + log(" · 무관·타인소유 세그먼트는 zero_rate 가 baseline 에서 떨어지면 그 규칙이 관련 없는") + log(" 결과를 노출시킨 것이다 — 기각 사유.") + log(" · 컷은 현행 그대로다. 문자열 후보의 자격은 게이트가 정한다.") + + if args.json: + out = Path(args.json) + out.write_text(json.dumps(result, ensure_ascii=False, indent=1) + "\n", + encoding="utf-8") + log(f"\n저장: {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 3f425c931b840a74bd61e1b120e730ec5cb01be2 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 09:06:07 +0900 Subject: [PATCH 10/34] =?UTF-8?q?style:=20lexical=5Fsweep=20ruff=20?= =?UTF-8?q?=EC=A7=80=EC=A0=81=202=EA=B1=B4=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20import=20=EC=A0=95=EB=A0=AC=C2=B7=ED=96=89=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- tools/search_cut/lexical_sweep.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tools/search_cut/lexical_sweep.py b/tools/search_cut/lexical_sweep.py index 1f17b47..91986ca 100644 --- a/tools/search_cut/lexical_sweep.py +++ b/tools/search_cut/lexical_sweep.py @@ -33,7 +33,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from rank_score import ( # noqa: E402 - GuardError, SERVICE_LIMIT, aggregate, cut, is_word_query, metrics_for, zero_rate, + SERVICE_LIMIT, + GuardError, + aggregate, + cut, + is_word_query, + metrics_for, + zero_rate, ) ROOT = Path(__file__).resolve().parents[2] @@ -202,7 +208,8 @@ def main() -> int: for rule in RULES: m, diag = evaluate(entries, lex=lex, gate=gate, rule=rule, limit=args.limit, default_uid=default_uid) - extra = f"{diag['lex_hits']}/{diag['lex_only_added']}/{diag['gate_dropped_expected']}" + d = diag + extra = f"{d['lex_hits']}/{d['lex_only_added']}/{d['gate_dropped_expected']}" log(row_str(f"{gate}·{rule}", m, extra)) result["grid"][seg_name][f"{gate}.{rule}"] = {"metrics": m, "diag": diag} From 39dba0d207467205c363854f8197578053e8b537 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 09:27:35 +0900 Subject: [PATCH 11/34] =?UTF-8?q?feat(S15P11A705-337):=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=A7=88=EC=9D=98=20LLM=20=EC=9E=AC=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=E2=80=94=20=EA=B0=95=EB=93=B1=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=C2=B7=EC=A7=88=EC=9D=98=20=EC=BA=90=EC=8B=9C=C2=B7=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20off=20=ED=94=8C=EB=9E=98=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RewriteClient: 검색 전용 짧은 예산(SEARCH_LLM_TIMEOUT_SEC 5s · SEARCH_LLM_ATTEMPTS 2, 판정의 90s·3회를 상속하지 않음), 벤더 3종 폴백, 질의 단위 FIFO 캐시(비결정성 흡수), 빈 결과·4배 초과 결과는 원문 복귀. SearchService: SEARCH_LLM_ENABLED(기본 off)일 때만 재작성을 시도하고 실패는 강등 — 원문으로 현행과 동일 검색. 임베딩·컷 판정은 재작성문 기준(대역이 임베딩 텍스트를 따름). 계약 테스트 9건: off 무호출·성공 경로·강등 2종· 미주입 방어·컷 분기 추종·캐시·빈 결과·폭주 방어. Co-Authored-By: Claude Fable 5 --- app/client/rewrite_client.py | 252 ++++++++++++++++++++++++++++++++++ app/core/config.py | 13 ++ app/main.py | 17 ++- app/service/search_service.py | 21 ++- tests/test_search_rewrite.py | 192 ++++++++++++++++++++++++++ 5 files changed, 491 insertions(+), 4 deletions(-) create mode 100644 app/client/rewrite_client.py create mode 100644 tests/test_search_rewrite.py diff --git a/app/client/rewrite_client.py b/app/client/rewrite_client.py new file mode 100644 index 0000000..840bded --- /dev/null +++ b/app/client/rewrite_client.py @@ -0,0 +1,252 @@ +"""검색 질의 재작성 클라이언트 (S15P11A705-337, P49 §3). + +검색 요청이 임베딩되기 전에 LLM 이 질의를 한 번 다듬는다 — 줄임말을 풀고(`부캠`→`부트캠프`) +붙여쓰기를 편다. 임베딩 모델이 의미 연결을 못 하는 약어 실패의 처방이며, 근거 측정은 +`docs/implements/2026-08-05-short-query-boundary.md` 와 P49 §2 에 있다. + +## 판정 클라이언트와 무엇이 다른가 + +`llm_client.py` 의 판정은 백그라운드 경로라 시도 예산이 길다(타임아웃 90s · 총 3회). +재작성은 **사용자가 기다리는 동기 경로**라 예산이 짧다 — 타임아웃·시도 횟수를 검색 전용 +설정으로 받고 판정의 값을 상속하지 않는다(티켓 명시). 실패의 의미도 다르다 — 판정 실패는 +상태머신에 기록되지만, **재작성 실패는 호출자(SearchService)가 원문으로 되돌리는 강등**이고 +오류로 번지지 않는다. + +벤더 어댑터를 `vendors.py` 와 공유하지 않는 이유: 그쪽 request 빌더는 키워드 판정의 +구조화 출력 스키마를 몸체에 박고 있다. 재작성의 스키마는 `{"query": string}` 하나라 +빌더를 이 파일에 따로 둔다 — 벤더별 함정(Gemini thinking 끄기 · Anthropic tool 강제)은 +그쪽 실측(테스트 C-2)을 그대로 따른다. + +## 캐시 + +같은 질의는 같은 재작성을 돌려준다. LLM 이 비결정적이라 캐시가 없으면 같은 검색이 +회차마다 다른 결과를 낼 수 있다(P48 §3 2단계가 요구한 성질). 프로세스 메모리 FIFO 이며 +상한을 넘으면 오래된 것부터 버린다 — 시연 규모에서는 사실상 전부 남는다. +""" +from __future__ import annotations + +import itertools +import json +from collections import OrderedDict +from typing import Sequence + +import httpx + +from app.client._calls import meter +from app.client._usage import record as record_usage +from app.client.retry import RetryPolicy, call_with_retry +from app.client.vendors import resolve_chain +from app.core.errors import ( + PermanentError, + SchemaViolationError, + TransientError, + classify_http_status, +) +from app.core.redact import redact, redact_body + +_MAX_OUTPUT_TOKENS = 128 + +SYSTEM = ( + "당신은 장소 기록 검색 서비스의 질의 정규화기입니다.\n" + "사용자의 검색어를 저장된 기록의 표현에 가깝게 한 줄로 다시 씁니다.\n" + "규칙:\n" + "- 줄임말·약어는 원래 말로 풀어 씁니다. 예: 부캠 → 부트캠프.\n" + "- 붙여 쓴 말은 자연스러운 띄어쓰기로 고칩니다.\n" + "- 뜻을 더하거나 빼지 않습니다. 검색어에 없던 개념을 추가하지 마세요.\n" + "- 고유명사나 가게 이름으로 보이면 그대로 둡니다.\n" + "- 확신이 없으면 원문을 그대로 반환합니다.\n" + '- 결과는 JSON {"query": "다시 쓴 검색어"} 하나만 반환합니다.' +) + +_SCHEMA = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + "additionalProperties": False, +} + + +def _openai_request(root: str, key: str, model: str, query: str): + return ( + f"{root}/api.openai.com/v1/chat/completions", + {"Authorization": f"Bearer {key}", "content-type": "application/json"}, + { + "model": model, + "messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": query}, + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "query_rewrite", + "strict": True, + "schema": _SCHEMA, + }, + }, + "max_completion_tokens": _MAX_OUTPUT_TOKENS, + }, + ) + + +def _openai_parse(payload: dict) -> dict: + return json.loads(payload["choices"][0]["message"]["content"]) + + +def _gemini_request(root: str, key: str, model: str, query: str): + return ( + f"{root}/generativelanguage.googleapis.com/v1beta/models/{model}:generateContent", + {"x-goog-api-key": key, "content-type": "application/json"}, + { + "systemInstruction": {"parts": [{"text": SYSTEM}]}, + "contents": [{"role": "user", "parts": [{"text": query}]}], + "generationConfig": { + "responseMimeType": "application/json", + "responseSchema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + "maxOutputTokens": _MAX_OUTPUT_TOKENS, + # 판정 쪽 실측(테스트 C-2)과 같은 이유 — thinking 이 지연·토큰을 늘린다. + "thinkingConfig": {"thinkingBudget": 0}, + }, + }, + ) + + +def _gemini_parse(payload: dict) -> dict: + return json.loads(payload["candidates"][0]["content"]["parts"][0]["text"]) + + +def _anthropic_request(root: str, key: str, model: str, query: str): + return ( + f"{root}/api.anthropic.com/v1/messages", + { + "x-api-key": key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + { + "model": model, + "max_tokens": _MAX_OUTPUT_TOKENS, + "system": SYSTEM, + "messages": [{"role": "user", "content": query}], + "tools": [ + { + "name": "rewrite_query", + "description": "다시 쓴 검색어를 보고한다.", + "input_schema": _SCHEMA, + } + ], + # 판정 쪽과 같은 이유 — 강제하지 않으면 산문 응답이 스키마 위반이 된다. + "tool_choice": {"type": "tool", "name": "rewrite_query"}, + }, + ) + + +def _anthropic_parse(payload: dict) -> dict: + for block in payload.get("content", []): + if block.get("type") == "tool_use": + return block["input"] + raise KeyError("tool_use block not found") + + +_BUILDERS = { + "openai": (_openai_request, _openai_parse), + "gemini": (_gemini_request, _gemini_parse), + "anthropic": (_anthropic_request, _anthropic_parse), +} + + +class RewriteClient: + def __init__( + self, + gms_base_url: str, + api_key: str, + chain: Sequence[tuple[str, str]], + *, + timeout: float, + retry: RetryPolicy, + cache_size: int = 256, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._root = gms_base_url.split("/gmsapi/")[0] + "/gmsapi" + self._key = api_key + # resolve_chain 으로 벤더 이름을 검증하고, 요청 생성은 이 파일의 빌더를 쓴다. + self._chain = [(c.adapter.vendor, c.model) for c in resolve_chain(chain)] + self._timeout = timeout + self._retry = retry + self._cache: OrderedDict[str, str] = OrderedDict() + self._cache_size = cache_size + self._transport = transport + + def _for(self, attempt: int) -> tuple[str, str]: + return self._chain[min(attempt, len(self._chain) - 1)] + + async def rewrite(self, query: str) -> str: + """질의를 다듬은 한 줄을 돌려준다. 실패는 오류로 던진다 — 강등은 호출자 몫이다. + + 빈 결과·공백뿐인 결과는 스키마 위반으로 취급한다. 재작성이 원문보다 훨씬 길면 + (4배 초과) 모델이 개념을 덧붙인 것이므로 버리고 원문을 돌려준다 — 「뜻을 더하지 + 않는다」 규칙의 기계 방어다. + """ + cached = self._cache.get(query) + if cached is not None: + return cached + + attempts = itertools.count() + async with httpx.AsyncClient( + timeout=self._timeout, transport=self._transport + ) as client: + + async def attempt() -> str: + return await self._once(client, self._for(next(attempts)), query) + + try: + result = await call_with_retry(attempt, self._retry, stage="rewrite") + except SchemaViolationError as exc: + raise PermanentError( + f"rewrite schema violation after {self._retry.attempts} attempt(s): {exc}" + ) from exc + + rewritten = result.strip() + if not rewritten or len(rewritten) > max(len(query) * 4, 40): + rewritten = query + self._cache[query] = rewritten + if len(self._cache) > self._cache_size: + self._cache.popitem(last=False) + return rewritten + + async def _once( + self, client: httpx.AsyncClient, call: tuple[str, str], query: str + ) -> str: + vendor, model = call + build, parse = _BUILDERS[vendor] + url, headers, body = build(self._root, self._key, model, query) + async with meter.call("rewrite", model=model, vendor=vendor) as rec: + try: + resp = await client.post(url, headers=headers, json=body) + except httpx.HTTPError as exc: + rec.status = type(exc).__name__ + raise TransientError( + f"rewrite request failed ({vendor}:{model}): {redact(str(exc))}" + ) from exc + + rec.status = resp.status_code + if resp.status_code != 200: + raise classify_http_status( + resp.status_code, + f"rewrite error: {vendor}:{model} {resp.status_code} " + f"{redact_body(resp.text)}", + ) + + try: + payload = resp.json() + record_usage("rewrite", payload, vendor=vendor, model=model) + data = parse(payload) + return str(data["query"]) + except (KeyError, IndexError, TypeError, ValueError) as exc: + raise SchemaViolationError( + f"rewrite parse failed ({vendor}:{model}): {exc}" + ) from exc diff --git a/app/core/config.py b/app/core/config.py index 714991f..05e94bd 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -178,6 +178,19 @@ class Settings(BaseSettings): 5, alias="SEARCH_WORD_QUERY_MAX_CHARS" ) + # 검색 질의 LLM 재작성 (S15P11A705-337, P49 §3). **기본 off** — 검증 게이트(P49 §7) + # 통과 전에는 어떤 환경에서도 켜지 않는다. off 면 검색은 현행과 동일하게 동작한다. + # 이 플래그는 배포 통제 수단이 아니라, 운영 중 LLM 장애 시 기존 검색으로 되돌리는 + # 안전장치다(배포 통제는 브랜치 격리가 한다). + search_llm_enabled: bool = Field(False, alias="SEARCH_LLM_ENABLED") + # 재작성 호출 예산. 판정(90s·3회)을 상속하지 않는다 — 검색은 사용자가 기다리는 + # 동기 경로다. attempts 는 폴백 포함 총 HTTP 시도 횟수(RetryPolicy 와 같은 정의). + search_llm_timeout_sec: float = Field(5.0, alias="SEARCH_LLM_TIMEOUT_SEC") + search_llm_attempts: int = Field(2, alias="SEARCH_LLM_ATTEMPTS") + # 질의 단위 재작성 캐시 상한. 같은 질의가 회차마다 다른 재작성을 받으면 검색이 + # 비결정적이 된다 — 캐시가 그 성질을 막는다(P48 2단계 요구). + search_rewrite_cache_size: int = Field(256, alias="SEARCH_REWRITE_CACHE_SIZE") + # PROCESSING 재선점 만료 — Spring 재스캔 만료와 동일 값 processing_expiry_sec: int = Field(600, alias="PROCESSING_EXPIRY_SEC") diff --git a/app/main.py b/app/main.py index 69f1296..1f80a92 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,8 @@ from app.client.embedding_client import EmbeddingClient from app.client.kakao_local_client import HttpKakaoLocalClient from app.client.llm_client import LLMClient +from app.client.retry import RetryPolicy +from app.client.rewrite_client import RewriteClient from app.client.vision_client import GmsGeminiVisionClient from app.core.config import get_settings from app.core.db import Database @@ -69,6 +71,17 @@ async def lifespan(app: FastAPI): api_key=settings.gms_api_key, chain=settings.judge_vendors, ) + # 검색 질의 재작성 (S15P11A705-337). 기본 off — SEARCH_LLM_ENABLED 가 + # false 면 SearchService 가 호출하지 않아 검색은 현행과 동일하다. + # 판정과 같은 벤더 체인을 쓰되 예산(타임아웃·시도)은 검색 전용 값이다. + rewrite_client = RewriteClient( + gms_base_url=settings.gms_base_url, + api_key=settings.gms_api_key, + chain=settings.judge_vendors, + timeout=settings.search_llm_timeout_sec, + retry=RetryPolicy(attempts=settings.search_llm_attempts), + cache_size=settings.search_rewrite_cache_size, + ) preset_cache = PresetCache() async with db.acquire() as conn: @@ -105,7 +118,9 @@ async def lifespan(app: FastAPI): app.state.embedding_client = embedding_client app.state.llm_client = llm_client app.state.preset_cache = preset_cache - app.state.search_service = SearchService(db, embedding_client, settings) + app.state.search_service = SearchService( + db, embedding_client, settings, rewrite_client=rewrite_client + ) app.state.context_processing_service = ContextProcessingService( db, embedding_service, keyword_service ) diff --git a/app/service/search_service.py b/app/service/search_service.py index 2a6efe4..b50d5c4 100644 --- a/app/service/search_service.py +++ b/app/service/search_service.py @@ -17,9 +17,10 @@ from __future__ import annotations from app.client.embedding_client import EmbeddingClient +from app.client.rewrite_client import RewriteClient from app.core.config import Settings from app.core.db import Database -from app.core.errors import ProfileMismatchError +from app.core.errors import PermanentError, ProfileMismatchError, TransientError from app.repository import context_embedding_repo @@ -29,10 +30,12 @@ def __init__( db: Database, embedding_client: EmbeddingClient, settings: Settings, + rewrite_client: RewriteClient | None = None, ) -> None: self._db = db self._embedding = embedding_client self._settings = settings + self._rewrite = rewrite_client async def search( self, user_id: int, query: str, limit: int, embedding_profile: str @@ -42,7 +45,19 @@ async def search( embedding_profile, self._settings.embedding_profile ) - query_embedding = await self._embedding.embed_one(query) + # LLM 재작성 (S15P11A705-337, P49 §3). 기본 off. 실패·타임아웃이면 원문으로 + # 검색한다 — 오류가 아니라 강등이고, 강등 시 동작은 이 기능 도입 전과 동일하다. + # 이후 임베딩·컷 판정은 전부 재작성된 질의 기준이다 — 컷의 단어형/문장형 분기는 + # 임베딩된 텍스트의 유사도 대역을 따라가는 장치이므로, 임베딩 입력과 판정 입력이 + # 갈리면 안 된다. + query_text = query + if self._settings.search_llm_enabled and self._rewrite is not None: + try: + query_text = await self._rewrite.rewrite(query) + except (TransientError, PermanentError): + query_text = query + + query_embedding = await self._embedding.embed_one(query_text) async with self._db.acquire() as conn: rows = await context_embedding_repo.search( @@ -55,7 +70,7 @@ async def search( "contextId": r["context_id"], "similarity": round(float(r["similarity"]), 4), } - for r in self._cut(rows, query) + for r in self._cut(rows, query_text) ] def _is_word_query(self, query: str) -> bool: diff --git a/tests/test_search_rewrite.py b/tests/test_search_rewrite.py new file mode 100644 index 0000000..57d085c --- /dev/null +++ b/tests/test_search_rewrite.py @@ -0,0 +1,192 @@ +"""검색 질의 LLM 재작성 — 강등·캐시·플래그 off 계약 (S15P11A705-337). + +고정하는 계약은 넷이다. + + ① 플래그 off(기본값)면 재작성 클라이언트가 호출되지 않고 원문이 임베딩된다 + — 현행 검색과 동작이 같다 + ② 재작성 성공이면 재작성문이 임베딩되고 컷 판정도 재작성문 기준이다 + ③ 재작성 실패(일시·영구)는 오류가 아니라 강등이다 — 원문으로 검색이 계속된다 + ④ 같은 질의는 캐시로 같은 재작성을 받는다 — LLM 을 한 번만 부른다 + +DB 는 가짜 커넥션으로 대체한다 — 여기서 재는 것은 재작성 경로이지 SQL 이 아니다. +""" +from __future__ import annotations + +from contextlib import asynccontextmanager + +import pytest + +from app.client.retry import RetryPolicy +from app.client.rewrite_client import RewriteClient +from app.core.config import Settings +from app.core.errors import PermanentError, TransientError +from app.service.search_service import SearchService +from tests.test_unit import _ENV # noqa: F401 (환경 키 한 벌을 재사용한다) + + +def _settings(monkeypatch, **overrides) -> Settings: + from tests.test_unit import _ENV as env + for k, v in {**env, **overrides}.items(): + monkeypatch.setenv(k, v) + return Settings(_env_file=None) + + +class _FakeEmbedding: + def __init__(self): + self.calls: list[str] = [] + + async def embed_one(self, text: str): + self.calls.append(text) + return [0.0] * 4 + + +class _FakeRewrite: + """RewriteClient 자리에 꽂는 가짜 — 호출 수와 동작을 제어한다.""" + + def __init__(self, result: str | None = None, error: Exception | None = None): + self.result = result + self.error = error + self.calls = 0 + + async def rewrite(self, query: str) -> str: + self.calls += 1 + if self.error is not None: + raise self.error + return self.result if self.result is not None else query + + +class _FakeDb: + """`acquire()` 만 흉내낸다. repo.search 는 monkeypatch 로 비운다.""" + + @asynccontextmanager + async def acquire(self): + yield None + + +@pytest.fixture +def no_rows(monkeypatch): + async def _empty(conn, user_id, profile, embedding, limit): + return [] + + monkeypatch.setattr( + "app.service.search_service.context_embedding_repo.search", _empty + ) + + +PROFILE = "openai-text-embedding-3-small-1536-cosine-v1" + + +async def _search(service): + return await service.search(1, "부캠", 20, PROFILE) + + +@pytest.mark.anyio +async def test_flag_off_never_calls_rewrite(monkeypatch, no_rows): + """① 기본값(off)에서 재작성은 호출 0회, 임베딩 입력은 원문이다.""" + settings = _settings(monkeypatch) + assert settings.search_llm_enabled is False + emb, rw = _FakeEmbedding(), _FakeRewrite(result="부트캠프") + service = SearchService(_FakeDb(), emb, settings, rewrite_client=rw) + await _search(service) + assert rw.calls == 0 + assert emb.calls == ["부캠"] + + +@pytest.mark.anyio +async def test_flag_on_embeds_rewritten_query(monkeypatch, no_rows): + """② 성공 시 재작성문이 임베딩된다.""" + settings = _settings(monkeypatch, SEARCH_LLM_ENABLED="true") + emb, rw = _FakeEmbedding(), _FakeRewrite(result="부트캠프") + service = SearchService(_FakeDb(), emb, settings, rewrite_client=rw) + await _search(service) + assert rw.calls == 1 + assert emb.calls == ["부트캠프"] + + +@pytest.mark.anyio +@pytest.mark.parametrize("error", [TransientError("t"), PermanentError("p")]) +async def test_failure_degrades_to_original_query(monkeypatch, no_rows, error): + """③ 실패는 강등 — 응답이 실패하지 않고 원문으로 검색한다.""" + settings = _settings(monkeypatch, SEARCH_LLM_ENABLED="true") + emb, rw = _FakeEmbedding(), _FakeRewrite(error=error) + service = SearchService(_FakeDb(), emb, settings, rewrite_client=rw) + result = await _search(service) + assert result == [] + assert emb.calls == ["부캠"] + + +@pytest.mark.anyio +async def test_flag_on_without_client_uses_original(monkeypatch, no_rows): + """클라이언트 미주입이면 플래그가 켜져 있어도 원문 경로다 — 조립 실수의 방어선.""" + settings = _settings(monkeypatch, SEARCH_LLM_ENABLED="true") + emb = _FakeEmbedding() + service = SearchService(_FakeDb(), emb, settings, rewrite_client=None) + await _search(service) + assert emb.calls == ["부캠"] + + +def test_cut_follows_rewritten_query_band(monkeypatch): + """② 컷의 단어형/문장형 분기는 임베딩된 텍스트를 따라간다. + + `부캠`(단어형 0.24)이 `신한 부트캠프`(문장형 0.30)로 재작성되면, 0.27 은 + 단어형 하한은 넘지만 문장형 하한에 걸려야 한다 — 판정 입력과 임베딩 입력이 + 갈리면 실측 근거(대역이 임베딩 텍스트를 따른다)가 무너진다. + """ + settings = _settings(monkeypatch) + service = SearchService(None, None, settings) + rows = [{"similarity": 0.27}] + assert service._cut(rows, "부캠") == rows # 단어형 0.24 통과 + assert service._cut(rows, "신한 부트캠프") == [] # 문장형 0.30 탈락 + + +# ── RewriteClient 자체 — 캐시·빈 결과·폭주 방어 ────────────────────────────── +# +# HTTP 는 transport 스텁으로 자른다(다른 클라이언트 테스트와 같은 이음새). + +import httpx # noqa: E402 + + +def _client_with(responses: list[str], **kw) -> tuple[RewriteClient, list]: + hits: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + hits.append(request.url.path) + body = responses[min(len(hits) - 1, len(responses) - 1)] + return httpx.Response(200, json={ + "choices": [{"message": {"content": body}}] + }) + + client = RewriteClient( + "https://gms.example/gmsapi/api.openai.com/v1", + "key", + [("openai", "gpt-4o-mini")], + timeout=1.0, + retry=RetryPolicy(attempts=1), + transport=httpx.MockTransport(handler), + **kw, + ) + return client, hits + + +@pytest.mark.anyio +async def test_cache_returns_same_result_without_second_call(): + """④ 같은 질의 두 번 → HTTP 1회. 값도 같다.""" + client, hits = _client_with(['{"query": "부트캠프"}']) + first = await client.rewrite("부캠") + second = await client.rewrite("부캠") + assert first == second == "부트캠프" + assert len(hits) == 1 + + +@pytest.mark.anyio +async def test_blank_rewrite_falls_back_to_original(): + """빈 결과는 원문이다 — 검색어가 통째로 사라지는 것을 막는다.""" + client, _ = _client_with(['{"query": " "}']) + assert await client.rewrite("부캠") == "부캠" + + +@pytest.mark.anyio +async def test_oversized_rewrite_falls_back_to_original(): + """원문의 4배를 넘는 재작성은 모델이 개념을 덧붙인 것 — 버리고 원문을 쓴다.""" + client, _ = _client_with([f'{{"query": "{"부트캠프 근처 맛집과 카페 그리고 " * 8}"}}']) + assert await client.rewrite("부캠") == "부캠" From 2dd8185299273a759043be9e097739800fa49dfb Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 12:40:22 +0900 Subject: [PATCH 12/34] =?UTF-8?q?docs:=20P50=C2=B7P51=20=EC=8B=A0=EC=84=A4?= =?UTF-8?q?,=20P48=20=EC=97=AD=EC=82=AC=ED=99=94,=20P49=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=EC=9D=98=EB=AF=B8=20=EC=A0=95=ED=95=A9=20=EA=B0=9C?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P50(3계층 분리와 Place metadata 결합)·P51(프리셋 거버넌스 — 열린 발견, 닫힌 사용)을 신설한다. 원천은 2026-08-06 사용자 안건 2건이며, P51 확정으로 -338(프리셋 어휘 보강)이 폐기되고 -339 는 재정렬 전용 의미의 축소 재측정 + 런타임 구현으로 재정의된다. P48 은 Superseded in part 로 역사화한다 — 0·1단계 자산 실현, 2·3단계는 P49 승계, §8 미결 전 항목에 처분 표기. P49 는 검증 세션 지적 2회분을 반영한다: P48(합집합·컷 전 융합)과 P49(컷 후 재정렬 전용)의 병합 의미 구분, 문자열 규칙의 I54 확정 반영(부분일치 채택·어절 경계 기각), 후보 집합 불변 계약(측정 지점·limit 1회 절단 포함), 공용 계약 개정 경로를 실행 그래프에 추가, 작업 그래프에서 프리셋 개정 체인 제거. 번호(P50·P51)는 잠정이며 병합 직전 확정한다. 티켓 귀속은 미정 — 배치는 후속 판단. Co-Authored-By: Claude Fable 5 --- docs/proposals/P48-search-signal-expansion.md | 34 ++-- docs/proposals/P49-multi-signal-search.md | 73 ++++--- .../P50-three-layer-place-metadata.md | 165 +++++++++++++++ .../P51-keyword-preset-governance.md | 191 ++++++++++++++++++ docs/proposals/README.md | 2 + 5 files changed, 424 insertions(+), 41 deletions(-) create mode 100644 docs/proposals/P50-three-layer-place-metadata.md create mode 100644 docs/proposals/P51-keyword-preset-governance.md diff --git a/docs/proposals/P48-search-signal-expansion.md b/docs/proposals/P48-search-signal-expansion.md index 29a22a9..6dc36c1 100644 --- a/docs/proposals/P48-search-signal-expansion.md +++ b/docs/proposals/P48-search-signal-expansion.md @@ -1,12 +1,20 @@ # P48: 개인 검색의 신호 확장 — 단일 코사인에서 다신호로 -- **상태**: Proposed +- **상태**: Superseded in part — 0단계·1단계 오프라인 자산은 실현됐고, 2·3단계 런타임 설계는 [P49](P49-multi-signal-search.md) 가 승계·대체했다. 이 문서는 원안과 실험 기반의 보존본이다 - **날짜**: 2026-08-05 - **주도(Driver)**: AI 파트 -- **관련 PR/커밋**: 없음(구현 전) -- **관련 티켓**: 미발급 +- **관련 PR/커밋**: ai#114(하네스·I52·I53 인수) · `39dba0d`(2단계 대체 구현 — P49 §3) · `8a79628`(3단계 규칙 확정 — I54) +- **관련 티켓**: S15P11A705-336(인수) · -337(2단계 대체) · -339(1단계 채택값·재정렬 기준) · back 문자열 구현 티켓(발급 예정) - **근거 리포트**: [implements/2026-08-05-search-rank-baseline.md](../implements/2026-08-05-search-rank-baseline.md)(I52 · 0단계 산출) · [implements/2026-07-31-search-cut.md](../implements/2026-07-31-search-cut.md)(`S15P11A705-213`) · [implements/2026-08-03-search-recall-probe.md](../implements/2026-08-03-search-recall-probe.md)(`S15P11A705-255`) · [implements/2026-08-03-word-query-cut.md](../implements/2026-08-03-word-query-cut.md)(`S15P11A705-266`) +> **[승계 공지 — 2026-08-06]** 이 문서는 검색 신호 확장의 원안과 실험 기반을 보존하는 +> 선행 문서다. 현재 실행 상태: **0단계 완료**(I52) · **1단계 오프라인 구현·실측 완료** +> (ai#114 로 인수, 통합 브랜치 병합 대기 — 런타임 병합 정책은 +> [P49](P49-multi-signal-search.md) §4 가 컷 이후 재정렬 전용으로 변경) · +> **2단계는 P49 §3 과 S15P11A705-337 로 대체**(프리셋 제한 출력과 단어형 한정은 현행 +> 런타임 설계가 아니다 — 약어를 프리셋 밖 표현으로 풀어야 해서다) · +> **3단계는 P49 와 I54 로 승계**(규칙 확정, back 구현 대기). +> > **0단계는 반영이 끝났고 나머지는 여전히 제안이다.** `tools/search_cut/rank_score.py` 와 > baseline 이 있으므로 §3 0단계는 제안이 아니라 기록이다(I52). **앱 런타임은 아직 어느 > 절도 건드리지 않았고 티켓도 발급되지 않았다.** @@ -454,13 +462,15 @@ rank·fusion sweep GMS 0회 · DB 0회 필수 | 낡은 artifact 로 계산한다 | §4.1 — `profile`·`preset_version`·생성 조건을 함께 저장하고, 어긋나면 실패시킨다 | | 코퍼스 규모의 일반화 한계 | 현행 행렬의 Record 수로는 개선폭이 실서비스로 일반화되지 않는다. **퇴행 탐지로만 쓰고**, 확대는 별도 트랙(`tools/demo_seed`)으로 분리한다 | -## 8. 선결 조건과 미결 +## 8. 원안 당시 선결 조건과 미결의 처분 + +> **2026-08-06 처분 주석.** 아래는 원안 시점의 목록이며, 각 항목의 현재 처분을 붙인다. **선결 조건** 1. ~~0단계 완료와 baseline 보존~~ — **완료**(I52). -2. artifact 확장. 데모 DB와 GMS 키 환경이 필요하다. -3. **티켓 발급.** 단계별로 분리하며, 3단계는 back 티켓과 연결한다. +2. artifact 확장. 데모 DB와 GMS 키 환경이 필요하다. — **해소**(ai#114: `word_grid`·`recall_probe` 에 `context_id` 추가, `keyword_matrix.json` 신규) +3. **티켓 발급.** 단계별로 분리하며, 3단계는 back 티켓과 연결한다. — **해소**(-336·-337·-339 발급, back 티켓 발급 예정) **Jira 키 없이 런타임 구현을 시작하지 않는다**(`CONTRIBUTING.md`). **환경이 없을 때** @@ -471,9 +481,9 @@ rank·fusion sweep GMS 0회 · DB 0회 필수 **미결** -- 신호 합산 방식(가중합 · RRF)과 가중치 — 1단계 sweep으로 정한다. -- `confidence` NULL 처리 규칙 — 실험 축으로 비교해 정한다(§1-d). -- artifact가 질의 벡터를 담을지 전체 Preset cosine을 담을지 — 크기 실측 후(§4.2). -- 2단계의 실행 여부 — 「검색 경로에 LLM 없음」 원칙 변경이므로 1단계 결과를 보고 판단한다. -- 3단계의 병합 위치와 응답 계약 — back과 합의 전이다. -- 코퍼스 확대 시점과 방법. +- 신호 합산 방식(가중합 · RRF)과 가중치 — 1단계 sweep으로 정한다. → **-339 잔존** (P49 재정렬 전용 의미로 재측정) +- `confidence` NULL 처리 규칙 — 실험 축으로 비교해 정한다(§1-d). → **장기 보류** (현행 데이터에 NULL 0건이라 실험 표본이 없다) +- artifact가 질의 벡터를 담을지 전체 Preset cosine을 담을지 — 크기 실측 후(§4.2). → **해소** (전체 Preset cosine 채택, `keyword_matrix.json`) +- 2단계의 실행 여부 — 「검색 경로에 LLM 없음」 원칙 변경이므로 1단계 결과를 보고 판단한다. → **P49 승계** (자유 재작성으로 대체 구현, `39dba0d`) +- 3단계의 병합 위치와 응답 계약 — back과 합의 전이다. → **P49 승계** (규칙은 I54 로 확정, back 구현·합의 잔존) +- 코퍼스 확대 시점과 방법. → **장기 보류** diff --git a/docs/proposals/P49-multi-signal-search.md b/docs/proposals/P49-multi-signal-search.md index ec2bac1..4d8c486 100644 --- a/docs/proposals/P49-multi-signal-search.md +++ b/docs/proposals/P49-multi-signal-search.md @@ -3,7 +3,7 @@ - **상태**: Proposed - **날짜**: 2026-08-05 - **주도(Driver)**: AI -- **관련 PR/커밋**: 없음(구현 전) +- **관련 PR/커밋**: ai#114(P48 하네스·I52·I53 인수, base `search-upgrade`, 병합 대기) · `39dba0d`(-337 질의 재작성 런타임 구현) · `8a79628`(문자열 병합 규칙 오프라인 확정 — I54. back 런타임은 미구현) - **관련 문서**: [P48](P48-search-signal-expansion.md)(`origin/leo`, 인수 예정) · 근거 실측 기록 [2026-08-05-multi-signal-investigation.md](../troubleshooting/2026-08-05-multi-signal-investigation.md)(T73~T78 잠정) · 관련 트랙 `S15P11A705-336` - **번호는 잠정이다.** P48 은 `origin/leo` 브랜치가 사용 중이다. 병합 직전에 번호를 재확인해 확정한다(T48·T60 규칙). @@ -16,7 +16,7 @@ **추가하려는 검색 방식.** 기존 벡터 검색은 그대로 두고, 세 가지를 더한다. 1. **LLM 질의 재작성** — 검색 전에 LLM 이 약어·구어를 임베딩이 이해하는 표기로 바꿔 쓴다. -2. **Preset 키워드 점수** — 저장 시점에 이미 판정해 둔 키워드를 검색 순위에 반영한다. leo 가 설계·구현한 P48 1단계를 그대로 인수한다. +2. **Preset 키워드 점수** — 저장 시점에 이미 판정해 둔 키워드를 검색 순위에 반영한다. P48 1단계의 데이터·계산·하네스를 인수하되, 병합 정책은 컷 이후 재정렬 전용으로 바꾼다(§3). 3. **문자열 검색** — 기록 본문에 질의 문자열이 그대로 있는 경우를 별도 경로로 찾는다. 본문을 소유한 Spring 이 수행하고 병합한다. **기대 효과.** 실패 사례 3건이 모두 해소된다. `부캠`과 `신한 부캠`은 질의 재작성으로, `신한`은 문자열 검색으로 해결된다. Preset 키워드 점수는 그 외 질의의 순위를 개선한다. @@ -47,7 +47,7 @@ 이 방식은 P48 2단계의 개정이다. P48 2단계는 LLM 출력을 Preset 목록으로 제한하는데, 그러면 Preset 에 없는 `부트캠프`로 확장할 수 없어 약어 문제를 해결하지 못한다(근거: T74). 제한을 풀고, 잘못된 재작성에 대한 방어는 원문 복귀·캐시·프롬프트 규칙(확신이 없으면 원문 유지)으로 옮긴다. -**Preset 키워드 점수 (B).** 기록을 저장할 때 파이프라인이 이미 각 기록에 Preset 키워드를 판정해 둔다. 현행 검색은 이 판정을 읽지 않는다. 이 방식은 질의와 Preset 의 임베딩 유사도로 질의에 맞는 키워드를 고르고, 그 키워드를 가진 기록의 순위를 올린다. LLM 호출이 없다. leo 가 P48 1단계로 설계와 오프라인 구현을 완료했고 측정 하네스와 테스트까지 있으므로 그대로 인수한다. +**Preset 키워드 점수 (B).** 기록을 저장할 때 파이프라인이 이미 각 기록에 Preset 키워드를 판정해 둔다. 현행 검색은 이 판정을 읽지 않는다. 이 방식은 질의와 Preset 의 임베딩 유사도로 질의에 맞는 키워드를 고르고, 그 키워드를 가진 기록의 순위를 올린다. LLM 호출이 없다. leo 가 P48 1단계로 설계·오프라인 구현·측정 하네스·테스트를 완료했으므로 데이터 소스와 점수 계산, 하네스는 그대로 인수한다. 다만 런타임 병합 정책은 P48 의 「후보 합집합 후 컷」이 아니라 이 문서 §4 의 「컷 이후 재정렬 전용」으로 변경한다 — 변경 이유는 §5 에 있다. **문자열 검색 (D).** 기록 본문에 질의 문자열이 그대로 있는지 확인한다. 본문(`core.context.body`)은 Spring 소유 스키마에 있고 FastAPI 의 접근은 공용 계약이 금지하므로, 이 검색과 결과 병합은 Spring 이 수행한다. P48 3단계가 권장한 위치와 같다. 문자열이 있다고 해서 그 기록이 질의의 대상이라는 뜻은 아니므로, §5 의 검증 절차를 함께 둔다. @@ -57,25 +57,29 @@ 1. 질의가 들어오면 LLM 재작성을 시도한다. 실패하면 원문을 쓴다. 2. (재작성된) 질의를 임베딩해 벡터 검색을 수행하고, 기존 컷(τ·r)을 원래 코사인 값 기준으로 적용한다. 여기서 살아남은 후보가 결과의 기본 집합이다. -3. Preset 키워드 점수는 이 기본 집합의 순서를 조정한다. 어떤 후보를 결과에 넣을지는 바꾸지 않는다. -4. 문자열 검색의 결과 중 §5 의 검증을 통과한 것을 결과에 추가한다. 추가 위치는 보수적으로 정한다. 벡터 검색이 찾은 후보를 밀어내지 않는다. +3. Preset 키워드 점수는 이 기본 집합의 순서를 조정한다. 어떤 후보를 결과에 넣을지는 바꾸지 않는다. 이 성질은 구현 시 계약 테스트로 고정한다 — 키워드 점수 적용 전후의 후보 Record id 집합이 같아야 하며 허용되는 차이는 순서뿐이다. 문자열 검색(4번)은 의도적으로 후보를 추가하므로 이 불변식은 키워드 단계까지만 적용한다. +4. 문자열 검색의 결과 중 §5 의 게이트를 통과한 것을 벡터 컷 통과 집합과 합쳐 RRF(k=60) 순위로 재정렬한다 — 오프라인 실측으로 확정된 규칙이다(I54). 최종 절단은 RRF 순위 기준으로 `limit` 을 한 번만 적용한다. 이 코퍼스의 실측에서 이 절단이 벡터 생존자를 제거한 사례는 관측되지 않았고, 합집합이 `limit` 을 넘는 경우의 절단 순서는 RRF 하위부터라는 것을 계약으로 고정한다. 5. 응답의 `similarity` 필드는 원래 코사인 값을 유지한다. 병합 점수는 노출하지 않는다. back 의 소비 코드(`RecordSearchService.java`)는 순서를 재정렬하지 않고 값으로 필터하지 않는 것을 확인했으므로, 순서 변경은 그대로 반영되고 계약 변경은 없다. 원칙은 세 가지다. 첫째, 어떤 후보도 근거 없이 결과에 들어오지 않는다. 벡터 후보는 측정된 컷을, 문자열 후보는 검증 절차를 통과해야 한다. 둘째, 한 검색 방식의 안전장치를 다른 방식이 대체하지 않는다. 셋째, 신규 처리가 실패하면 기존 검색 방식으로 되돌린다. 되돌린 상태의 동작은 현행과 동일하다. -병합 방식(순위 결합 방법과 가중치)의 구체 값은 여기서 정하지 않는다. 키워드 점수 실측에서 병합 방식이 결과에 결정적이라는 것이 확인됐으므로(§5), 문자열 검색의 병합 규칙은 별도 실측으로 정한다(§8 의 작업 3). +병합 방식이 결과에 결정적이라는 것이 키워드 점수 실측에서 확인됐다(§5). 그래서 갈래마다 병합 규칙을 실측으로 정한다 — **문자열 검색은 확정됐다**(작업 3 완료, I54: 단어형 한정·부분일치 게이트 + RRF k=60). **키워드 재정렬의 방식·값은 작업 4 가 정한다** — 실험 범위를 문서로 고정한다: 비교는 BASE(키워드 없음)·binary 가중 재정렬·RRF 재정렬 셋이고, confidence·IDF 는 이번 일정에서 재개방하지 않는다(P48 실측에서 주 채택 근거가 아니었다). + +**후보 집합 불변의 측정 지점.** 같은 요청·같은 `limit` 에서, **문자열 병합 직전의 API 반환 후보 Record id 집합**은 키워드 신호 on/off 가 동일해야 한다. 키워드 재정렬 뒤에 두 번째 후보 절단을 수행하지 않는다 — 재정렬 대상이 이미 `limit` 이하(컷 통과 집합)이므로 절단할 것이 없다. ## 5. 관련 없는 결과를 막는 방법 새 신호를 더할 때 가장 먼저 깨지는 것이 「관련 없는 질의에서 결과를 노출하지 않는다」는 성질이라는 것이 실측으로 확인됐다. -키워드 점수 실측에서, 가중합 방식은 일부 정답을 컷 기준 위로 끌어올려 결과에 복구시켰다. 그러나 같은 계산이 관련 없는 후보도 기준 위로 끌어올렸다. 관련 없는 문장형 질의 15건 중 결과가 노출되지 않는 질의가 11건에서 9건으로 줄었다. 반대로 순위 융합(RRF) 방식은 관련 없는 결과를 늘리지 않았지만, 문장형 정답의 1위 적중률을 0.8333 에서 0.5833 으로 떨어뜨렸다. 자세한 수치는 근거 기록 T73 에 있다. +**과거 관측 (P48 구조).** 키워드 점수를 P48 방식 — 후보 합집합을 만들고 컷을 병합 점수에 거는 구조 — 으로 실측했을 때, 가중합은 일부 정답을 컷 기준 위로 끌어올려 결과에 복구시켰지만 같은 계산이 관련 없는 후보도 기준 위로 끌어올렸다. 관련 없는 문장형 질의 15건 중 결과가 노출되지 않는 질의가 11건에서 9건으로 줄었다. 순위 융합(RRF)은 관련 없는 결과를 늘리지 않았지만 문장형 정답의 1위 적중률을 0.8333 에서 0.5833 으로 떨어뜨렸다. 자세한 수치는 근거 기록 T73 에 있다. **이 결과는 키워드 점수가 후보 포함 여부를 바꿀 수 있는 구조에서 나온 것이다.** + +**P49 의 결정.** 그 위험을 구조적으로 피하기 위해 이 문서는 키워드 신호를 컷 이후 재정렬 전용으로 제한한다(§4). 이 구조에서는 관련 없는 질의에서 벡터 후보가 0건이면 재정렬할 대상도 0건이므로, 키워드 신호만으로는 무관 결과가 새로 노출될 수 없다. 따라서 floor 와 weight 는 노출 방어가 아니라 **순위 품질의 축**이 되고, 재정렬 전용 의미에서의 채택값은 재측정으로 정한다(§8 작업 4). -따라서 차단 장치를 검색 방식마다 분리해서 둔다. +차단 장치는 검색 방식마다 분리해서 둔다. - **기존 컷 유지.** 벡터 후보의 포함 여부는 지금처럼 원래 코사인에 대한 τ·r 로 판정한다. 병합 점수에는 컷을 걸지 않는다. 측정 근거가 없는 새 임계값을 만들지 않기 위해서다. -- **키워드 floor.** 질의-Preset 유사도가 floor 미만이면 키워드 점수를 만들지 않는다. floor 0.35 에서 관련 없는 질의의 무노출 비율이 현행과 같아지는 것을 측정했다. floor 는 sweep 의 필수 축으로 둔다. -- **문자열 경계 검사.** 문자열 검색은 단어형 질의에서만 켠다. 문장형 질의의 조사·부사(「자주」·「좋은」)가 만드는 우연한 일치를 차단하기 위해서다. 매치는 어절 시작 경계에서만 인정한다. 「신한은행」·「신한에서」는 인정하고 「대신한」은 차단한다. 이 두 검사는 결정적이라 지연이 없다. 그래도 남는 유형이 있다. 문자열은 있으나 기록의 주제가 아닌 경우(「신한은행 ATM 옆 골목의 라멘집」)와 같은 표기의 다른 대상(가게 이름 `우주` 와 「우주처럼 넓은」)이다. 이를 의미 수준에서 거를지, 어디서 거를지는 미결이다(§9). +- **키워드 신호의 후보 불변.** §4 의 계약 테스트가 「키워드 점수는 후보를 추가·제거하지 않는다」를 고정한다 — 과거 관측의 실패 모드가 구조적으로 재현될 수 없게 하는 장치다. +- **문자열 게이트 — 실측으로 확정됨(I54).** 문자열 검색은 단어형 질의에서만 켜고, 매치는 부분일치를 쓴다. 단어형 한정은 문장형 질의의 조사·부사(「자주」·「좋은」)가 만드는 우연한 일치를 차단한다. 어절 시작 경계 검사는 원안에 있었으나 채택하지 않는다 — 실측에서 기대 정답 6건을 제외하는 손해만 관측됐고, 막으려는 유형(「대신한」류)은 이 코퍼스의 무관·타인 소유 세그먼트에서 한 건도 발생하지 않아 이득을 잴 수 없었다. 문자열 경로가 실패하면 벡터 결과만 반환한다. 남는 유형 — 문자열은 있으나 기록의 주제가 아닌 경우와 동형어 — 의 의미 수준 검증은 운영 코퍼스 재평가 조건으로 남는다(§9). - **실패 시 기존 검색으로 복귀.** 재작성 실패·키워드 신호 없음·문자열 검증 불가 등 어떤 단계가 실패해도 응답은 실패하지 않는다. 해당 단계만 생략하고, 전부 생략되면 현행 검색과 같은 응답을 낸다. ## 6. 개발 및 브랜치 격리 방식 @@ -90,9 +94,8 @@ ai 레포 └─ search-upgrade (통합 브랜치) ├─ -leo-acquisition leo 하네스 인수 ├─ -query-rewrite LLM 질의 재작성 - ├─ -preset-revision Preset 개선 - ├─ -fusion-adoption 키워드 점수 채택값 확정 - └─ -lexical-offline 문자열 검색 병합 규칙 실측 + ├─ -fusion-adoption 키워드 점수 채택값 확정 (재정렬 기준) + └─ (완료) lexical-merge-measure 문자열 병합 규칙 실측 — I54, `8a79628` back 레포 dev └─ search-upgrade @@ -125,20 +128,27 @@ CI 는 확인이 필요하다. 대상 브랜치가 `search-upgrade` 인 PR 에 ## 8. 구현 순서 -Preset 개선을 포함한 전체 순서다. 원칙은 하나다. Preset 개정에 종속된 작업(키워드 점수 채택값 확정)만 개정 뒤에 세우고, 나머지는 전부 병렬로 진행한다. +> **2026-08-06 개정.** Preset 개선(구 작업 2)은 이 트랙에서 제거됐다 — 근거는 +> [P51 §10](P51-keyword-preset-governance.md)(음식·장소 명사 보강은 계층 위반이고 해당 +> 질의는 문자열 검색이 회복). 이에 따라 작업 4 의 선행 조건(Preset 개정)도 사라졌고, +> 작업 4 는 「재정렬 전용 병합 규칙 기준의 축소 재측정」으로 재정의됐다. + +원칙은 하나다. 게이트(작업 6) 전에 네 갈래 — 인수 병합, 재작성 잔여 검증, 채택값 확정, back 구현 — 가 전부 끝나야 하며, 네 갈래는 서로 병렬이다. ``` -[작업 0] leo 인수 ─┬─→ [작업 1] LLM 재작성 (Preset 무관) ──────────────────────┐ - ├─→ [작업 2] Preset 개선 설계→개정→재판정 → [작업 4] 키워드 채택값 확정 ─┐ - └─→ [작업 3] 문자열 병합 규칙 실측 → [작업 5] back 구현 ────────┼─→ [작업 6] 검증(§7) - ┘ +[결정] P50·P51 승인 → -338 폐기 · -339 개정 +[작업 0] leo 인수 병합 ─┬─→ [작업 1] LLM 재작성 잔여 검증 ─────────────────────┐ + ├─→ [작업 4] 키워드 재정렬 구현·채택값 확정 ──────────────┼─→ [작업 6] 검증(§7) + └─→ [작업 3] 문자열 규칙 실측(완료·I54) → [작업 5] back 구현 ┘ +[공용 계약 개정·파트 합의] ──────────────────────────────────────→ dev 병합 선행 또는 동반 ``` +공용 계약 개정 경로는 게이트와 별개의 필수 선행이다 — §9 마지막 미결이 근거이며, 개정하지 않기로 결정하면 이 트랙의 런타임 변경은 dev 에 병합할 수 없다. P50 1단계(Place 필드 보존)는 이 그래프와 별도의 front+back 병렬 경로다. + - **작업 0. leo 인수.** leo 브랜치를 rebase 해 하네스·테스트·문서를 통합 브랜치에 들인다. 발견된 버그 2건(T76·T77) 수정, 리포트 번호 충돌(I51→I52) 해소, artifact 커밋, 실측 리포트 작성, 통합 브랜치 생성과 CI 확인, DB 스냅샷 준비를 포함한다. 모든 후속 작업의 기반이므로 유일한 전면 선행 작업이다. - **작업 1. LLM 재작성 (병렬).** Preset 과 무관하므로 기다릴 이유가 없다. 실패 사례 3건 중 2건을 해소하는, 시연 가치 대비 위험이 가장 낮은 작업이다. 관련 없는 질의 15건의 재측정을 채택 조건으로 포함한다. -- **작업 2. Preset 개선 (병렬).** 키워드 점수가 반영되지 않은 단어형 질의 54건 목록(근거: T75)을 설계 입력으로 쓴다. 개정 후 재임베딩과 기록 재판정은 스냅샷 DB 에서 한다. 시연 규모(기록 42건)에서는 실행 비용이 작고, 병목은 설계 합의다. - **작업 3. 문자열 병합 규칙 실측 (병렬).** 측정 artifact 에 본문 매치 정보를 추가해, 병합 방식과 경계 검사 조합을 오프라인으로 훑는다. back 이 구현을 시작하기 전에 규칙을 확정해서 넘기기 위한 작업이다. 파트 간 재작업을 줄이는 것이 목적이다. -- **작업 4. 키워드 채택값 확정.** Preset 개정을 기다리는 유일한 작업이다. 개정 전 값으로 확정하면 개정 후 다시 재야 한다. floor 축과 반복 측정(재현성 회차)을 포함한다. +- **작업 4. 키워드 재정렬 구현·채택값 확정.** 두 부분이다. **측정** — 현행 Preset·현행 측정 자료로, §4 의 재정렬 전용 규칙에서 BASE·binary 재정렬·RRF 재정렬을 비교하고 floor·weight 를 확정한다(GMS·DB 호출 없음). 오프라인 결정성 회차(같은 자료·같은 인자에서 출력 일치)와 artifact 신선도 가드(profile·preset_version 불일치 시 실패)를 포함한다. **런타임 구현** — 컷 통과 후보의 `context_keyword` 조회, `keyword_status=COMPLETED` 만 신호 사용, PUBLIC+PRIVATE_ONLY 사용·BLOCKED 제외(P48 §1-b·1-c 그대로), 채택값으로 순서만 변경, 기본 off 플래그, 오류 시 벡터 순서 복귀, on/off 후보 집합 불변 계약 테스트, 채택값의 `app/core/config.py` 반영. 이 구현이 없으면 채택값이 있어도 세 번째 검색 신호가 런타임에 존재하지 않는다. - **작업 5. back 구현.** 문자열 검색과 병합을 작업 3 의 확정 규칙대로 구현한다. 유일한 타 파트 의존 작업이라 일정 위험이 가장 크다. - **작업 6. 검증.** §7 기준 전부 통과 후 dev 병합, 시연 DB 반영, 리허설. @@ -146,12 +156,13 @@ Preset 개선을 포함한 전체 순서다. 원칙은 하나다. Preset 개정 ## 9. 아직 결정하지 않은 사항 -- **문자열 검색의 병합 규칙과 값** — 작업 3 의 실측으로 정한다. 키워드 실측의 결론(가중합·RRF 의 상반된 결과)을 그대로 가져다 쓰지 않는다. -- **문자열 매치의 의미 검증 위치** — 결정적 검사(단어형 한정·어절 경계)만으로 충분한지, LLM 판정이 필요한지, 필요하다면 ai 에 판정 API 를 추가할지. 작업 3 의 실측 후 판정한다. -- **키워드 점수 채택값** — floor 0.35 · binary weight 0.05 는 개정 전 Preset 기준의 관측이다. 개정 후 재측정으로 확정한다. +- ~~문자열 검색의 병합 규칙과 값~~ — **해소(2026-08-06).** 오프라인 실측으로 확정했다(I54): 단어형 질의 한정·부분일치 게이트 + RRF k=60 병합. back 런타임 구현이 남는다. +- **문자열 매치의 의미 검증 위치** — 결정적 검사 중 「단어형 한정·부분일치」는 채택되고 어절 경계 요구는 손해만 관측되어 기각됐다(I54). LLM 의미 판정의 필요성은 이 코퍼스에서 잴 수 없어 운영 코퍼스 재평가 조건으로 남는다. +- **키워드 점수 채택값** — 재정의됨. floor 0.35 · binary weight 0.05 는 P48 구조(후보 합집합·컷 전 융합) 기준의 관측이다. §4 의 재정렬 전용 규칙에서의 채택값은 작업 4 의 축소 재측정으로 확정한다. Preset 개정 선행은 제거됐다. - **통합 브랜치의 최종 병합 방식** — PR 단위 squash 규약을 통합 브랜치 전체에 적용하면 티켓별 이력이 사라진다. merge commit 을 권고하나 규약 예외이므로 중앙 판정이 필요하다. -- **Preset 개선의 구체 범위** — 미반영 54건 목록 기반의 설계는 작업 2 의 몫이다. 이 문서는 순서와 종속 관계만 정한다. +- ~~Preset 개선의 구체 범위~~ — **제거.** 프리셋 변경은 이 트랙에서 빠졌고 [P51](P51-keyword-preset-governance.md) 의 거버넌스 루프로 이관됐다. 이 제거는 P50·P51 승인과 한 묶음이다 — 이 문서 묶음이 승인되는 시점에 -338 폐기도 함께 확정되며, Jira 반영이 후속된다. - **재작성의 실제 효과** — 목표 문장의 검색 성공은 측정됐지만, LLM 이 그 목표 문장을 안정적으로 만들어내는지는 미측정이다. 작업 1 에서 검증한다. +- **공용 계약 개정** — 재작성(-337)은 계약의 「검색어 LLM 분해」 제외 조항과, 키워드 융합은 「독립 Keyword 후보 검색」 제외 조항과 겹친다(`05_AI_설계.md` §9.4·§15.2). dev 병합 시점에 계약 개정이 선행 또는 동반돼야 하며 파트 간 합의가 필요하다. --- @@ -164,9 +175,9 @@ Preset 개선을 포함한 전체 순서다. 원칙은 하나다. Preset 개정 | A | 기존 벡터 검색 + 이중 컷 | `-213`·`-266`·`-273` 세 차례 실측, 실서버 대조 22/22 일치 | | B | Preset 키워드 점수 (P48 1단계) | 대행 실측 완료. floor 0.35 · binary weight 0.05 만 채택 기준 4종 통과 (T73) | | C | LLM 질의 재작성 | 재작성 자체는 미실측. 목표 문장의 검색 성공은 실측됨 (`-273`) | -| D | 문자열 검색 (P48 3단계) | 미실측. `신한`의 본문 존재는 확인 (`-255`) | +| D | 문자열 검색 (P48 3단계) | **오프라인 실측 완료(I54, 2026-08-06)** — 게이트·병합 규칙 확정. back 런타임 미구현 | | E | 병합 규칙 (가중합·RRF) | 키워드 점수에 한해 실측 (T73) | -| F | 문자열 매치 경계·의미 검사 | 설계만. 미실측 | +| F | 문자열 매치 경계·의미 검사 | **결정적 검사는 실측 완료(I54)** — 단어형 한정·부분일치 채택, 어절 경계 기각. 의미 검증은 운영 코퍼스 조건 | 실패 사례별로 어느 방식이 해결하는지의 대응은 다음과 같다. ◎ 는 해결, △ 는 부분 기여, ✗ 는 영향 없음이다. @@ -176,11 +187,11 @@ Preset 개선을 포함한 전체 순서다. 원칙은 하나다. Preset 개정 | `신한 부캠` | ✗ | ✗ | ◎ | △ | | `신한` | ✗ | ✗ | ✗ | ◎ | | Preset 축 질의 순위 | — | ◎ | — | — | -| 무관 질의 무노출 유지 | ◎ (기반) | 조건부 (floor≥0.35) | 위협 가능 | 검증 필요 | +| 무관 질의 무노출 유지 | ◎ (기반) | ◎ (재정렬 전용 구조 — §5) | 위협 가능(작업 1 재측정) | ◎ (실측 — 매치 0건, I54) | ## 부록 B. 키워드 점수 실측 수치 요약 -측정 조건: `origin/leo` `fcb397c` 하네스 · 시연 DB 스냅샷 시점 기록 42건 · 소유자 3명 · Preset 27종 v1. 상세 표와 산출물 경로는 근거 기록 T73 에 있다. +측정 조건: `origin/leo` `fcb397c` 하네스 · 시연 DB 스냅샷 시점 기록 42건 · 소유자 3명 · Preset 27종 v1. 상세 표와 산출물 경로는 근거 기록 T73 에 있다. **이 수치는 P48 구조(후보 합집합·컷 전 융합)의 실측이다** — §4 의 재정렬 전용 구조에서의 채택값은 재측정 대상이다(§8 작업 4). - 기본 설정(floor 0.25): 가중합에서 `신한` 복구(컷 후 4위). 동시에 관련 없는 문장형 질의의 무노출이 11/15 에서 9/15 로 감소. RRF 는 무노출 유지, 문장형 hit@1 0.8333→0.5833 퇴행. - floor 0.35 + binary weight 0.05: 단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129. 문장형 hit@3 1.0 유지 · MRR 0.9028→0.9167. 관련 없는 질의 무노출은 현행과 동일. 단 키워드 점수가 반영되는 단어형 질의가 66건 중 12건으로 축소되고 `신한` 복구는 사라진다. @@ -195,7 +206,11 @@ Preset 개선을 포함한 전체 순서다. 원칙은 하나다. Preset 개정 | 임베딩 모델 교체 | 의미 연결이 개선될 가능성은 있으나 미측정이다. 저장 벡터 전량 재생성과 기존 컷 값 전면 재측정이 필요해 시연 일정(08-10) 안에서는 비현실적이다 | | 단어형 판정 경계 변경 | 회복되는 질의가 사용자가 실제로 치지 않는 기능어 조합뿐이라는 측정 결과가 있다(`-273` 의 처방 P1 항목) | | 상대 컷(r) 완화 | 사용자 보고가 없는 증상이고, r 을 나누지 않기로 한 기존 측정 판단(`-266`)을 뒤집으려면 격자 재측정이 선행되어야 한다 | -| RAG·ANN 인덱스·Context 청킹·HyDE·placeMeta 결합 | P48 §5 의 기각·보류 판단을 유지한다 | +| RAG·ANN 인덱스·Context 청킹·HyDE | P48 §5 의 기각·보류 판단을 유지한다 | +| Place metadata 를 Context 임베딩 입력에 혼합 | P48 §5 의 보류를 유지한다 — 계층 분리 원칙([P50](P50-three-layer-place-metadata.md))과 같은 방향 | +| Place metadata 를 병합 점수에 직접 혼합 | 이번 릴리스 범위에서 제외한다 | + +Place 의 region·category 를 **구조 필터**로 쓰는 방식은 기각된 것이 아니다 — P50 §5.3 이 다른 계층(점수가 아니라 SQL 필터)에서 채택했고, 재작성 출력에서 필터를 추출하는 것은 장기 확장 지점이다(P50 §6). 위 두 행의 제외는 「점수·임베딩에 섞는 방식」에 한정된다. ## 부록 D. 근거 문서 좌표 diff --git a/docs/proposals/P50-three-layer-place-metadata.md b/docs/proposals/P50-three-layer-place-metadata.md new file mode 100644 index 0000000..96499a7 --- /dev/null +++ b/docs/proposals/P50-three-layer-place-metadata.md @@ -0,0 +1,165 @@ +# P50. 3계층 분리와 Place metadata 결합 + +- **상태**: Proposed +- **날짜**: 2026-08-06 +- **주도(Driver)**: AI (back·front·공용 계약 협의 필요 — §8의 개정 목록) +- **관련 PR/커밋**: 없음(구현 전) +- **관련 문서**: [P51](P51-keyword-preset-governance.md)(프리셋 거버넌스 — Context 계층의 짝 문서) · [P49](P49-multi-signal-search.md)(검색 다신호) · P42(MVP Feed에서 Place metadata 제외 결정 — 이 문서가 그 후속이다) +- **번호는 잠정이다.** 병합 직전에 재확인해 확정한다(T48·T60 규칙). + +> 이 문서의 원천은 2026-08-06 사용자가 전달한 카카오맵 결합 안건이다. 안건의 결론을 본문에 풀어 적고, 이 세션의 실측·코드 확인으로 검증한 판정을 함께 적는다. 수치는 측정 시점의 관측값이며 정본 좌표는 관련 리포트에 있다. + +## 1. 제안 요약 + +**문제.** PinLog의 정보 구조는 설계상 3계층(장소의 객관적 사실 / 사용자의 주관적 맥락 / 결합된 파생 특징)으로 나뉘어 있지만, 실제로는 이름뿐인 상태다. Place 사실 계층은 절반만 존재하고(§2), 결합 계층은 미구현이며, Context 의미 계층에 장소 어휘가 스며들 뻔했다. + +**제안.** 세 계층을 실체화한다. Place 계층은 카카오 응답 필드를 온전히 저장하고(§4), Context 계층은 프리셋 거버넌스로 순수성을 유지하고([P51](P51-keyword-preset-governance.md)), 결합은 원본을 섞지 않고 조회·추천·검색 단계에서만 수행한다(§5). + +**기대 효과.** 키워드 처리가 실패하거나 비어 있어도 지역·업종 기반 추천이 동작한다. 장소 종류를 프리셋으로 중복 생성하지 않아 프리셋 증식을 막는다. 검색에 지역·업종 구조 필터를 정확하게 걸 수 있다. + +**최대 위험.** 계층 혼합이다. 카카오 카테고리에서 키워드를 자동 생성하거나, 서로 다른 축의 특징을 하나의 유사도 집합에 섞으면 두 계층의 의미가 함께 무너진다(§3·§7). + +**핵심 한 문장.** 카카오는 장소가 무엇인지 설명하고, AI 프리셋은 사용자가 그 장소를 어떻게 경험했는지 설명하며, PinLog는 추천·검색·컬렉션 계층에서 두 설명을 분리된 신호로 결합한다. + +## 2. 배경과 문제 — 3계층이 이름뿐인 이유 + +**첫째, Place 사실 계층이 절반만 존재하고 데이터 유실이 지금도 진행 중이다.** 현재 장소 저장 경로는 카카오 응답 중 5개 필드만 서버로 보낸다. 카카오가 이미 반환한 도로명주소·전화번호·장소 URL·업종 카테고리·카테고리 그룹 코드가 저장 시점마다 버려진다. 추가 API 호출 없이 보존할 수 있는 값이다. + +**둘째, 결합 계층이 미구현이다.** 공용 계약은 「MVP에서 Place category·region은 Feed 후보 채널로 쓰지 않는다 — `core.place`가 두 값을 컬럼으로 저장하지 않고, 도입하려면 DB·Front 계약과 백필이 함께 필요하다. 후속 과제로 미룬다」고 명시했다(`static/05_AI_설계.md` §14.2, 결정 근거는 P42). 이 문서는 정확히 그 후속 과제의 실행 설계다. + +**셋째, Context 의미 계층에 장소 어휘가 유입될 뻔했다.** 검색 실측에서 프리셋 신호가 반영되지 않은 단어형 질의 44종의 절반가량이 음식·장소 종류 명사였고, 이를 프리셋 정의문에 보강하는 안이 검토됐다(경위는 [P51 §2](P51-keyword-preset-governance.md)). 이 안은 장소 사실을 Context 의미 축에 넣는 계층 위반이며, 그 질의들은 문자열 검색이 회복함이 별도로 실측됐다([I54](../implements/2026-08-06-lexical-merge-rule.md)). 근거 수치는 [I53](../implements/2026-08-05-fusion-measurement.md)과 [T75](../troubleshooting/2026-08-05-multi-signal-investigation.md)에 있다. + +## 3. 원칙 — 세 계층과 혼합 금지 + +**1계층: 장소의 객관적 사실.** 출처는 카카오 로컬 API다. 장소 이름·주소·좌표·업종·전화번호·URL. 이 정보는 사용자가 왜 이 장소를 저장했는지와 무관하다. 같은 피자집을 두고 어떤 사용자는 친구들과 저녁을 먹었고, 어떤 사용자는 혼자 조용히 기다렸다. 따라서 장소 카테고리가 「음식점 > 양식 > 피자」라는 이유로 Context에 식사 키워드를 자동 부여하면 안 된다. + +**2계층: 사용자의 주관적 맥락.** 출처는 사용자가 작성한 Context와 AI 키워드 파이프라인이다. 동행·활동·분위기·상황의 4축 프리셋과 visibility가 이 계층에 속한다. `ai.context_keyword`가 Record나 Place가 아니라 Context 단위로 저장되는 것이 이 계층의 실체다. + +**3계층: 결합된 파생 특징.** 「마포구의 조용한 피자집」 같은 표현은 별도 원본이 아니라 1·2계층에서 생성되는 뷰 또는 추천 특징값이다. 원본을 섞어 저장하지 않는다. + +**혼합 금지의 근거.** 서로 다른 축의 특징을 하나의 유사도 집합(예: Jaccard)에 넣으면 의미가 왜곡된다. 카페를 20회 방문하고 조용함을 5회 기록한 사용자와, 카페 3곳에 활기참이 5회인 컬렉션은 「카페」가 겹친다는 이유로 높은 유사도를 받지만 사용자가 원하는 것은 조용한 장소일 수 있다. 특징은 네임스페이스로 분리한다. + +| 특징 축 | 예 | 출처 | +|---|---|---| +| `PLACE_CATEGORY` | `FD6`, 피자 | 카카오 | +| `REGION` | 서울, 마포구, 연남동 | 카카오 주소·좌표 | +| `CONTEXT_KEYWORD` | 혼자, 조용함, 기념일 | AI 프리셋 | +| `BEHAVIOR` | 클릭, 저장, 노출 | PinLog 이벤트 | +| `TIME` | 저장 시각, 최신성 | PinLog | + +점수도 축별로 따로 계산하고(keywordAffinity·categoryAffinity·regionAffinity·recency 등) 최종 단계에서만 가중합한다. 기존 피드 설계가 이미 이 형태이므로, 이 결합은 새 설계가 아니라 기존 설계의 데이터 기반을 완성하는 작업이다. + +## 4. Place 계층 — 저장·비저장·갱신 + +### 4.1 저장해야 하는 필드 + +| 필드 | 용도 | +|---|---| +| `id` | 카카오 장소 식별자 | +| `place_name` | 장소 표시 | +| `address_name` | 지번 주소 | +| `road_address_name` | 사용자 표시·검색 | +| `x`, `y` | 지도·지역·거리 계산 | +| `category_group_code` | 안정적인 대분류 특징 (음식점 `FD6`, 카페 `CE7` 등 18개 그룹) | +| `category_group_name` | 표시·디버깅 | +| `category_name` | 세부 업종 경로 (예: 음식점 > 양식 > 피자) | +| `phone` | 장소 상세 | +| `place_url` | 카카오맵 상세 연결 | + +`category_group_code`는 일부 장소에서 빈 문자열일 수 있다(주요 그룹에 속하지 않는 장소). 따라서 `category_name`과 둘 중 하나만 저장하면 안 된다 — 한쪽만 저장하면 [P51 §6](P51-keyword-preset-governance.md)의 개념 분류(장소 카테고리 여부 판별)에 구멍이 생긴다. + +`core.place`의 권장 형태는 provider 구분(`provider`·`provider_place_id`), 카테고리 원문 보존(`category_path_raw` — 카카오가 관리하는 외부 문자열이므로 불변 코드로 취급하지 않는다), 지역 3단계(`region_1depth`~`region_3depth`), 동기화 시각(`provider_first_seen_at`·`provider_synced_at`)을 포함한다. 구체 스키마는 back 소유이므로 이 문서는 요구 필드까지만 정한다. + +### 4.2 저장하지 않는 값 + +- **`distance`** — 장소의 속성이 아니라 요청 좌표에 따라 달라지는 요청 상대값이다. 검색 응답 DTO에는 포함하되 `core.place`에는 저장하지 않는다. +- **`meta` (total_count·pageable_count·is_end·same_name)** — 검색 세션에만 쓰는 값이다. `same_name`은 질의의 지역 해석 안내에 유용하지만 선택한 Place의 속성이 아니다. 참고로 카카오 키워드 검색은 `pageable_count` 최대 45라, 기본 `size=15`면 실질 최대 3페이지다. + +### 4.3 갱신 정책 — Context의 역사적 사실과 장소의 현재 상태를 가른다 + +현행 「최초 저장 후 갱신하지 않음」을 그대로 두면 도로명주소·전화·URL이 영구히 비고, 업종·상호 변경이 반영되지 않는다. 그러나 전면 갱신은 Context 불변 모델(P1)과 충돌하는 것처럼 보일 수 있어 경계를 명시한다. + +``` +변경하면 안 되는 것 사용자가 당시 작성한 Context · 당시 사진 · 기록 생성 시각 +갱신할 수 있는 것 장소 전화번호 · 카카오 URL · 도로명주소 · 현재 상호명 · + 현재 카테고리 · provider_synced_at +``` + +정책: 같은 카카오 Place id가 다시 들어오면 null 필드를 보충하고, 변경 가능한 장소 메타데이터를 갱신하고, `provider_synced_at`을 갱신한다. 사용자 Context는 건드리지 않는다. + +역사적 표시 정보까지 보존하려면 장기적으로 `core.record_place_snapshot` 분리가 가능하지만, 지금은 null 보충과 재선택 시 최신화부터 적용하는 것이 현실적이다(§7). + +### 4.4 운영 리스크 — 카카오 쿼터 + +2026-07-21부터 카카오맵 API의 활성화·무료 쿼터 정책이 변경됐다. 추가 API(좌표→행정구역 등)를 도입하면 호출 수 관리가 조건이 된다. AI 서버의 장소 제안 경로(`kakao_local_client`)도 같은 쿼터를 공유하므로 도입 시 합산 관리가 필요하다. + +## 5. 결합 계층 — 어디서 어떻게 결합하는가 + +### 5.1 키워드 파이프라인과의 결합 수준 — 판정 + +| 수준 | 내용 | 판정 | +|---|---|---| +| A. 후단 결합 | 판정은 Context만으로, Place는 조회·추천 단계에서만 결합 | **채택** — Context 키워드의 의미가 보존된다. 판정 파이프라인 무변경 | +| B. LLM 보조 입력 | 판정 프롬프트에 Place category를 보조로 제공 | **조건부 보류** — 아래 계약과 실험이 선행돼야 한다 | +| C. 카테고리→키워드 자동 생성 | `FD6→MEAL`, `CE7→COFFEE_CHAT` 류 일대일 규칙 | **기각** — §7 | + +수준 B를 도입한다면 다음 계약이 필수다: **Place metadata는 Context에 표현된 의미를 해석하는 보조 근거일 뿐, Context에 없는 활동이나 상황을 새로 추론하는 근거로 쓰지 않는다.** 「친구랑 들렀다」+피자집에서 `WITH_FRIENDS`는 되지만 `MEAL` 자동 생성은 안 된다. 도입 전 Context-only(BASE)와 Place 보조(PLACE-AUX)의 분리 실험이 필요하며, 평가는 fit 0 감소·Context에 없는 활동의 과잉 생성·카테고리별 편향·민감 키워드 증가·기존 정답 회귀·평균 선택 개수를 함께 본다. 후보 도달과 판정 정확도의 분리 측정 원칙은 [P51 §7](P51-keyword-preset-governance.md)과 같다. + +### 5.2 피드 추천 — 3개 프로필의 분리 계산 + +사용자와 컬렉션 각각에 대해 키워드 프로필(PUBLIC+PRIVATE_ONLY), 장소 카테고리 프로필, 지역 프로필을 만들고, 유사도를 축별로 따로 계산한 뒤 최종 점수에서만 합친다(follow + keywordAffinity + categoryAffinity + regionAffinity + recency − impressionPenalty). + +이 구조의 가장 큰 실질 가치는 **AI 키워드 처리가 실패하거나 비어 있어도 추천이 멈추지 않는다**는 것이다. 저장 직후에는 Place region·category가 즉시 쓸 수 있고, AI 처리 완료 후 Context 키워드가 더해진다. + +컬렉션 특징값의 공개 경계: Context 축은 PUBLIC 키워드만, Place 축은 공개 컬렉션에 실제 포함된 공개 장소에서만 집계한다. 비공개 Record·비공개 컬렉션의 장소를 섞지 않는다. 기존 정책(타인 컬렉션 특징값 = PUBLIC 키워드 + Place metadata)과 일치한다. + +### 5.3 검색 — 두 종류를 가른다 + +**기록을 만들기 위한 장소 검색**은 카카오 기능(중심 좌표·반경·거리순 정렬·카테고리 필터·페이징)을 적극 활용한다. UI 구성은 front·back 소관이므로 이 문서는 방향만 적는다. + +**저장한 기록의 자연어 검색**은 Context 임베딩 중심을 유지하고, Place 데이터는 점수에 섞지 않고 **구조 필터**로 붙인다. + +``` +Context 유사도 후보 → JOIN Record → JOIN Place +WHERE place.region_2depth = '마포구' AND place.category_group_code = 'CE7' +``` + +「마포구에서 친구랑 간 피자집」 같은 자연어에서 조건을 추출하더라도 지역→region 필터, 피자집→category 필터, 친구→Context 의미로 분해한다. **세 값을 하나의 통합 임베딩 점수로 섞지 않는다** — [P49](P49-multi-signal-search.md)의 신호 분리 원칙과 같은 사상이고, 기존 미결 M7(검색 장소·시간 필터, back 주도·AI 협의)의 구체화다. + +### 5.4 화면 — 출처 구분 + +장소 정보(피자·연남동·마포구)와 내 기록 키워드(친구·식사·조용함)를 구분해 표시한다. 칩 디자인 분리(장소 칩 = 카카오 사실 / AI 칩 = Context 해석 / 개인 칩 = PRIVATE_ONLY)는 사용자가 「장소가 피자집이라서 식사로 판정됐다」와 「내 문장을 읽고 식사로 판정됐다」를 혼동하는 것을 막는다. 수준 B를 도입할 경우 이 구분이 특히 중요해진다. + +## 6. 검색 고도화 트랙과의 접점 + +- **재작성 클라이언트가 필터 추출의 확장 지점이다.** 검색 질의 재작성(S15P11A705-337)의 출력을 「재작성문 하나」에서 「재작성문 + 구조 필터」로 확장하면 §5.3의 자연어 조건 분해를 담을 수 있다. 현행 구현은 이 확장을 막지 않는 형태다. +- **문자열 검색 규칙([I54](../implements/2026-08-06-lexical-merge-rule.md))과 병립한다.** 문자열 검색은 본문 매치, 구조 필터는 Place 속성 매치로 서로 다른 신호다. +- **알려진 긴장 1건.** 프리셋 정의문에 음식 명사를 보강하는 안(폐기 경위는 [P51 §10](P51-keyword-preset-governance.md))은 검색 매칭용이었더라도 Place 어휘를 Context 임베딩에 넣는 부분 위반이었다. 장기 해소책은 표시·판정·검색 임베딩 입력의 분리(aliases — 전수 검토 문서의 스키마 v2 항목)다. + +## 7. 채택하지 않은 것 + +| 대안 | 기각 사유 | +|---|---| +| 카카오 카테고리→키워드 자동 생성 (`FD6→MEAL` 류) | 장소와 맥락의 재혼합이다. 피자집에서 포장만 했을 수 있고 카페에서 면접을 봤을 수 있다. 계층 분리의 반대 방향 | +| 카카오 데이터를 프리셋 목록으로 흡수 | 네임스페이스 혼합으로 유사도 계산이 왜곡된다(§3의 Jaccard 예) | +| 좌표→행정구역 API 즉시 도입 | 추가 호출 비용·쿼터 리스크(§4.4). 행정 코드가 실제 기능에 필요할 때, 저장 시 1회 호출 + 캐시 조건으로 재검토 | +| `core.record_place_snapshot` 즉시 도입 | 현 단계 과설계. null 보충·재선택 최신화로 충분하며, 역사적 표시 보존 요구가 실재할 때 재검토 | + +## 8. 적용 로드맵 — 소유 파트와 선행 관계 + +실행 시점의 판단은 사용자와 중앙의 몫이다. 이 표는 순서와 소유만 정한다. + +| 단계 | 내용 | 소유 | 선행 조건 | +|---|---|---|---| +| 1 | **필드 보존** — front가 검색 응답의 카카오 필드(도로명·전화·URL·카테고리 3종)를 서버로 전송, back이 저장. 프리뷰·저장 주소 정책 통일(도로명 우선) | front + back | 없음 — **추가 카카오 호출 0.** 유실이 진행 중이므로 우선순위 최상 | +| 2 | **Place 정규화** — region 3단계·`category_path_raw`·`provider_synced_at` 컬럼과 null 보충 정책. 공용 계약 개정: P42 해제 + `05_AI_설계.md` §14.2·§15.2 + `06_데이터모델` + `07_ERD` | back + docs 합의 | 1단계 | +| 3 | **피드 category·region 프로필** — 물리 테이블보다 조회 집계 + Redis 캐시 우선(기존 결정 유지) | back | 2단계 | +| 4 | **미매칭 개념 분석에 Place 차원 결합** — [P51 §5](P51-keyword-preset-governance.md)의 오프라인 보강 배치와 동일 작업 | AI | 1단계(category 저장) | +| 5 | **수준 B 실험** — §5.1의 계약·실험 요건 충족 시에만 | AI | 4단계 + 평가 하네스 | + +## 9. 미결 + +- 지역·카테고리 유사도를 분리 계산할지, 기존처럼 하나의 geo/category affinity로 둘지 — 데이터가 충분할 때의 판단이다. +- 행정·법정동 코드 도입 조건과 캐시 설계. +- 공용 계약 개정(2단계)의 시점과 주체 — 파트 간 합의 절차가 필요하다. +- 민감 개념 심사 주체 — [P51 §12](P51-keyword-preset-governance.md)와 공유되는 미결이다. diff --git a/docs/proposals/P51-keyword-preset-governance.md b/docs/proposals/P51-keyword-preset-governance.md new file mode 100644 index 0000000..3d9f553 --- /dev/null +++ b/docs/proposals/P51-keyword-preset-governance.md @@ -0,0 +1,191 @@ +# P51. Keyword 프리셋 거버넌스 — 열린 발견, 닫힌 사용 + +- **상태**: Proposed +- **날짜**: 2026-08-06 +- **주도(Driver)**: AI +- **관련 PR/커밋**: 없음(구현 전) +- **관련 문서**: [P50](P50-three-layer-place-metadata.md)(3계층 분리 — Place 계층의 짝 문서) · [P47](P47-keyword-preset-label-axis.md)(표시 라벨·축 정의) · P26(프리셋 구성·판정) · P12(visibility 3등급) +- **번호는 잠정이다.** 병합 직전에 재확인해 확정한다(T48·T60 규칙). + +> 이 문서의 원천은 2026-08-06 사용자가 전달한 프리셋 고도화 방식 비교 안건이다. 안건의 결론을 본문에 풀어 적고, 이 저장소의 실측·코드 확인으로 검증·보정한 판정을 함께 적는다. 프리셋 운영 인프라(release·검증기·스키마 v2)의 상세는 별도 전수 검토 문서(프로젝트 저장소 밖, `PinLog_keyword_preset_total_review.md`)에 있으며, 이 문서는 그 인프라를 승격 절차의 선행 조건으로 참조한다 — **이 문서가 절차를, 전수 검토가 인프라를 맡는 관계다.** + +## 1. 제안 요약 + +**제안.** 프리셋 구조의 고도화 방향을 「열린 발견, 닫힌 사용」으로 확정한다. LLM은 프리셋으로 표현하지 못한 새 개념을 자유롭게 **제안**하되, 제안된 개념은 정식 키워드·추천 계산에 넣지 않고 후보군으로 축적한다. 반복 출현하고 심사를 통과한 개념만 정식 프리셋으로 승격한다. + +**현행 구조가 이미 절반을 갖추고 있다.** 판정 LLM은 후보 ID에서만 선택하고, 적합한 것이 없으면 빈 배열이 정상 완료이며(P30·계약 시나리오 14), 표현하지 못한 개념은 `unmatchedConcepts`로 저장된다(`ai.context_keyword_analysis`). 이 문서는 그 저장을 발견 채널로 구조화하고 승격 게이트를 세우는 것이다. + +**기대 효과.** 사용자 표현의 사각지대를 실데이터로 발견하면서, 제품의 공통 언어(불변 `code`·visibility·재현성)는 그대로 유지한다. + +**최대 위험.** 승격 남발이다. 게이트(§6)가 그 방어선이고, 특히 장소 카테고리 성격의 개념은 프리셋이 아니라 Place 계층으로 보낸다([P50](P50-three-layer-place-metadata.md)). + +## 2. 현재 문제 + +**표현력의 상한.** 현행 27종 프리셋은 동행·활동·분위기·상황의 4축이다. 검색 실측에서, 안전한 키워드 신호 조건(floor 0.35)에서 단어형 평가 질의 66건 중 54건이 프리셋 신호를 받지 못했다([I53](../implements/2026-08-05-fusion-measurement.md)·T75). **이 수치는 검색 실험의 결과이지 키워드 생성 실패율이 아니다** — 그러나 27종이 사용자 언어의 긴 꼬리를 덮지 못한다는 신호로는 유효하다. 그 54건을 분석하면 절반가량이 음식·장소 명사(프리셋이 아니라 Place 계층의 몫)이고 나머지가 고유명사·사물이라, 실제 프리셋 후보는 훨씬 적다. + +**빈 배열의 사용자 체감.** 적합한 키워드가 없을 때 빈 배열을 반환하는 현행 실패는 데이터로는 안전하지만, 사용자에게는 「AI가 내 기록을 이해하지 못했다」로 느껴지기 쉽다. + +**직전 이력.** 검색 미반영 질의를 줄이려고 기존 프리셋의 정의문·예문에 음식 명사를 보강하는 안(S15P11A705-338)이 검토됐다. 이 안은 두 근거로 폐기가 제안된 상태다 — ① 장소 어휘를 Context 축에 넣는 계층 위반([P50 §2](P50-three-layer-place-metadata.md)), ② 해당 질의는 문자열 검색이 회복함이 실측됨([I54](../implements/2026-08-06-lexical-merge-rule.md)). 폐기 확정은 사용자 승인 사안이다(§12). + +## 3. 대안 3방식의 비교와 판정 + +| 기준 | 현행 27개 | LLM 자유 생성 | 대규모 확장 | +|---|---|---|---| +| 즉각적 표현력 · 체감 적합도 | 낮음~중간 | **가장 높음** | 중간~높음 | +| 공통 code 정규화 · 추천 호환 | **매우 높음** | 매우 낮음 (거의 비호환) | 높음 | +| 프라이버시 통제 | **강함** | 가장 약함 | 강함 (사전 심사 가능) | +| 재현성 | 높음 | 낮음 | 중간~높음 | +| 변경·배포 비용 | 낮음 | 지속적으로 높음 | 릴리스마다 높음 | +| 장기 데이터 품질 | 안정 | 파편화 위험 | 설계 품질에 좌우 | + +### 3.1 자유 생성을 정식 사용하면 안 되는 네 가지 이유 + +1. **동의어 파편화가 추천을 끊는다.** 같은 개념이 기분전환·리프레시·머리식히기·환기·스트레스해소로 갈라진다. 추천이 정확한 `code` 일치 기준(weighted Jaccard)이므로, 사용자 프로필의 「기분전환」과 컬렉션의 「머리식히기」는 겹침 0이 된다. 표시 적합도는 오르지만 추천 연결성은 내려간다. 해결하려면 군집화·정규화·온톨로지가 필요한데, 그것은 결국 프리셋을 다시 만드는 일이다. +2. **키워드가 기록 수에 비례해 증식한다.** Context 1,000건이면 출현 1~2회짜리 문자열 수백 개가 생기고, 키워드가 아니라 짧은 요약문 모음이 된다. 집계·프로필·비교·탐색·정렬·visibility 전부가 불안정해진다. +3. **프라이버시 위험이 가장 크다.** 현행 프리셋은 이별·상담·건강 등 민감 범주를 의도적으로 제외했다. 자유 생성은 사람 이름·회사명·건강 상태가 포함된 문자열(「민수와데이트」·「퇴사고민」·「병원진료후」)을 만들 수 있고, 생성 텍스트 자체가 개인정보일 수 있어 visibility 판정을 함께 시켜도 완전한 방어가 안 된다. 방어하려면 생성→개인정보 검사→정규화→visibility 판정→중복 검사→공개 판단의 새 관리 시스템이 필요하다. +주의할 평가 함정이 하나 있다. 내부 사용자 설문만 보면 자유 생성이 세 방식 중 가장 좋은 결과를 낼 가능성이 크다 — 체감 적합도가 실제로 가장 높기 때문이다. 그러나 추천 오프라인 평가와 키워드 중복률을 함께 보면 평가가 뒤집힐 수 있다. 초기에 좋아 보이고 데이터가 쌓일수록 나빠지는 형태이므로, 도입 판단을 설문 단독으로 하면 안 된다. + +4. **비결정성은 이 저장소의 실측 사실이다.** 같은 조건으로 재판정만 해도 Context의 26%에서 선택이 흔들렸고(T39), 저장된 confidence의 라벨 분리도 재판정 한 번에 사라졌다(T47). 후보 ID 선택도 이 정도인데 어휘 선택까지 모델에 맡기면 같은 Context가 회차마다 다른 키워드를 갖는다. + +### 3.2 대규모 확장이 자동 개선이 아닌 세 가지 이유 + +1. **후보 도달률이 떨어진다.** 판정 LLM은 전체 목록이 아니라 임베딩 TOP-K 후보만 받는다. 27개에서 TOP-10은 전체의 약 37%지만 100개에서는 10%다. 정답 프리셋이 후보에서 빠지면 판정 단계에서 복구할 수 없다(후보 밖 ID 폐기 — P30·P31). 확장에는 후보 검색 개편이 선행돼야 한다(§7). +2. **미매칭이 오분류로 바뀐다.** 휴식·기분전환·스트레스해소·멍때리기·혼자만의시간처럼 경계가 가까운 항목이 늘면, 빈 배열 대신 「관련 있지만 가장 적합하지 않은」 선택과 과다 선택이 는다. 빈 키워드 비율만 보면 개선으로 보이지만 사용자 체감은 여전히 나쁠 수 있다. +3. **릴리스 비용이 크다.** 프리셋 정의·예문이 바뀌면 임베딩 분포가 바뀌어 기존 실측값(후보 floor·검색 신호 채택값)이 무효가 되고 Context 전량 재판정이 필요하다(T75의 종속 분석과 동일). 예상 품질 곡선은 40~60개까지는 표현력 이득이 혼동 증가보다 크고, 80~120개부터 빠르게 악화된다 — 정확한 최적점은 실측 대상이다. + +**판정.** 현행 유지도, 자유 생성도, 무작정 확장도 아니다. 발견은 LLM에 열고 사용은 프리셋 체계로 닫는 §4의 구조가 종합 최적이다. + +## 4. 제안 구조 + +``` +Context + ↓ +기존 프리셋 판정 (현행 그대로) + ↓ +적합한 키워드 존재? + ├─ Yes → ai.context_keyword 저장 (정식 — 추천·visibility·불변 code) + └─ No → unmatchedConcepts 에 제안 개념 저장 (후보 — 아래 성질) + ↓ + 오프라인 보강 배치 (§5) — 정규화·군집·민감도·기존핏·Place 차원 + ↓ + 승격 게이트 (§6) — 유형 분류·기준 충족·심사 + ↓ + 정식 프리셋 승격 = 릴리스 1회 (§6.3) +``` + +두 데이터의 성질을 명확히 가른다. + +| | 정식 키워드 (`ai.context_keyword`) | 제안 개념 (`ai.context_keyword_analysis`) | +|---|---|---| +| 추천·컬렉션 특징값 | 사용 | **사용 안 함** | +| 타인 공개 | visibility 정책대로 | **안 함** | +| 개인화 즉시 사용 | 사용 | 안 함 | +| 식별자 | 불변 `keyword_id`·`code` | 자유 문자열 (비정규) | +| 역할 | 제품의 공통 언어 | 프리셋 설계의 입력 데이터 | + +## 5. 수집 계층 — 런타임은 바꾸지 않는다 + +**판정 LLM의 출력 스키마를 확장하지 않는다.** 안건의 구조화 예시(정규화 라벨·사유·민감도·기존 후보 적합도)를 판정 출력에 직접 요구하면, 판정 스키마 변경으로 기존 판정 품질 실측이 전부 무효가 되고 출력 필드가 늘수록 스키마 위반·과잉 생성 위험이 커진다. 정의문 일괄 개정이 실험에서 기각된 전례와 같은 이유로, 발견 채널의 지능은 런타임 밖에 둔다. + +런타임은 현행 그대로 짧은 개념 문자열을 저장하고, **오프라인 보강 배치**가 저장된 개념에 다음을 채운다. + +- 정규화 라벨 (띄어쓰기·표기 통일) +- 동의어 군집 (개념 임베딩 유사도 기반 — 기분전환·리프레시·머리식히기를 한 군집으로) +- 민감도 등급 (개인 이름·회사·의료·상담 포함 여부) +- 기존 프리셋과의 최근접 코사인 (기존 동의어인지, 독립 개념인지의 1차 신호) +- **Place 차원** — 해당 Context가 속한 Record의 장소 카테고리·지역 ([P50 §8](P50-three-layer-place-metadata.md) 4단계와 동일 작업. `core` 조인은 런타임이 아니라 분석 도구의 영역이며 측정 도구 선례를 따른다) + +## 6. 승격 게이트 + +### 6.1 유형 분류 — Place 차원이 있어야 가능한 판별 + +| 유형 | 예 | 처분 | +|---|---|---| +| 1. 사실상 장소 카테고리 | 피자·브런치·카페·병원·미술관 | **프리셋 추가 금지** — Place category가 이미 표현. [P50](P50-three-layer-place-metadata.md)의 몫 | +| 2. 여러 업종에 걸쳐 반복되는 맥락 | 기분전환·마음정리·시간때우기·대기 | **신규 프리셋 후보** — 장소 속성이 아니라 방문 맥락일 가능성 | +| 3. 민감·개인적 개념 | 면접·퇴사상담·병원진료·이별·심리상담 | 반복돼도 즉시 승격 금지 — PRIVATE_ONLY/BLOCKED/미승격 심사 | +| 4. 일회성 고유명사·사건 | 민수 생일·신한 면접·프로젝트 회의 | 미승격 — 분석 데이터로만 | + +유형 1 판별의 핵심 신호가 「특정 장소 카테고리에만 집중 출현하는가」이므로, 이 분류는 §5의 Place 차원 결합 없이는 성립하지 않는다. 기존 프리셋의 동의어로 판별되면 승격이 아니라 해당 프리셋의 description·examples 개선으로 처리한다. + +### 6.2 승격 기준 — 2단 + +``` +운영 기준 (정식 승격 요건) + 서로 다른 사용자 5명 이상 + 전체 10회 이상 출현 + + 기존 프리셋과 명확히 구분 (§5의 최근접 코사인 + 사람 심사) + + Place metadata 가 아님 (유형 1 아님) + + 민감정보가 아님 (유형 3 심사 통과) + + 추천·표시 가치가 있음 + +파일럿 기준 (현 데이터 규모용) + 현재 시연 데이터는 소유자 3명 · Context 42건이라 운영 기준에 도달할 수 없다. + 현 규모에서는 승격을 실행하지 않고, 분류·군집·판단표 산출까지의 + 루프 리허설로 파이프라인 자체를 검증한다. +``` + +### 6.3 승격 절차와 릴리스 규율 + +``` +동의어 군집화 → 신규 code 정의 → visibility 판정 → 임베딩 생성 +→ 기존 Context 재판정 → BASE 대비 평가 → 프리셋 release +``` + +승격 1회가 릴리스 1회다. 여러 개념을 모아 한 번에 바꾸지 않고 소규모로 반복한다 — 릴리스마다 재판정·재평가 비용이 있으므로(§3.2), 변경 단위가 작아야 퇴행의 원인을 가릴 수 있다. 릴리스에 필요한 인프라(사전 검증기·release 추적·DEPRECATED 상태·code 재사용 금지)는 전수 검토 문서의 Phase 2~4가 정의하며, **그 인프라가 승격 실행의 선행 조건이다**(§11). + +## 7. 확장의 전제 조건 + +프리셋이 40개를 넘기 전에 다음을 끝낸다. + +1. **후보 검색 개편 실측** — 전역 TOP-10 유지 / category별 TOP-2×4+전역 2 / 전체 목록 전달(27~40개 규모에서는 전수 전달도 실측 가치가 있다)의 비교. 전수 검토 문서 §9.2의 실험안과 동일하다. +2. **지표 분리** — 정답이 후보에 들어왔는가(Candidate Recall@K)와 후보 안에서 LLM이 정답을 골랐는가(Judge Accuracy)를 따로 잰다. 최종 오분류만 세면 어느 단계의 실패인지 가릴 수 없다. +3. **상한 가드** — 표적 40~60개. 80~120개 대역은 중복 개념·후보 누락·판정 혼동이 빠르게 늘어나는 구간으로 예상되며(§3.2 — 실측 전 추정임을 명시), 그 대역에 진입하려면 별도 실측 근거가 필요하다. +4. **4축 유지** — 축 신설은 이 문서의 범위 밖이고 별도 계약 개정이다. + +## 8. 조건부 항목 — AI 임시 태그 + +사용자 체감을 위해 LLM 제안 개념을 화면에 보여줄 수 있다. 단 다음 요건이 전부 지켜질 때만이다. + +- 본인에게만 표시 (타인 공개·추천·컬렉션 특징값 사용 금지) +- 「AI 임시 태그」 등 비정규 데이터임이 화면에서 구분됨 ([P50 §5.4](P50-three-layer-place-metadata.md)의 칩 구분과 결합) +- 저장 위치는 정식 테이블이 아니라 analysis 쪽 — 「닫힌 사용」이 코드 구조로 보장되게 + +front·back의 응답 계약과 화면 변경이 필요하므로 후순위이며, 도입 여부 자체가 미결이다(§12). + +## 9. 검색 트랙과의 경계 + +「닫힌 사용」은 검색에도 적용된다 — **제안 개념과 임시 태그는 검색 신호로도 쓰지 않는다.** 검색의 표현력 부족은 검색 트랙이 별도 신호로 해결한다. + +| 검색 실패 유형 | 담당 | 근거 | +|---|---|---| +| 약어·줄임말 (`부캠`) | 질의 LLM 재작성 (S15P11A705-337) | [P49 §3](P49-multi-signal-search.md) | +| 본문 문자열 존재 (`신한`) | 문자열 검색 병합 | [I54](../implements/2026-08-06-lexical-merge-rule.md) | +| 프리셋 축 질의 순위 | 키워드 신호 융합 | [I53](../implements/2026-08-05-fusion-measurement.md) | +| 프리셋 미표현 개념 | **이 문서의 승격 루프** (검색 신호 아님) | §4 | + +## 10. 기각 전수 + +| 기각 대상 | 근거 | +|---|---| +| 자유 생성 키워드의 정식 사용 (타인 공개·피드 추천·공통 프로필·컬렉션 특징값) | §3.1의 4문제 — code 기반 추천·visibility·재현성이라는 핵심 이점을 모두 잃는다 | +| 무작정 대규모 확장 (3~4배) | §3.2 — 후보 도달률 하락·오분류 전환·릴리스 비용. 표적 확장(§7)만 허용 | +| 기존 프리셋에 음식·장소 명사 보강 (S15P11A705-338 원안) | 계층 위반([P50](P50-three-layer-place-metadata.md)) + 해당 질의는 문자열 검색이 회복(I54). 폐기 확정은 사용자 승인 대기 | +| 유형 1(장소 카테고리) 개념의 프리셋 승격 | Place 계층과의 중복 생성 — 프리셋 증식의 주 원인 차단 | + +## 11. 적용 로드맵 + +| 구분 | 내용 | 선행 조건 | +|---|---|---| +| AI 단독 | 오프라인 보강 배치 도구 (§5) — 정규화·군집·민감도·기존핏 + Place 차원 | Place category 저장([P50 §8](P50-three-layer-place-metadata.md) 1단계) 전에는 Place 차원만 비워 두고 가동 가능 | +| AI 단독 | 파일럿 리허설 (§6.2) — 현 데이터로 분류·군집·판단표 산출 | 보강 배치 | +| 인프라 (전수 검토 Phase 2~4) | validate_presets·dry-run, release 추적, DEPRECATED·code 재사용 금지, aliases(표시·판정·검색 임베딩 입력 분리) | 승격 실행 전 필수 | +| 확장 실행 | §6.3 절차 — 운영 기준 충족 개념이 생겼을 때 | 후보 검색 개편 실측(§7) | + +## 12. 미결 + +- 승격 심사의 주체와 주기 — 유형 3(민감) 심사 기준 포함. [P50 §9](P50-three-layer-place-metadata.md)와 공유. +- 민감도 등급의 구체 기준 (LOW/…/차단의 경계). +- AI 임시 태그 도입 여부 (§8). +- 파일럿 리허설의 실행 시점. +- 표시·판정·검색 임베딩 입력 분리(aliases)의 실험 — 정의문 개정이 실험에서 기각된 전례가 있으므로 입력 구성 변경은 반드시 기존 평가 하네스 비교 후 채택. +- S15P11A705-338 폐기의 최종 확정 (사용자 승인). diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 7406e8a..3908981 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -26,6 +26,8 @@ | [P47](P47-keyword-preset-label-axis.md) | Keyword 프리셋 표시 라벨·축 정의·스키마 개정안 | Proposed | AI | | [P48](P48-search-signal-expansion.md) | 개인 검색의 신호 확장 — 단일 코사인에서 다신호로 | Proposed | AI | | [P49](P49-multi-signal-search.md) | 다신호 검색 개선 — 질의 재작성·키워드 점수·문자열 검색 추가와 검증 기준 | Proposed | AI | +| [P50](P50-three-layer-place-metadata.md) | 3계층 분리와 Place metadata 결합 — 장소 사실·Context 의미·파생 결합의 실체화 (번호 잠정) | Proposed | AI(+back 협의) | +| [P51](P51-keyword-preset-governance.md) | Keyword 프리셋 거버넌스 — 열린 발견, 닫힌 사용 (번호 잠정) | Proposed | AI | ## 제안 — 전수 (Accepted) From 09f507a6675e0d4cce0621d3163e62ff2224094f Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 14:27:33 +0900 Subject: [PATCH 13/34] =?UTF-8?q?docs(S15P11A705-340):=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=82=B0=EC=B6=9C=EB=AC=BC=20=EA=B0=80=EB=8F=85?= =?UTF-8?q?=EC=84=B1=20=EC=9E=AC=EA=B5=AC=EC=84=B1=20=E2=80=94=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EA=B3=84=EC=95=BD=20=EC=86=8C=EA=B8=89=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(S15P11A705-340): 문서 재구성 내용 보존 대조 스크립트 추가 재구성 전후로 수치·티켓·경로·URL·문서번호 토큰의 다중집합을 비교한다. 이후 문서 재구성 웨이브 전체가 재사용한다. Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 프리셋 개정 측정 리포트를 문서 작성 계약에 맞게 재구성 WRITING-CONTRACT 적용: 은유·압축어를 시스템 동작 서술로 풀고, 대시로 연결된 다중 주장을 문장 단위로 분리했다. 요약을 조건별 판정·근거 구조로 재작성하고, 수치에는 분자·분모와 의미 설명을 붙였다. 내용 보존: 측정 수치·판정·기각 사유·한계 서술·링크를 모두 유지했다. preserve_check 차집합은 참조 추가·깨진 상호참조 정정(§2.1·§2.2→§2, §8.2①→§8.3①)·수치 명시 추가뿐이며 손실 항목이 없다. Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 개인 검색 명세를 문서 작성 계약에 맞게 재구성 §6.1 결과 컷 절을 중심으로 재작성했다. 「침묵」 은유를 「결과를 반환하지 않는다/무노출」로 바꾸고, 대시로 이어진 다중 주장을 문장으로 분리했으며, 11/15 같은 분수 표기에 분자·분모의 의미를 병기했다. 섹션 번호·소제목 앵커는 외부 문서가 참조하므로 유지했다. preserve_check 차집합은 수치 병기 추가로 인한 증가뿐이며 감소 항목이 없다(내용 손실 없음). Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T68·T69 장애 기록을 문서 작성 계약에 맞게 재구성 h1 을 은유 없는 내용 요약 문장으로 바꾸고, 「이 티켓을 구했다」 「조용히 틀린 값」 「잡아먹는가」 류의 표현을 실제 시스템 동작 서술로 풀었다. 진단 경로·수치·처방·다음 사람에게 절의 내용은 모두 유지했다. T 번호와 절 구조는 외부 참조가 있어 유지했다. preserve_check 차집합은 주어 명시·용어 정의 추가로 인한 증가 3건뿐이며 감소 항목이 없다(내용 손실 없음). Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): docs/README.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): P1 Context 불변 모델 ADR 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): code-review.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): workflow.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): P4 소프트 삭제 마커 ADR 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): P5 정확 cosine 검색 ADR 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): cost-estimate.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-23-architecture-diagrams 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): partial-resume.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-candidate-threshold 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): P26 Keyword 프리셋·판정 ADR 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): model-profile.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): keyword-preset.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-23-fastapi-implementation 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): state-machine.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): deletion-race-control.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-23-keyword-matching-eval 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): P43 S1 판단 복원 기록을 표 해체로 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-23-keyword-preset-seed 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-db-error-classification 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): context-processing.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): architecture.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): P44 협업 운영 기준 ADR 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): integration-tests.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-24-e3-test-harness 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): P45 공개 설정 정본 ADR 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-docs-index-check 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): failure-recovery.md 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-28-s1-implementation-recovery 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-embedding-grid 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): P47 프리셋 개정안을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-gms-call-observability 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T16~T18 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-27-e2e-verification 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T19~T21 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): WORKLOG.md 기존 항목의 은유·압축 표현을 정리 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-29-dev-deployment-gates 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-gms-error-body-redaction 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T22~T24 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-29-sealed-secret-handoff 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T25~T26 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T27~T28 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-judge-prompt-rule 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T53~T55 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T64~T65 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-29-demo-seeding 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T61~T63 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-30-coverage-gate 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-judge-vote 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T50~T52·T56 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-30-retry-and-error-classification 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T66~T67 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): mermaid 검증 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-search-cut 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T40~T42 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-30-judge-vendor-fallback 을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T37~T39 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-search-error-contract 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T29~T36 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-30-real-data-e2e 를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): WORKLOG 검수 잔존 3건 정리 (갈래·이쪽 편·초록) Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T57~T60 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-seed-guard 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-07-31-ticket-audit-96-77 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T70~T72 반복 사고 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-08-03-dead-config-keys 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): T43~T49 장애 기록을 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-08-03-dev-deploy-gap 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-08-03-docs-index-oneway 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-08-03-error-wording-split 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-08-03-gms-image-probe 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-08-03-gms-vision-probe 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): W2 검수 지적 3건 정리 — 문장 내 연결 대시·코드블록 산문·judge-vote 제목 복원 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-08-03-preset-display-name-deploy-chain 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-08-03-search-recall-probe 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-08-03-word-query-cut 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): 2026-08-05-short-query-boundary 리포트를 문서 작성 계약에 맞게 재구성 Co-Authored-By: Claude Fable 5 * docs(S15P11A705-340): W3b 검수 잔존 표현 정리 — 침묵·갈래·하한을 탄다 계열 Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- docs/README.md | 14 +- docs/WORKLOG.md | 34 +- docs/development/code-review.md | 6 +- docs/development/workflow.md | 2 +- .../2026-07-23-architecture-diagrams.md | 34 +- .../2026-07-23-fastapi-implementation.md | 86 ++- .../2026-07-23-keyword-matching-eval.md | 41 +- .../2026-07-23-keyword-preset-seed.md | 30 +- docs/implements/2026-07-24-e3-test-harness.md | 50 +- .../implements/2026-07-27-e2e-verification.md | 172 ++--- .../2026-07-28-s1-implementation-recovery.md | 166 ++-- docs/implements/2026-07-29-demo-seeding.md | 315 +++----- .../2026-07-29-dev-deployment-gates.md | 64 +- .../2026-07-29-sealed-secret-handoff.md | 130 ++-- docs/implements/2026-07-30-coverage-gate.md | 138 ++-- .../2026-07-30-judge-vendor-fallback.md | 124 +-- docs/implements/2026-07-30-real-data-e2e.md | 124 +-- ...26-07-30-retry-and-error-classification.md | 98 +-- .../2026-07-31-candidate-threshold.md | 284 +++---- .../2026-07-31-db-error-classification.md | 231 +++--- .../implements/2026-07-31-docs-index-check.md | 301 ++++---- docs/implements/2026-07-31-embedding-grid.md | 210 ++--- .../2026-07-31-gms-call-observability.md | 58 +- .../2026-07-31-gms-error-body-redaction.md | 156 ++-- .../2026-07-31-judge-prompt-rule.md | 319 ++++---- docs/implements/2026-07-31-judge-vote.md | 344 +++++---- docs/implements/2026-07-31-search-cut.md | 166 ++-- .../2026-07-31-search-error-contract.md | 111 +-- docs/implements/2026-07-31-seed-guard.md | 153 ++-- .../2026-07-31-ticket-audit-96-77.md | 12 +- .../implements/2026-08-03-dead-config-keys.md | 103 +-- docs/implements/2026-08-03-dev-deploy-gap.md | 143 ++-- .../2026-08-03-docs-index-oneway.md | 125 ++- .../2026-08-03-error-wording-split.md | 79 +- docs/implements/2026-08-03-gms-image-probe.md | 187 ++--- .../implements/2026-08-03-gms-vision-probe.md | 45 +- .../2026-08-03-preset-description.md | 726 ++++++++++-------- ...-08-03-preset-display-name-deploy-chain.md | 148 ++-- .../2026-08-03-search-recall-probe.md | 197 ++--- docs/implements/2026-08-03-word-query-cut.md | 335 ++++---- .../2026-08-05-short-query-boundary.md | 329 ++++---- docs/proposals/P1-immutable-context.md | 36 +- docs/proposals/P26-keyword-preset-judgment.md | 40 +- docs/proposals/P4-is-deleted-cancelled.md | 36 +- docs/proposals/P43-s1-judgment-recovery.md | 77 +- .../proposals/P44-ai-repository-governance.md | 32 +- docs/proposals/P45-public-config-in-code.md | 49 +- .../P47-keyword-preset-label-axis.md | 80 +- docs/proposals/P5-exact-cosine.md | 28 +- docs/spec/architecture.md | 6 +- docs/spec/context-processing.md | 24 +- docs/spec/cost-estimate.md | 18 +- docs/spec/deletion-race-control.md | 4 +- docs/spec/failure-recovery.md | 63 +- docs/spec/integration-tests.md | 13 +- docs/spec/keyword-preset.md | 14 +- docs/spec/model-profile.md | 10 +- docs/spec/partial-resume.md | 15 +- docs/spec/personal-search.md | 169 ++-- docs/spec/state-machine.md | 6 +- .../2026-07-23-fastapi-local-verification.md | 24 +- .../2026-07-24-e3-ci-and-search-path.md | 32 +- .../2026-07-27-e2e-env-issues.md | 40 +- ...026-07-28-shared-worktree-and-env-cache.md | 22 +- .../2026-07-30-seeding-quota-and-encoding.md | 56 +- .../2026-07-31-db-error-pitfalls.md | 51 +- .../2026-07-31-docs-index-check.md | 24 +- .../2026-07-31-error-contract-pitfalls.md | 33 +- .../2026-07-31-judge-prompt-ab.md | 58 +- docs/troubleshooting/2026-07-31-judge-vote.md | 40 +- .../2026-07-31-local-e2e-and-ci-pitfalls.md | 28 +- .../2026-07-31-log-redaction-pitfalls.md | 32 +- .../2026-07-31-search-cut-measurement.md | 21 +- .../2026-07-31-tau-measurement.md | 16 +- .../2026-08-03-dead-config-key-audit.md | 36 +- .../2026-08-03-preset-description.md | 117 +-- .../2026-08-03-repeat-incidents.md | 73 +- .../mermaid-headless-validation.md | 12 +- tools/doc_rewrite/preserve_check.sh | 8 + 79 files changed, 3931 insertions(+), 3902 deletions(-) create mode 100644 tools/doc_rewrite/preserve_check.sh diff --git a/docs/README.md b/docs/README.md index 4721dc8..63e20a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # PinLog AI 파트 문서 -FastAPI AI 서버의 **설계·결정·구현 기록**입니다. 공용 계약의 단일 원본은 `Team-PinLog/docs`의 `static/05_AI_설계.md`이며, 여기서는 계약을 참조만 하고 구현 방법을 다룹니다. +FastAPI AI 서버의 **설계·결정·구현 기록**입니다. 공용 계약의 단일 원본은 `Team-PinLog/docs`의 `static/05_AI_설계.md`입니다. 이 디렉터리의 문서는 그 계약을 참조만 하고, 구현 방법을 다룹니다. 모든 문서가 공유하는 전제는 **Context 불변성**입니다(계약 §4.2): @@ -8,15 +8,15 @@ FastAPI AI 서버의 **설계·결정·구현 기록**입니다. 공용 계약 동일한 context_id는 항상 동일한 Context 본문을 의미한다. ``` -수정은 구 Context 삭제와 신 Context 생성의 조합이며 새 `context_id`를 받습니다. 따라서 본문 세대를 구분하는 버전 개념이 없고, 수정 경합은 삭제 경합 방어로 흡수됩니다. +수정은 구 Context 삭제와 신 Context 생성의 조합이며 새 `context_id`를 받습니다. 따라서 본문 세대를 구분하는 버전 개념이 없습니다. 수정 도중 발생하는 경합도 별도 장치 없이 삭제 경합 방어가 함께 처리합니다. ## 구역 | 구역 | 내용 | |---|---| -| [`spec/`](spec/) | 설계·구현 명세 — "무엇을 만들 것인가" | -| [`proposals/`](proposals/) | 제안·결정(P 번호) + 미결 — "왜 그렇게 정했나" | -| [`implements/`](implements/) | 구현 리포트 — "어떻게 만들었나" | +| [`spec/`](spec/) | 설계·구현 명세. "무엇을 만들 것인가" | +| [`proposals/`](proposals/) | 제안·결정(P 번호)과 미결 사항. "왜 그렇게 정했나" | +| [`implements/`](implements/) | 구현 리포트. "어떻게 만들었나" | | [`troubleshooting/`](troubleshooting/) | 문제 해결 | | [`WORKLOG.md`](WORKLOG.md) | 시간순 작업 로그 | | [`development/`](development/) | Jira→PR 워크플로와 코드 리뷰 운영 규칙 | @@ -25,8 +25,8 @@ FastAPI AI 서버의 **설계·결정·구현 기록**입니다. 공용 계약 1. [architecture.md](spec/architecture.md) — 어디에 무엇이 있는지 (모듈·계층·DB 세션 경계, 구조도) 2. [context-processing.md](spec/context-processing.md) — 주 파이프라인 `POST /internal/v1/context/process` -3. [state-machine.md](spec/state-machine.md) · [deletion-race-control.md](spec/deletion-race-control.md) · [partial-resume.md](spec/partial-resume.md) — 정합성 3종 +3. [state-machine.md](spec/state-machine.md) · [deletion-race-control.md](spec/deletion-race-control.md) · [partial-resume.md](spec/partial-resume.md) — 데이터 정합성을 지키는 세 명세 4. [keyword-preset.md](spec/keyword-preset.md) · [personal-search.md](spec/personal-search.md) · [model-profile.md](spec/model-profile.md) — 기능별 상세 5. [failure-recovery.md](spec/failure-recovery.md) · [integration-tests.md](spec/integration-tests.md) — 운영과 검증 -> 구현 명세와 계약이 어긋나면 계약이 우선하며, 계약 변경은 이 레포가 아니라 docs 레포에서 합니다. +> 구현 명세와 계약이 어긋나면 계약이 우선합니다. 계약 변경은 이 레포가 아니라 docs 레포에서 합니다. diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 1c72d70..2e36d60 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -7,17 +7,17 @@ > - **줄 순서가 보장되지 않습니다.** 시간순으로 읽을 때는 표의 날짜 컬럼을 기준으로 삼습니다. > - **기존 줄을 수정하면 중복이 생길 수 있습니다.** 두 브랜치가 같은 줄을 각각 고치면 두 버전이 나란히 남습니다. 이미 있는 줄을 정정할 때는 병합 결과에 중복이 없는지 확인하세요. 줄을 **추가**하는 평소 작업에서는 신경 쓸 필요가 없습니다. > -> **GitHub은 이 설정을 반영하지 않습니다.** 로컬에서 충돌 없이 병합되는 상황도 PR 화면에서는 여전히 `CONFLICTING`으로 표시됩니다. 따라서 `dev`를 브랜치에 머지해 다시 올리는 절차 자체는 그대로 필요합니다 — 달라지는 것은 **그 머지에서 손으로 충돌을 풀 일이 없어진다는 것**입니다(`back` 실측 `S15P11A705-91`, `ai` 실측 `S15P11A705-158`). +> **GitHub은 이 설정을 반영하지 않습니다.** 로컬에서 충돌 없이 병합되는 상황도 PR 화면에서는 여전히 `CONFLICTING`으로 표시됩니다. 따라서 `dev`를 브랜치에 머지해 다시 올리는 절차 자체는 그대로 필요합니다. 달라지는 것은 **그 머지에서 손으로 충돌을 풀 일이 없어진다는 것**입니다(`back` 실측 `S15P11A705-91`, `ai` 실측 `S15P11A705-158`). | 날짜 | 작업 | 관련 문서 | |---|---|---| -| 2026-08-03 | 단어형 질의로 컷 격자를 다시 훑어 **`τ_abs` 를 질의 길이로 갈랐다**(`S15P11A705-266`, 단어형 0.24 / 문장형 0.30 · `r`=0.60 불변). `-255` 가 「`#87` 은 컷 조정만으로 회복된다」로 판정한 것의 후속이고 **이 티켓이 `ai#87` 을 닫는다.** **`-255` 의 22건으로는 답할 수 없었다** — 그것은 세 이슈의 원인을 가르기 위한 진단 집합이라 무관 통제가 `치과` 하나인데, 컷 값 판단은 「무관 대역이 어디까지 올라오는가」가 전부다. **`-213` 이 정확히 그 함정을 밟은 기록이 있다**(`personal-search.md §6` 이 무관 **1건**으로 「간격 +0.2120, 컷 불필요」를 결론 냈고 15건으로 늘리자 -0.0176 이 되어 뒤집혔다). 무관 통제를 45행으로 늘리자 역전이 **-0.0515 → -0.1687** 로 깊어졌다. **기대 정답을 손으로 짝짓지 않았다** — 단어형은 정답이 여럿이라(`신한`→6건 · `라멘`→2건) 손으로 정하면 판정자 재량이 결과를 만들므로 **본문 문자열 포함으로 계산**했고, 이 기준이 `ai#87` 의 요구(「본문에 있는 말로 검색하면 나와야 한다」)와 정확히 일치한다 — `-255` 가 실측한 「완전 일치는 유사도에 유의미하게 기여하지 않는다」는 **재는 쪽의 기준이 틀렸다가 아니라 임베딩이 그 기준을 못 따라간다**는 뜻이다. 부작용이 이득이었다 — 같은 질의를 소유자 셋에게 던지면 정답 있는 행과 없는 행이 동시에 생겨 **통제가 공짜로 늘었다**(정답 66행 · 교차 96행 · 무관 45행). 통제를 두 층으로 갈랐다 — `cross`(같은 어휘인데 그 소유자엔 없다)는 같은 생활권이라 무르고, 합치면 「무관 통과」가 물러진다. 라벨을 요구하지 않는 대신(207행은 손 라벨이 비현실적이다) 꼬리 제거율을 포기하고 무관 통제를 늘렸다. **계약의 전제를 정정한다 — 「문장형 12건 회귀가 주 리스크」는 틀렸다.** 격자 **240조합 전량**에서 문장형 정답 누락 0 이다(이 티켓이 움직이는 방향이 컷을 **푸는** 쪽이라 구조적이고, 문장형 정답 최솟값 0.3642 는 격자 상단보다 위다). **진짜 손실은 다른 열에 있었다** — 문장형 무관 질의 침묵이 `-213` 의 11/15 에서 0.24 단일값이면 **5/15 로 반토막** 난다. 이 정정이 채택안을 바꿨다 — 손실이 「정답 누락」이 아니라 별도 축이라 **「가르면 그 손실이 0 이 된다」는 셋째 길**이 열렸다. **채택값을 정한 것은 회복률이 아니라 「1위 손실」이다**(측정 도중 추가한 지표) — 0.26 은 회복 68/71(96%)로 충분해 보이지만 **컷 전 1위인 정답 3건을 0건으로 만든다** (`비건`→플랜트 0.2583 **1위**인데 결과 0건 · `스팟`→동교공원 0.2438 1위 · `야경`→언덕위 0.2552 1위). 「1위인데 0건」은 컷이 유사도 순서를 존중하지 않는다는 뜻이고 회복률 96% 뒤에 숨는다. **0.24 는 「컷 전 1위 정답을 하나도 잃지 않는 가장 높은 값」이고 0.25 부터 깨진다**(격자를 0.01 간격으로 좁힌 이유 — 후보가 데이터점 하나 `스팟` 0.2438 에 붙어 `-213` 의 0.02 격자에서는 0.24~0.26 사이가 통째로 안 보인다). **`r` 은 가르지 않았다** — 무관 통과를 **어느 값에서도 한 건도** 못 줄이고(`-213` 이 문장형에서 본 「1위를 언제나 남긴다」가 단어형에서 그대로 재현) 단어형 손실도 `r=0.75` 까지 0 이라 이 교환에 참여하지 않는다. 갈랐다면 **재지 않은 축을 코드가 만드는 셈**이다. `τ(문장)=0.34` 도 기각했다(무관 통과가 1/15 로 더 좋지만 이 티켓은 문장형을 재지 않고 `matrix.json` 을 재사용했을 뿐이라 **남의 측정 위에서 남의 마진 판단을 바꾸지 않는다**). 결과 — 단어형 회복 **61/71 → 71/71** · 1위 손실 **5 → 0** · 정답 누락 11/66 → 2/66, 문장형 **완전 불변**, 대가는 단어형 무관 통과 10/45 → 26/45. GMS 임베딩 **배치 1회**(69건) · 격자 240조합은 GMS·DB 미호출. **실서버 대조 87/87 PASS** — 재구성 쪽에 길이 분기를 **다시 적어**(구현 `import` 금지, `-213` 규칙) 「서버가 분기를 실제로 타는가」가 관측되게 했고, 두 하한에서 **결과가 갈리는 60행**만 골랐다(갈리지 않는 행은 서버가 무엇을 하든 통과한다). **`ai#87` 증상 회복을 실서버로 직접 확인** — 「그네」 0건→6건(동교어린이공원 3위) · 「스팟」 0→2건(1위) · 「비건」 0→1건(1위) · 「산책」 0→4건. **441건 통과**(테스트 함수 11건 추가 — 단위 9 + API 관통 2). **검토 확정 10건 중 A·B·C 를 반영했다** — 배선 고정(#4 major)·킬스위치를 분기 앞으로(#2)·`recall_probe.py` 분기 반영(#3)·경계 5자 고정(#8)·공백을 유니코드 전체로(#1·#5)·끊긴 참조 셋(#9·#10)·수치 정정(#6). **넷을 변이 드릴로 확인했다** — 「테스트를 더했다」와 「그 테스트가 그 결함을 잡는다」는 다른 명제라 실제로 어긋내 보고 대응하는 테스트만 실패하는 것을 봤다. **`recall_probe.py` 가 이 티켓이 고친 증상을 계속 「① 컷이 잘랐다」로 읽고 있었다** — 진단 도구가 닫힌 증상을 미해결로 보고하는 상태였고, 분기를 넣으니 `그네`·`스팟`·`산책`·`라멘` 이 「— 통과」로 바뀌고 `신한`·`부캠` 은 ③ 그대로다. 그 도구의 `--replay` 가 **커밋된 행렬을 판정 결과로 덮어쓰던 것**도 함께 막았다(기본은 쓰지 않고, `--out` 이 입력과 같으면 멈춘다) — 행렬은 GMS 를 불러야 다시 뜨므로 그 손실이 판정보다 무겁다 · line 99.83%/branch 98.99%(`search_service.py` 100%). **길이 분기의 호출부 배선을 API 관통 테스트로 고정했다** — `_cut(rows, query)` 의 `query` 기본값 때문에 **`, query` 가 사라져도 커버리지 100% 인 채로** 모든 단어형이 0.30 으로 돌아가 `ai#87` 이 재발한다(RED 드릴로 확인). 유사도를 두 하한 사이 `[0.24, 0.30)` 에 두는 것이 요점이고 기존 검색 테스트는 질의와 저장 텍스트가 같아 유사도 1.0 이라 이 배선을 못 잡는다 — **커버리지가 안전을 보증하지 못하는 자리다.** **`-228` 의 `T68`(임베딩이 같은 배치 구성으로도 비결정적 · `|Δsim|` 최대 **0.0044**)이 병합된 뒤 재현성을 실측으로 답했다** — 그 잡음이 이 격자 간격(0.01)·채택 마진(0.0038)과 같은 자릿수라 값 자체가 위협받았다. **두 층을 갈라야 한다** — 격자 내부는 임베딩을 한 번 떠서 굳히고 오프라인으로 훑으므로 애초에 노출되지 않고, 노출되는 것은 **채택값의 절대 마진**이다. 같은 질의 집합을 **회차 3개**로 떠서 대조하니(공통 2,346셀) **|Δsim| 상한 0.000209**(T68 의 21배 작다) · 스프레드>0.0044 인 셀 **0개** · **순위 변동 0건** · 경계점 `스팟` 스프레드 **0.000000** · 격자 판정 τ=0.20~0.30 **전 구간 3회 일치**였다. **답: 이 격자에서 인접 값의 차이는 재측정 운이 아니다.** `T68` 의 조언(「τ 를 다시 정할 일이 있으면 격자를 0.01 보다 촘촘하게 두지 않는다」)은 이 티켓이 이미 지켰다. **왜 `T68` 보다 작은지는 단정하지 않는다** — 그쪽은 프리셋 27개 중 **3개만** 갈렸고(나머지는 코사인 1.000000) 이쪽 질의는 전부 짧은 단어라 텍스트 길이가 관련 있어 **보이나** 통제한 측정이 없다. `T68` 의 처방(`base2` 상시 조건)을 그대로 따라 **`word_sweep.py --repro` 를 상시 관측으로 남겼다** — 한 번 재고 문서에만 적으면 다음 사람은 그 조건이 아직 유효한지 알 방법이 없다. **알려진 약점 둘** — 0.24 의 마진 0.0038 은 재측정 잡음(관측 상한의 18배 여유)이 아니라 **경계를 데이터점 하나가 정한다는 것** 자체가 문제이고(재시딩·데이터 증가로 그 점이 움직이면 `스팟` 이 다시 사라진다), **경계 5자는 측정이 아니라 판단**이다(측정한 단어형이 전부 공백 없는 2~5자라 「글자 수」와 「어절 수」를 가를 수 없었고, 두 조건을 **함께** 요구해 애매한 질의를 문장형=더 세게 자름 쪽으로 기울였다). `ai#86`(`신한`)·`ai#88`(`부캠`)은 그대로 — `-255` 판정대로 컷으로 못 푼다(0.24 로 내려도 카츠요가 6위·8위) (S15P11A705-266) | [단어형 컷 리포트](implements/2026-08-03-word-query-cut.md) · [spec/personal-search §6.1](spec/personal-search.md) · [tools/search_cut/](../tools/search_cut/) · [ai#87](https://github.com/Team-PinLog/ai/issues/87) | +| 2026-08-03 | 단어형 질의로 컷 격자를 다시 훑어 **`τ_abs` 를 질의 길이로 갈랐다**(`S15P11A705-266`, 단어형 0.24 / 문장형 0.30 · `r`=0.60 불변). `-255` 가 「`#87` 은 컷 조정만으로 회복된다」로 판정한 것의 후속이고 **이 티켓이 `ai#87` 을 닫는다.** **`-255` 의 22건으로는 답할 수 없었다** — 그것은 세 이슈의 원인을 가르기 위한 진단 집합이라 무관 통제가 `치과` 하나인데, 컷 값 판단은 「무관 대역이 어디까지 올라오는가」가 전부다. **`-213` 이 정확히 같은 오류를 범한 기록이 있다**(`personal-search.md §6` 이 무관 **1건**으로 「간격 +0.2120, 컷 불필요」를 결론 냈고 15건으로 늘리자 -0.0176 이 되어 뒤집혔다). 무관 통제를 45행으로 늘리자 역전이 **-0.0515 → -0.1687** 로 깊어졌다. **기대 정답을 손으로 짝짓지 않았다** — 단어형은 정답이 여럿이라(`신한`→6건 · `라멘`→2건) 손으로 정하면 판정자 재량이 결과를 만들므로 **본문 문자열 포함으로 계산**했고, 이 기준이 `ai#87` 의 요구(「본문에 있는 말로 검색하면 나와야 한다」)와 정확히 일치한다 — `-255` 가 실측한 「완전 일치는 유사도에 유의미하게 기여하지 않는다」는 **재는 쪽의 기준이 틀렸다가 아니라 임베딩이 그 기준을 못 따라간다**는 뜻이다. 부작용이 이득이었다 — 같은 질의를 소유자 셋에게 던지면 정답 있는 행과 없는 행이 동시에 생겨 **통제 표본이 추가 측정 없이 늘었다**(정답 66행 · 교차 96행 · 무관 45행). 통제를 두 층으로 갈랐다. `cross`(같은 어휘인데 그 소유자엔 없다)는 같은 생활권이라 판별력이 약하고, 합치면 「무관 통과」 기준이 약해진다. 라벨을 요구하지 않는 대신(207행은 손 라벨이 비현실적이다) 꼬리 제거율을 포기하고 무관 통제를 늘렸다. **계약의 전제를 정정한다 — 「문장형 12건 회귀가 주 리스크」는 틀렸다.** 격자 **240조합 전량**에서 문장형 정답 누락 0 이다(이 티켓이 움직이는 방향이 컷을 **푸는** 쪽이라 구조적이고, 문장형 정답 최솟값 0.3642 는 격자 상단보다 위다). **진짜 손실은 다른 열에 있었다.** 문장형 무관 질의의 무노출이 `-213` 의 15건 중 11건(11/15)에서 0.24 단일값이면 **15건 중 5건(5/15)으로 절반 아래로 줄어든다.** 이 정정이 채택안을 바꿨다 — 손실이 「정답 누락」이 아니라 별도 축이라 **「가르면 그 손실이 0 이 된다」는 셋째 길**이 열렸다. **채택값을 정한 것은 회복률이 아니라 「1위 손실」이다**(측정 도중 추가한 지표) — 0.26 은 회복 68/71(96%)로 충분해 보이지만 **컷 전 1위인 정답 3건을 0건으로 만든다** (`비건`→플랜트 0.2583 **1위**인데 결과 0건 · `스팟`→동교공원 0.2438 1위 · `야경`→언덕위 0.2552 1위). 「1위인데 0건」은 컷이 유사도 순서를 존중하지 않는다는 뜻이고 회복률 96% 뒤에 숨는다. **0.24 는 「컷 전 1위 정답을 하나도 잃지 않는 가장 높은 값」이고 0.25 부터 깨진다**(격자를 0.01 간격으로 좁힌 이유 — 후보가 데이터점 하나 `스팟` 0.2438 에 붙어 `-213` 의 0.02 격자에서는 0.24~0.26 사이가 통째로 안 보인다). **`r` 은 가르지 않았다** — 무관 통과를 **어느 값에서도 한 건도** 못 줄이고(`-213` 이 문장형에서 본 「1위를 언제나 남긴다」가 단어형에서 그대로 재현) 단어형 손실도 `r=0.75` 까지 0 이라 이 교환에 참여하지 않는다. 갈랐다면 **재지 않은 축을 코드가 만드는 셈**이다. `τ(문장)=0.34` 도 기각했다(무관 통과가 1/15 로 더 좋지만 이 티켓은 문장형을 재지 않고 `matrix.json` 을 재사용했을 뿐이라 **남의 측정 위에서 남의 마진 판단을 바꾸지 않는다**). 결과 — 단어형 회복 **61/71 → 71/71** · 1위 손실 **5 → 0** · 정답 누락 11/66 → 2/66, 문장형 **완전 불변**, 대가는 단어형 무관 통과 10/45 → 26/45. GMS 임베딩 **배치 1회**(69건) · 격자 240조합은 GMS·DB 미호출. **실서버 대조 87/87 PASS** — 재구성 쪽에 길이 분기를 **다시 적어**(구현 `import` 금지, `-213` 규칙) 「서버가 분기를 실제로 타는가」가 관측되게 했고, 두 하한에서 **결과가 갈리는 60행**만 골랐다(갈리지 않는 행은 서버가 무엇을 하든 통과한다). **`ai#87` 증상 회복을 실서버로 직접 확인** — 「그네」 0건→6건(동교어린이공원 3위) · 「스팟」 0→2건(1위) · 「비건」 0→1건(1위) · 「산책」 0→4건. **441건 통과**(테스트 함수 11건 추가 — 단위 9 + API 관통 2). **검토 확정 10건 중 A·B·C 를 반영했다** — 배선 고정(#4 major)·킬스위치를 분기 앞으로(#2)·`recall_probe.py` 분기 반영(#3)·경계 5자 고정(#8)·공백을 유니코드 전체로(#1·#5)·끊긴 참조 셋(#9·#10)·수치 정정(#6). **넷을 변이 드릴로 확인했다** — 「테스트를 더했다」와 「그 테스트가 그 결함을 잡는다」는 다른 명제라 실제로 어긋내 보고 대응하는 테스트만 실패하는 것을 봤다. **`recall_probe.py` 가 이 티켓이 고친 증상을 계속 「① 컷이 잘랐다」로 읽고 있었다** — 진단 도구가 닫힌 증상을 미해결로 보고하는 상태였고, 분기를 넣으니 `그네`·`스팟`·`산책`·`라멘` 이 「— 통과」로 바뀌고 `신한`·`부캠` 은 ③ 그대로다. 그 도구의 `--replay` 가 **커밋된 행렬을 판정 결과로 덮어쓰던 것**도 함께 막았다(기본은 쓰지 않고, `--out` 이 입력과 같으면 멈춘다) — 행렬은 GMS 를 불러야 다시 뜨므로 그 손실이 판정보다 무겁다 · line 99.83%/branch 98.99%(`search_service.py` 100%). **길이 분기의 호출부 배선을 API 관통 테스트로 고정했다** — `_cut(rows, query)` 의 `query` 기본값 때문에 **`, query` 가 사라져도 커버리지 100% 인 채로** 모든 단어형이 0.30 으로 돌아가 `ai#87` 이 재발한다(RED 드릴로 확인). 유사도를 두 하한 사이 `[0.24, 0.30)` 에 두는 것이 요점이고 기존 검색 테스트는 질의와 저장 텍스트가 같아 유사도 1.0 이라 이 배선을 못 잡는다 — **커버리지가 안전을 보증하지 못하는 자리다.** **`-228` 의 `T68`(임베딩이 같은 배치 구성으로도 비결정적 · `|Δsim|` 최대 **0.0044**)이 병합된 뒤 재현성을 실측으로 답했다** — 그 잡음이 이 격자 간격(0.01)·채택 마진(0.0038)과 같은 자릿수라 값 자체가 위협받았다. **두 층을 갈라야 한다** — 격자 내부는 임베딩을 한 번 떠서 굳히고 오프라인으로 훑으므로 애초에 노출되지 않고, 노출되는 것은 **채택값의 절대 마진**이다. 같은 질의 집합을 **회차 3개**로 떠서 대조하니(공통 2,346셀) **|Δsim| 상한 0.000209**(T68 의 21배 작다) · 스프레드>0.0044 인 셀 **0개** · **순위 변동 0건** · 경계점 `스팟` 스프레드 **0.000000** · 격자 판정 τ=0.20~0.30 **전 구간 3회 일치**였다. **답: 이 격자에서 인접 값의 차이는 재측정 운이 아니다.** `T68` 의 조언(「τ 를 다시 정할 일이 있으면 격자를 0.01 보다 촘촘하게 두지 않는다」)은 이 티켓이 이미 지켰다. **왜 `T68` 보다 작은지는 단정하지 않는다** — 그쪽은 프리셋 27개 중 **3개만** 갈렸고(나머지는 코사인 1.000000) 이쪽 질의는 전부 짧은 단어라 텍스트 길이가 관련 있어 **보이나** 통제한 측정이 없다. `T68` 의 처방(`base2` 상시 조건)을 그대로 따라 **`word_sweep.py --repro` 를 상시 관측으로 남겼다** — 한 번 재고 문서에만 적으면 다음 사람은 그 조건이 아직 유효한지 알 방법이 없다. **알려진 약점 둘** — 0.24 의 마진 0.0038 은 재측정 잡음(관측 상한의 18배 여유)이 아니라 **경계를 데이터점 하나가 정한다는 것** 자체가 문제이고(재시딩·데이터 증가로 그 점이 움직이면 `스팟` 이 다시 사라진다), **경계 5자는 측정이 아니라 판단**이다(측정한 단어형이 전부 공백 없는 2~5자라 「글자 수」와 「어절 수」를 가를 수 없었고, 두 조건을 **함께** 요구해 애매한 질의를 문장형=더 세게 자름 쪽으로 기울였다). `ai#86`(`신한`)·`ai#88`(`부캠`)은 그대로 — `-255` 판정대로 컷으로 못 푼다(0.24 로 내려도 카츠요가 6위·8위) (S15P11A705-266) | [단어형 컷 리포트](implements/2026-08-03-word-query-cut.md) · [spec/personal-search §6.1](spec/personal-search.md) · [tools/search_cut/](../tools/search_cut/) · [ai#87](https://github.com/Team-PinLog/ai/issues/87) | | 2026-08-03 | 운영에서 「본문에 그대로 있는 말로 검색해도 안 나온다」가 셋 올라와(`ai#86` 신한 · `#87` 그네 · `#88` 부캠) **원인을 실측으로 갈랐다**(`S15P11A705-255`). **고치지 않았다 — 컷 값도 모델도 코드도 그대로다.** 측정보다 먼저 배제한 것은 티켓이 「넷째일 수 있다」로 남긴 가능성이다 — 검색 Query 가 `embedding_status = 'COMPLETED'` 를 요구하므로 그 Context 가 `FAILED` 로 굳었으면 유사도를 재는 일 자체가 무의미한데, 전량 42/42 `COMPLETED` · 벡터 42건 · `is_deleted` 0 으로 **정상**이었다. **결론은 「셋 다 같은 원인」이 아니다** — `그네` 는 컷이 잘랐고 풀면 3위(**①**), `신한` 은 풀어도 6위 · `부캠` 은 8위(**③**)다. **가른 것은 계약의 최소 비교군에 더한 무관 기준선이다** — 어느 본문에도 없는 2자 `치과` 의 top-1 이 **0.2953** 인데 본문에 그대로 있는 `스팟` 은 **0.2438**, `그네` 0.2671, `신한` 0.2301 로 **셋 다 그보다 낮다.** 즉 **완전 일치는 유사도에 유의미하게 기여하지 않는다** — `#86` 이 「완전 일치인데 안 걸린다」로 가장 심각하다고 본 전제가 실은 이 현상의 한 사례다. 본문을 고정하고 질의 길이만 바꾸자 경계가 깨끗하게 드러났다 — 본문 A·B 모두 **3자 이상은 전부 ≥0.3189, 2자는 전부 ≤0.2818** 이고 `τ_abs=0.30` 이 그 사이에 있다. 다만 「2자면 안 된다」는 **틀렸다** — `우주` 0.4199 · `공원` 0.3670 이 2자로 통과하며, 갈림은 「그 단어가 본문의 주제를 짚는가(우주·공원)와 부수 정보인가(스팟·신한)」로 **보이나 그것을 통제한 측정은 없다**(사후 해석이다). **`#87` 의 BPE 가설은 반증됐다** — 프로파일이 `text-embedding-3-small` 이라 토크나이저가 `cl100k_base` 로 특정되어 실제로 봤고, `그네`[49706, 76242, 97] 와 `그네팟`[49706, 76242, …] 의 **앞 두 토큰이 같다**(`신한`↔`신한 부트캠프`, `부캠`↔`부트캠프` 도 공유). 한국어가 바이트 단위로 잘게 쪼개져 접두 토큰이 그대로 남으므로 「한 덩어리로 묶여 멀어진다」가 성립하지 않는다 — `그네` 는 부분어라서가 아니라 **다른 2자 질의와 같은 대역**이라서 안 된다. 반면 **`#88` 의 진단은 확증됐다** — `부트캠프` 는 카츠요 1위 0.3254 인데 `부캠` 은 8위 0.2105 이고, 그러면서 `부캠` 이 그대로 있는 쿠로코를 1위(0.4102)로 짚는다. **표기는 알고 `부캠→부트캠프` 의미 연결만 못 한다.** 그래서 처방이 셋 다 다르다 — `#86` 은 원리상 임베딩으로 못 풀고(2자 대역이 무관 질의와 **겹침이 아니라 역전**이라 어떤 임계값으로도 못 가른다) 키워드 검색이 맞고, `#88` 은 의미 연결이라 모델 교체(`-199`)의 영역이며, `#87` 은 컷 조정만으로 회복된다. **`신한` 이 카츠요에서만 낮은 기제는 답하지 못했다** — 0건이 아니라 3건이 나오고 셋 다 「신한」 보유라 검색은 「신한」을 알아보는데, 상위 셋은 「신한 **부캠**」이고 카츠요만 「신한 **부트캠프**」다. 표현 일치 효과는 실측했으나(`신한 부캠` 질의에서 카츠요 0.3189 ↔ `신한 부트캠프` 질의에서 0.3793) **왜 「신한 부캠」이 「신한」 단독에 더 가까운지는 단정하지 않는다.** 본문 길이는 원인이 아니다 — 플랜트(30자) 0.3889 와 카츠요(32자) 0.2301 이 같은 길이에서 갈리고 순위 상관도 약하다(단어형 ρ -0.12 · 문장형 +0.07, 17점 표본이라 유의하지 않다). **`-213` 의 한계와 같은 구조이고 더 심하다** — 저쪽은 문장형에서 정답↔무관이 **-0.0176** 겹쳤고 이번은 단어형에서 **-0.0515** 로 역전이다. 「컷 값으로는 더 못 간다」가 강화된다(단어형을 살리려면 `τ_abs` 를 0.24 아래로 내려야 하는데 그 값은 무관 대역 0.2953 을 통째로 통과시킨다). GMS 임베딩 배치 1회 · 코드 변경 없음 · 로컬 시연 DB(`:15432`)만 사용. **실서버 대조는 하지 않았다**(`-213` 의 `verify_live.py` 가 같은 재구성 규칙으로 27/27 을 이미 확인했고 이 티켓은 규칙이 아니라 질의만 바꿨다 — 다만 이 질의 집합으로 실서버를 때린 것은 아니다) (S15P11A705-255) | [재현율 판별 리포트](implements/2026-08-03-search-recall-probe.md) · [tools/search_cut/](../tools/search_cut/) · [ai#86](https://github.com/Team-PinLog/ai/issues/86)·[#87](https://github.com/Team-PinLog/ai/issues/87)·[#88](https://github.com/Team-PinLog/ai/issues/88) | | 2026-08-03 | DB 실패에도 응답 본문이 `embedding upstream ...`를 답하던 것을 층 이름별로 가른다(`S15P11A705-229`). `-221`이 상태 코드(`503`/`502`)와 로그는 정확히 맞혔지만 본문만 사실과 어긋난 채 남겨 뒀고, 그 티켓이 *"핸들러를 바꾸지 않는다"*로 확정하며 본문 개정을 후속으로 미뤘다 — 이 티켓이 그 후속이다. `-221`이 이미 둔 하위 타입(`DatabaseTransientError`/`DatabasePermanentError`)을 `app/main.py`의 기존 두 핸들러 안에서 `isinstance`로 보고 `database unavailable`/`database rejected the request`로 가르되, **원인 값은 여전히 싣지 않는다** — `-220`이 세운 "예외 메시지를 본문에 옮기지 않는다"와 `-205`가 실측으로 확정한 누출 경로 차단 원칙을 재확인만 했다. 새 핸들러를 추가하지 않아 `-220`의 "변환은 예외 핸들러 한 곳에서만" 원칙도 그대로다. **공용 계약 `static/05_AI_설계.md`는 반영하지 않았다** — ①그 문서는 `failure-recovery.md`를 파트별 세부 문서로 참조만 하고 응답 본문 문구 자체를 담고 있지 않으며, ②`back` 레포 `AiSearchClient.translate`를 직접 읽어 `502`/`503` 등 이 티켓이 다루는 상태 코드는 본문을 파싱하지 않고 상태 코드만 보고 버림을 확인했다(`serverProfile` 필드를 보는 `422`만 예외). 검증은 `-220`·`-221`의 원칙을 그대로 지켰다 — Fake 없이 실제 `asyncpg` 풀을 실패시켜 관통하는 계약 테스트에 신규 2건(DB 축 새 문구)과 회귀 2건(GMS 문구 불변)을 더했다. RED로 신규 2건이 여전히 `embedding upstream ...`를 답함을 먼저 확인했고, 핸들러 수정 뒤 GREEN. 전체 **425건 통과**, line 99.83%/branch 98.99%(게이트 80/80 유지). 실서버 대조는 별도로 하지 않았다 — 문구만 바뀌었고 `-221`의 관통 테스트가 이미 실제 DB 실패 경로를 검증하고 있어 새로 확인할 것이 없다고 판단했다 (S15P11A705-229) | [오류 문구 분리 리포트](implements/2026-08-03-error-wording-split.md) · [spec/failure-recovery §2.5 「응답 본문」](spec/failure-recovery.md) | -| 2026-07-31 | 검색 중 DB 실패를 일시 오류로 분류해 **503** 으로 내보낸다(`S15P11A705-221`). `-220` 이 `TransientError→503`·`PermanentError→502` 핸들러를 넣으며 **500 을 「우리 코드의 결함」 전용으로 비워 뒀는데**, DB 실패만 아무도 분류하지 않아 그대로 500 이었다 — 커넥션 풀 고갈이나 DB 재기동처럼 기다리면 낫는 상황이 「코드가 깨졌다」와 같은 코드를 썼고 그만큼 `-220` 의 전제가 깨져 있었다. **명세는 이미 이쪽 편이었다** — `failure-recovery.md` §2.1 이 *"DB 연결 실패, 잠금 타임아웃, 직렬화 실패"* 를 일시 오류로 못박고 있었으므로 이 티켓은 새 정책이 아니라 **명세와 코드의 간극**을 메운 것이다. **가장 값진 발견은 티켓 전제가 어긋났다는 것이다** — 티켓은 *"asyncpg 예외를 분류"* 라고 적었지만 실측하면 **DB 접속 실패는 `asyncpg` 예외가 아니다.** 포트 거부(`ConnectionRefusedError`)·DNS(`socket.gaierror`)·접속 타임아웃·**커넥션 풀 획득 타임아웃**이 전부 stdlib `OSError` 이고, `08xxx` `PostgresConnectionError` 는 *이미 붙어 있던* 연결이 끊길 때의 SQLSTATE 다. 문구대로 asyncpg 만 분류했으면 **테스트는 전부 초록인데** 티켓의 완료 조건만 조용히 거짓으로 남았을 것이다(T53). 같은 이유로 분류를 repository 함수가 아니라 **세션 경계**(`app/core/db.py`)에 걸었다 — 데코레이터 방식은 `pool.acquire()` 가 그 밖이라 접속 실패가 애초에 들어오지 않는다. 경계는 한 줄이다 — **서버·연결의 상태 때문에 실패했으면 일시적, 우리가 보낸 질의 때문이면 우리 결함.** SQLSTATE 군 단위로 긋고 군 안에서 성격이 갈리는 셋(`25xxx`·`55xxx`·`42xxx`)만 잎으로 집었다(Transient `OSError`(접속 단계)·`08`·`40`·`53`·`57`·`58`·`55P03`·`25P03`·`25P04` / Permanent `28`·`3D000`·`42501`). **미분류(500) 목록이 분류 목록만큼 중요하다** — `42xxx` 나머지·`22xxx`·`23xxx`·`XX000` 은 그대로 500 에 남긴다. 넓게 잡으면 버그가 「일시적으로 사용할 수 없습니다」 뒤에 영구히 숨고 재시도해도 안 되는데 알림이 안 울린다. 가장 논쟁적인 것은 **`asyncpg.InterfaceError` 를 분류하지 않은 것**이다 — 한 타입이 `connection is closed`(수명주기)와 `the server expects 2 arguments, 1 was passed`(우리 결함)를 함께 쓰고, 통째로 감싸면 후자가 숨는다. 수명주기 쪽은 정상 경로에서 나오지 않는다(풀에서 갓 받은 커넥션이 죽어 있으면 질의는 `08003` 이고 그쪽은 이미 일시 오류다 — 실측). `OSError` 는 **접속 단계에서만** 번역한다 — 커넥션을 넘겨준 뒤의 블록에는 호출부 코드가 함께 들어와 무관한 `OSError` 가 503 으로 둔갑할 수 있다. **`-220` 의 핸들러를 한 줄도 고치지 않았다** — `DatabaseTransientError(TransientError)` 하위 타입이라 기존 핸들러가 그대로 받고, 덤으로 핸들러 로그의 타입 이름이 「게이트웨이가 아니라 DB」를 말한다. 예외 메시지에는 **타입 이름과 SQLSTATE 만** 담는다 — DSN 에 DB 비밀번호가 있고 접속 실패 예외에 host·port 가 섞이며, `-220` 이 게이트웨이 응답 200자 누출을 발견하고 세운 기준이 그대로 적용된다. SQLSTATE 를 남기는 것은 *"풀이 고갈됐다"* 와 *"DB 가 재기동 중이다"* 를 가르는 유일한 값이라서다(`app.core.db` 로거, 일시 WARN·영구 ERROR). 검증은 `-220` 의 원칙을 지켰다 — **DB 도 Fake 로 바꾸지 않는다.** 닫힌 포트를 가리키는 `min_size=0` 풀·서버가 실제로 죽인 백엔드(`08003`)·실제 `statement_timeout` 취소(`57014`)·실제 `3D000` 으로 냈고(계약 8건), 표 전체는 DB 없이 분류 함수로 고정했다(단위 40건). `min_size=0` 이 필요한 이유가 T54 다 — 운영 `min_size=1` 풀은 접속이 깨지면 **기동**에서 죽어 요청 경로에 닿지 못한다. RED 4건 → 전체 **356건 통과**, line 99.82% branch 98.84%. 실서버 대조는 전용 pgvector 컨테이너를 `docker stop` 해서 냈다(**시연 DB 를 건드리지 않았다**) — `origin/dev` **500 + 트레이스백** ↔ 이 브랜치 **503** `{"detail":"embedding upstream unavailable"}`. **재시도는 넣지 않았다**(DB 재시도는 커넥션 풀 동작과 얽혀 별건). **남은 한계 둘** — 응답 본문이 DB 실패에도 `embedding upstream ...` 을 답하고(상태 코드·로그는 정확하며 `back` 은 본문을 읽지 않는다. 본문 변경은 `-220` 계약의 개정이라 후속), `53100` 디스크 가득 참을 일시적으로 둔 것은 재시도로 낫지 않아 표에서 가장 약한 칸이다 (S15P11A705-221) | [DB 오류 분류 리포트](implements/2026-07-31-db-error-classification.md) · [spec/failure-recovery §2.5](spec/failure-recovery.md) · [T53~T55](troubleshooting/2026-07-31-db-error-pitfalls.md) | -| 2026-07-31 | 판정 프롬프트로 「본문에 근거 없으면 미선택」을 세우려 했으나 **두 개정안 모두 채택하지 않았다**(`S15P11A705-219`). `-210` 이 후보 선정 층(τ)으로는 못 푼다고 끝내며 이 층을 지목했고, 진단은 맞았으나 처방이 따라오지 않는다. B(무엇이 근거가 아닌지를 유형으로 지정)는 오분류 Δ **-0.60행**으로 효과가 없었고, B 가 조항으로 이름 붙여 겨눈 넷(`265 WALK`·`274`/`289 DRINK`·`284 WITH_FAMILY`)이 **5회 중 5회 그대로**였다. 축을 바꾼 C(후보 목록의 출처를 고지하고 「고르지 않는 것이 기본」을 세움)는 5회에서 Δ -2.60 이었으나 **표본을 10회로 늘리자 -1.60 으로 줄었고**(사전 기준인 범위 비중첩도, 보강한 순열검정 p=0.081 도 미달) — 5회에서 멈추고 기준을 낮췄다면 부풀린 값을 채택했을 것이다. **교환비 0.19 는 `-210` 이 τ 에서 기각한 1.22 보다 훨씬 좋지만, 교환비는 「효과가 있다」가 먼저 서야 의미가 있는 값이고 그 앞 질문을 못 넘었다.** 결정적인 것은 셋이다 — ①**사용자가 보는 손실(fit 0건 Context)이 A·B·C 모두 8.00 ± 0.00** 으로 회차 운으로도 안 움직인다, ②**C 가 겨눈 유형 다섯이 오히려 늘고 셋이 줄어** 규칙과 실제 변화 사이에 대응이 없다, ③**오분류 30종 중 10/10 으로 고정된 것은 2종뿐**(그마저 같은 본문 한 쌍이라 실질 하나)이고 나머지 25종은 회차마다 얼굴이 바뀌는데 **흔들리는 fit 은 6종뿐**이다 — 정상 판정은 안정적이고 오분류만 흔들린다. 프롬프트 규칙은 「항상 붙는 것」을 겨누는 도구인데 그 표적이 사실상 하나뿐이라 잔액이 노이즈와 같은 크기로 나온다. τ 가 **분포가 겹쳐서** 못 갈랐다면 프롬프트는 **겨눌 것이 서 있지 않아서** 안 된다. A 회차끼리의 불일치 중앙 11/42(26%)가 `-210` 의 대조군 값과 정확히 일치해 **다른 하네스·다른 회차로 재현**됐다. 측정 설계에서 넷을 막았다 — 벤더 폴백을 끄고 단일 고정(`-175` 에서 벤더별 일치도가 0.53~0.93 으로 갈렸다), `labels.yaml` 원본 불변 + `labels_extra.yaml` 로 커버리지만 확장(재판정은 τ 스윕과 달리 **없던 행을 만든다** — 24종), 라벨링 시 조건 은닉(보고 붙이면 라벨이 결론을 따라간다), 사전 기준을 지우지 않고 자를 **더하기**. **실제 판정 모델은 `gpt-4o-mini` 였다** — `.env` 의 `PINLOG_JUDGE_MODEL=gemini-2.5-flash` 는 `-175` 가 `PINLOG_JUDGE_CHAIN` 으로 대체해 읽히지 않는 키인데 값이 그럴듯해(체인 2순위) `-210` 리포트 §7 의 조건 표가 그것을 읽은 것으로 보인다(T43). 측정 중 `confidence` 가 유사도가 못 한 분리를 보여(하한 0.70 에서 unfit 4 제거·fit 유실 0) 후속으로 올릴 뻔했으나, **같은 조건으로 한 번 더 받으니 분리가 사라졌다** — 판정은 선택뿐 아니라 confidence 도 비결정적이다(T47). 그래서 후속은 프롬프트 문구가 아니라 **판정 분산 자체를 줄이는 쪽**(n회 판정 다수결)으로 제안한다. GMS 판정 1,092회·실패 0·코드 변경 없음 | [판정 프롬프트 리포트](implements/2026-07-31-judge-prompt-rule.md) · [T43~T49](troubleshooting/2026-07-31-judge-prompt-ab.md) · `tools/prompt_ab/` | +| 2026-07-31 | 검색 중 DB 실패를 일시 오류로 분류해 **503** 으로 내보낸다(`S15P11A705-221`). `-220` 이 `TransientError→503`·`PermanentError→502` 핸들러를 넣으며 **500 을 「우리 코드의 결함」 전용으로 비워 뒀는데**, DB 실패만 아무도 분류하지 않아 그대로 500 이었다 — 커넥션 풀 고갈이나 DB 재기동처럼 기다리면 낫는 상황이 「코드가 깨졌다」와 같은 코드를 썼고 그만큼 `-220` 의 전제가 깨져 있었다. **명세는 이미 이 동작을 규정하고 있었다** — `failure-recovery.md` §2.1 이 *"DB 연결 실패, 잠금 타임아웃, 직렬화 실패"* 를 일시 오류로 못박고 있었으므로 이 티켓은 새 정책이 아니라 **명세와 코드의 간극**을 메운 것이다. **가장 값진 발견은 티켓 전제가 어긋났다는 것이다** — 티켓은 *"asyncpg 예외를 분류"* 라고 적었지만 실측하면 **DB 접속 실패는 `asyncpg` 예외가 아니다.** 포트 거부(`ConnectionRefusedError`)·DNS(`socket.gaierror`)·접속 타임아웃·**커넥션 풀 획득 타임아웃**이 전부 stdlib `OSError` 이고, `08xxx` `PostgresConnectionError` 는 *이미 붙어 있던* 연결이 끊길 때의 SQLSTATE 다. 문구대로 asyncpg 만 분류했으면 **테스트는 전부 통과인데** 티켓의 완료 조건만 조용히 거짓으로 남았을 것이다(T53). 같은 이유로 분류를 repository 함수가 아니라 **세션 경계**(`app/core/db.py`)에 걸었다 — 데코레이터 방식은 `pool.acquire()` 가 그 밖이라 접속 실패가 애초에 들어오지 않는다. 경계는 한 줄이다 — **서버·연결의 상태 때문에 실패했으면 일시적, 우리가 보낸 질의 때문이면 우리 결함.** SQLSTATE 군 단위로 긋고 군 안에서 성격이 갈리는 셋(`25xxx`·`55xxx`·`42xxx`)만 잎으로 집었다(Transient `OSError`(접속 단계)·`08`·`40`·`53`·`57`·`58`·`55P03`·`25P03`·`25P04` / Permanent `28`·`3D000`·`42501`). **미분류(500) 목록이 분류 목록만큼 중요하다** — `42xxx` 나머지·`22xxx`·`23xxx`·`XX000` 은 그대로 500 에 남긴다. 넓게 잡으면 버그가 「일시적으로 사용할 수 없습니다」 뒤에 영구히 숨고 재시도해도 안 되는데 알림이 안 울린다. 가장 논쟁적인 것은 **`asyncpg.InterfaceError` 를 분류하지 않은 것**이다 — 한 타입이 `connection is closed`(수명주기)와 `the server expects 2 arguments, 1 was passed`(우리 결함)를 함께 쓰고, 통째로 감싸면 후자가 숨는다. 수명주기 쪽은 정상 경로에서 나오지 않는다(풀에서 갓 받은 커넥션이 죽어 있으면 질의는 `08003` 이고 그쪽은 이미 일시 오류다 — 실측). `OSError` 는 **접속 단계에서만** 번역한다 — 커넥션을 넘겨준 뒤의 블록에는 호출부 코드가 함께 들어와 무관한 `OSError` 가 503 으로 둔갑할 수 있다. **`-220` 의 핸들러를 한 줄도 고치지 않았다** — `DatabaseTransientError(TransientError)` 하위 타입이라 기존 핸들러가 그대로 받고, 덤으로 핸들러 로그의 타입 이름이 「게이트웨이가 아니라 DB」를 말한다. 예외 메시지에는 **타입 이름과 SQLSTATE 만** 담는다 — DSN 에 DB 비밀번호가 있고 접속 실패 예외에 host·port 가 섞이며, `-220` 이 게이트웨이 응답 200자 누출을 발견하고 세운 기준이 그대로 적용된다. SQLSTATE 를 남기는 것은 *"풀이 고갈됐다"* 와 *"DB 가 재기동 중이다"* 를 가르는 유일한 값이라서다(`app.core.db` 로거, 일시 WARN·영구 ERROR). 검증은 `-220` 의 원칙을 지켰다 — **DB 도 Fake 로 바꾸지 않는다.** 닫힌 포트를 가리키는 `min_size=0` 풀·서버가 실제로 죽인 백엔드(`08003`)·실제 `statement_timeout` 취소(`57014`)·실제 `3D000` 으로 냈고(계약 8건), 표 전체는 DB 없이 분류 함수로 고정했다(단위 40건). `min_size=0` 이 필요한 이유가 T54 다 — 운영 `min_size=1` 풀은 접속이 깨지면 **기동**에서 실패해 요청 경로에 닿지 못한다. RED 4건 → 전체 **356건 통과**, line 99.82% branch 98.84%. 실서버 대조는 전용 pgvector 컨테이너를 `docker stop` 해서 냈다(**시연 DB 를 건드리지 않았다**) — `origin/dev` **500 + 트레이스백** ↔ 이 브랜치 **503** `{"detail":"embedding upstream unavailable"}`. **재시도는 넣지 않았다**(DB 재시도는 커넥션 풀 동작과 얽혀 별건). **남은 한계 둘** — 응답 본문이 DB 실패에도 `embedding upstream ...` 을 답하고(상태 코드·로그는 정확하며 `back` 은 본문을 읽지 않는다. 본문 변경은 `-220` 계약의 개정이라 후속), `53100` 디스크 가득 참을 일시적으로 둔 것은 재시도로 낫지 않아 표에서 가장 약한 칸이다 (S15P11A705-221) | [DB 오류 분류 리포트](implements/2026-07-31-db-error-classification.md) · [spec/failure-recovery §2.5](spec/failure-recovery.md) · [T53~T55](troubleshooting/2026-07-31-db-error-pitfalls.md) | +| 2026-07-31 | 판정 프롬프트로 「본문에 근거 없으면 미선택」을 세우려 했으나 **두 개정안 모두 채택하지 않았다**(`S15P11A705-219`). `-210` 이 후보 선정 층(τ)으로는 못 푼다고 끝내며 이 층을 지목했고, 진단은 맞았으나 처방이 따라오지 않는다. B(무엇이 근거가 아닌지를 유형으로 지정)는 오분류 Δ **-0.60행**으로 효과가 없었고, B 가 조항으로 이름 붙여 대상으로 삼은 넷(`265 WALK`·`274`/`289 DRINK`·`284 WITH_FAMILY`)이 **5회 중 5회 그대로**였다. 축을 바꾼 C(후보 목록의 출처를 고지하고 「고르지 않는 것이 기본」을 세움)는 5회에서 Δ -2.60 이었으나 **표본을 10회로 늘리자 -1.60 으로 줄었고**(사전 기준인 범위 비중첩도, 보강한 순열검정 p=0.081 도 미달) — 5회에서 멈추고 기준을 낮췄다면 부풀린 값을 채택했을 것이다. **교환비 0.19 는 `-210` 이 τ 에서 기각한 1.22 보다 훨씬 좋지만, 교환비는 「효과가 있다」가 먼저 서야 의미가 있는 값이고 그 앞 질문을 못 넘었다.** 결정적인 것은 셋이다 — ①**사용자가 보는 손실(fit 0건 Context)이 A·B·C 모두 8.00 ± 0.00** 으로 회차 운으로도 안 움직인다, ②**C 가 대상으로 삼은 유형 다섯이 오히려 늘고 셋이 줄어** 규칙과 실제 변화 사이에 대응이 없다, ③**오분류 30종 중 10/10 으로 고정된 것은 2종뿐**(그마저 같은 본문 한 쌍이라 실질 하나)이고 나머지 25종은 회차마다 구성원이 바뀌는데 **흔들리는 fit 은 6종뿐**이다. 즉 정상 판정은 안정적이고 오분류만 흔들린다. 프롬프트 규칙은 「항상 붙는 것」을 대상으로 삼는 도구인데 그 대상이 사실상 하나뿐이라 잔액이 노이즈와 같은 크기로 나온다. τ 가 **분포가 겹쳐서** 못 갈랐다면, 프롬프트는 **고정된 대상이 없어서** 효과가 없다. A 회차끼리의 불일치 중앙 11/42(26%)가 `-210` 의 대조군 값과 정확히 일치해 **다른 하네스·다른 회차로 재현**됐다. 측정 설계에서 넷을 막았다 — 벤더 폴백을 끄고 단일 고정(`-175` 에서 벤더별 일치도가 0.53~0.93 으로 갈렸다), `labels.yaml` 원본 불변 + `labels_extra.yaml` 로 커버리지만 확장(재판정은 τ 스윕과 달리 **없던 행을 만든다** — 24종), 라벨링 시 조건 은닉(보고 붙이면 라벨이 결론을 따라간다), 사전 기준을 지우지 않고 자를 **더하기**. **실제 판정 모델은 `gpt-4o-mini` 였다** — `.env` 의 `PINLOG_JUDGE_MODEL=gemini-2.5-flash` 는 `-175` 가 `PINLOG_JUDGE_CHAIN` 으로 대체해 읽히지 않는 키인데 값이 그럴듯해(체인 2순위) `-210` 리포트 §7 의 조건 표가 그것을 읽은 것으로 보인다(T43). 측정 중 `confidence` 가 유사도가 못 한 분리를 보여(하한 0.70 에서 unfit 4 제거·fit 유실 0) 후속으로 올릴 뻔했으나, **같은 조건으로 한 번 더 받으니 분리가 사라졌다** — 판정은 선택뿐 아니라 confidence 도 비결정적이다(T47). 그래서 후속은 프롬프트 문구가 아니라 **판정 분산 자체를 줄이는 쪽**(n회 판정 다수결)으로 제안한다. GMS 판정 1,092회·실패 0·코드 변경 없음 | [판정 프롬프트 리포트](implements/2026-07-31-judge-prompt-rule.md) · [T43~T49](troubleshooting/2026-07-31-judge-prompt-ab.md) · `tools/prompt_ab/` | | 2026-07-29 | S15P11A705-154 — protected Environment 기반 3 runtime owner 값 + Infra PR credential handoff를 canonical workflow로 정렬 | [runtime Secret handoff](implements/2026-07-29-sealed-secret-handoff.md) | -| 2026-07-30 | 판정 LLM 이 Gemini 한 경로에 묶여 있던 것을 벤더 어댑터로 풀고 폴백 체인을 넣었다. **429 는 게이트웨이 전역이 아니라 프로바이더 경로별로 걸린다** — `-176` 재측정에서 같은 시각·같은 키로 Gemini 만 92% 였고 OpenAI·Anthropic 은 12/12 였다(임베딩 49회가 안 막힌 것과 같은 그림). 순서는 실측대로 `gpt-4o-mini`(100%·0.91s) → `gemini-2.5-flash`(기준선 유지) → `claude-haiku-4-5`(프로바이더가 셋째라 동시 장애 가능성이 가장 낮다)이고, `gpt-4.1-nano`(일치도 0.53)·`flash-lite`(58%)는 기각, 일치도 1위 `gpt-4.1-mini`(0.93)는 응답 1.37s·최대 4.67s 라 속도를 택했다(그 차이는 판정 비결정성 범위 안). **시도 예산을 체인 길이에 곱하지 않았다** — 벤더마다 3회면 최악 3×3×90s = 810s 로 `§3.2` 상한(PROCESSING 만료 600s)을 넘어 재스캔이 살아 있는 판정을 중복 실행한다. `attempts` 를 판정 1건의 총 시도로 두고 n번째 시도가 n번째 벤더를 쓰게 하니 최악이 270s 로 폴백 이전과 같고, 부수 효과가 오히려 본질이었다 — 막힌 벤더에 백오프를 걸고 다시 던지는 것보다 다른 경로로 즉시 넘어가는 편이 성공 확률이 높다(위 실측). **체인을 하나로 줄이면 시도가 그 벤더로 모여 폴백 이전과 정확히 같아지므로 롤백이 설정 한 줄이다.** `PermanentError`(400·401·403)는 폴백하지 않는다 — 키·설정 문제는 다른 벤더에서도 같은 답이고 넘어가면 GMS 호출만 3배가 된다(`-121` 결함 3 의 재발 형태). 구조화 출력 위반은 폴백 사유에 넣었다(방식이 벤더마다 달라 한쪽이 절단·차단으로 깨져도 다른 쪽은 성공할 수 있다). **프로브 스키마를 그대로 옮기지 않았다** — 프로브는 `keywordId` 하나만 요구하는 축약본이었고 운영은 `confidence`·`unmatchedConcepts` 까지 받아야 하는데, OpenAI strict 는 모든 객체에 `additionalProperties: false` 와 전 property `required` 를 요구해 Gemini 스키마를 재사용하면 400 이고 **400 은 영구 오류라 폴백 없이 판정이 죽는다.** 티켓 범위는 토큰 로그의 `vendor` 였지만 `model_profile` 도 함께 고쳤다 — 폴백이 생긴 순간 설정 1순위와 답한 모델이 갈라지고, 그러면 *"어떤 모델의 판단이었는지 구분"*(`keyword-preset` §5.2)이 거짓이 된다. `PINLOG_JUDGE_MODEL` 은 `PINLOG_JUDGE_CHAIN` 이 대체했고(모델 하나로는 순서를, 벤더 없이는 경로·인증 헤더를 표현할 수 없다) 배포 봉인 대상이 아니어서 영향 범위는 로컬 `.env` 뿐이다. 형식은 config 가, 지원 벤더인지는 어댑터 레지스트리만 알 수 있어 클라이언트 생성이 본다(둘 다 기동 시점). RED 드릴 2회 실측 — 폴백 무력화 6건 실패, `PermanentError` 폴백 허용 14건 실패(신규 3건 + 기존 재시도 계약 11건). `239 passed` · line 99.78% branch 98.46%. **실 GMS 는 부르지 않았다**(MockTransport 로 세 봉투를 만든다). **스모크는 체인 전체를 증명하지 않는다** — 1순위가 429 면 2순위로 넘어가 통과하므로 3순위가 죽어 있는데 모르는 상태가 가능하다(벤더별 확인은 `probe_vendors.py` 수동, 상시 관측은 `-96` 범위). 임베딩 폴백은 제외다 — 프로바이더가 바뀌면 벡터 공간이 달라져 기존 데이터와 비교가 불가능하고, `embedding_profile` 이 바로 그것을 막는 장치다 (S15P11A705-175) | [벤더 폴백 리포트](implements/2026-07-30-judge-vendor-fallback.md) · [spec/failure-recovery §3.4](spec/failure-recovery.md) · [tests/README](../tests/README.md) | +| 2026-07-30 | 판정 LLM 이 Gemini 한 경로에 묶여 있던 것을 벤더 어댑터로 풀고 폴백 체인을 넣었다. **429 는 게이트웨이 전역이 아니라 프로바이더 경로별로 걸린다** — `-176` 재측정에서 같은 시각·같은 키로 Gemini 만 92% 였고 OpenAI·Anthropic 은 12/12 였다(임베딩 49회가 안 막힌 것과 같은 그림). 순서는 실측대로 `gpt-4o-mini`(100%·0.91s) → `gemini-2.5-flash`(기준선 유지) → `claude-haiku-4-5`(프로바이더가 셋째라 동시 장애 가능성이 가장 낮다)이고, `gpt-4.1-nano`(일치도 0.53)·`flash-lite`(58%)는 기각, 일치도 1위 `gpt-4.1-mini`(0.93)는 응답 1.37s·최대 4.67s 라 속도를 택했다(그 차이는 판정 비결정성 범위 안). **시도 예산을 체인 길이에 곱하지 않았다** — 벤더마다 3회면 최악 3×3×90s = 810s 로 `§3.2` 상한(PROCESSING 만료 600s)을 넘어 재스캔이 살아 있는 판정을 중복 실행한다. `attempts` 를 판정 1건의 총 시도로 두고 n번째 시도가 n번째 벤더를 쓰게 하니 최악이 270s 로 폴백 이전과 같고, 부수 효과가 오히려 본질이었다 — 막힌 벤더에 백오프를 걸고 다시 던지는 것보다 다른 경로로 즉시 넘어가는 편이 성공 확률이 높다(위 실측). **체인을 하나로 줄이면 시도가 그 벤더로 모여 폴백 이전과 정확히 같아지므로 롤백이 설정 한 줄이다.** `PermanentError`(400·401·403)는 폴백하지 않는다 — 키·설정 문제는 다른 벤더에서도 같은 답이고 넘어가면 GMS 호출만 3배가 된다(`-121` 결함 3 의 재발 형태). 구조화 출력 위반은 폴백 사유에 넣었다(방식이 벤더마다 달라 한쪽이 절단·차단으로 깨져도 다른 쪽은 성공할 수 있다). **프로브 스키마를 그대로 옮기지 않았다** — 프로브는 `keywordId` 하나만 요구하는 축약본이었고 운영은 `confidence`·`unmatchedConcepts` 까지 받아야 하는데, OpenAI strict 는 모든 객체에 `additionalProperties: false` 와 전 property `required` 를 요구해 Gemini 스키마를 재사용하면 400 이고 **400 은 영구 오류라 폴백 없이 판정이 실패로 끝난다.** 티켓 범위는 토큰 로그의 `vendor` 였지만 `model_profile` 도 함께 고쳤다 — 폴백이 생긴 순간 설정 1순위와 답한 모델이 갈라지고, 그러면 *"어떤 모델의 판단이었는지 구분"*(`keyword-preset` §5.2)이 거짓이 된다. `PINLOG_JUDGE_MODEL` 은 `PINLOG_JUDGE_CHAIN` 이 대체했고(모델 하나로는 순서를, 벤더 없이는 경로·인증 헤더를 표현할 수 없다) 배포 봉인 대상이 아니어서 영향 범위는 로컬 `.env` 뿐이다. 형식은 config 가, 지원 벤더인지는 어댑터 레지스트리만 알 수 있어 클라이언트 생성이 본다(둘 다 기동 시점). RED 드릴 2회 실측 — 폴백 무력화 6건 실패, `PermanentError` 폴백 허용 14건 실패(신규 3건 + 기존 재시도 계약 11건). `239 passed` · line 99.78% branch 98.46%. **실 GMS 는 부르지 않았다**(MockTransport 로 세 봉투를 만든다). **스모크는 체인 전체를 증명하지 않는다.** 1순위가 429 면 2순위로 넘어가 통과하므로, 3순위가 동작 불능인데 모르는 상태가 가능하다(벤더별 확인은 `probe_vendors.py` 수동, 상시 관측은 `-96` 범위). 임베딩 폴백은 제외다 — 프로바이더가 바뀌면 벡터 공간이 달라져 기존 데이터와 비교가 불가능하고, `embedding_profile` 이 바로 그것을 막는 장치다 (S15P11A705-175) | [벤더 폴백 리포트](implements/2026-07-30-judge-vendor-fallback.md) · [spec/failure-recovery §3.4](spec/failure-recovery.md) · [tests/README](../tests/README.md) | | 2026-07-23 | AI 공용 설계를 docs `static/05` 단일 원본으로 확립 (docs#2) | [proposals](proposals/README.md) (P16) | | 2026-07-23 | FastAPI AI 서버 구현 명세 작성 + version→deletion race 리네임 (ai#1) | [spec/](spec/) | | 2026-07-23 | Keyword Preset seed 27개 (ai#2) | [implements](implements/2026-07-23-keyword-preset-seed.md), [spec/keyword-preset.md](spec/keyword-preset.md) | @@ -49,31 +49,31 @@ | 2026-07-28 | AI 레포 협업 운영 기준과 Jira→PR 리뷰 절차 수립, `app` branch coverage 비차단 측정 도입 (S15P11A705-108) | [CONTRIBUTING](../CONTRIBUTING.md), [P44](proposals/P44-ai-repository-governance.md), [development](development/) | | 2026-07-29 | dev 배포 게이트 3종(I23) — `GET /ready`(DB `SELECT 1` + Preset ≥1, GMS 미호출) · `GMS_BASE_URL` `/gmsapi/` 기동 fail-fast(값 미노출 위해 `SettingsError`) · `app.smoke.gms_roundtrip` 양방향 실호출, pytest 52→66 (ai#33 ← [ai#32](https://github.com/Team-PinLog/ai/pull/32) 인프라 요청) | [implements](implements/2026-07-29-dev-deployment-gates.md), [README](../README.md) | | 2026-07-29 | AI 소유 값의 클러스터 전달 경로를 만들었다(I24). GitHub Actions Secret 은 Pod 에 자동 전달되지 않으므로 `kubeseal --raw` 로 값 7종을 개별 봉인해 `encryptedData` 만 artifact 로 넘긴다 — `kubectl create secret --dry-run` 경로는 중간에 평문 base64 YAML 을 만들어 쓰지 않았다. **EMBEDDING 넷은 비밀이 아니지만**(정본 P32) Infra 가 주입 경로를 하나로 요구해 같은 경로로 다룬다. 앱의 기동 검사(`/gmsapi/` 형식·profile 정합)를 봉인 시점으로 앞당겨 배포 전에 실패시키고, controller 인증서는 SHA-256 지문으로 고정했다 — 엉뚱한 공개키로 봉인하면 복호화 실패가 배포 시점에야 드러난다. **실제 봉인은 미실행**(Actions Secret 등록 후 가능) (S15P11A705-96) | [handoff 리포트](implements/2026-07-29-sealed-secret-handoff.md), [ai#32](https://github.com/Team-PinLog/ai/pull/32) | -| 2026-07-29 | 봉인 workflow 의 kubeseal 설치가 체크섬 검증에서 죽던 것을 고쳤다. `curl -o kubeseal.tar.gz` 로 받아 놓고 manifest 는 `kubeseal-0.27.1-linux-amd64.tar.gz` 를 가리켜, `sha256sum -c` 가 manifest 에 적힌 이름으로 파일을 열다 실패했다 (`No such file or directory` / `FAILED open or read`). 다운로드·검증·해제가 같은 변수를 쓰도록 파일명을 하나로 묶었다. **Infra 가 실제로 실행해서 발견했다** — 병합 전 검증이 YAML 파싱과 `bash -n` 까지였고 그 둘은 이 결함을 잡지 못한다. 이번엔 실제 다운로드·검증·해제를 로컬에서 돌려 통과를 확인했고, 원 버전이 같은 오류로 죽는 것도 재현했다 (S15P11A705-96) | [handoff 리포트](implements/2026-07-29-sealed-secret-handoff.md), [ai#32](https://github.com/Team-PinLog/ai/pull/32) | -| 2026-07-29 | 발표 시연 데이터를 **back API 경로로** 만드는 시딩 도구 `tools/demo_seed/`(I25)와 E2E 재확인. **SQL 직접 INSERT를 택하지 않았다** — 데이터는 있는데 파이프라인은 안 돈 상태를 만들고, 그러면 "새 Context를 지금 추가하면 Keyword가 붙는다"를 발표 당일에 처음 실행하게 된다. API로 만들면 `-102`의 PENDING 생성과 `/context/process` 호출이 매 건 타므로 **시딩이 곧 통합 검증**이다. SQL은 API가 없는 두 곳(OAuth로만 생기는 `core.member`, soft delete뿐이라 불가능한 `--reset` hard delete)에만 쓰고 `demo-seed` provider 표식으로 범위를 좁혔다. **GMS 29회**(Context 14 × 2 + 프리셋 1)로 잡은 근거는 비용이 아니라 **429** — 판정이 분당 약 2건만 통과한다(실측). 429는 `PROCESSING` 잔류 + `PROCESSING_EXPIRY_SEC` 600초 때문에 Context 하나를 10분 얼리므로, 시딩이 `PENDING`으로 되돌려 회수하는 루프를 넣었다(로컬에 없는 재스캔 대행). `--reset` 2회 완주로 재현 확인, 시연 3종(검색 4질의 전원 1위 · 피드 8장 소유자 4명 · Keyword 32행) 전부 통과. `tools/e2e/` 4종 중 2종 정상(검색 수치가 I21과 소수점 넷째 자리까지 동일 — **임베딩은 결정적**), `run_equivalence`는 429로 중단(회귀 아님). **back 소관 발견 2건**(탐색 슬롯이 최고점 후보를 흡수해 팔로우 소유자가 하단 배치 · Record 상세 `keywords` 고정 빈 배열)은 back 코드를 고치지 않고 계약 질문으로 올린다 (S15P11A705-58) | [데모 시딩 리포트](implements/2026-07-29-demo-seeding.md), [tools/demo_seed/](../tools/demo_seed/) | +| 2026-07-29 | 봉인 workflow 의 kubeseal 설치가 체크섬 검증에서 실패하던 것을 고쳤다. `curl -o kubeseal.tar.gz` 로 받아 놓고 manifest 는 `kubeseal-0.27.1-linux-amd64.tar.gz` 를 가리켜, `sha256sum -c` 가 manifest 에 적힌 이름으로 파일을 열다 실패했다 (`No such file or directory` / `FAILED open or read`). 다운로드·검증·해제가 같은 변수를 쓰도록 파일명을 하나로 묶었다. **Infra 가 실제로 실행해서 발견했다** — 병합 전 검증이 YAML 파싱과 `bash -n` 까지였고 그 둘은 이 결함을 잡지 못한다. 이번엔 실제 다운로드·검증·해제를 로컬에서 돌려 통과를 확인했고, 원 버전이 같은 오류로 실패하는 것도 재현했다 (S15P11A705-96) | [handoff 리포트](implements/2026-07-29-sealed-secret-handoff.md), [ai#32](https://github.com/Team-PinLog/ai/pull/32) | +| 2026-07-29 | 발표 시연 데이터를 **back API 경로로** 만드는 시딩 도구 `tools/demo_seed/`(I25)와 E2E 재확인. **SQL 직접 INSERT를 택하지 않았다** — 데이터는 있는데 파이프라인은 안 돈 상태를 만들고, 그러면 "새 Context를 지금 추가하면 Keyword가 붙는다"를 발표 당일에 처음 실행하게 된다. API로 만들면 `-102`의 PENDING 생성과 `/context/process` 호출이 매 건 타므로 **시딩이 곧 통합 검증**이다. SQL은 API가 없는 두 곳(OAuth로만 생기는 `core.member`, soft delete뿐이라 불가능한 `--reset` hard delete)에만 쓰고 `demo-seed` provider 표식으로 범위를 좁혔다. **GMS 29회**(Context 14 × 2 + 프리셋 1)로 잡은 근거는 비용이 아니라 **429** — 판정이 분당 약 2건만 통과한다(실측). 429는 `PROCESSING` 잔류 + `PROCESSING_EXPIRY_SEC` 600초 때문에 Context 하나를 10분 동안 처리 불가로 만들므로, 시딩이 `PENDING`으로 되돌려 회수하는 루프를 넣었다(로컬에 없는 재스캔 대행). `--reset` 2회 완주로 재현 확인, 시연 3종(검색 4질의 전원 1위 · 피드 8장 소유자 4명 · Keyword 32행) 전부 통과. `tools/e2e/` 4종 중 2종 정상(검색 수치가 I21과 소수점 넷째 자리까지 동일 — **임베딩은 결정적**), `run_equivalence`는 429로 중단(회귀 아님). **back 소관 발견 2건**(탐색 슬롯이 최고점 후보를 흡수해 팔로우 소유자가 하단 배치 · Record 상세 `keywords` 고정 빈 배열)은 back 코드를 고치지 않고 계약 질문으로 올린다 (S15P11A705-58) | [데모 시딩 리포트](implements/2026-07-29-demo-seeding.md), [tools/demo_seed/](../tools/demo_seed/) | | 2026-07-29 | 설정값을 비밀/공개로 가르고 **공개 값의 정본을 코드로 옮겼다**(P45). Embedding Profile 넷만 기본값이 없어 배포 설정에만 존재했고, 그 결과 **Profile 교체가 git 이력도 리뷰도 남기지 않았다** — 기존 임베딩을 전부 조회 대상에서 빼는 결정인데 콘솔 편집 한 번으로 가능했다. 값 자체는 공용 계약 `05 §7.1` 표에 공개돼 있어 숨겨진 적도 없다. 주입을 **덮어쓰기**로 낮추면 원 논거(*"배포 설정 누락이 조용한 불일치가 된다"*)의 전제인 '누락'이 사라지고, 불일치 탐지는 기동 시 `_profile_consistency` 와 런타임 §3.1 이 그대로 맡는다. `config/*.yaml` 계층 도입은 설정 소스가 셋이 되어 기각했다. **공용 계약 개정(docs#27)이 선행이다** — 처음에 하위 명세만 고쳤다가 `CONTRIBUTING` 의 *"공용 계약과 충돌하면 소유 파트와 합의한 뒤 양쪽 문서를 갱신"* 을 어긴 것을 발견하고 되돌렸다. 봉인 대상도 7종 → 3종으로 줄었다(정본이 이미지에 있으면 주입할 것이 없다) (S15P11A705-96) | [P45](proposals/P45-public-config-in-code.md), [model-profile](spec/model-profile.md), docs#27 | | 2026-07-29 | 테스트 pgvector 를 운영·back 실제 버전에 digest 까지 정합화했다. `conftest.py` 가 `0.8.1-pg16` 인 채로 남아 **테스트가 운영과 다른 DB 에서 돌고 있었다** — back#31 이 `compose.yaml` 을 `0.8.5-pg16@sha256:1d53…` 로 올렸고 운영 pgvector 도 이미 0.8.5(infra#41)였으므로 어긋난 쪽은 ai 였다. digest 를 `back/compose.yaml` 현행에서 재확인해 티켓 본문 값(2026-07-28 관측)과 동일함을 확인하고 고정했다. **태그만 올리지 않은 이유**는 롤링·재태깅 시 같은 태그가 다른 이미지를 가리키기 때문이다. 실측으로 `0.8.1-pg16` → pgvector ext 0.8.1·PostgreSQL 16.12, `0.8.5-pg16@digest` → 0.8.5·16.14 로 **두 층이 함께 움직였음**을 확인했고 69 테스트 전부 통과해 동작 변화는 드러나지 않았다. 불일치 경고가 남아 있던 `tests/README`·implements 근거·P41·P43·s1-recovery 미결 행도 실제와 맞췄다. **CI 러너에서 digest pull 이 network policy 상 통과하는지는 로컬에서 확인 불가 — PR CI 가 첫 검증이다** (S15P11A705-122) | [tests/README](../tests/README.md), [implements](implements/2026-07-24-e3-test-harness.md), [P43](proposals/P43-s1-judgment-recovery.md) | | 2026-07-30 | infra 공용 action pin 을 병합 후 정본으로 옮기고, 보존 규칙 위반을 복원하고, `dev` 2단 브랜치를 준비했다. **pin `84458bf3` 은 `infra#82` 병합 전 브랜치 커밋이라 `infra` `main` 과 diverged 였다**(ahead 6·behind 6, `action.yml` 내용도 달랐다) — `16bfae0d` 로 옮기기 전에 대상 SHA 의 입력 계약(`policy`·`revision`)과 출력이 byte 단위로 같은지 직접 대조했고, 유일한 차이는 back OAuth `CLIENT_ID` 3종의 `unset` 방어 추가였다. workflow 와 contract test 의 `ACTION` 상수는 문자열이 정확히 일치해야 step 을 찾으므로 **한쪽만 바꾸면 `StopIteration` 으로 깨진다**(실측 RED 2건). 또 `ai#39` 가 handoff 리포트를 86줄→42줄로 덮어써 `implements` 보존 원칙(*"삭제하지 않고 상태 표시만 갱신"*)을 위반한 것을 복원했다 — **현재 내용을 지우지 않고** 상태 노트를 상단에, 인프라와 합의한 설계 근거 4건(`--raw` 선택·중간 평문 YAML 회피·strict scope·기동 검증)을 하단 보존 절로 얹었다(+101/−1, 유일한 삭제는 갱신된 SHA 줄). `dev` 는 트리거만 넓히고 **image publish 는 `main` 전용으로 남겼다** — `infra` 의 `ai-image-update.yaml` 이 source branch 를 `main` 으로 어서션하므로 넓히면 GitOps 반영이 거부된다 (S15P11A705-154) | [handoff 리포트](implements/2026-07-29-sealed-secret-handoff.md), [CONTRIBUTING](../CONTRIBUTING.md), [ai#40](https://github.com/Team-PinLog/ai/pull/40) | -| 2026-07-30 | BD-39 가 *"두 값이 같다"* 로 명시한 명제를 사람의 눈에서 CI 로 옮겼다. Embedding Profile 정본을 `back` 의 `application.yml` 리터럴로 두는 (a)안은 그대로 두고 **대조 장치만** 붙인다 — `back#98` 리뷰가 "그 명제를 지키는 장치가 현재 사람의 눈뿐"이라고 지적한 것에 대한 값싼 응답이다. 두 레포가 모두 public 이라 raw endpoint 를 **무인증**으로 읽는다. 토큰을 쓰지 않은 것은 편의가 아니라 경계다 — 주면 `ai` CI 가 타 레포 접근 권한을 상시로 들고 다닌다(대신 `back` 이 private 이 되면 조회 실패로 exit 1 이며 조용히 통과하지 않는다). **런타임 값이 아니라 선언된 리터럴을 비교한다** — 양쪽 다 환경변수 덮어쓰기를 허용하므로 프로세스 환경이 결과를 바꾸면 CI 가 무엇을 검증하는지 알 수 없게 된다. `ai` 쪽은 `Settings()` 를 인스턴스화하지 않고 필드 선언만 읽는다(인스턴스화는 환경변수와 profile 정합 검증을 함께 돌린다). 조회 실패도 exit 1 로 두었다. 실제 두 값이 같은 동안 이 잡은 늘 초록이라 대조기의 탐지 능력 자체는 검증되지 않은 채 남으므로, 네트워크를 타지 않는 14 케이스로 그것을 못박고 **CI 에서 실제 RED 를 관측했다**(run `30507610160` — 리터럴을 `v1`→`v2` 로만 어긋내 `config.py` 의 profile 정합 검증은 통과시킨 채 새 잡만 붉게 만들었다). 리뷰가 함께 제안한 **"기동 시 FastAPI 조회 대조"는 채택하지 않았다** — `app/api/probe.py` 가 *"credential·endpoint·profile 값을 어떤 분기에서도 싣지 않는다"* 로 비노출을 명시하므로 Profile 노출 엔드포인트 신설은 그 결정의 개정이 되고, 이 티켓 범위 밖이다. BD-39 문서 자체도 개정하지 않았다. `ai#40` 줄 누락도 함께 메웠다 (S15P11A705-156) | [ai#40](https://github.com/Team-PinLog/ai/pull/40), [back#98 리뷰](https://github.com/Team-PinLog/back/pull/98), [probe.py](../app/api/probe.py) | -| 2026-07-30 | 두 클라이언트가 HTTP 상태 코드를 **정반대로** 분류하던 것을 명세에 맞췄다. `embedding` 은 `>= 500` 만 Transient 로 보아 **`429` 한 번에 Context 가 영구 실패**했고(`failure-recovery` §2.1 은 Transient), `llm` 은 **모든** non-200 을 Transient 로 보아 **인증 실패가 재스캔 주기 5분마다 GMS 호출을 만들었다**(§2.2 는 `400`·`401`·`403` 을 Permanent). 원인은 두 클라이언트가 각자 상태 코드 표를 들고 있었던 것이라, 매핑을 `errors.py` 의 `classify_http_status` 한 곳으로 모았다 — §2 가 *"`errors.py` 에서 분류한다"* 로 지목한 파일이다. `429` 를 `>= 500` **보다 먼저** 판정해야 4xx 로 떨어지지 않으므로 그 순서를 회귀 테스트로 못박았다. 재시도(§3.1, 총 3회·`0.5→1.0s`·상한 `4.0s`·full jitter)는 `client/retry.py` 에 넣고 **재시도 대상을 `TransientError` 한 종류로 뒀다** — §3.1 의 대상 목록이 §2.1 의 Transient 집합과 같으므로 재시도용 상태 코드 표를 두 번째로 만들지 않았다. **표가 둘이면 갈라지고, 갈라진 결과가 위 두 오분류였다.** 백오프 값을 `Settings` 로 열지 않은 것은 §3.2 의 상한(*"두 호출의 타임아웃 합 + 재시도 시간 < PROCESSING 만료 600s"*)에 묶인 값이기 때문이다 — env 로 열면 그 상한이 배포마다 달라진다. 현재 최악값 `3 × (60 + 90) + 2 × 1.5 = 453s` 이고 **부등식 자체를 테스트가 지킨다**. 대신 `RetryPolicy` 를 생성자 인자로 받아 테스트가 `sleep`·`jitter` 를 주입한다(실제로 잠드는 테스트를 만들지 않는다). 구조화 출력 위반은 `SchemaViolationError(TransientError)` 로 두어 재시도 중엔 Transient 로 동작하고 소진 시 `judge` 가 `PermanentError` 로 **승격**한다 — LLM 출력은 비결정론적이라 재요청에 성공 여지가 있고, 승격을 client 안에서 끝내야 service 가 보는 분류가 §2 의 두 종류로 유지된다(하위 타입인 채 새면 `except TransientError` 가 먼저 잡아 무한 재판정이 되므로 그것도 테스트로 막았다). Embedding 응답 형식 위반은 프로바이더가 같은 형식으로 답하므로 재시도 없이 즉시 Permanent 다. **결함의 근본 원인은 테스트였다** — `fakes.py` 의 `raise_exc` 가 어느 테스트에서도 쓰이지 않아(grep 0건) Transient/Permanent 파이프라인 경로가 한 번도 실행된 적이 없었다. 그 경로를 실제로 주입하자 **티켓에 없던 결함이 하나 더 드러났다**: `keyword_service` 에 `PermanentError` 핸들러가 아예 없어(`context_processing` 광범위 except 없음, `context.py` 는 `BackgroundTasks.add_task`) `llm_client` 만 고치면 401 이 **BackgroundTasks 까지 새어 트레이스백만 남기고 단계는 PROCESSING 에 머문다** — 고치려던 무한 재시도가 경로만 바꿔 남는 구조였다(되돌려 RED 확인). `embedding_service` 는 이 결선을 이미 갖고 있어 비대칭이던 쪽만 맞췄고 **상태 전이 규칙은 바꾸지 않았다**. 로그 레벨도 §2.1 WARN·§2.2 ERROR 로 맞췄다(둘 다 INFO/WARNING 이어서 일시 장애가 묻히고 배포 설정 문제가 알림에 오르지 않았다). client HTTP 계층 테스트는 신설했고 이는 `integration-tests` §4.2(*"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake"*)와 **충돌이 아니다** — §4.2 는 *파이프라인이 client 를 무엇으로 대체하는가* 의 규칙이고, 인터페이스 Fake 는 client 를 통째로 대체하므로 `_embed_batch` 가 429 를 어떻게 분류하는지 볼 수 없다. **`docs/spec/` 은 고치지 않았다**(§4.2 의 계층 구분 명문화는 후속 별건). `132 passed`(착수 baseline 74), Docker 29.6.1 Testcontainers 전량. **Circuit Breaker 와 타임아웃 60/90 재산정은 티켓 제외 범위** — 전자는 인증 실패 같은 전 서비스 영향 오류가 개별 Context FAILED 로 누적되는 문제를 남기고, 후자는 재시도 도입으로 최악값이 150s → 453s 가 된 것과 함께 별건이다 (S15P11A705-121) | [구현 리포트](implements/2026-07-30-retry-and-error-classification.md), [spec/failure-recovery.md](spec/failure-recovery.md), [tests/README](../tests/README.md), [ai#44](https://github.com/Team-PinLog/ai/pull/44) | +| 2026-07-30 | BD-39 가 *"두 값이 같다"* 로 명시한 명제를 사람의 눈에서 CI 로 옮겼다. Embedding Profile 정본을 `back` 의 `application.yml` 리터럴로 두는 (a)안은 그대로 두고 **대조 장치만** 붙인다 — `back#98` 리뷰가 "그 명제를 지키는 장치가 현재 사람의 눈뿐"이라고 지적한 것에 대한 값싼 응답이다. 두 레포가 모두 public 이라 raw endpoint 를 **무인증**으로 읽는다. 토큰을 쓰지 않은 것은 편의가 아니라 경계다 — 주면 `ai` CI 가 타 레포 접근 권한을 상시로 들고 다닌다(대신 `back` 이 private 이 되면 조회 실패로 exit 1 이며 조용히 통과하지 않는다). **런타임 값이 아니라 선언된 리터럴을 비교한다** — 양쪽 다 환경변수 덮어쓰기를 허용하므로 프로세스 환경이 결과를 바꾸면 CI 가 무엇을 검증하는지 알 수 없게 된다. `ai` 쪽은 `Settings()` 를 인스턴스화하지 않고 필드 선언만 읽는다(인스턴스화는 환경변수와 profile 정합 검증을 함께 돌린다). 조회 실패도 exit 1 로 두었다. 실제 두 값이 같은 동안 이 잡은 늘 통과라 대조기의 탐지 능력 자체는 검증되지 않은 채 남으므로, 네트워크를 타지 않는 14 케이스로 그것을 못박고 **CI 에서 실제 RED 를 관측했다**(run `30507610160` — 리터럴을 `v1`→`v2` 로만 어긋내 `config.py` 의 profile 정합 검증은 통과시킨 채 새 잡만 실패하게 만들었다). 리뷰가 함께 제안한 **"기동 시 FastAPI 조회 대조"는 채택하지 않았다** — `app/api/probe.py` 가 *"credential·endpoint·profile 값을 어떤 분기에서도 싣지 않는다"* 로 비노출을 명시하므로 Profile 노출 엔드포인트 신설은 그 결정의 개정이 되고, 이 티켓 범위 밖이다. BD-39 문서 자체도 개정하지 않았다. `ai#40` 줄 누락도 함께 메웠다 (S15P11A705-156) | [ai#40](https://github.com/Team-PinLog/ai/pull/40), [back#98 리뷰](https://github.com/Team-PinLog/back/pull/98), [probe.py](../app/api/probe.py) | +| 2026-07-30 | 두 클라이언트가 HTTP 상태 코드를 **정반대로** 분류하던 것을 명세에 맞췄다. `embedding` 은 `>= 500` 만 Transient 로 보아 **`429` 한 번에 Context 가 영구 실패**했고(`failure-recovery` §2.1 은 Transient), `llm` 은 **모든** non-200 을 Transient 로 보아 **인증 실패가 재스캔 주기 5분마다 GMS 호출을 만들었다**(§2.2 는 `400`·`401`·`403` 을 Permanent). 원인은 두 클라이언트가 각자 상태 코드 표를 들고 있었던 것이라, 매핑을 `errors.py` 의 `classify_http_status` 한 곳으로 모았다. §2 가 *"`errors.py` 에서 분류한다"* 로 지목한 파일이다. `429` 를 `>= 500` **보다 먼저** 판정해야 4xx 로 떨어지지 않으므로 그 순서를 회귀 테스트로 못박았다. 재시도(§3.1, 총 3회·`0.5→1.0s`·상한 `4.0s`·full jitter)는 `client/retry.py` 에 넣고 **재시도 대상을 `TransientError` 한 종류로 뒀다** — §3.1 의 대상 목록이 §2.1 의 Transient 집합과 같으므로 재시도용 상태 코드 표를 두 번째로 만들지 않았다. **상태 코드 표가 두 곳에 있으면 서로 어긋나게 되고, 어긋난 결과가 위 두 오분류였다.** 백오프 값을 `Settings` 로 열지 않은 것은 §3.2 의 상한(*"두 호출의 타임아웃 합 + 재시도 시간 < PROCESSING 만료 600s"*)에 묶인 값이기 때문이다 — env 로 열면 그 상한이 배포마다 달라진다. 현재 최악값 `3 × (60 + 90) + 2 × 1.5 = 453s` 이고 **부등식 자체를 테스트가 지킨다**. 대신 `RetryPolicy` 를 생성자 인자로 받아 테스트가 `sleep`·`jitter` 를 주입한다(실제로 잠드는 테스트를 만들지 않는다). 구조화 출력 위반은 `SchemaViolationError(TransientError)` 로 두어 재시도 중엔 Transient 로 동작하고 소진 시 `judge` 가 `PermanentError` 로 **승격**한다 — LLM 출력은 비결정론적이라 재요청에 성공 여지가 있고, 승격을 client 안에서 끝내야 service 가 보는 분류가 §2 의 두 종류로 유지된다(하위 타입인 채 새면 `except TransientError` 가 먼저 잡아 무한 재판정이 되므로 그것도 테스트로 막았다). Embedding 응답 형식 위반은 프로바이더가 같은 형식으로 답하므로 재시도 없이 즉시 Permanent 다. **결함의 근본 원인은 테스트였다** — `fakes.py` 의 `raise_exc` 가 어느 테스트에서도 쓰이지 않아(grep 0건) Transient/Permanent 파이프라인 경로가 한 번도 실행된 적이 없었다. 그 경로를 실제로 주입하자 **티켓에 없던 결함이 하나 더 드러났다**: `keyword_service` 에 `PermanentError` 핸들러가 아예 없어(`context_processing` 광범위 except 없음, `context.py` 는 `BackgroundTasks.add_task`) `llm_client` 만 고치면 401 이 **BackgroundTasks 까지 새어 트레이스백만 남기고 단계는 PROCESSING 에 머문다** — 고치려던 무한 재시도가 경로만 바꿔 남는 구조였다(되돌려 RED 확인). `embedding_service` 는 이 결선을 이미 갖고 있어 비대칭이던 쪽만 맞췄고 **상태 전이 규칙은 바꾸지 않았다**. 로그 레벨도 §2.1 WARN·§2.2 ERROR 로 맞췄다(둘 다 INFO/WARNING 이어서 일시 장애가 묻히고 배포 설정 문제가 알림에 오르지 않았다). client HTTP 계층 테스트는 신설했고 이는 `integration-tests` §4.2(*"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake"*)와 **충돌이 아니다** — §4.2 는 *파이프라인이 client 를 무엇으로 대체하는가* 의 규칙이고, 인터페이스 Fake 는 client 를 통째로 대체하므로 `_embed_batch` 가 429 를 어떻게 분류하는지 볼 수 없다. **`docs/spec/` 은 고치지 않았다**(§4.2 의 계층 구분 명문화는 후속 별건). `132 passed`(착수 baseline 74), Docker 29.6.1 Testcontainers 전량. **Circuit Breaker 와 타임아웃 60/90 재산정은 티켓 제외 범위** — 전자는 인증 실패 같은 전 서비스 영향 오류가 개별 Context FAILED 로 누적되는 문제를 남기고, 후자는 재시도 도입으로 최악값이 150s → 453s 가 된 것과 함께 별건이다 (S15P11A705-121) | [구현 리포트](implements/2026-07-30-retry-and-error-classification.md), [spec/failure-recovery.md](spec/failure-recovery.md), [tests/README](../tests/README.md), [ai#44](https://github.com/Team-PinLog/ai/pull/44) | | 2026-07-30 | `dev` 전환의 마지막 조각 — `CONTRIBUTING.md` 가 2단 구성으로 개정된 뒤 `main` 을 가리킨 채 남은 문서 6곳을 정합시켰다(`docs/development/workflow.md` 4·8·30·33·53행, `P44` 52행). **`image publish` 의 `main` 기준 서술은 유지했다** — `infra` 의 `ai-image-update.yaml` 이 `test "$SOURCE_BRANCH" = main` 으로 어서션하므로 이것까지 `dev` 로 바꾸면 GitOps 반영이 끊긴다. `workflow.md` 는 이제 "`dev` 에 병합하는 실행 순서" 이므로, 그 문서만 읽는 사람이 `main` 이 언제 무엇을 받는지 모르게 되는 것을 막기 위해 §5 에 릴리스 병합(`dev`→`main`)과 publish 가 거기서 일어난다는 서술을 한 줄 넣었다. `P44` 는 `상태: Accepted` 인 결정 문서라 표 값만 조용히 바꾸지 않고, 작성 시점(2026-07-28)에는 `main` 단일 구성이었고 2단 전환이 `-154`·`-158` 에서 이뤄졌다는 각주를 함께 남겼다 — `implements/` 와 달리 `proposals/` 에 삭제 금지 원칙은 없지만, Accepted 결정문을 이력 없이 고치면 무엇이 언제 정해졌는지가 사라진다. **`ai-ci / check` 와 `ai-ci / embedding profile parity` 둘 다 required 로 확인**했으나(`main`·`dev` 동일) `CONTRIBUTING.md` 병합 조건 절은 여전히 `ai-ci / check` 하나만 적고 있다 — 정본 재개정은 이 티켓 범위 밖이라 문서를 정본에 맞춰 두고 갭만 올린다 (S15P11A705-158) | [workflow](development/workflow.md), [P44](proposals/P44-ai-repository-governance.md), [CONTRIBUTING](../CONTRIBUTING.md) | | 2026-07-30 | required status checks 가 둘인데 문서가 하나만 적던 것을 정정했다. `ai#42` 병합 후 `main`·`dev` 양쪽 protection 에 `ai-ci / embedding profile parity` 가 추가됐으나 정본이 갱신되지 않아, **낡은 값이 `CONTRIBUTING.md`→`workflow.md`→`P44` 로 퍼졌다** — 직전 작업(위 줄)에서 갭을 관측했지만 정본 재개정이 범위 밖이라 하위 문서를 낡은 정본에 맞춰 둔 상태였다. 이번에 **정본을 먼저 고치고 하위를 거기에 맞췄다.** 실제 설정을 API 로 직접 대조한 결과 `main`·`dev` 가 **완전히 동일**했다(strict·검사 둘·미해결 대화 차단·관리자 포함·승인 0). 그래서 검사 목록만 고치지 않고 **`관리자 포함 보호` 를 `main` 전용으로 적던 서술도 함께 정정**했다 — 이것도 틀린 값이었고, 원인은 같은 조건을 두 bullet 에 중복 기재해 한쪽만 낡을 수 있는 구조였다. 조건은 공통으로 한 번만 적고 bullet 은 "무엇을 병합하는가" 차이만 남겼다. `P44` 의 *"문서와 실제 GitHub 설정 불일치"* 완화가 **실제로 실패한 사례**이므로("API 로 재조회" 는 설정을 읽는 것까지만 다루고 문서를 고치는 주체를 정하지 않았다) 완화 자체를 "설정을 바꾼 사람이 같은 작업에서 정본을 갱신한다" 로 강화하고 발생 사실을 각주로 남겼다. 정본에는 검사 이름이 protection 과 문자열까지 일치해야 하며 개편 시 이 절을 먼저 고친다는 순서 규칙을 박아 두었다. 위 줄은 그 시점의 사실 기록이라 고치지 않고 새 줄을 더했다 — WORKLOG 가 `merge=union` 이라 기존 줄 수정은 중복 위험이 있다(직전 작업에서 문서화한 주의의 첫 적용) (S15P11A705-158) | [CONTRIBUTING](../CONTRIBUTING.md), [workflow](development/workflow.md), [P44](proposals/P44-ai-repository-governance.md) | -| 2026-07-30 | `app` coverage 를 비차단 측정에서 **병합 차단 게이트**로 전환했다. **티켓 기준선(07-28, 52 tests · line 76.95% · branch 62.5%)이 낡아 착수 시 재측정했다** — 그 사이 `-121` 이 58 테스트를 더해 `b45aa93` 시점 실측은 146 tests · line 88.80% · branch 82.08% 였고, 티켓이 지목한 미검증 4곳 중 **둘은 이미 `-121` 이 덮은 뒤**였다. 두 지표 모두 이미 80% 를 넘겨 게이트만 켜도 통과했지만 그러지 않았다 — **branch 여유가 2 포인트뿐**이라 다음 기능 PR 이 분기를 몇 개 추가하는 것만으로 무관한 이유로 붉어진다. 남은 공백(`load_presets.py` **0%**, `main.py` 58%, 재측정으로 드러난 `gms_roundtrip.py` 76%)을 계약·실패 경로로 메워 **181 tests · line 99.74% · branch 98.11%** 로 올렸다. **`--cov-fail-under` 를 쓰지 않았다** — `--cov-branch` 를 켜면 그 값은 statement 와 branch 를 **합산한 하나의 비율**이라(기준선에서 합산 88% · branch 82%) statement 759 개가 branch 106 개를 압도해 **branch 가 60% 대로 떨어져도 통과한다**. 완료 조건이 "각각" 인 이상 합산 게이트는 조건을 검사하지 못하므로 `tools/check_coverage_gate.py` 가 둘을 나눠 판정한다. 임계값은 스크립트 상수이며 **CLI 로 덮을 수 없다**(덮을 수 있으면 CI 가 조용히 낮은 값을 넘겨 무력화할 수 있다). `--cov-branch` 없는 리포트는 branch 키 자체가 없어(실측) 분기 검사가 공짜로 통과하므로 **판정 불가로 끊는다** — "측정하지 못했다" 는 통과가 아니다. **통과만 확인하면 아무것도 강제하지 않는 게이트를 놓치므로 RED 4 종을 실측했다**: 테스트 제거(line 42.82%·branch 34.91%), 임계값 99.9 상향(되돌림), `--cov-branch` 누락, 리포트 부재 — 전부 exit 1. 임계값 하향을 막는 테스트도 함께 붉어지는 것을 확인했다. 신설한 두 계층은 **요청 경로 밖이라 파이프라인·API 테스트로는 한 줄도 실행되지 않던 곳**이다 — `test_api.py` 가 lifespan 을 우회하고 `app.state` 에 Fake 를 직접 꽂기 때문이다. lifespan 테스트는 **진짜 클라이언트를 조립하는지**를 단언하므로 Fake 로 바꾸지 않았다(생성자는 IO 를 하지 않아 실호출 금지와 충돌하지 않고, Fake 로 바꾸면 그 단언이 사라진다). `if __name__ == "__main__"` 아래는 `runpy` 로 검증하는데, **새 네임스페이스라 캐시된 모듈 패치가 보이지 않아** 원본 클라이언트 모듈의 속성을 갈아 끼워야 한다(함정을 `tests/README` 에 기록). **제외로 수치를 맞추지 않았다** — `pragma`·`omit` 없이 2 line · 2 branch 를 미달로 남겼다. 둘 다 `FOR UPDATE` 로 잠근 직후의 재검사라 같은 트랜잭션에서 도달 불가지만, **도달 불가가 구조에 딸린 성질**이라 저장 로직이 잠금 밖으로 나가면 도달 가능해진다 — `pragma` 를 붙이면 그때 아무도 모르고, 미달로 두면 리포트가 계속 그 줄을 가리킨다(여유 18 포인트라 비용이 없다). 함께 `integration-tests.md` §4.2 에 **계층 구분을 명문화**했다(`-121` 후속) — §4.2 가 *"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake"* 라고만 적어 client 단위 테스트의 HTTP mock 이 위반인지가 `-121` 중 **두 번** 질문으로 올라왔고, 판정은 충돌이 아니라 **층이 다름**이었다. 경계를 한 문장으로 고정했다: **`app/client/` 밖의 코드는 HTTP 를 몰라야 하고 그 코드를 검증하는 테스트도 HTTP 를 몰라야 한다.** `-121` 이 `docs/spec/` 을 못 고친 것은 그 작업이 *구현을 계약에 맞추는 것*이었기 때문이고, 이번은 계약 자체의 공백을 메우는 것이라 허용됐다 (S15P11A705-110) | [게이트 리포트](implements/2026-07-30-coverage-gate.md), [spec/integration-tests.md](spec/integration-tests.md), [CONTRIBUTING](../CONTRIBUTING.md), [tests/README](../tests/README.md) | +| 2026-07-30 | `app` coverage 를 비차단 측정에서 **병합 차단 게이트**로 전환했다. **티켓 기준선(07-28, 52 tests · line 76.95% · branch 62.5%)이 낡아 착수 시 재측정했다** — 그 사이 `-121` 이 58 테스트를 더해 `b45aa93` 시점 실측은 146 tests · line 88.80% · branch 82.08% 였고, 티켓이 지목한 미검증 4곳 중 **둘은 이미 `-121` 이 덮은 뒤**였다. 두 지표 모두 이미 80% 를 넘겨 게이트만 켜도 통과했지만 그러지 않았다 — **branch 여유가 2 포인트뿐**이라 다음 기능 PR 이 분기를 몇 개 추가하는 것만으로 무관한 이유로 실패한다. 남은 공백(`load_presets.py` **0%**, `main.py` 58%, 재측정으로 드러난 `gms_roundtrip.py` 76%)을 계약·실패 경로로 메워 **181 tests · line 99.74% · branch 98.11%** 로 올렸다. **`--cov-fail-under` 를 쓰지 않았다** — `--cov-branch` 를 켜면 그 값은 statement 와 branch 를 **합산한 하나의 비율**이라(기준선에서 합산 88% · branch 82%) statement 759 개가 branch 106 개를 압도해 **branch 가 60% 대로 떨어져도 통과한다**. 완료 조건이 "각각" 인 이상 합산 게이트는 조건을 검사하지 못하므로 `tools/check_coverage_gate.py` 가 둘을 나눠 판정한다. 임계값은 스크립트 상수이며 **CLI 로 덮을 수 없다**(덮을 수 있으면 CI 가 조용히 낮은 값을 넘겨 무력화할 수 있다). `--cov-branch` 없는 리포트는 branch 키 자체가 없어(실측) 분기 검사가 공짜로 통과하므로 **판정 불가로 끊는다** — "측정하지 못했다" 는 통과가 아니다. **통과만 확인하면 아무것도 강제하지 않는 게이트를 놓치므로 RED 4 종을 실측했다**: 테스트 제거(line 42.82%·branch 34.91%), 임계값 99.9 상향(되돌림), `--cov-branch` 누락, 리포트 부재 — 전부 exit 1. 임계값 하향을 막는 테스트도 함께 실패하는 것을 확인했다. 신설한 두 계층은 **요청 경로 밖이라 파이프라인·API 테스트로는 한 줄도 실행되지 않던 곳**이다 — `test_api.py` 가 lifespan 을 우회하고 `app.state` 에 Fake 를 직접 꽂기 때문이다. lifespan 테스트는 **진짜 클라이언트를 조립하는지**를 단언하므로 Fake 로 바꾸지 않았다(생성자는 IO 를 하지 않아 실호출 금지와 충돌하지 않고, Fake 로 바꾸면 그 단언이 사라진다). `if __name__ == "__main__"` 아래는 `runpy` 로 검증하는데, **새 네임스페이스라 캐시된 모듈 패치가 보이지 않아** 원본 클라이언트 모듈의 속성을 갈아 끼워야 한다(함정을 `tests/README` 에 기록). **제외로 수치를 맞추지 않았다** — `pragma`·`omit` 없이 2 line · 2 branch 를 미달로 남겼다. 둘 다 `FOR UPDATE` 로 잠근 직후의 재검사라 같은 트랜잭션에서 도달 불가지만, **도달 불가가 구조에 딸린 성질**이라 저장 로직이 잠금 밖으로 나가면 도달 가능해진다 — `pragma` 를 붙이면 그때 아무도 모르고, 미달로 두면 리포트가 계속 그 줄을 가리킨다(여유 18 포인트라 비용이 없다). 함께 `integration-tests.md` §4.2 에 **계층 구분을 명문화**했다(`-121` 후속) — §4.2 가 *"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake"* 라고만 적어 client 단위 테스트의 HTTP mock 이 위반인지가 `-121` 중 **두 번** 질문으로 올라왔고, 판정은 충돌이 아니라 **층이 다름**이었다. 경계를 한 문장으로 고정했다: **`app/client/` 밖의 코드는 HTTP 를 몰라야 하고 그 코드를 검증하는 테스트도 HTTP 를 몰라야 한다.** `-121` 이 `docs/spec/` 을 못 고친 것은 그 작업이 *구현을 계약에 맞추는 것*이었기 때문이고, 이번은 계약 자체의 공백을 메우는 것이라 허용됐다 (S15P11A705-110) | [게이트 리포트](implements/2026-07-30-coverage-gate.md), [spec/integration-tests.md](spec/integration-tests.md), [CONTRIBUTING](../CONTRIBUTING.md), [tests/README](../tests/README.md) | | 2026-07-30 | 가공 데모 14건에 **실사용자 기록 23건**을 더해 37건으로 전체 통합을 검증했다. 검색 1위 일치 10/12(가공 4/4·실데이터 6/8), 피드·Keyword PASS. 시딩 15분 8초·회수 0회로 `--pace 25`가 GMS 쿼터 안에 들어감을 재확인했다. 실패 2건의 원인이 갈렸다 — 하나는 축약어(「피맥」↔「피자에 맥주」), 하나는 **임베딩 입력이 Context 본문뿐이라 장소명이 검색에 안 걸리는 것**으로 후자는 설계 관측이다. 응답에 실려 오던 GMS 토큰을 두 클라이언트가 전부 버리고 있어 `PINLOG_TOKEN_LOG` 게이트 계측을 신설했고, 판정이 임베딩의 17배(839.6 대 37.7)를 쓰며 그 대부분이 후보 Preset 27개를 매번 싣는 prompt 임을 실측했다 (S15P11A705-174) | [실데이터 E2E](implements/2026-07-30-real-data-e2e.md) · [T27·T28](troubleshooting/2026-07-30-seeding-quota-and-encoding.md) | | 2026-07-30 | **시딩이 느렸던 것은 GMS 가 아니라 우리 `--pace 25` 였다.** 재측정에서 같은 코드가 분당 30건 이상을 통과했고(간격 1s 15/15 · 동시 10건 1.7초), 같은 데이터 37건이 `--pace 1` 로 **42초**에 완주했다 — 15분 8초에서 21.6배. 결과와 토큰은 같았고 검색 정확도도 2회차 10/12 로 재현됐다. `T27` 이 07-29 관측을 상수로 적은 것을 시점·경로 의존으로 정정하고 기본값을 1 로 낮췄다. 벤더 비교에서 **429 가 Gemini 경로에만 나는 것**을 확인해(OpenAI·Anthropic 12/12) 폴백을 `-175` 로 발주했다. 함께 보고서 두 오류를 고쳤다 — 판정 후보는 27개가 아니라 top-10 이고(프리셋 총수와 혼동), 데이터가 원본·계약 자리채움·시연 구성 세 층인데 그 구분 없이 「실데이터」로 뭉뚱그렸다 (S15P11A705-176) | [실데이터 E2E](implements/2026-07-30-real-data-e2e.md) · [T27 정정](troubleshooting/2026-07-30-seeding-quota-and-encoding.md) | -| 2026-07-31 | 임베딩 `502` 가 검색 `500` 이 되던 운영 버그(`ai#69`)를 끊었다. **분류도 재시도도 이미 맞았다** — `-121` 이 `classify_http_status` 로 `502→TransientError` 를 확정했고 `test_client_retry.py` 가 상태 코드별로 단언한다. 비어 있던 것은 **그 예외가 응답이 되는 지점**이었고, `app/main.py` 의 핸들러는 `ProfileMismatchError`(422) 하나뿐이라 분류된 나머지가 uvicorn 까지 올라가 500 이 됐다. 클라이언트 테스트는 예외가 던져지는 것까지 보고 API 테스트는 Fake 를 꽂아 성공 형식만 봐서, **두 파일 사이에 계층 하나가 통째로 비어 있는데 각자는 자기 범위에서 통과하고 있었다.** `TransientError→503` · `PermanentError→502` 로 갈랐고, `502` 를 고른 근거는 **500 을 비워 두는 것**이다 — 분류된 실패까지 500 이면 "AI 가 깨졌나 게이트웨이가 깨졌나" 를 가르려던 목적이 절반만 남는다. 티켓이 열어 둔 *"back 이 재시도해도 소용없다는 신호"* 논거는 **쓰지 않았다**: `AiSearchClient` 는 애초에 검색을 재시도하지 않는다(사용자 요청 경로). **`back` 이 500 과 503 을 구분하지 않는다는 사실을 확인했다** — `translate()` 가 `422`+`serverProfile` 과 `401`/`403` 외 **모든** 상태를 `AiSearchException.unavailable()`(→`503 SEARCH_UNAVAILABLE`)로 묶으므로 사용자 화면은 이미 같고, **이 변경이 바꾸는 것은 화면이 아니라 관측**이다. `ai#69` 두 번째 답변의 *"AI 가 503 을 주면 back 이 전달한다"* 는 맞지만 500 도 이미 그렇게 전달되고 있다는 사실이 빠져 있었다. 그래서 `back` 변경은 하지 않고 별건으로 남긴다. 응답 본문은 **고정 문구 한 줄**이다 — `back` 이 본문을 읽지 않고(파싱하는 것은 `serverProfile` 뿐), 업스트림 상태 코드는 `-197` 계측이 이미 남기며, 예외 메시지를 그대로 실으면 게이트웨이 응답 200자(`resp.text[:200]`)가 응답으로 새기 때문이다. **핸들러 부재는 500 만 낸 게 아니었다** — 대조군 로그에서 예외가 uvicorn 까지 올라가며 트레이스백에 업스트림 본문이 그대로 찍히는 것을 봤고(스텁 401 의 키 힌트가 노출됐다), `probe.py`·§2.4 원칙 4 위반이라 핸들러가 그 누출도 막는다. 검증은 **핸들러를 넣기 전에 RED 14 건**을 확인했고(실패 형태가 「500 이 나왔다」가 아니라 **예외가 응답 계층으로 샜다** 였다 — 운영에서 uvicorn 이 500 을 만드는 바로 그 지점), 넣은 뒤 18 건 GREEN · 전체 309 건 통과다. 테스트만으로는 「핸들러를 넣었다」에 가까워 **로컬 GMS 스텁으로 실제 502 를 만들었다**: 같은 스텁에 물린 uvicorn 두 대에서 `origin/dev`(a5e1142)는 `502`·`401` 둘 다 **HTTP 500**, 이 브랜치는 **503**·**502** 였고 정상 응답은 200 으로 실데이터 5건을 돌려줬다. 계약 테스트는 **Fake client 를 쓰지 않는다** — `raise_exc` 주입은 분류 경로를 건너뛰어 `classify_http_status` 가 바뀌어도 통과하며, 그 모양이 `ai#69` 를 놓친 구멍이다. 명세도 함께 메웠다(`failure-recovery.md` §2.5 · `personal-search.md` §6.2) — **§2.1·§2.2 는 Context 처리 경로의 상태 반영만 정하고 API 층 변환을 말하지 않아서**, 코드만 고치면 같은 공백이 남는다. **DB 실패는 여전히 500 이다** — §2.1 이 DB 연결 실패를 일시 오류로 두지만 `SearchService` 가 asyncpg 예외를 분류하지 않는다. 범위 밖으로 남기고 후속 후보로 올린다 (S15P11A705-220) | [오류 응답 계약](implements/2026-07-31-search-error-contract.md), [failure-recovery §2.5](spec/failure-recovery.md), [personal-search §6.2](spec/personal-search.md), [T43~T45](troubleshooting/2026-07-31-error-contract-pitfalls.md), [ai#69](https://github.com/Team-PinLog/ai/issues/69) | -| 2026-07-31 | 임베딩 개선 축 둘을 교차시켜 **넷을 실경로로** 쟀다(입력에 장소명 결합 × `small`/`large`). 1위 일치는 A 10 · B 9 · C 10 · D 10 / 12 이고 top-3 는 넷 다 12/12 다 — **합계로는 어느 조건도 기준선을 넘지 못한다.** 그런데 합계가 같은 A·C·D 가 **서로 다른 질의에서 실패한다**: 8번(공원)은 `large` 가 고치고(C·D) 장소명 결합과 무관하며, 7번(축약어 「피맥」)은 **둘을 겹쳤을 때만**(D) 통과하고, 1번은 D 에서만 깨지고, 6번은 A 에서 `+0.0087` 로 통과한 동전 던지기다. **`-174` 가 8번의 원인으로 지목한 「장소명이 임베딩에 없다」는 진단이 틀렸다** — 맞았다면 입력에 장소명을 넣은 B 가 고쳤어야 하는데 B 는 못 고쳤고 장소명을 넣지 않은 C 가 고쳤다. 원인은 어휘 부재가 아니라 `small` 이 「그네팟」·「산책하면서 머물다」를 「공원」과 잇지 못한 것으로 보인다. 장소명 결합은 **양날**이다 — 저장 입력에만 붙고 질의에는 붙지 않는 비대칭이 유사도 전반을 끌어내린다(1번 0.5021→0.3344). **비용 축은 저장 하나뿐**이다: 벡터가 정확히 2배(6,148→12,292 B)이고 **토큰은 조건 간 차이가 없다**(임베딩 토큰은 입력 텍스트가 정하므로 모델 크기와 무관), 시딩 소요도 42.5~44.2초로 GMS 왕복이 지배한다. `vector(3072)` 이 동작하는 것은 **벡터 인덱스가 없기 때문**이며(37행 순차 스캔) 색인이 필요해지면 pgvector 의 2000차원 상한이 되살아난다. **채택 판단은 하지 않는다** — 12건 표본의 1건 차이이고 세 조건이 동률이며 기대값을 우리가 작성했다(`-174` §3.3). 측정 전제로 두 가지를 고쳤다: `seed.py` 가 `social_account.email` 을 채우지 않아 back `V6__social_account_email_not_null.sql` 에 걸려 **기동조차 못 하던 것**(시연 도구가 현행 스키마와 어긋나 있었다. `{key}@demo-seed.invalid` — RFC 2606 예약 TLD 라 실재 주소로 오인되지 않는다)과, `--reset` 이 `demo-seed` 소유만 지워 남은 **고아 임베딩 8건**이 저장 비용 평균을 오염시키던 것(하네스가 조건 profile 로 한정해 세게 했다). **로컬 DB 는 복구했다** — `alter_dim.py --to 1536` · 프리셋 실배포 profile 재적재 · 데모 37건 재시딩까지 실행해 `grid-*` 행이 하나도 남지 않은 것을 확인했다. **Flyway 마이그레이션은 만들지 않았다**(측정과 채택은 별건) (S15P11A705-191) | [4조건 측정](implements/2026-07-31-embedding-grid.md), [tools/emb_grid/](../tools/emb_grid/), [실데이터 E2E](implements/2026-07-30-real-data-e2e.md) | -| 2026-07-31 | 시연 도구 결함 3건을 **값이 아니라 「조용함」** 쪽에서 고쳤다. 셋 다 `-191` 이 값은 이미 고쳤고(`email` 채움·고아 8행 삭제) 여기서 만든 것은 재발 경로다. `tools/demo_seed/preflight.py` 가 **`--reset` 보다 먼저** 돌고 걸리면 아무것도 지우지 않은 채 exit 2 로 끝난다 — 결함 3 과 `T28` 이 **둘 다 reset 이 지운 뒤에 죽어** 데이터를 잃었기 때문에 순서가 방어의 절반이다. **결함 1 의 방어 대상을 「NOT NULL 제약」이 아니라 「우리가 값을 주지 않는 컬럼」으로 잡았다** — `email` 은 `V4` 에서 nullable 로 태어났고 그때는 아무 오류도 나지 않으며 `V6` 가 제약을 걸 때는 이미 늦다. `WRITE_CONTRACT` 가 시딩이 직접 INSERT 하는 두 테이블의 **모든 컬럼**을 `SEED`/`DB`/`NULL` 로 선언하고 미선언 컬럼이 나타나면 멈춘다. 이 방향이면 back 이 언제 무엇을 걸지 알 필요가 없어 **back 이 우리 CI 가 아니라는 현실에서 작동한다**. 티켓 후보 중 「기동 스모크」는 넣지 않았다(그 시점엔 DB 가 이미 오염돼 예방이 아니라 관측이다), 「트랜잭션 경계」도 아니다(INSERT 는 정상 커밋됐고 며칠 뒤 다른 프로세스가 죽었다). **결함 2 는 「지우지 않는다」로 판단했다** — `tools/e2e/` 검증 데이터는 **의도적으로** `core` 대응 행이 없어(README 명시) 고아 정의에 전부 걸리므로 자동 삭제는 남의 하네스를 깨뜨린다. 세어서 보여주고 `--prune-orphans` 로 사람이 정한다. 다만 `reset()` 이 `ai.context_keyword_analysis` 를 지우지 않던 것은 논쟁이 아니라 **누락**이라 그냥 고쳤다(**259행 중 222행이 고아**였고 FK 가 없어 DB 도 알려주지 않았다). 목록을 `ORPHAN_TABLES` 하나로 묶어 **빠뜨리면 집계가 먼저 고아로 보고**하게 했다. **결함 3 은 묶는 것과 드러내는 것을 둘 다** — `shared_root()` 가 `--git-common-dir` 로 키 경로를 메인 워킹트리에 고정하고, 인증 프로브가 back 이 그 키를 실제로 받아들이는지 한 번 통과시켜 본다. 경로 고정은 흔한 원인 하나만 없애고 back 에 주입된 키가 다르면 여전히 401 이라 **프로브가 본체**다. **셋 다 일부러 어긋내 RED 를 봤다**(`-156` 과 같은 이유): 판정 함수에 `return []` 뮤턴트를 넣어 5 케이스 RED · 별도 `guardprobe` DB 에 back 마이그레이션 9 개를 적용하고 `ADD COLUMN nickname` 으로 **nullable 추가 시점에 걸리는 것**을 실측 · 키를 어긋내 `HTTP 401` BLOCK · `:5433` 에서 테이블 없음 BLOCK. **실 DB 실행이 오탐 하나를 잡았다** — `GENERATED ALWAYS AS IDENTITY` 는 `column_default` 가 NULL 이라 `id` 가 "NOT NULL 인데 기본값 없음" 으로 걸렸고 단위 테스트는 그 쿼리를 타지 않아 못 봤다(`is_identity`·`is_generated` 포함으로 수정). 조사 중 **로컬 pgvector 가 둘이고 `.env` 의 `DATABASE_URL` 이 시연 정본(`:15432`)이 아니라 07-27 잔재(`:5433`)를 가리키는 것**을 발견해 preflight 첫 줄에 접속 대상을 찍게 했다 — 기본값 자체는 `-174` 절차·타 세션 습관과 얽혀 이 티켓에서 바꾸지 않았다. back 은 §7 절차로 실기동했고 **첫 기동은 로컬 jar 이 07-30 빌드라 V6 를 담지 않아 Flyway 가 거부**해서 `bootJar` 리빌드 후 정상 기동했다. `254 passed` · `ruff check .` 통과 (S15P11A705-198) | [구현 리포트](implements/2026-07-31-seed-guard.md), [tools/demo_seed/](../tools/demo_seed/), [임베딩 4조건](implements/2026-07-31-embedding-grid.md) | +| 2026-07-31 | 임베딩 `502` 가 검색 `500` 이 되던 운영 버그(`ai#69`)를 고쳤다. **분류도 재시도도 이미 맞았다** — `-121` 이 `classify_http_status` 로 `502→TransientError` 를 확정했고 `test_client_retry.py` 가 상태 코드별로 단언한다. 비어 있던 것은 **그 예외가 응답이 되는 지점**이었고, `app/main.py` 의 핸들러는 `ProfileMismatchError`(422) 하나뿐이라 분류된 나머지가 uvicorn 까지 올라가 500 이 됐다. 클라이언트 테스트는 예외가 던져지는 것까지 보고 API 테스트는 Fake 를 꽂아 성공 형식만 봐서, **두 파일 사이에 계층 하나가 통째로 비어 있는데 각자는 자기 범위에서 통과하고 있었다.** `TransientError→503` · `PermanentError→502` 로 갈랐고, `502` 를 고른 근거는 **500 을 비워 두는 것**이다 — 분류된 실패까지 500 이면 "AI 가 깨졌나 게이트웨이가 깨졌나" 를 가르려던 목적이 절반만 남는다. 티켓이 열어 둔 *"back 이 재시도해도 소용없다는 신호"* 논거는 **쓰지 않았다**: `AiSearchClient` 는 애초에 검색을 재시도하지 않는다(사용자 요청 경로). **`back` 이 500 과 503 을 구분하지 않는다는 사실을 확인했다** — `translate()` 가 `422`+`serverProfile` 과 `401`/`403` 외 **모든** 상태를 `AiSearchException.unavailable()`(→`503 SEARCH_UNAVAILABLE`)로 묶으므로 사용자 화면은 이미 같고, **이 변경이 바꾸는 것은 화면이 아니라 관측**이다. `ai#69` 두 번째 답변의 *"AI 가 503 을 주면 back 이 전달한다"* 는 맞지만 500 도 이미 그렇게 전달되고 있다는 사실이 빠져 있었다. 그래서 `back` 변경은 하지 않고 별건으로 남긴다. 응답 본문은 **고정 문구 한 줄**이다 — `back` 이 본문을 읽지 않고(파싱하는 것은 `serverProfile` 뿐), 업스트림 상태 코드는 `-197` 계측이 이미 남기며, 예외 메시지를 그대로 실으면 게이트웨이 응답 200자(`resp.text[:200]`)가 응답으로 새기 때문이다. **핸들러 부재는 500 만 낸 게 아니었다** — 대조군 로그에서 예외가 uvicorn 까지 올라가며 트레이스백에 업스트림 본문이 그대로 찍히는 것을 봤고(스텁 401 의 키 힌트가 노출됐다), `probe.py`·§2.4 원칙 4 위반이라 핸들러가 그 누출도 막는다. 검증은 **핸들러를 넣기 전에 RED 14 건**을 확인했고(실패 형태가 「500 이 나왔다」가 아니라 **예외가 응답 계층으로 샜다** 였다 — 운영에서 uvicorn 이 500 을 만드는 바로 그 지점), 넣은 뒤 18 건 GREEN · 전체 309 건 통과다. 테스트만으로는 「핸들러를 넣었다」에 가까워 **로컬 GMS 스텁으로 실제 502 를 만들었다**: 같은 스텁에 물린 uvicorn 두 대에서 `origin/dev`(a5e1142)는 `502`·`401` 둘 다 **HTTP 500**, 이 브랜치는 **503**·**502** 였고 정상 응답은 200 으로 실데이터 5건을 돌려줬다. 계약 테스트는 **Fake client 를 쓰지 않는다** — `raise_exc` 주입은 분류 경로를 건너뛰어 `classify_http_status` 가 바뀌어도 통과하며, 그 모양이 `ai#69` 를 놓친 구멍이다. 명세도 함께 메웠다(`failure-recovery.md` §2.5 · `personal-search.md` §6.2) — **§2.1·§2.2 는 Context 처리 경로의 상태 반영만 정하고 API 층 변환을 말하지 않아서**, 코드만 고치면 같은 공백이 남는다. **DB 실패는 여전히 500 이다** — §2.1 이 DB 연결 실패를 일시 오류로 두지만 `SearchService` 가 asyncpg 예외를 분류하지 않는다. 범위 밖으로 남기고 후속 후보로 올린다 (S15P11A705-220) | [오류 응답 계약](implements/2026-07-31-search-error-contract.md), [failure-recovery §2.5](spec/failure-recovery.md), [personal-search §6.2](spec/personal-search.md), [T43~T45](troubleshooting/2026-07-31-error-contract-pitfalls.md), [ai#69](https://github.com/Team-PinLog/ai/issues/69) | +| 2026-07-31 | 임베딩 개선 축 둘을 교차시켜 **넷을 실경로로** 쟀다(입력에 장소명 결합 × `small`/`large`). 1위 일치는 A 10 · B 9 · C 10 · D 10 / 12 이고 top-3 는 넷 다 12/12 다 — **합계로는 어느 조건도 기준선을 넘지 못한다.** 그런데 합계가 같은 A·C·D 가 **서로 다른 질의에서 실패한다**: 8번(공원)은 `large` 가 고치고(C·D) 장소명 결합과 무관하며, 7번(축약어 「피맥」)은 **둘을 겹쳤을 때만**(D) 통과하고, 1번은 D 에서만 깨지고, 6번은 A 에서 `+0.0087` 차이로 통과해 우연에 가깝다. **`-174` 가 8번의 원인으로 지목한 「장소명이 임베딩에 없다」는 진단이 틀렸다** — 맞았다면 입력에 장소명을 넣은 B 가 고쳤어야 하는데 B 는 못 고쳤고 장소명을 넣지 않은 C 가 고쳤다. 원인은 어휘 부재가 아니라 `small` 이 「그네팟」·「산책하면서 머물다」를 「공원」과 잇지 못한 것으로 보인다. 장소명 결합은 **이득과 손해가 함께 있다.** 저장 입력에만 붙고 질의에는 붙지 않는 비대칭이 유사도 전반을 끌어내린다(1번 0.5021→0.3344). **비용 축은 저장 하나뿐**이다: 벡터가 정확히 2배(6,148→12,292 B)이고 **토큰은 조건 간 차이가 없다**(임베딩 토큰은 입력 텍스트가 정하므로 모델 크기와 무관), 시딩 소요도 42.5~44.2초로 GMS 왕복이 지배한다. `vector(3072)` 이 동작하는 것은 **벡터 인덱스가 없기 때문**이며(37행 순차 스캔) 색인이 필요해지면 pgvector 의 2000차원 상한이 되살아난다. **채택 판단은 하지 않는다** — 12건 표본의 1건 차이이고 세 조건이 동률이며 기대값을 우리가 작성했다(`-174` §3.3). 측정 전제로 두 가지를 고쳤다: `seed.py` 가 `social_account.email` 을 채우지 않아 back `V6__social_account_email_not_null.sql` 에 걸려 **back 이 기동조차 못 하던 것**(시연 도구가 현행 스키마와 어긋나 있었다. `{key}@demo-seed.invalid` — RFC 2606 예약 TLD 라 실재 주소로 오인되지 않는다)과, `--reset` 이 `demo-seed` 소유만 지워 남은 **고아 임베딩 8건**이 저장 비용 평균을 오염시키던 것(하네스가 조건 profile 로 한정해 세게 했다). **로컬 DB 는 복구했다** — `alter_dim.py --to 1536` · 프리셋 실배포 profile 재적재 · 데모 37건 재시딩까지 실행해 `grid-*` 행이 하나도 남지 않은 것을 확인했다. **Flyway 마이그레이션은 만들지 않았다**(측정과 채택은 별건) (S15P11A705-191) | [4조건 측정](implements/2026-07-31-embedding-grid.md), [tools/emb_grid/](../tools/emb_grid/), [실데이터 E2E](implements/2026-07-30-real-data-e2e.md) | +| 2026-07-31 | 시연 도구 결함 3건을 **값이 아니라 「조용함」** 쪽에서 고쳤다. 셋 다 `-191` 이 값은 이미 고쳤고(`email` 채움·고아 8행 삭제) 여기서 만든 것은 재발 경로다. `tools/demo_seed/preflight.py` 가 **`--reset` 보다 먼저** 돌고 걸리면 아무것도 지우지 않은 채 exit 2 로 끝난다 — 결함 3 과 `T28` 이 **둘 다 reset 이 지운 뒤에 실패해** 데이터를 잃었기 때문에 순서가 방어의 절반이다. **결함 1 의 방어 대상을 「NOT NULL 제약」이 아니라 「우리가 값을 주지 않는 컬럼」으로 잡았다** — `email` 은 `V4` 에서 nullable 로 태어났고 그때는 아무 오류도 나지 않으며 `V6` 가 제약을 걸 때는 이미 늦다. `WRITE_CONTRACT` 가 시딩이 직접 INSERT 하는 두 테이블의 **모든 컬럼**을 `SEED`/`DB`/`NULL` 로 선언하고 미선언 컬럼이 나타나면 멈춘다. 이 방향이면 back 이 언제 무엇을 걸지 알 필요가 없어 **back 이 우리 CI 가 아니라는 현실에서 작동한다**. 티켓 후보 중 「기동 스모크」는 넣지 않았다(그 시점엔 DB 가 이미 오염돼 예방이 아니라 관측이다), 「트랜잭션 경계」도 아니다(INSERT 는 정상 커밋됐고 며칠 뒤 다른 프로세스가 죽었다). **결함 2 는 「지우지 않는다」로 판단했다** — `tools/e2e/` 검증 데이터는 **의도적으로** `core` 대응 행이 없어(README 명시) 고아 정의에 전부 걸리므로 자동 삭제는 남의 하네스를 깨뜨린다. 세어서 보여주고 `--prune-orphans` 로 사람이 정한다. 다만 `reset()` 이 `ai.context_keyword_analysis` 를 지우지 않던 것은 논쟁이 아니라 **누락**이라 그냥 고쳤다(**259행 중 222행이 고아**였고 FK 가 없어 DB 도 알려주지 않았다). 목록을 `ORPHAN_TABLES` 하나로 묶어 **빠뜨리면 집계가 먼저 고아로 보고**하게 했다. **결함 3 은 묶는 것과 드러내는 것을 둘 다** — `shared_root()` 가 `--git-common-dir` 로 키 경로를 메인 워킹트리에 고정하고, 인증 프로브가 back 이 그 키를 실제로 받아들이는지 한 번 통과시켜 본다. 경로 고정은 흔한 원인 하나만 없애고 back 에 주입된 키가 다르면 여전히 401 이라 **프로브가 본체**다. **셋 다 일부러 어긋내 RED 를 봤다**(`-156` 과 같은 이유): 판정 함수에 `return []` 뮤턴트를 넣어 5 케이스 RED · 별도 `guardprobe` DB 에 back 마이그레이션 9 개를 적용하고 `ADD COLUMN nickname` 으로 **nullable 추가 시점에 걸리는 것**을 실측 · 키를 어긋내 `HTTP 401` BLOCK · `:5433` 에서 테이블 없음 BLOCK. **실 DB 실행이 오탐 하나를 잡았다** — `GENERATED ALWAYS AS IDENTITY` 는 `column_default` 가 NULL 이라 `id` 가 "NOT NULL 인데 기본값 없음" 으로 걸렸고 단위 테스트는 그 쿼리를 타지 않아 못 봤다(`is_identity`·`is_generated` 포함으로 수정). 조사 중 **로컬 pgvector 가 둘이고 `.env` 의 `DATABASE_URL` 이 시연 정본(`:15432`)이 아니라 07-27 잔재(`:5433`)를 가리키는 것**을 발견해 preflight 첫 줄에 접속 대상을 찍게 했다 — 기본값 자체는 `-174` 절차·타 세션 습관과 얽혀 이 티켓에서 바꾸지 않았다. back 은 §7 절차로 실기동했고 **첫 기동은 로컬 jar 이 07-30 빌드라 V6 를 담지 않아 Flyway 가 거부**해서 `bootJar` 리빌드 후 정상 기동했다. `254 passed` · `ruff check .` 통과 (S15P11A705-198) | [구현 리포트](implements/2026-07-31-seed-guard.md), [tools/demo_seed/](../tools/demo_seed/), [임베딩 4조건](implements/2026-07-31-embedding-grid.md) | | 2026-07-31 | `dev` 의 세 gate 가 열렸는데 **무엇이 실패하는지 볼 수단이 없던 것**을 메웠다(`-197` 감사 §4-c 의 관측 3종). GMS 호출 1회를 감싸 벤더·모델·상태 코드·결과 분류·소요 시간을 남기고, 60초 창 집계를 한 줄로 낸다. **`_usage.py` 와 합치지 않았다** — 그쪽은 `PINLOG_TOKEN_LOG` 게이트가 있고 **200 응답을 파싱한 뒤**에 불려 실패한 호출을 한 줄도 남기지 않는다. 실패율의 분자가 애초에 거기 없고 dev 배포에는 그 env 가 없어 분모도 없다. 로그로 합치면 반대로 토큰 JSONL 이 실패 행과 섞여 `-174` 집계가 깨진다. **재선점은 쿼리를 갈랐다** — `try_start` 가 신규 시작과 만료 재선점에 같은 rowcount `1` 을 주는데, 호출부가 미리 읽은 값으로 가르면 keyword 단계는 근거가 아예 없고(`try_start` 전에 상태를 안 읽는다) embedding 단계는 경합 창 때문에 거짓 양성이 섞인다. 재선점은 드물지만 「앞선 처리가 600s 안에 못 끝났다」는 신호라 거짓 양성이 섞이면 신호를 못 믿는다. CTE 로 UPDATE 직전 상태를 같은 문장에서 읽어 원자성을 지켰다(추가 조회가 아니므로 state-machine §3.2 와 충돌하지 않는다 — 그 규칙은 `0` 의 이유를 묻지 말라는 것이고 이쪽은 `1` 이 무엇인지를 묻는다). 레벨은 **성공 DEBUG · 실패 WARNING · 창 집계 INFO · 재선점 WARNING** 이다 — 성공까지 INFO 면 정상 트래픽이 실패 행을 덮고, 성공을 안 세면 분모가 없다. 집계는 타이머가 아니라 다음 호출이 밀어내므로 유휴 시 조용하고, 마지막 창은 lifespan `finally` 의 `flush()` 가 낸다. **계측을 붙이다 httpx 가 요청마다 INFO 로 전체 URL 을 남기는 것을 발견했다** — `probe.py` 의 endpoint 미노출 기준을 어기면서 성공 호출까지 INFO 라 새 설계를 무의미하게 만들고 있었다. **caplog 를 로거 이름으로 걸러 읽는 테스트가 이것을 통과시켰고**, 실제 실행 출력을 눈으로 보고서야 드러났다(자기 로거만 보면 옆에서 새는 것을 못 본다). `configure_logging` 이 httpx 를 WARNING 으로 올리고, 로거를 가리지 않고 전부 읽는 테스트를 따로 뒀다. 값 노출 기준은 **벤더·모델은 싣고**(공개 설정 P45, 어느 경로가 막혔는가가 곧 원인) credential·endpoint·요청/응답 본문은 안 싣는다 — 요청 본문에는 사용자 Context 원문이 있다. 전송 실패에서 예외 메시지 대신 타입 이름을 쓰는 것도 httpx 가 메시지에 URL 을 넣기 때문이다. `/metrics` 는 만들지 않았다(`infra ai-serving.md` 검증 순서 7 이 prod 승격 전 별도 승인으로 이관). 기존 `resp.text[:200]` 노출은 진단에 필요해 남기고 별도 판단 항목으로 올린다 (S15P11A705-197) | [GMS 호출 관측](implements/2026-07-31-gms-call-observability.md), [failure-recovery §2.4](spec/failure-recovery.md), [state-machine §3.2](spec/state-machine.md) | -| 2026-07-31 | 개인 검색에 **두 컷**(`τ_abs=0.30` 절대 하한 · `r=0.60` 1위 대비 상대 하한)을 넣고 `limit` 기본값을 공용 계약 08 §6.1 의 `size` 기본값과 같은 20 으로 맞췄다(back 이 `sizeOrDefault()` 로 항상 명시해 보내 **드러나지 않은 채 어긋나 있었다**). **`personal-search.md §6` 의 「컷오프를 적용하지 않는다」를 뒤집는다** — 그 근거는 무관 질의 **1건**의 실측(관련 top-1 최소 0.5263 vs 무관 top-1 최대 0.3143, 간격 +0.2120)이었는데, 무관 질의를 질의 5종 × 소유자 3명 = **15건**으로 늘리자 그 간격이 **-0.0176 으로 뒤집혔다**(「치과 임플란트 상담 받을 곳」 → 연남칼국수 0.3819 가 기대 정답 최솟값 0.3642 를 넘는다). 즉 어떤 `τ_abs` 도 무관 질의를 전부 침묵시키면서 정답을 전부 살릴 수 없고, **동시에 「그러므로 컷을 걸지 말자」도 성립하지 않는다** — 컷이 없으면 무관 질의에 보유 기록 전량(소유자별 6·11·17건, 15질의 합 170행)이 반환된다. **티켓이 채택한 조합을 측정이 지지했다.** 검증 질의 12건만 재면 `r` 이 압도적으로 보이지만(`r=0.80` 꼬리 97.4% 제거, `τ_abs` 는 어떤 값과 조합해도 결과 동일) `r` 은 정의상 **1위를 언제나 남겨** 무관 질의를 0.40~0.90 전 구간에서 **15건 중 0건도** 침묵시키지 못한다. `τ_abs` 는 반대로 같은 안전 마진에서 꼬리 제거가 약하다(상한의 83% 에서 62.3%, `r` 은 상한의 75% 에서 73.7%). **정답이 있는 질의만 재면 컷의 절반이 안 보인다**는 것이 이 측정의 방법론적 결론이다(T40). 채택값에서 정답 누락 0/12 · 빈 결과 0/12 · 꼬리 76.3%(비관)·71.5%(낙관) 제거 · 무관 질의 11/15 침묵이다. **안전 상한(`τ_abs` 0.36 · `r` 0.80)에 붙이지 않고 17%·25% 마진을 뒀다** — 두 축의 상한을 **같은 데이터점 하나**(「친구들이랑 피자에 맥주 마신 곳」의 정답이 3위 `0.3642`·`r=0.807`, `-174`·`-191` 네 조건에서 계속 실패한 축약어 「피맥」 건)가 정하고, 여기에 **배치 구성이 바뀌면 같은 텍스트의 유사도가 `10⁻⁴` 규모로 흔들리는 것**(0.5264→0.5258, T42)이 겹치는데 상한과 정답 최솟값의 거리가 `0.0042` 였다. 컷은 `LIMIT` **뒤에서** 건다 — 유사도 하위만 자르므로 `WHERE` 와 결과가 같고(단조), 그렇다면 이미 고정된 §4 Query 를 건드리지 않는 쪽이 낫다. `r` 의 기준은 **컷 전 1위**다(살아남은 것의 1위로 재계산하면 자기충족 컷이 된다). 하네스 `tools/search_cut/` 는 `-210` 의 구조를 따라 질의를 한 번 임베딩해 굳히고 격자를 오프라인으로 훑는다(**GMS 임베딩 배치 1회**) — 다만 `-210` 의 재구성이 근사였던 것(후보가 줄면 LLM 판정이 뒤집힌다)과 달리 **검색 경로에는 LLM 이 없어 정확**하고, 그래서 대조군 없이 **정확 일치**를 요구했다(이 브랜치 코드로 띄운 :8002 에 27건 실호출 → **27/27 일치**). 검증 스크립트는 컷 규칙을 구현에서 `import` 하지 않고 다시 적는다(하면 구현이 명세와 달라도 둘이 함께 틀린다). `.search/matrix.json` 을 **커밋했다** — 다시 뜨려면 GMS 를 부르고 `tau_grid` 의 것과 달리 Context 본문을 담지 않는다(장소명까지). 라벨은 `plausible` 을 **넉넉히** 잡아 컷의 이득을 과대평가하지 않는 방향으로 뒀다. **`limit` 을 20 으로 올리는 것 자체는 꼬리를 늘리므로**(반환 104→142행) 컷과 분리해 머지하면 안 된다. 후속: 소유자별 편차를 단일 `τ_abs` 가 모른다(같은 무관 질의가 6 Record 소유자에게 0.2686, 17 Record 소유자에게 0.3819) · 정답/무관 겹침은 컷이 아니라 **임베딩 품질**의 문제다. `291 passed` · line 99.71% · branch 98.12% · `ruff check .` 통과 (S15P11A705-213) | [컷 측정](implements/2026-07-31-search-cut.md), [spec/personal-search.md §6.1](spec/personal-search.md), [T40~T42](troubleshooting/2026-07-31-search-cut-measurement.md), [ai#70](https://github.com/Team-PinLog/ai/pull/70) | +| 2026-07-31 | 개인 검색에 **두 컷**(`τ_abs=0.30` 절대 하한 · `r=0.60` 1위 대비 상대 하한)을 넣고 `limit` 기본값을 공용 계약 08 §6.1 의 `size` 기본값과 같은 20 으로 맞췄다(back 이 `sizeOrDefault()` 로 항상 명시해 보내 **드러나지 않은 채 어긋나 있었다**). **`personal-search.md §6` 의 「컷오프를 적용하지 않는다」를 뒤집는다** — 그 근거는 무관 질의 **1건**의 실측(관련 top-1 최소 0.5263 vs 무관 top-1 최대 0.3143, 간격 +0.2120)이었는데, 무관 질의를 질의 5종 × 소유자 3명 = **15건**으로 늘리자 그 간격이 **-0.0176 으로 뒤집혔다**(「치과 임플란트 상담 받을 곳」 → 연남칼국수 0.3819 가 기대 정답 최솟값 0.3642 를 넘는다). 즉 어떤 `τ_abs` 도 무관 질의를 전부 무노출로 만들면서 정답을 전부 살릴 수 없고, **동시에 「그러므로 컷을 걸지 말자」도 성립하지 않는다** — 컷이 없으면 무관 질의에 보유 기록 전량(소유자별 6·11·17건, 15질의 합 170행)이 반환된다. **티켓이 채택한 조합을 측정이 지지했다.** 검증 질의 12건만 재면 `r` 이 압도적으로 보이지만(`r=0.80` 꼬리 97.4% 제거, `τ_abs` 는 어떤 값과 조합해도 결과 동일) `r` 은 정의상 **1위를 언제나 남겨** 무관 질의를 0.40~0.90 전 구간에서 **15건 중 0건도** 무노출로 만들지 못한다. `τ_abs` 는 반대로 같은 안전 마진에서 꼬리 제거가 약하다(상한의 83% 에서 62.3%, `r` 은 상한의 75% 에서 73.7%). **정답이 있는 질의만 재면 컷의 절반이 안 보인다**는 것이 이 측정의 방법론적 결론이다(T40). 채택값에서 정답 누락 0/12 · 빈 결과 0/12 · 꼬리 76.3%(비관)·71.5%(낙관) 제거이고, 무관 질의는 15건 중 11건 무노출(11/15)이다. **안전 상한(`τ_abs` 0.36 · `r` 0.80)에 붙이지 않고 17%·25% 마진을 뒀다** — 두 축의 상한을 **같은 데이터점 하나**(「친구들이랑 피자에 맥주 마신 곳」의 정답이 3위 `0.3642`·`r=0.807`, `-174`·`-191` 네 조건에서 계속 실패한 축약어 「피맥」 건)가 정하고, 여기에 **배치 구성이 바뀌면 같은 텍스트의 유사도가 `10⁻⁴` 규모로 흔들리는 것**(0.5264→0.5258, T42)이 겹치는데 상한과 정답 최솟값의 거리가 `0.0042` 였다. 컷은 `LIMIT` **뒤에서** 건다 — 유사도 하위만 자르므로 `WHERE` 와 결과가 같고(단조), 그렇다면 이미 고정된 §4 Query 를 건드리지 않는 쪽이 낫다. `r` 의 기준은 **컷 전 1위**다(살아남은 것의 1위로 재계산하면 자기충족 컷이 된다). 하네스 `tools/search_cut/` 는 `-210` 의 구조를 따라 질의를 한 번 임베딩해 굳히고 격자를 오프라인으로 훑는다(**GMS 임베딩 배치 1회**) — 다만 `-210` 의 재구성이 근사였던 것(후보가 줄면 LLM 판정이 뒤집힌다)과 달리 **검색 경로에는 LLM 이 없어 정확**하고, 그래서 대조군 없이 **정확 일치**를 요구했다(이 브랜치 코드로 띄운 :8002 에 27건 실호출 → **27/27 일치**). 검증 스크립트는 컷 규칙을 구현에서 `import` 하지 않고 다시 적는다(하면 구현이 명세와 달라도 둘이 함께 틀린다). `.search/matrix.json` 을 **커밋했다** — 다시 뜨려면 GMS 를 부르고 `tau_grid` 의 것과 달리 Context 본문을 담지 않는다(장소명까지). 라벨은 `plausible` 을 **넉넉히** 잡아 컷의 이득을 과대평가하지 않는 방향으로 뒀다. **`limit` 을 20 으로 올리는 것 자체는 꼬리를 늘리므로**(반환 104→142행) 컷과 분리해 머지하면 안 된다. 후속: 소유자별 편차를 단일 `τ_abs` 가 모른다(같은 무관 질의가 6 Record 소유자에게 0.2686, 17 Record 소유자에게 0.3819) · 정답/무관 겹침은 컷이 아니라 **임베딩 품질**의 문제다. `291 passed` · line 99.71% · branch 98.12% · `ruff check .` 통과 (S15P11A705-213) | [컷 측정](implements/2026-07-31-search-cut.md), [spec/personal-search.md §6.1](spec/personal-search.md), [T40~T42](troubleshooting/2026-07-31-search-cut-measurement.md), [ai#70](https://github.com/Team-PinLog/ai/pull/70) | | 2026-07-31 | GMS 오류 응답 본문이 **예외 메시지를 타고 로그로 새던 경로**를 원천에서 막았다(`-205`). 티켓은 「분류 밖 예외 → 트레이스백」 하나를 남은 구멍으로 봤지만 실제 경로는 **여섯이고 그중 다섯이 분류가 정상 동작하는 평상시 경로**다 — `retry.py` 가 재시도 1회마다, 두 service 가 일시·영구 오류마다 예외 객체를 `%s` 로 찍는다. `failure-recovery.md §2.4` 원칙 4 는 응답 본문을 *"어느 레벨에서도 남기지 않습니다"* 라고 적고 있었으나 **코드는 그때도 남기고 있었다**(§2.6 으로 갈라 정정). 실제 GMS 로 네 경로에 오류 19건을 넣어 본문을 실측했다 — **자격 증명은 한 건도 에코되지 않고**(401 은 게이트웨이 고정 문구, 벤더가 답하기 전에 끊는다) endpoint 는 맨 호스트로 실리며, **OpenAI 는 요청 값을 앞뒤 3자만 남기고 잘라 되돌린다**(`Invalid value: 'PIN...def'` — 완전 일치 검사를 빠져나가 「안 샌다」로 오판할 뻔했다, T61). 그래서 규칙의 근거를 「관측된 것을 지운다」가 아니라 **「되돌아올 수 있는 자리를 막는다」**로 잡았다. `app/core/redact.py` 가 자격 증명(`sk-`·`AIza`·JWT·`Bearer`·`key=value`)을 endpoint(URL·맨 호스트)보다 **먼저** 지우고, **마스킹이 절단보다 먼저다**(순서를 뒤집으면 200자 경계에 걸친 키의 앞부분이 남는다). **본문 200자는 지우지 않는다** — 실측한 벤더 400 본문에 마스킹 대상이 한 글자도 없었고 전부 진단 문구였다. 막는 지점을 로그 호출부가 아니라 client 로 잡은 것이 설계의 전부다(트레이스백 경로에는 애초에 호출부가 없다). `tests/test_log_redaction.py` 18건이 AST 로 단일 원천을 지킨다. pytest 404 · line 99.83% / branch 98.99% (`-223` 병합 후 재검증) | [implements](implements/2026-07-31-gms-error-body-redaction.md), [failure-recovery §2.6](spec/failure-recovery.md), [troubleshooting](troubleshooting/2026-07-31-log-redaction-pitfalls.md) (T61~T63) | | 2026-07-31 | 판정 n회 다수결 — 구현 + 실측 후 **n=1 유지 결론** (S15P11A705-223) | 판정을 `PINLOG_JUDGE_VOTE_N` 회 불러 **엄격 다수결**(`votes*2 > n`)로 접는 경로를 넣고, 기본값 1 이 현행과 **정확히 같음**을 실데이터로 고정했다(회차 30개를 n=1 로 접은 결과 원본과 30/30 일치). **분모를 성공 수가 아니라 n 으로 고정**한 것이 설계의 핵심 — 낮추면 n=3 에서 1회만 성공했을 때 그 1회가 곧 다수결이 되어 「다수결을 켠 채 n=1 을 실행」하는 상태가 되고, 정족수 미달을 「선택 0건 정상 완료」로 저장하면 판정 실패가 성공으로 기록된다(T54). 짝수 n 은 기동 차단 — 동점 때문이 아니라 바로 아래 홀수에 **지배당하기** 때문이다(n=4 는 3표·n=3 은 2표인데 호출은 33% 더 든다). 측정은 **새로 부르지 않고 접었다** — n회 다수결 1회분과 독립 회차 n개의 다수결이 같은 확률변수이므로 회차 30개(1,260호출)로 2,940호출어치 조건 셋을 얻었고(`-210` 이 유사도 행렬로 τ 를 재구성한 것과 같은 수법), 다수결 규칙은 서비스 코드를 그대로 부른다. `run_live.py` 가 `KeywordService._judge_n` 실호출로 대조 — 전 지표 범위 겹침, Context당 호출 정확히 3.0. **다수결은 설계대로 작동했다**: 비결정성 24%→17%→12%, 흔들리던 오분류 13종 완전 제거, 라벨없음 0.80→0.00, **정상 판정을 오히려 늘려 교환비가 이 프로젝트 최초로 음수**(`-210` τ 는 1.22 로 기각됐다). **그런데 오분류 행은 안 준다** — 10.13→9.40→9.17, 범위 겹침, p=0.365/0.210 으로 사전·보강 기준 모두 미달. 이유가 §4 다: 다수결은 소수의견을 지우는 장치라 **다수의견은 반대로 굳힌다** — 지운 13종이 각 0.03~0.37행으로 드물었고(-1.7행) 동시에 70~97%이던 5종을 100%로 굳혀(+0.8행) 잔액이 노이즈 크기로 남는다. 100% 생존한 7종(`274`·`289 DRINK`·`265 WALK`·`284 WITH_FAMILY`·`275`/`290 VIEW_GOOD`·`266 TRENDY`)은 `-219` 가 프롬프트로 못 움직인 것과 **같은 목록**이다 — `-210`(후보 선정)·`-219`(판정 문구)·`-223`(판정 분산)이 서로 다른 층에서 같은 곳을 가리켰고, 남은 것은 **후보의 정의**(프리셋 `description`, `back#136`)로 AI 파트 소관이 아니다. `fit 0건 Context` 8.00 — 세 티켓 연속 불변(다수결이 한 일은 줄인 것이 아니라 표준편차를 0 으로 만든 것). 비용 실측: 호출 3~5배, Context당 지연 1.57→2.01→2.60s(동시 호출이라 n배 아님), 최악 지연은 **n 에 비례하지 않는다**(n=1 3.94s > n=3 3.70s — 꼬리는 GMS 쪽이다). 개선 폭당 호출 115~175회/오분류 1행. **`back#136` 이 7종을 고치면 남는 오분류가 전부 흔들림이 되므로 그때 다시 재야 한다 — 순서가 중요하다.** GMS 2,142호출·실패 0·전량 `gpt-4o-mini`. `386 passed` · line 99.83% · branch 98.98% (`origin/dev` 병합 후. 이 브랜치 단독은 338) · `ruff check .` 통과 | [다수결 측정](implements/2026-07-31-judge-vote.md), [T57~T60](troubleshooting/2026-07-31-judge-vote.md), [tools/judge_vote/](../tools/judge_vote/) | | 2026-07-31 | 문서 색인 정합을 **CI 로 옮겼다**(티켓 없음). 07-31 하루에 색인 사고가 다섯 번 났고 **대응이 다섯 번 다 「문서에 문장 추가」였다** — 네 번째(T53 중복)는 첫 번째의 대응(T48 「PR 직전에 다시 확인하라」)을 그대로 따랐는데도 났다. 따를 수 없는 규칙이어서가 아니라 *「내가 세는 35분 사이에 남이 병합했는가」* 를 사람이 알 방법이 없기 때문이다. 그중 **셀 수 있는 것**을 `ai-ci / check` 스텝으로 옮겼다 — ①전수 표의 `T##`·`I##` 중복 ②파일 표 ↔ 파일 시스템의 고아·누락 ③전수 표에만 있고 파일 표에 없는 문서(사고 5, `-205` 가 발견). **검사를 먼저 만들어 `dev` 에 돌리고, 위반을 별도 커밋으로 정리한 뒤에 켰다**(순서가 반대였으면 무관한 PR 이 전부 막힌다). 착수 시점 `dev` 에 고아 3건이 있었다 — `-197`·`-223` 은 전수 표에만 링크가 있었고 `-219` 는 어느 표에도 없었다(셋 다 WORKLOG 에는 있다). **일부러 통과시키는 것**을 잡는 것만큼 고정했다: 결번(T9·T10 은 back 레포)과 두 표의 **내용** 불일치, 그리고 **파일 표에만 있고 전수 표가 안 가리키는 문서**. 마지막 것은 켤 수 없다 — 트러블슈팅 전수 표는 설계상 문서를 안 가리켜(문서 링크 0종) 정상 문서 15건이, 구현 쪽은 번호 없는 문서 7건이 위반으로 나온다. 연속성을 검사하면 없던 규칙이 생기고, 일치를 검사하면 **표 이중화라는 문제를 규칙으로 승격**시킨다. 재현은 실제 `docs/` 사본에 사고 1·2·3 을 그대로 심어 확인했고, **검사가 내 문서 두 개의 색인 누락도 잡았다**(T64·T65·I35 를 정하기 전에 돌렸다). 메시지는 「중복입니다」로 끝내지 않고 **다음 빈 번호와 붙여 넣을 표 행**을 준다. **셸이 아니라 Python** 인 것은 `pytest` 가 같은 함수를 불러 CI 와 로컬이 같은 코드를 쓰기 위함이고, **새 잡이 아니라 `check` 안의 스텝**인 것은 필수 상태 검사가 두 이름뿐이라 잡을 늘리면 실패해도 병합을 못 막기 때문이다. 표 이중화 조사 결과 **줄일 수 없다** — 트러블슈팅 전수 표는 문서 링크가 **0종**이고(T↔문서 매핑의 유일한 출처가 파일 표다) 구현 전수 표는 파일 표 24개 중 17개만 덮으며 13행은 애초에 문서가 아닌 산출이다. 두 표는 같은 것의 두 판이 아니라 **서로 다른 두 축**이다. 함께 고친 것 — `merge=union` 이 표 안에 남긴 빈 줄 6개가 **GFM 표를 끊고 있었다**(에디터·diff 에서는 표로 보인다, T64). `400 passed` · line 99.83% · branch 98.98% · `ruff check .` 통과 | [색인 검사 리포트](implements/2026-07-31-docs-index-check.md), [T64·T65](troubleshooting/2026-07-31-docs-index-check.md), [tools/check_docs_index.py](../tools/check_docs_index.py) | -| 2026-08-03 | 읽히지 않는 설정 키 전수조사(`S15P11A705-224`). `-219`(T43)가 이미 `PINLOG_JUDGE_MODEL` 하나를 짚었지만 이번에는 `.env`·`.env.example`·`config.py`·배포 SealedSecret(8키)을 전부 대조하고, `Settings` 필드 15개 각각에 sentinel 값을 런타임 주입해 grep이 아니라 실제 반영 여부로 확인했다(`-210`이 "임계값이 없다"고 적었다가 `config.py:114`에 있었던 사고와 같은 함정을 경계). **읽히지 않는 키는 정확히 2개**(`PINLOG_JUDGE_MODEL`·`PRESET_CACHE_TTL_SEC`)였고 **둘 다 저장소는 이미 정리가 끝나 있었다** — 전자는 `-175`가 `PINLOG_JUDGE_CHAIN`으로 대체, 후자는 `ai#24`(2026-07-27)가 이미 `config.py`·`.env.example`에서 제거. 남은 문제는 개발자 로컬 `.env`(gitignored)에만 있던 잔재였고 이번에 주석 처리했다. **계약의 전제도 정정했다** — "`.env` 13 대 `.env.example` 12"를 단일 차집합(하나만 다르다)으로 가정했지만 실제로는 `.env`만의 키 2개(둘 다 죽음)·`.env.example`만의 키 1개(`PINLOG_JUDGE_CHAIN`, 살아 있음)인 양방향 구성이었다 — 둘 다 정상이라 `.env.example`에 추가할 키는 없다. **배포 Secret은 애초에 두 죽은 키를 담지 않았다**(인프라 소관이라 값은 확인만, 고치지 않음). `-197`(`app/client/_calls.py`) 계측이 벤더·모델을 이미 개별 호출(DEBUG)·창 요약(INFO, 벤더 단위)로 남기고 있어 **보강하지 않았다** — 기본 로그 레벨(INFO)에서는 벤더 단위까지 보이고 벤더→모델은 `PINLOG_JUDGE_CHAIN`(공개 값)과 1:1이라 특정 가능하다. 폴백 체인 구조(`-175`)·`-210` 리포트는 계약대로 손대지 않았다. 코드·`.env.example` 변경 없음(이미 올바른 상태를 확인) | [죽은 설정 키 리포트](implements/2026-08-03-dead-config-keys.md), [T66·T67](troubleshooting/2026-08-03-dead-config-key-audit.md) | +| 2026-08-03 | 읽히지 않는 설정 키 전수조사(`S15P11A705-224`). `-219`(T43)가 이미 `PINLOG_JUDGE_MODEL` 하나를 짚었지만 이번에는 `.env`·`.env.example`·`config.py`·배포 SealedSecret(8키)을 전부 대조하고, `Settings` 필드 15개 각각에 sentinel 값을 런타임 주입해 grep이 아니라 실제 반영 여부로 확인했다(`-210`이 "임계값이 없다"고 적었다가 `config.py:114`에 있었던 사고와 같은 함정을 경계). **읽히지 않는 키는 정확히 2개**(`PINLOG_JUDGE_MODEL`·`PRESET_CACHE_TTL_SEC`)였고 **둘 다 저장소는 이미 정리가 끝나 있었다** — 전자는 `-175`가 `PINLOG_JUDGE_CHAIN`으로 대체, 후자는 `ai#24`(2026-07-27)가 이미 `config.py`·`.env.example`에서 제거. 남은 문제는 개발자 로컬 `.env`(gitignored)에만 있던 잔재였고 이번에 주석 처리했다. **계약의 전제도 정정했다** — "`.env` 13 대 `.env.example` 12"를 단일 차집합(하나만 다르다)으로 가정했지만 실제로는 `.env`만의 키 2개(둘 다 읽히지 않음)·`.env.example`만의 키 1개(`PINLOG_JUDGE_CHAIN`, 코드가 읽음)인 양방향 구성이었다 — 둘 다 정상이라 `.env.example`에 추가할 키는 없다. **배포 Secret은 애초에 그 두 키를 담지 않았다**(인프라 소관이라 값은 확인만, 고치지 않음). `-197`(`app/client/_calls.py`) 계측이 벤더·모델을 이미 개별 호출(DEBUG)·창 요약(INFO, 벤더 단위)로 남기고 있어 **보강하지 않았다** — 기본 로그 레벨(INFO)에서는 벤더 단위까지 보이고 벤더→모델은 `PINLOG_JUDGE_CHAIN`(공개 값)과 1:1이라 특정 가능하다. 폴백 체인 구조(`-175`)·`-210` 리포트는 계약대로 손대지 않았다. 코드·`.env.example` 변경 없음(이미 올바른 상태를 확인) | [죽은 설정 키 리포트](implements/2026-08-03-dead-config-keys.md), [T66·T67](troubleshooting/2026-08-03-dead-config-key-audit.md) | | 2026-08-03 | `back#138` 에 남기고 못 지킨 약속 — GMS 게이트웨이 멀티모달(이미지) 지원 재고 — 을 이행했다(`S15P11A705-227`). 1×1 PNG(68바이트)를 크기 요인 배제를 위해 쓰고(`-205` T62 — 큰 본문은 게이트웨이가 「모델 없음」으로 오진한다), 세 경로(OpenAI·Gemini·Anthropic)에 벤더 스펙 그대로 이미지 파트를 실어 각 1회씩 **총 3호출**로 끝냈다(공용 게이트웨이 최소 침습 원칙). **결론: 지원한다** — 셋 다 200 이고, 응답이 빈 문자열·거부가 아니라 이미지 내용에 대한 실제 답(색상 등 한 단어)이었다 — 게이트웨이가 이미지 파트를 버렸다면 나올 수 없는 응답이다. `back#138` 이 이미지 분석 흐름(`Front → Spring → FastAPI`)의 **유일한 선행 조건**으로 남긴 것이 이걸로 해소된다. 오류 응답을 만들지는 않았다 — 지원 여부 확인이 목적이라 정상 200 으로 끝났고, 오류 본문 형태는 `-205` 가 텍스트 경로에서 이미 실측해 뒀다(같은 게이트웨이 구현이므로 이미지 경로도 같은 형태를 따를 것으로 본다, 별도 확인은 안 함). 스크립트는 세션 스크래치패드에만 두고 커밋하지 않았고, `.env` 값은 메모리에서만 읽어 모든 출력에 키 문자열 치환 + 20자 이상 토큰 마스킹을 거쳤다(안전망 — 실제로는 한 건도 안 걸림, `-205` 결론과 일치). 남은 것 — OpenAI 경로의 `prompt_tokens` 가 8,524 로 텍스트 프롬프트 길이 대비 크게 뜬 것은 원인 조사 없이 관측만 남긴다(이미지 분석 기능 설계 시 토큰 예산에 반영 필요). 429·5xx 본문, 실사용 크기(최대 10 MiB) 이미지, 다중 이미지는 재지 않았다 — 범위 밖 | [비전 재고](implements/2026-08-03-gms-vision-probe.md), [back#138](https://github.com/Team-PinLog/back/issues/138) | | 2026-08-03 | `-226` 이 명시적으로 한 방향만 켠 문서 색인 검사(④)의 **반대 방향**(파일 표에 번호가 있는데 전수 표에 없다)을 마저 닫았다(`S15P11A705-230`). ④는 전수 표 링크가 매핑의 유일한 출처였기 때문에 반대 방향을 검사할 수 없었다 — 이번에 `docs/implements` 파일 표에 **번호 컬럼을 추가**해 매핑의 출처를 파일 표 자신으로 옮기고, 그 컬럼으로 새 검사(⑤)를 켰다. `docs/troubleshooting` 은 그대로 뒀다 — 전수 표가 설계상 문서를 안 가리켜 컬럼을 넣어도 대조 출처가 못 된다(`-226` 실측 유지). 번호 없던 7건을 다시 세어(계약이 재확인을 요구했다) 하나씩 판정했다 — 5건은 문서에 실코드·실측 산출이 있어 신규 번호(I37~I41), `candidate-threshold` 는 새 번호가 아니라 **이미 있던 I30 에 빠진 반영처 링크**였을 뿐이라 그것만 채웠고, `ticket-audit-96-77` 은 문서 자신이 "만든 것이 아니라 확인한 결과"라고 규정해 「없음」으로 명시했다(빈 칸이 아니라 명시적 상태 — ⑤ 는 빈 칸을 형식 위반으로 잡는다). RED 먼저 확인했다 — 검사·테스트를 작성한 뒤 실제 README에 아직 번호 컬럼이 없는 상태로 돌려 26건 형식 위반을 관측하고, 컬럼을 채워 GREEN 전환했다. **계약이 경고한 T56 함정이 실제로 났다** — `origin/dev` 병합 중 `merge=union` 이 번호 컬럼 없는 낡은 파일 표 전체를 되살렸고, 동시에 병합된 병렬 PR(`#81`, `S15P11A705-227`)이 같은 I36 을 잡아 충돌했다. 낡은 블록은 지우고, 이번에 들어가는 쪽(내 `retry-and-error-classification`)만 다음 빈 번호(I41)로 재번호해 해소했다 — `#81` 의 I36 은 이미 `dev` 에 병합된 채였으므로 그대로 뒀다. `-226` 리포트 §4.1 이 "검사가 있어도 충돌 자체는 안 없어진다"고 적은 것과 정확히 같은 모양으로 재현됐다. `ruff check .` 통과 · 전량 pytest 통과 · line 99.83% · branch 98.99% · `python tools/check_docs_index.py` 통과(I1~I42) | [단방향 검사 리포트](implements/2026-08-03-docs-index-oneway.md), [tools/check_docs_index.py](../tools/check_docs_index.py) | | 2026-08-03 | `-210`(τ)·`-219`(프롬프트)·`-223`(다수결)이 판정 경로의 세 층을 각각 재고 전부 「바꾸지 않는다」로 끝난 뒤, 셋이 공통으로 지목한 마지막 축 — **프리셋의 정의** — 을 쟀다(`S15P11A705-228`). **`examples` 만 채택하고 `description` 은 기각한다.** 두 필드가 들어가는 곳이 달라(`examples` 는 임베딩 입력만, `description` 은 임베딩+판정 프롬프트 양쪽) 조건을 `D`·`E`·`DE` 로 갈랐고, **갈라 재지 않았으면 `D` 의 해로움이 `DE` 안에 묻힌 채 배포됐다** — `D` 는 `fit 0건 Context` 를 8.20 → **10.00** 으로 악화시키고 범위가 분리된다(세 티켓 동안 `8.00 ± 0.00` 으로 꿈쩍 않던 지표가 처음 움직였는데 나쁜 쪽이다). `E` 는 오분류 11.00 → 6.90행(p=0.0001) · 정상 손실 -0.80(p=0.16) · 교환비 0.20 이고 **순이득 부호가 라벨 처리 양극단에서 다 양수**다(네 티켓 중 유일). 셋이 못 지운 100% 고정 오분류 7행 중 3행을 지웠고 그중 2행은 판정 이전에 후보에서 빠진다. 사전 기준(범위 비중첩)은 못 넘었고 **무르지 않고 그대로 적었다** — 대신 `-219`·`-223` 에 없던 독립 증거 넷(후보 선정 층·라벨 42건 밖 홀드아웃 30건·순이득 부호·회차 확장에서 효과가 커진 것)을 근거로 판단했다. 자기충족 방어 셋을 뒀다: 대상은 라벨에서 고르되 **수정 내용은 프리셋 텍스트 안에서만 진단**(다른 프리셋 소관 어휘를 걷는다) · 안 고친 22종을 대조군으로 분리 집계 · 42건 밖 본문 30건을 새로 만들어 반대 방향 예측 검증. `DE` 는 라벨 밖 행이 5.60행으로 뛰어 부호가 안 정해지고, **그 라벨을 개정안 설계자가 붙이면 자기 확인이 되므로 모른 채로 넘긴다.** 측정 중 T68(임베딩 API 가 같은 배치 구성으로도 비결정적 — `T42` 의 전제를 정정)·T69(`tau_grid/score.py` 의 τ 격자가 `rank>K` 로 밀린 행을 안 셈, 임베딩이 바뀐 행렬에서만 발현) 발견. **DB 재적재와 `version` 처리는 이 PR 밖**이며 리포트 §8.2 가 근거와 함께 남긴다 — 특히 시드 머리말이 `version` 을 자기 소관이 아니라고 규정하는데 코드는 거기서 읽어, 올릴 경로가 지금 없다 | [프리셋 개정 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md), [tools/preset_desc/](../tools/preset_desc/) | | 2026-08-03 | `ai#97`(표지 생성)·`ai#98`(장소 제안)이 함께 기다리던 GMS 이미지 실사용 조건을 두 축으로 쟀다(`S15P11A705-253`, 2026-08-03 17:07~17:26 KST · root `https://gms.ssafy.io/gmsapi` · 호출 50회). **축 A(생성)는 지원한다** — `gpt-image-1`·`gemini-2.5-flash-image` 둘 다 1024×1024 PNG 를 냈고, 게이트웨이에 모델 allowlist 가 있어 `dall-e-3`·`imagen-3` 은 400 이다. **축 B 는 가설 C** — 토큰은 **치수**에 붙고 바이트에는 안 붙는다(512×512 고정에서 바이트 36배에 `prompt_tokens` 8,523 불변). `-227` 이 남긴 8,524 는 게이트웨이 가산이 아니라 `gpt-4o-mini` 의 타일 요금이었다(잔차 0). 설계의 핵심은 **치수와 바이트를 따로 움직인 합성 이미지**다 — 실제 사진으로는 세 가설이 같은 곡선을 내 갈리지 않는다. **그리고 티켓이 묻지 않은 것이 더 급했다 — 게이트웨이 요청 본문 상한**(96,024 B 통과 · 129,564 B 거부)이 있고 넘으면 「Model not found in request」로 온다(`-205` T62·`-225` 와 같은 원인). 이미지 원본 약 70 KB 가 확인된 통과 대역이라 **실사용 대화 캡처는 토큰을 쓰기 전에 막힌다** — `ai#98` 의 전제가 바뀐다. 정책 거절 층도 갈렸다(OpenAI 400 / **Gemini 200 + `finishReason=PROHIBITED_CONTENT`**) | [implements](implements/2026-08-03-gms-image-probe.md), [tools/gms_image/](../tools/gms_image/), [비전 재고](implements/2026-08-03-gms-vision-probe.md) | -| 2026-08-03 | 하루에 갈래마다 서너 번씩 난 **반복 사고 세 갈래**를 트러블슈팅으로 남겼다(`S15P11A705-293`, T70~T72). **개별 사고의 해결이 아니라 갈래를 적는 문서다** — 각 건의 처리는 자기 티켓·PR·이슈에 있고 여기에는 「무엇이 반복됐는가」만 남는다. **갈래마다 마지막 사고는 앞선 사고의 대응이 이미 있는 상태에서 났다** — 07-31 색인 사고 다섯 건과 같은 모양이고, 차이는 성실성이 아니라 강제 장치다. ① **사본에서 꺼낸 값**(T70) — 철회된 단정이 그 문서를 근거로 인용하며 되살아나고, 코드 한 행을 인용하며 **바로 다음 행**을 놓친 오독이 **이미 병합된 작업의 설계 근거**가 되고, 원장에 적어 둔 정정이 계약에서 원래 문구로 되돌아오고, 계약에 실은 수치가 여러 번 틀렸다. **넷 다 사본이 아니라 원문을 연 다른 사람이 잡았다** — 「쓰기 직전 재조회」 규칙은 이미 있었는데도 났다. 사본은 자기가 낡았다고 말하지 않으므로 대응을 읽는 쪽이 아니라 **쓰는 쪽**에 뒀다(`R-18` 「값 대신 출처」). 철회하는 쪽도 대상을 좁힌다 — **폐기된 것은 값이 아니라 값의 항상성이다.** ② **반만 덮는 자동화**(T71) — 「성공했다」와 「실제로 반영됐다」가 다르고 실패가 조용하다. **성공 판정을 네 층으로 가른다**(프로세스 생존 ≠ 실행 성공 ≠ 실제 변경 발생 ≠ 최종 전달 성공). 감시 고아는 heartbeat 로 고쳤고(판정은 사람이 한다), 배포 자동화 둘은 인프라 소관이라 이슈로 기록만 했다. **「다음 스케줄이 복구한다」는 전제 자체가 약하다.** ③ **공개 저장소 실사용자 기록**(T72) — 훅이 **바깥으로 나가는 발화**만 검사해 스크립트가 만들고 사람이 `git add` 한 산출물은 검사 지점을 한 번도 안 거쳤고, `.gitignore` 예외가 「커밋하라」고 **적극적으로 지시**하고 있었다. **대응 미정** — 선택지 넷 중 마스킹 문구의 경로 안내 제거만 즉시 가능하다. 위치·규모는 적지 않는다(위치 비공개 선례). **이 문서 자신이 T70 의 첫 시험이라 수치·코드 행 번호를 옮기지 않고 좌표만 가리킨다** | [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | -| 2026-08-03 | 표시명 개정(`S15P11A705-292`)이 코드에서 운영 화면까지 간 **배포 체인을 기록했다**(I50). **체인 여섯 단계의 소관을 갈랐다** — 우리(`ai#102` dev · `ai#104` 릴리스) · 자동화(`image-publish` 는 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**) · 인프라 소관(`infra#185`) · 클러스터 sync · 화면. **핵심은 미결 판정 하나를 실측으로 닫은 것이다** — 프리셋 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 부트스트랩 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 이미지 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 「값이 같으면 재실행되지 않는다」로 판정했고 독립 검증이 논거를 반증했으나 **클러스터 관측 경로가 없어 「확인 불가」로 끝나 있었다** — 배포가 답을 냈다. **그래도 표기가 낡은 것은 기록 정합성 문제로 남는다**(배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다). **릴리스가 두 번 막혔고 둘 다 다음에 그대로 다시 만난다** — base 검사는 `main` base 를 `release/*`·`hotfix/*` 로 한정하므로 `dev` 를 그대로 열면 CI 가 막고(`ai#103` 이 그렇게 닫혔다), strict 상태 검사는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(병합 커밋 `032e391f`). 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 헛돌았고 갱신을 만든 것은 수동 실행이라 **`success` 두 번의 뜻이 다르다.** 확인 수단이 화면뿐이라 「아직인가 실패인가」가 갈리지 않아 한 번 오판했다가 재확인에서 뒤집혔다. **값은 옮기지 않고 좌표만 적었다**(`T70` 적용) | [배포 체인 리포트](implements/2026-08-03-preset-display-name-deploy-chain.md), [I48](implements/2026-08-03-dev-deploy-gap.md), [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | +| 2026-08-03 | 하루에 유형마다 서너 번씩 난 **반복 사고 세 유형**을 트러블슈팅으로 남겼다(`S15P11A705-293`, T70~T72). **개별 사고의 해결이 아니라 반복 유형을 적는 문서다** — 각 건의 처리는 자기 티켓·PR·이슈에 있고 여기에는 「무엇이 반복됐는가」만 남는다. **유형마다 마지막 사고는 앞선 사고의 대응이 이미 있는 상태에서 났다** — 07-31 색인 사고 다섯 건과 같은 모양이고, 차이는 성실성이 아니라 강제 장치다. ① **사본에서 꺼낸 값**(T70) — 철회된 단정이 그 문서를 근거로 인용하며 되살아나고, 코드 한 행을 인용하며 **바로 다음 행**을 놓친 오독이 **이미 병합된 작업의 설계 근거**가 되고, 원장에 적어 둔 정정이 계약에서 원래 문구로 되돌아오고, 계약에 실은 수치가 여러 번 틀렸다. **넷 다 사본이 아니라 원문을 연 다른 사람이 잡았다** — 「쓰기 직전 재조회」 규칙은 이미 있었는데도 났다. 사본은 자기가 낡았다고 말하지 않으므로 대응을 읽는 쪽이 아니라 **쓰는 쪽**에 뒀다(`R-18` 「값 대신 출처」). 철회하는 쪽도 대상을 좁힌다 — **폐기된 것은 값이 아니라 값의 항상성이다.** ② **반만 덮는 자동화**(T71) — 「성공했다」와 「실제로 반영됐다」가 다르고 실패가 조용하다. **성공 판정을 네 층으로 가른다**(프로세스 생존 ≠ 실행 성공 ≠ 실제 변경 발생 ≠ 최종 전달 성공). 감시 고아는 heartbeat 로 고쳤고(판정은 사람이 한다), 배포 자동화 둘은 인프라 소관이라 이슈로 기록만 했다. **「다음 스케줄이 복구한다」는 전제 자체가 약하다.** ③ **공개 저장소 실사용자 기록**(T72) — 훅이 **바깥으로 나가는 발화**만 검사해 스크립트가 만들고 사람이 `git add` 한 산출물은 검사 지점을 한 번도 안 거쳤고, `.gitignore` 예외가 「커밋하라」고 **적극적으로 지시**하고 있었다. **대응 미정** — 선택지 넷 중 마스킹 문구의 경로 안내 제거만 즉시 가능하다. 위치·규모는 적지 않는다(위치 비공개 선례). **이 문서 자신이 T70 의 첫 시험이라 수치·코드 행 번호를 옮기지 않고 좌표만 가리킨다** | [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | +| 2026-08-03 | 표시명 개정(`S15P11A705-292`)이 코드에서 운영 화면까지 간 **배포 체인을 기록했다**(I50). **체인 여섯 단계의 소관을 갈랐다** — 우리(`ai#102` dev · `ai#104` 릴리스) · 자동화(`image-publish` 는 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**) · 인프라 소관(`infra#185`) · 클러스터 sync · 화면. **핵심은 미결 판정 하나를 실측으로 닫은 것이다** — 프리셋 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 부트스트랩 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 이미지 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 「값이 같으면 재실행되지 않는다」로 판정했고 독립 검증이 논거를 반증했으나 **클러스터 관측 경로가 없어 「확인 불가」로 끝나 있었다** — 배포가 답을 냈다. **그래도 표기가 낡은 것은 기록 정합성 문제로 남는다**(배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다). **릴리스가 두 번 막혔고 둘 다 다음에 그대로 다시 만난다** — base 검사는 `main` base 를 `release/*`·`hotfix/*` 로 한정하므로 `dev` 를 그대로 열면 CI 가 막고(`ai#103` 이 그렇게 닫혔다), strict 상태 검사는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(병합 커밋 `032e391f`). 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 변경 없이 끝났고, 갱신을 만든 것은 수동 실행이라 **`success` 두 번의 뜻이 다르다.** 확인 수단이 화면뿐이라 「아직인가 실패인가」가 갈리지 않아 한 번 오판했다가 재확인에서 뒤집혔다. **값은 옮기지 않고 좌표만 적었다**(`T70` 적용) | [배포 체인 리포트](implements/2026-08-03-preset-display-name-deploy-chain.md), [I48](implements/2026-08-03-dev-deploy-gap.md), [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | | 2026-08-03 | 외부 리뷰 문서를 **P47 제안 문서로 다시 썼다** — 프리셋의 표시 라벨·축 정의·스키마 개정안(`S15P11A705-228`·`-269`·`-270`·`-271`·`-292`, `ai#90` 후속). **proposals 최초의 `Proposed`** 다 — §7 이후가 미실행이고 §11 실측이 없다. **다만 §6(표시 라벨)은 문서를 쓰는 사이에 반영이 끝났다** — `-292` 가 `display_name` 을 명사·명사구로 통일해 `ai#102`(`dev`)·`ai#104`(`main`)로 나갔고, 그래서 그 절만 제안이 아니라 **기록**으로 다시 썼다(제목에 「반영 완료」 · 표의 「권장 표시명」을 「반영값」으로 · 정본은 문서가 아니라 `data/keyword_preset.yaml` 임을 명시). **초안과 실제가 갈린 셋을 확정으로 남겼다** — `ALONE` 은 초안의 `1인` 을 기각하고 `혼자` 를 유지했고(축의 나머지가 관계어인데 수량 표현만 이질적이다), `TRENDY` 는 `examples` 가 유행이 아니라 미감을 가리키므로 `감성` 으로 확정했으며, `RETRO` 는 **초안에 없던 결정**으로 `복고` 가 됐다(같은 축의 어휘 층위 통일 · `description` 과 정합). **선결 조건이 통째로 바뀌었다** — 「프리셋을 고치면 기존 판정을 전부 재처리한다」로 정해지면서 「판을 구분할 경로」(행 `version` 을 올릴 경로가 없다는 사실, `-269`)가 선결에서 §3-F 로 내려갔고, 그 자리에 **「재처리할 수단이 없다」**가 들어갔다: 완료 State 를 되돌리는 코드가 없고 · 재스캔이 잡는 상태에 완료가 없으며 · 한 번에 밀면 게이트웨이 한도에 걸려 재시도 예산이 소진되면 실패로 굳는다(부재의 범위는 `ai#100`). **「정할 것」이 아니라 「만들 것」이라 §13 0단계가 구현 항목이 됐고**, 같은 이유로 §10.3 세트 release 는 운영 용도가 빠지고 평가 재현만 남아 우선순위가 내려갔다. **원본의 다섯 곳을 정정했다**: ① Feed 표시 정렬을 절째로 빼고 `back` P46 을 가리켰다(원본 제안이 그 결정이 **명시적으로 기각한** 방향이었다), ② 「CI 에서 검증되지 않는 것」 목록을 없애고 방향과 「테스트 코드가 정본」만 남겼다(이미 있는 테스트를 없다고 적고 있었다), ③ 배포 서술을 hook 메커니즘 존재라는 구조 사실로 줄였다(나머지는 `infra` 소관), ④ `version` 을 「세트 release 와 분리」가 아니라 판 식별 문제로 재배치, ⑤ 표시명 오염을 「임베딩 입력」이 아니라 **임베딩·판정 프롬프트 양쪽**으로 고쳤다 — 그래서 이 변경은 임베딩 실험이 아니라 프롬프트 실험 대상이다. **구조 사실 일곱을 보강했다**(개정에 기존 판정 재처리가 따라붙는다 · 후보 슬롯 경쟁으로 프리셋 단위 개선이 전역에서 상쇄된다 · 저빈도 프리셋 처분이 라벨 개정보다 앞선다 · 표시명 개정이 백엔드 조회의 정렬과 중복 흡수에 파급된다 · **`is_active` 를 끌 경로가 없어 tombstone 은 구멍 메우기다** · 공용 계약 개정 단계 · 임베딩 비결정성 때문에 1회 측정으로 채택을 가르지 않는다). **값을 쓰지 않았다** — 측정 수치·현행 상수·행 번호·배포 상태를 전부 출처 참조로 바꿨고 남은 숫자는 §6.2 의 반영값뿐인데 그것도 YAML 이 정본임을 표 앞에 못박았다. 문서가 값을 안고 있으면 값이 바뀔 때 문서만 낡는다. **`§6` 이 실측 없이 먼저 나간 것은 §14 에 감수 항목으로 남겼다** — 표시 계층만 손댄 범위였기 때문이고, 대가로 라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 §11 에 남는다 | [P47](proposals/P47-keyword-preset-label-axis.md), [근거 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md) | diff --git a/docs/development/code-review.md b/docs/development/code-review.md index ec9455a..8011788 100644 --- a/docs/development/code-review.md +++ b/docs/development/code-review.md @@ -23,8 +23,8 @@ - 작성자는 각 의견에 변경 내용 또는 변경하지 않은 이유를 답한다. - 규칙 자체가 바뀌었다면 코드뿐 아니라 기준 문서도 함께 고친다. -모든 대화는 병합 전에 해결한다. 해결 표시는 작성자와 리뷰어의 합의로 수행하며, -미해결 의견을 남긴 채 병합하거나 병합 후 리뷰를 정상 절차로 사용하지 않는다. +모든 대화는 병합 전에 해결한다. 해결 표시는 작성자와 리뷰어의 합의로 수행한다. +미해결 의견을 남긴 채 병합하지 않으며, 병합 후 리뷰를 정상 절차로 사용하지 않는다. ## 담당 경계 @@ -33,7 +33,7 @@ - Spring·DB·트랜잭션·캐시·백엔드 테스트: 백엔드 담당자 교차 계약 PR은 Draft 단계에서 관련 담당자를 모두 reviewer로 지정한다. 필수 -승인 수가 0인 설정은 리뷰가 불필요하다는 뜻이 아니며, 실제 검토 증거는 PR +승인 수가 0인 설정은 리뷰가 불필요하다는 뜻이 아니다. 실제 검토 증거는 PR 대화와 Jira에 남긴다. ## 병합 체크리스트 diff --git a/docs/development/workflow.md b/docs/development/workflow.md index 9d6a55c..b909cbe 100644 --- a/docs/development/workflow.md +++ b/docs/development/workflow.md @@ -2,7 +2,7 @@ 상세 규칙은 [`CONTRIBUTING.md`](../../CONTRIBUTING.md)가 기준이다. 이 문서는 하나의 작업을 시작해 `dev`에 병합하는 실행 순서를 정리한다. `dev`가 통합 -브랜치이고 `main`은 배포 브랜치다 — 일상 작업의 병합 대상은 `dev`다. +브랜치이고 `main`은 배포 브랜치다. 따라서 일상 작업의 병합 대상은 `dev`다. ```text Jira 발급·담당자 지정 diff --git a/docs/implements/2026-07-23-architecture-diagrams.md b/docs/implements/2026-07-23-architecture-diagrams.md index 89f1881..828174e 100644 --- a/docs/implements/2026-07-23-architecture-diagrams.md +++ b/docs/implements/2026-07-23-architecture-diagrams.md @@ -1,4 +1,4 @@ -# 작업 리포트 — architecture.md 구조도(Mermaid) 보강 +# architecture.md 구조도 보강 — Mermaid 다이어그램 4종을 추가하고 문법 검증을 통과했다 - **상태**: 완료 - **날짜**: 2026-07-23 @@ -6,29 +6,29 @@ - **브랜치**: `docs/ai-work-records` ← `main` - **대상**: `docs/architecture.md` -## 목표 +## 배경 -`architecture.md`는 내용은 충실했으나 다이어그램이 전혀 없이 ASCII 텍스트 트리·표뿐이었다. 시스템 경계·진입 경로·계층 의존·트랜잭션 흐름을 **그림으로** 보강해, 구조를 한눈에 파악하고 경계 위반(FastAPI의 `core.*` 접근 금지)을 시각적으로 못박는다. +`architecture.md`는 설명 내용은 충실했지만 다이어그램이 하나도 없었고, 구조 정보가 ASCII 텍스트 트리와 표로만 표현되어 있었다. 이 작업은 시스템 경계·진입 경로·계층 의존·트랜잭션 흐름을 그림으로 보강한다. 목적은 두 가지다. 독자가 구조를 한눈에 파악할 수 있게 하고, 경계 위반 규칙(FastAPI 는 `core.*` 스키마에 접근하지 않는다)을 시각적으로 분명하게 표시한다. -## 산출물 (다이어그램 4) +## 완료한 작업 — 다이어그램 4종 -| # | 위치 | 종류 | 내용 | -|---|---|---|---| -| 1 | §2 시스템 맥락 | flowchart LR | Client→Spring→FastAPI, ai/core 스키마·Redis·외부 API. **`core.*` 접근 금지 경계를 빨간 선(`linkStyle`)으로 표시** | -| 2 | §2 시스템 맥락 | flowchart TB | 두 진입 경로 — `context/process`(비동기 202) vs `search`(동기) | -| 3 | §3 모듈 구조 | flowchart TB | 계층 의존 방향 api→service→repository/client/cache, 단방향 규칙 | -| 4 | §6.1 DB 세션 경계 | sequenceDiagram | TX1 사전검사 → TX2 조건부 PROCESSING → 모델 호출(잠금 밖) → TX3 `FOR UPDATE` 재검사·저장, 경합 시 폐기 | +1. **시스템 맥락** (§2 시스템 맥락, flowchart LR) — Client → Spring → FastAPI 로 이어지는 호출 관계와 ai/core 스키마·Redis·외부 API 의 위치를 그렸다. FastAPI 가 `core.*` 에 접근하면 안 된다는 금지 경계를 빨간 선(`linkStyle`)으로 표시했다. +2. **진입 경로** (§2 시스템 맥락, flowchart TB) — 두 진입 경로를 비교했다. `context/process`는 202 응답을 먼저 반환하는 비동기 경로이고, `search`는 동기 경로다. +3. **계층 의존** (§3 모듈 구조, flowchart TB) — api → service → repository/client/cache 로 향하는 계층 의존 방향과, 역방향 의존을 금지하는 단방향 규칙을 그렸다. +4. **DB 세션 경계** (§6.1 DB 세션 경계, sequenceDiagram) — 트랜잭션 흐름을 그렸다. TX1 에서 사전검사를 하고, TX2 에서 조건부로 PROCESSING 전이를 수행하고, 모델 호출은 잠금 밖에서 실행한 뒤, TX3 에서 `FOR UPDATE` 로 재검사하고 저장한다. 경합이 확인되면 결과를 폐기한다. -- §2 시스템 맥락 신설로 이후 섹션(§3~§8)과 하위(§6.1~6.3) 번호를 재정정. -- 기존 prose·표는 유지하고 다이어그램만 삽입. +문서 구조도 함께 정리했다. + +- §2 시스템 맥락 절을 신설했다. 이에 따라 이후 섹션(§3~§8)과 하위 절(§6.1~6.3)의 번호를 다시 매겼다. +- 기존 문장과 표는 그대로 유지하고 다이어그램만 삽입했다. ## 검증 -- **문법**: `mermaid.parse`(v11 + jsdom)로 **4/4 통과**. `linkStyle`·`classDef`·`alt/else`·노드 shape 유효. → 방법은 [troubleshooting/mermaid-headless-validation.md](../troubleshooting/mermaid-headless-validation.md). -- **내부 링크**: 상대 링크 전부 유효. -- GitHub는 `mermaid` 코드펜스를 자동 렌더하므로 PR에서 그림으로 표시된다. +- **문법**: `mermaid.parse`(v11 + jsdom)로 다이어그램 4종 전부 파싱을 통과했다(4/4). `linkStyle`·`classDef`·`alt/else`·노드 shape 문법이 유효함을 확인했다. 검증 방법은 [troubleshooting/mermaid-headless-validation.md](../troubleshooting/mermaid-headless-validation.md)에 기록했다. +- **내부 링크**: 문서 안의 상대 링크가 전부 유효함을 확인했다. +- GitHub 는 `mermaid` 코드펜스를 자동 렌더링하므로 PR 화면에서 그림으로 표시된다. ## 비고 -- 이 다이어그램은 구조 이해용이며, 상세 규칙의 원본은 여전히 각 구현 명세 문서다. -- 후보 추가 다이어그램: 재스캔/Finalizer 상태 흐름, 배포 토폴로지(미착수). +- 이 다이어그램은 구조 이해를 돕기 위한 것이다. 상세 규칙의 원본은 여전히 각 구현 명세 문서다. +- 추가 후보 다이어그램으로 재스캔/Finalizer 상태 흐름과 배포 토폴로지가 있다. 아직 착수하지 않았다. diff --git a/docs/implements/2026-07-23-fastapi-implementation.md b/docs/implements/2026-07-23-fastapi-implementation.md index 2e356b0..318af86 100644 --- a/docs/implements/2026-07-23-fastapi-implementation.md +++ b/docs/implements/2026-07-23-fastapi-implementation.md @@ -1,74 +1,76 @@ -# FastAPI 구현 — scaffold + /context/process + /search +# FastAPI 구현 — scaffold 와 /context/process·/search 를 계약 명세대로 구현했다 - **상태**: 완료 - **날짜**: 2026-07-23 - **관련 PR**: [ai#5](https://github.com/Team-PinLog/ai/pull/5)(scaffold + `/search` + Preset 부트스트랩), [ai#6](https://github.com/Team-PinLog/ai/pull/6)(`/context/process` 파이프라인 + 상태머신) - **근거 계약**: `static/05_AI_설계.md`, [spec/](../spec/) 전 문서 -- **판정 모델**: `gemini-2.5-flash`(테스트 C-2 확정, [P26](../proposals/P26-keyword-preset-judgment.md)) +- **판정 모델**: `gemini-2.5-flash` — 테스트 C-2 에서 확정했다([P26](../proposals/P26-keyword-preset-judgment.md)) ## 무엇을 만들었나 -`spec/`의 계약 명세를 실제 FastAPI 서버로 구현했다. 두 내부 엔드포인트(`/internal/v1/context/process`, `/internal/v1/search`)와 Preset 부트스트랩 CLI. 앱 코드 약 1,470줄(`app/`). +`spec/` 의 계약 명세를 실제 FastAPI 서버로 구현했다. 내부 엔드포인트 두 개(`/internal/v1/context/process`, `/internal/v1/search`)와 Preset 부트스트랩 CLI 를 만들었다. 앱 코드는 약 1,470줄이다(`app/`). -DB 접근은 **asyncpg + 원시 SQL**(계약 SQL 그대로). ORM을 두지 않았다 — 테이블이 5개이고 guarded UPDATE·`FOR UPDATE`·UPSERT·delete-insert·pgvector 연산이 spec에 이미 SQL로 명시돼 있어, 원시 SQL이 계약과 1:1로 대응한다. +DB 접근은 asyncpg 와 원시 SQL 로 구현했고 ORM 을 도입하지 않았다. 이유는 다음과 같다. 테이블이 5개뿐이고, guarded UPDATE·`FOR UPDATE`·UPSERT·delete-insert·pgvector 연산이 spec 에 이미 SQL 문으로 명시되어 있다. 이 조건에서는 원시 SQL 을 쓰는 쪽이 구현 코드와 계약 문서의 SQL 을 1:1 로 대응시킨다. ## 구현 파일 (architecture.md §3 모듈 구조) | 계층 | 파일 | 역할 | |---|---|---| -| core | `config.py` | 단일 Embedding Profile 주입, model/dim/distance 불일치 시 기동 실패 | -| core | `db.py` | asyncpg 풀, `search_path=ai, public` 고정(public=vector 확장 소재, core는 경로 밖 유지 — T21), pgvector 등록 | -| core | `errors.py` | 영구/일시 오류 + 저장 폐기 분류 | -| core | `security.py` | 내부 공유 시크릿 미들웨어(`/internal/*`) | -| client | `embedding_client.py` | GMS OpenAI 호환 `/embeddings`(하네스 `embed.py` 포팅) | -| client | `llm_client.py` | GMS Gemini `generateContent` + responseSchema + thinkingBudget=0 | -| cache | `preset_cache.py` | 기동 시 `is_active` + Profile 일치 Preset 적재, BLOCKED 제외, 0건이면 기동 실패 | -| repository | `ai_state_repo.py` | 조건부 전이(try_start/complete/fail), Stage 열거형만 컬럼 조립 | -| repository | `context_embedding_repo.py` | 검색 Query + UPSERT(`is_deleted` 제외) + fallback 조회 | -| repository | `context_keyword_repo.py` | delete-insert + analysis UPSERT | +| core | `config.py` | 단일 Embedding Profile 주입. model/dim/distance 가 불일치하면 기동에 실패한다 | +| core | `db.py` | asyncpg 풀. `search_path=ai, public` 고정(public 은 vector 확장이 있는 스키마이고, core 는 경로 밖에 유지한다. T21 참조). pgvector 타입 등록 | +| core | `errors.py` | 영구/일시 오류와 저장 폐기의 분류 | +| core | `security.py` | 내부 공유 시크릿 미들웨어(`/internal/*` 경로에 적용) | +| client | `embedding_client.py` | GMS 의 OpenAI 호환 `/embeddings` 호출(하네스 `embed.py` 를 포팅했다) | +| client | `llm_client.py` | GMS 의 Gemini `generateContent` 호출. responseSchema 와 thinkingBudget=0 을 사용한다 | +| cache | `preset_cache.py` | 기동 시 `is_active` 이고 Profile 이 일치하는 Preset 을 적재한다. BLOCKED 는 제외하고, 적재 결과가 0건이면 기동에 실패한다 | +| repository | `ai_state_repo.py` | 조건부 상태 전이(try_start/complete/fail). 컬럼 조립에는 Stage 열거형만 쓴다 | +| repository | `context_embedding_repo.py` | 검색 Query 와 UPSERT(`is_deleted` 는 갱신 대상에서 제외)와 fallback 조회 | +| repository | `context_keyword_repo.py` | 키워드 delete-insert 와 analysis UPSERT | | repository | `keyword_preset_repo.py` | Preset 적재 조회 | -| service | `search_service.py` | 질의 1회 임베딩 → 정확 cosine → Record 집계 | -| service | `embedding_service.py` | 재사용 판정 + 생성 + 저장 TX | -| service | `keyword_service.py` | 후보 TOP-K + 판정 + delete-insert 저장 | +| service | `search_service.py` | 질의 1회 임베딩 → 정확 cosine 계산 → Record 단위 집계 | +| service | `embedding_service.py` | 임베딩 재사용 판정 + 생성 + 저장 트랜잭션 | +| service | `keyword_service.py` | 후보 TOP-K 선정 + LLM 판정 + delete-insert 저장 | | service | `context_processing.py` | 파이프라인 오케스트레이션 | -| api | `internal/v1/{search,context}.py` | 라우터(context는 202 + BackgroundTask) | -| bootstrap | `load_presets.py` | `data/keyword_preset.yaml` → 임베딩 → `ai.keyword_preset` UPSERT | +| api | `internal/v1/{search,context}.py` | 라우터. context 는 202 응답 후 BackgroundTask 로 처리한다 | +| bootstrap | `load_presets.py` | `data/keyword_preset.yaml` → 임베딩 생성 → `ai.keyword_preset` UPSERT | ## 계약 대비 커버 범위 | spec | 구현 반영 | |---|---| -| [architecture](../spec/architecture.md) | 계층·세션 경계·`ai` 스키마 한정·`core.*` 미접근 | -| [context-processing](../spec/context-processing.md) | 사전 검사 → 재개 → Embedding → Keyword, 저장 불변식 | -| [state-machine](../spec/state-machine.md) | guarded 전이(PENDING/만료 PROCESSING→PROCESSING, →COMPLETED/FAILED), CANCELLED·retry_count·is_deleted 미기록 | -| [partial-resume](../spec/partial-resume.md) | 재사용 2조건, 벡터 재사용 시 임베딩 재호출 0 | -| [personal-search](../spec/personal-search.md) | 정확 cosine + Record `MAX` 집계, Profile 불일치 422 | -| [keyword-preset](../spec/keyword-preset.md) | 캐시 TOP-K(floor 0.30), 후보 밖 폐기, delete-insert, unmatchedConcepts | -| [model-profile](../spec/model-profile.md) | Profile 단일 주입, 차원 검증, 불일치 동작 | -| [deletion-race-control](../spec/deletion-race-control.md) | 저장 직전 `FOR UPDATE` 재검사(늦은 INSERT 폐기) | -| [failure-recovery](../spec/failure-recovery.md) | 영구=FAILED, 일시=상태 유지(재스캔 회수) | - -**미구현(후속)**: [integration-tests](../spec/integration-tests.md) 자동화(Testcontainers) + Dockerfile은 E3에서. 현재는 로컬 수동 검증. +| [architecture](../spec/architecture.md) | 계층 구조·세션 경계·`ai` 스키마 한정·`core.*` 미접근 | +| [context-processing](../spec/context-processing.md) | 사전 검사 → 재개 → Embedding → Keyword 순서, 저장 불변식 | +| [state-machine](../spec/state-machine.md) | guarded 전이(PENDING/만료 PROCESSING→PROCESSING, →COMPLETED/FAILED). CANCELLED·retry_count·is_deleted 는 기록하지 않는다 | +| [partial-resume](../spec/partial-resume.md) | 재사용 2조건. 벡터를 재사용하면 임베딩 API 를 다시 호출하지 않는다(재호출 0회) | +| [personal-search](../spec/personal-search.md) | 정확 cosine 계산과 Record `MAX` 집계, Profile 불일치 시 422 | +| [keyword-preset](../spec/keyword-preset.md) | 캐시 기반 TOP-K(floor 0.30), 후보 밖 판정 폐기, delete-insert, unmatchedConcepts | +| [model-profile](../spec/model-profile.md) | Profile 단일 주입, 차원 검증, 불일치 시 동작 | +| [deletion-race-control](../spec/deletion-race-control.md) | 저장 직전 `FOR UPDATE` 재검사로 늦은 INSERT 를 폐기한다 | +| [failure-recovery](../spec/failure-recovery.md) | 영구 오류는 FAILED 로, 일시 오류는 상태를 유지해 재스캔이 회수한다 | + +**미구현(후속)**: [integration-tests](../spec/integration-tests.md)의 자동화(Testcontainers)와 Dockerfile 은 E3 단계에서 한다. 현재는 로컬 수동 검증까지다. ## 검증 방법 -로컬 `pgvector/pgvector:pg16` + back Flyway(V1/V100/V101)로 `ai.*` 생성 + 부트스트랩 27 Preset 적재 후, 실제 GMS(임베딩·Gemini) 호출로 end-to-end 확인. +로컬에서 `pgvector/pgvector:pg16` 컨테이너를 띄우고 back 레포의 Flyway 마이그레이션(V1/V100/V101)으로 `ai.*` 테이블을 생성했다. 부트스트랩으로 Preset 27건을 적재한 뒤, 실제 GMS(임베딩·Gemini)를 호출해 end-to-end 로 확인했다. **`/search`** -- "친구들이랑 모임" → 친구 record 최상위(0.56), "가족과 저녁" → 가족 record(0.72) — 의미 매칭 정확 -- `userId` 범위 필터로 타 유저 record 차단 -- Profile 불일치 → **422**, 내부 시크릿 없음 → **401**, `/health` → ok -**`/context/process`** (PENDING state 선삽입 후 호출) +- 질의 "친구들이랑 모임"에는 친구 관련 record 가 최상위(유사도 0.56)로 반환됐고, "가족과 저녁"에는 가족 record(0.72)가 반환됐다. 의미 기반 매칭이 의도대로 동작한다. +- `userId` 범위 필터가 다른 유저의 record 를 결과에서 차단하는 것을 확인했다. +- Embedding Profile 이 불일치하면 422 를, 내부 시크릿이 없으면 401 을 반환했다. `/health` 는 ok 를 반환했다. + +**`/context/process`** — PENDING 상태 행을 먼저 삽입한 뒤 호출했다. + | 시나리오 | 결과 | |---|---| -| 정상 | embedding+keyword+analysis 저장, "여자친구/기념일" → WITH_PARTNER/MEAL/DATE_COURSE | -| 후보/판정 0 | keyword COMPLETED + 0건("주차/화장실" 부대시설 판정 기각) | -| CANCELLED | 처리 거부, embedding 미저장 | -| 부분 재개 | keyword만 PENDING → **임베딩 재호출 0회**(로그 확인), 판정만 재실행 | +| 정상 | embedding·keyword·analysis 가 저장됐다. "여자친구/기념일" 본문에 WITH_PARTNER/MEAL/DATE_COURSE 키워드가 붙었다 | +| 후보/판정 0건 | keyword 단계가 COMPLETED 로 끝나고 키워드는 0건으로 저장됐다. "주차/화장실" 본문은 부대시설 제외 규칙에 따라 판정이 기각됐다 | +| CANCELLED | 처리를 거부했고 embedding 을 저장하지 않았다 | +| 부분 재개 | keyword 단계만 PENDING 인 상태에서 판정만 다시 실행했다. 임베딩 API 재호출은 0회였다(로그로 확인) | ## 구현 중 해결한 이슈 -- `.env` UTF-8 BOM → 첫 키 파싱 실패(PowerShell `Set-Content` 기본 BOM). BOM 없이 재작성. -- pgvector가 embedding을 `Vector` 객체로 반환 → `to_numpy()` 변환. -- asyncpg `now() - $2` 파라미터 타입 추론 실패(`timestamptz < interval`) → `$2::interval` 캐스트. +- `.env` 파일이 UTF-8 BOM 으로 저장되어 첫 번째 키의 파싱이 실패했다. PowerShell `Set-Content` 의 기본 인코딩이 BOM 을 붙이는 것이 원인이었다. BOM 없이 다시 저장해 해결했다. +- pgvector 가 embedding 컬럼을 `Vector` 객체로 반환했다. `to_numpy()` 로 변환해 처리했다. +- asyncpg 가 `now() - $2` 식의 파라미터 타입을 추론하지 못했다(`timestamptz < interval` 비교). `$2::interval` 명시 캐스트로 해결했다. diff --git a/docs/implements/2026-07-23-keyword-matching-eval.md b/docs/implements/2026-07-23-keyword-matching-eval.md index 406d78c..416d9ca 100644 --- a/docs/implements/2026-07-23-keyword-matching-eval.md +++ b/docs/implements/2026-07-23-keyword-matching-eval.md @@ -1,33 +1,50 @@ -# 작업 리포트 — Keyword 매칭 평가 (요약·포인터) +# Keyword 매칭 평가 — 프리셋 27개 검증을 통과하고 판정 모델을 gemini-2.5-flash 로 확정했다 (요약·포인터) - **상태**: 완료 - **날짜**: 2026-07-23 - **관련 PR**: [ai#3](https://github.com/Team-PinLog/ai/pull/3) — 하네스 + A/B/C 실행 + 판정 모델 확정 - **상세 원본**: `tools/keyword_eval/REPORT.md` (수치·비교표 원본, ai#3로 병합됨) -> 이 문서는 **요약과 포인터**다. 수치 원본·하네스 코드는 `tools/keyword_eval/`가 소유하며, 중복 관리를 피하려고 여기서는 결론과 이 레포 문서와의 연결만 남긴다. **A/B/C 완료 — 판정 모델 `gemini-2.5-flash` 확정(M4 종결).** +> 이 문서는 요약과 포인터다. 수치 원본과 하네스 코드는 `tools/keyword_eval/` 이 소유한다. 같은 내용을 두 곳에서 관리하지 않기 위해 여기에는 결론과 이 레포 문서와의 연결만 남긴다. 테스트 A/B/C 를 모두 완료했고, 판정 모델을 `gemini-2.5-flash` 로 확정했다(미결 사항 M4 종결). ## 목표 -프리셋 27개([2026-07-23-keyword-preset-seed.md](2026-07-23-keyword-preset-seed.md))가 실제 임베딩·판정에서 건전한지, FastAPI·DB 없이 **선행 검증**한다. E(구현) 전에 해야 프리셋 결함 발견 시 재적재·재분류 비용을 피한다. 특히 **테스트 C가 확정하는 판정 프롬프트가 `/context/process`에 그대로 투입**되므로 구현 일부를 미리 끝내는 셈이다. +프리셋 27개([2026-07-23-keyword-preset-seed.md](2026-07-23-keyword-preset-seed.md))가 실제 임베딩·판정에서 건전하게 동작하는지를 FastAPI 서버·DB 없이 미리 검증한다. 구현(E) 전에 검증해야 하는 이유는, 프리셋 결함이 구현 후에 발견되면 재적재·재분류 비용이 들기 때문이다. 특히 테스트 C 가 확정하는 판정 프롬프트는 `/context/process` 에 그대로 투입되므로, 이 검증은 구현의 일부를 미리 끝내는 작업이기도 하다. ## 하네스 -`tools/keyword_eval/` — 팀이 실제 샘플로 재실행할 수 있게 커밋. `embed.py`(GMS 임베딩, 디스크 캐시), `samples.yaml`(임시 맥락 35개, self-reference 편향 → 경향 해석), `test_a/b/c`, `prompts/keyword_judgment.md`, `REPORT.md`. +`tools/keyword_eval/` 에 커밋했다. 팀이 실제 샘플로 재실행할 수 있게 하기 위해서다. 구성은 다음과 같다. -## 결과 (A/B/C) +- `embed.py` — GMS 임베딩 호출. 결과를 디스크에 캐시한다. +- `samples.yaml` — 임시 맥락 35개. 프리셋을 아는 작성자가 만든 샘플이라 self-reference 편향이 있으므로, 수치는 절대값이 아니라 경향으로 해석한다. +- `test_a/b/c` — 테스트 3종 실행 코드. +- `prompts/keyword_judgment.md` — 판정 프롬프트. +- `REPORT.md` — 수치 원본. -- **A 자기 중복**: cosine ≥ 0.9 병합 후보 **0건**. 프리셋 독립성 OK → 병합·삭제 불필요. -- **B 커버리지**(K=10, floor 0.30): 미매칭율 2.9%, 쏠림 max-share 10%·Gini 0.286, 사각지대 0. -- **C-1 프롬프트 안정화**(gpt-5-mini): 스키마 위반·파싱 실패·과잉 선택 각 0. 판정이 임베딩 후보 노이즈를 교정(`WITH_FAMILY`→`WITH_PARTNER`). **부대시설 제외 규칙 추가**, **하한 0.30 유지** 확정. -- **C-2 판정 모델 비교**: 확정 프롬프트로 3사 4모델(gpt-5-mini/nano, claude-haiku-4-5, gemini-2.5-flash) GMS 경로 실행. 정확도(스키마·선택 분포)는 4모델 사실상 동일 → 경량 tier로 충분. **`gemini-2.5-flash`(thinkingBudget=0) 확정** — 최속(1.12s)·최소 토큰(25314). gpt-5-nano 탈락(최장 지연·최다 토큰). confidence는 전 모델 변별력 낮음(랭킹 신호 미사용). Gemini는 function-calling이 malformed → `responseSchema`로 호출. +## 결과 -→ 확정 사항은 [P26](../proposals/P26-keyword-preset-judgment.md)에 반영(M4 종결). +### A — 프리셋 자기 중복 검사 + +프리셋끼리 임베딩이 지나치게 비슷해 병합해야 하는 쌍이 있는지 검사했다. cosine 유사도 0.9 이상인 병합 후보는 0건이었다. 프리셋들이 서로 독립적이므로 병합이나 삭제는 필요 없다. + +### B — 커버리지 + +후보 상한 K=10, 유사도 하한(floor) 0.30 조건으로 쟀다. 어느 프리셋에도 매칭되지 않은 샘플의 비율(미매칭율)은 2.9%였다. 특정 프리셋으로의 쏠림도 크지 않았다. 가장 많이 매칭된 프리셋의 점유율(max-share)이 10%였고, 매칭 분포의 불균형을 나타내는 Gini 계수는 0.286이었다. 어떤 샘플에서도 후보로 등장하지 않는 프리셋(사각지대)은 0개였다. + +### C-1 — 판정 프롬프트 안정화 (gpt-5-mini) + +프롬프트를 반복 실행하며 다듬어, 스키마 위반·파싱 실패·과잉 선택이 각각 0건이 되도록 안정화했다. LLM 판정이 임베딩 후보의 노이즈를 교정하는 것도 확인했다(임베딩이 올린 `WITH_FAMILY` 후보를 판정이 `WITH_PARTNER` 로 바로잡았다). 이 과정에서 부대시설 제외 규칙을 프롬프트에 추가했고, 후보 하한 0.30 은 유지하기로 확정했다. + +### C-2 — 판정 모델 비교 + +확정한 프롬프트로 3사 4모델(gpt-5-mini, gpt-5-nano, claude-haiku-4-5, gemini-2.5-flash)을 GMS 경로에서 실행해 비교했다. 정확도(스키마 준수·선택 분포)는 4모델이 사실상 동일했다. 따라서 경량 tier 모델로 충분하다. 그중 가장 빠르고(1.12s) 토큰을 가장 적게 쓴(25314) `gemini-2.5-flash`(thinkingBudget=0)를 확정했다. gpt-5-nano 는 지연이 가장 길고 토큰을 가장 많이 써서 탈락했다. 모델이 반환하는 confidence 값은 모든 모델에서 변별력이 낮아 랭킹 신호로 사용하지 않는다. Gemini 는 function-calling 응답이 malformed 로 나와서, 대신 `responseSchema` 방식으로 호출한다. + +확정 사항은 [P26](../proposals/P26-keyword-preset-judgment.md)에 반영했다(M4 종결). ## 남은 것 -- 팀 실제 샘플(프리셋 안 보고 작성)로 B/C 재측정 → Recall·트리키 케이스 유효 검증(현 샘플은 self-reference 편향). -- GMS 모델별 크레딧 단가표로 토큰→비용 확정([spec/cost-estimate.md](../spec/cost-estimate.md) §4 공식에 대입). +- 팀원이 프리셋을 보지 않고 작성한 실제 샘플로 B/C 를 다시 측정해야 한다. 현재 샘플은 self-reference 편향이 있어, Recall 과 트리키 케이스가 실제로 유효한지는 새 샘플로만 검증할 수 있다. +- GMS 모델별 크레딧 단가표가 나오면 토큰 사용량을 비용으로 환산해야 한다([spec/cost-estimate.md](../spec/cost-estimate.md) §4 공식에 대입). ## 관련 diff --git a/docs/implements/2026-07-23-keyword-preset-seed.md b/docs/implements/2026-07-23-keyword-preset-seed.md index 9f3c29a..7a1ecda 100644 --- a/docs/implements/2026-07-23-keyword-preset-seed.md +++ b/docs/implements/2026-07-23-keyword-preset-seed.md @@ -1,4 +1,4 @@ -# 작업 리포트 — Keyword Preset seed 초안 +# Keyword Preset seed 초안 — keyword_preset.yaml 에 프리셋 27개를 작성했다 - **상태**: 완료 - **날짜**: 2026-07-23 @@ -9,11 +9,11 @@ ## 목표 -Keyword Preset이 계약·명세로만 존재했다. 임베딩·분류·검색 실험과 이후 부트스트랩 적재가 가능하도록 **프리셋 시드 초안**을 만든다. 적재 계약은 `docs/keyword-preset.md`, 로더 코드는 E(구현) 소관이므로 이 작업은 **데이터 초안**까지다. +Keyword Preset 은 이 시점까지 계약·명세 문서로만 존재했고 실제 데이터가 없었다. 임베딩·분류·검색 실험과 이후 부트스트랩 적재가 가능하도록 프리셋 시드 초안을 만든다. 적재 계약은 `docs/keyword-preset.md` 가 정의하고 로더 코드는 구현 단계(E) 소관이므로, 이 작업의 범위는 데이터 초안까지다. ## 산출물 -`data/keyword_preset.yaml` — 27개. +`data/keyword_preset.yaml` — 프리셋 27개. | 범주 | 개수 | id 블록 | 예 | |---|---|---|---| @@ -22,23 +22,25 @@ Keyword Preset이 계약·명세로만 존재했다. 임베딩·분류·검색 | ATMOSPHERE | 7 | 3xx | QUIET, COZY, SPACIOUS | | SITUATION | 6 | 4xx | CELEBRATION, ANNIVERSARY(PRIVATE_ONLY) | -필드 규칙(확정): -- `description`: 20~40자 한 문장, 정의가 아니라 **의미 범위**(동의어·인접 개념). -- `examples`: 3~5개 구어체, **키워드 단어가 없는 문장 최소 1개**(문어체 금지). -- `visibility`: 기본 `PUBLIC`, 개인 유추 소지 시 `PRIVATE_ONLY`(사유 주석), `BLOCKED` 없음. -- `id`: 명시적 고정. 임베딩은 YAML에 미포함(부트스트랩이 생성 후 INSERT). +필드 규칙은 다음과 같이 확정했다. + +- `description`: 20~40자 한 문장. 사전적 정의가 아니라 의미 범위를 적는다. 동의어와 인접 개념을 포함한다. +- `examples`: 구어체 3~5개. 키워드 단어가 등장하지 않는 문장을 최소 1개 포함한다. 문어체는 쓰지 않는다. +- `visibility`: 기본은 `PUBLIC` 이다. 개인 정보를 유추할 소지가 있으면 `PRIVATE_ONLY` 로 지정하고 사유를 주석으로 남긴다. `BLOCKED` 항목은 없다. +- `id`: 명시적으로 고정한다. 임베딩 값은 YAML 에 포함하지 않는다. 부트스트랩이 임베딩을 생성한 뒤 INSERT 한다. ## 검증 -Python 점검 스크립트로 확인: -- [x] 총 27개, 범주 배분(6/8/7/6) 일치. -- [x] `id`·`code` 유일. -- [x] `visibility` 값 유효, PRIVATE_ONLY 2건(WITH_COLLEAGUES·ANNIVERSARY). -- [x] 모든 항목 `examples`에 키워드 단어 없는 문장 ≥1 포함. +Python 점검 스크립트로 다음을 확인했다. + +- [x] 총 27개이고 범주 배분(6/8/7/6)이 계획과 일치한다. +- [x] `id` 와 `code` 가 각각 유일하다. +- [x] `visibility` 값이 유효하다. PRIVATE_ONLY 는 2건(WITH_COLLEAGUES·ANNIVERSARY)이다. +- [x] 모든 항목의 `examples` 에 키워드 단어가 없는 문장이 1개 이상 있다. ## 후속 검증(별도 트랙) -이 시드는 문서 규칙만 만족한 상태였다. **실제 임베딩·매칭 품질**은 별도 평가 트랙에서 측정했고, 결과적으로 **보정 불필요**로 확인됐다 → [2026-07-23-keyword-matching-eval.md](2026-07-23-keyword-matching-eval.md), [P26](../proposals/P26-keyword-preset-judgment.md). +이 시드는 문서 규칙만 만족한 상태였다. 실제 임베딩·매칭 품질은 별도 평가 트랙에서 측정했고, 결과적으로 보정이 필요 없음을 확인했다. 결과는 [2026-07-23-keyword-matching-eval.md](2026-07-23-keyword-matching-eval.md)와 [P26](../proposals/P26-keyword-preset-judgment.md)에 있다. ## 관련 diff --git a/docs/implements/2026-07-24-e3-test-harness.md b/docs/implements/2026-07-24-e3-test-harness.md index b71b270..78e0856 100644 --- a/docs/implements/2026-07-24-e3-test-harness.md +++ b/docs/implements/2026-07-24-e3-test-harness.md @@ -1,56 +1,56 @@ -# E3 통합 테스트 하네스 + 저수준 계층 + 인프라 정비 +# E3 통합 테스트 — 하네스와 저수준 27케이스·파이프라인 20시나리오를 구현하고 테스트 인프라를 정비했다 - **상태**: 완료 (E3-PR1·PR2) -- **날짜**: 2026-07-24 (PR2 2026-07-27) +- **날짜**: 2026-07-24 (PR2 는 2026-07-27) - **관련 PR**: [ai#14](https://github.com/Team-PinLog/ai/pull/14)(E3-PR1), [ai#16](https://github.com/Team-PinLog/ai/pull/16)(핫픽스), [ai#18](https://github.com/Team-PinLog/ai/pull/18)(E3-PR2 파이프라인 20) - **근거 계약**: [spec/integration-tests.md](../spec/integration-tests.md) (§16 검증 시나리오) ## 무엇을 만들었나 -계약 §16 검증 시나리오를 자동 pytest 스위트로 옮기기 위한 **하네스 + 저수준 3계층(단위·저장소·API)**을 구현했다(E3-PR1). 파이프라인 시나리오 20개(`test_pipeline.py`, §3)는 E3-PR2(ai#18)로 **구현 완료**(19함수/20시나리오, 6·18 공유). 챗봇/GraphRAG 합류 대비 환경 통일(Python 3.12·lock)도 함께 반영했다. 프로덕션 `app/`은 `db.py` search_path 보정 1건만 변경(T21). +계약 §16 의 검증 시나리오를 자동 pytest 스위트로 옮기는 작업이다. E3-PR1 에서 하네스와 저수준 3계층(단위·저장소·API) 테스트를 구현했다. 파이프라인 시나리오 20개(`test_pipeline.py`, 계약 §3)는 E3-PR2([ai#18](https://github.com/Team-PinLog/ai/pull/18))에서 구현을 완료했다. 시나리오 6과 18이 한 테스트 함수를 공유하므로 함수 수는 19개다(19함수/20시나리오). 챗봇/GraphRAG 스택의 합류에 대비한 환경 통일(Python 3.12·lock 파일)도 함께 반영했다. 프로덕션 코드(`app/`)의 변경은 `db.py` 의 search_path 보정 1건뿐이다(T21). ## 하네스 (`tests/`) -- **`conftest.py`**: 세션 스코프 `PostgresContainer(PGVECTOR_IMAGE)` — 현재 `pgvector/pgvector:0.8.5-pg16@sha256:1d53…`(운영·back `compose.yaml`과 digest까지 일치. 최초 `0.8.1-pg16` → `S15P11A705-122`에서 정합화) → `schema/ai_snapshot.sql`(back V1/V100/V101 파생) 적용 → asyncpg 풀. **격리는 TRUNCATE**(동시성 테스트가 여러 커넥션을 쓰므로 롤백 격리 불가). `settings` fixture는 Profile을 주입(리터럴 금지). -- **`fakes.py`**: `FakeEmbeddingClient`/`FakeLLMClient` — sha256 기반 **결정론 벡터**(무작위는 유사도 순서 단언을 흔듦), **호출 횟수 기록**(여러 시나리오의 핵심 단언이 "호출 안 함"/"정확히 한 번"), `on_call` 훅(모델 호출과 저장 사이 창을 결정론적으로 재현, sleep 금지). -- **`builders.py`**: `make_state`/`make_embedding`/`make_preset`. `embedding_profile`·`is_deleted`·두 status를 항상 명시. **본문 버전 인자를 두지 않음**(제거된 개념 부활 방지) — 수정은 `context_id`가 다른 두 State로 표현. -- **`schema/ai_snapshot.sql`**: 테스트 전용 스냅샷. `ai` 스키마만(ai 테이블은 core FK 없음). 헤더에 back 파생 출처·갱신 위험 명시. +- **`conftest.py`** — 세션 스코프의 `PostgresContainer(PGVECTOR_IMAGE)`를 띄운다. 이미지는 현재 `pgvector/pgvector:0.8.5-pg16@sha256:1d53…` 로, 운영 환경과 back 레포 `compose.yaml` 의 이미지와 digest 까지 일치한다(최초에는 `0.8.1-pg16` 이었고 `S15P11A705-122` 에서 정합화했다. 경위는 아래 「결정」 참조). 컨테이너에 `schema/ai_snapshot.sql`(back V1/V100/V101 에서 파생)을 적용한 뒤 asyncpg 풀을 연다. 테스트 간 격리는 TRUNCATE 로 한다. 롤백 격리를 쓰지 않는 이유는 동시성 테스트가 여러 커넥션을 쓰기 때문이다. `settings` fixture 가 Embedding Profile 을 주입하며, 테스트 코드에 Profile 문자열 리터럴을 쓰는 것은 금지다. +- **`fakes.py`** — `FakeEmbeddingClient`/`FakeLLMClient`. 벡터는 sha256 기반으로 결정론적으로 생성한다. 무작위 벡터를 쓰면 유사도 순서 단언이 실행마다 흔들리기 때문이다. 호출 횟수를 기록한다. 여러 시나리오의 핵심 단언이 "호출하지 않았다" 또는 "정확히 한 번 호출했다"이기 때문이다. `on_call` 훅으로 모델 호출과 저장 사이의 시간 창을 결정론적으로 재현한다. sleep 으로 타이밍을 맞추는 방식은 금지다. +- **`builders.py`** — `make_state`/`make_embedding`/`make_preset`. `embedding_profile`·`is_deleted`·두 status 컬럼을 항상 명시한다. 본문 버전 인자는 두지 않았다. 버전은 설계에서 제거된 개념이라 테스트 빌더가 되살리면 안 되기 때문이다. 본문 수정 시나리오는 `context_id` 가 다른 두 State 로 표현한다. +- **`schema/ai_snapshot.sql`** — 테스트 전용 스키마 스냅샷. `ai` 스키마만 담는다(ai 테이블은 core 로의 FK 가 없다). 파일 헤더에 back 파생 출처와 갱신 누락 위험을 명시했다. ## 저수준 27케이스 (계층 배분) | 계층 | 파일 | 검증 | |---|---|---| -| 단위(DB 없음) | `test_unit.py` | 오류 분류, 후보 TOP-K(`_topk`), LLM 매핑·폐기(`_map` — 후보 밖/범위 밖 confidence 폐기·중복 접기), Profile 검증(config validator) | -| 저장소(실제 DB) | `test_repo.py` | 조건부 전이 rowcount(PENDING·만료 PROCESSING만), **is_deleted 제외 UPSERT 회귀**, delete-insert, 검색 DISTINCT ON(대표 contextId) | -| API(실제 DB, Fake 주입) | `test_api.py` | 202, 검색 형식(contextId 포함), Profile 422, 시크릿 401 | +| 단위(DB 없음) | `test_unit.py` | 오류 분류, 후보 TOP-K(`_topk`), LLM 응답 매핑·폐기(`_map` — 후보 밖 keywordId 와 범위 밖 confidence 의 폐기, 중복 접기), Profile 검증(config validator) | +| 저장소(실제 DB) | `test_repo.py` | 조건부 전이의 rowcount(PENDING·만료 PROCESSING 만 전이), `is_deleted` 를 제외하는 UPSERT 회귀, delete-insert, 검색 DISTINCT ON(대표 contextId 선택) | +| API(실제 DB, Fake 주입) | `test_api.py` | 202 접수, 검색 응답 형식(contextId 포함), Profile 불일치 422, 시크릿 없음 401 | ## 인프라 -- **`Dockerfile`**: `python:3.12-slim`, `requirements.lock` 설치, 비루트, `uvicorn app.main:app`. -- **`ai-ci.yml` 정비**(신규 생성 아님 — 기존 워크플로 수정): lock 설치 전환, **PR 제목 Jira 키 검증**(squash 병합이라 제목=최종 커밋), ruff·compileall·pytest. -- **환경 통일**: `pyproject.toml [project].requires-python=">=3.12,<3.13"`, `.python-version`, Dockerfile·ai-ci 전부 3.12. **lock 도입**: `requirements.lock`/`requirements-dev.lock`(uv, `--universal` 마커) — CI·Docker는 lock 설치. +- **`Dockerfile`** — `python:3.12-slim` 기반. `requirements.lock` 을 설치하고, 비루트 사용자로 `uvicorn app.main:app` 을 실행한다. +- **`ai-ci.yml` 정비** — 새로 만든 것이 아니라 기존 워크플로를 수정했다. lock 설치로 전환하고, PR 제목의 Jira 키 검증 스텝을 추가했다(squash 병합이므로 PR 제목이 최종 커밋 메시지가 된다). ruff·compileall·pytest 를 실행한다. +- **환경 통일** — `pyproject.toml` 에 `[project].requires-python=">=3.12,<3.13"` 을 명시하고, `.python-version`·Dockerfile·ai-ci 를 전부 3.12 로 맞췄다. lock 파일도 도입했다. `requirements.lock`/`requirements-dev.lock`(uv 로 생성, `--universal` 마커 사용)이며 CI 와 Docker 는 lock 으로 설치한다. ## 결정 -- **Python 3.12 통일, 상한 `<3.13`** — GraphRAG 스택(torch/transformers/igraph) wheel 안전판. 3.13 지원 확산 시 완화 재검토. -- **pgvector `0.8.5-pg16` + digest 고정** — 운영·back `compose.yaml`의 **실제 버전과 digest까지** 일치(재현성). 롤링 `pg16` 금지, 태그 단독 고정도 금지(같은 태그가 다른 이미지를 가리킬 수 있다). ※ 최초 결정은 `0.8.1-pg16` 태그 고정이었고 근거는 "back `compose.yaml`과 일치"였으나, back#31이 compose를 `0.8.5-pg16@sha256:1d53…`로 올려 그 근거가 무효화됐다(운영도 0.8.5 — infra#41). 어긋난 쪽이 ai였으므로 `S15P11A705-122`에서 digest까지 정합화했다. -- **lock 도입** — 합류자 재현성. `requirements.txt`(사람용 하한) + lock(정확 버전). -- **ai-ci PR 제목 Jira 키 검증** — 형식 보증(티켓 존재 보증 아님, 수용). +- **Python 3.12 로 통일하고 상한을 `<3.13` 으로 둔다.** GraphRAG 스택(torch/transformers/igraph)의 wheel 이 최신 Python 을 늦게 지원하므로 3.12 가 안전하다. 3.13 지원이 확산되면 상한 완화를 재검토한다. +- **pgvector 이미지는 `0.8.5-pg16` 에 digest 까지 고정한다.** 운영과 back `compose.yaml` 의 실제 이미지와 digest 까지 일치시켜 재현성을 확보했다. 롤링 태그 `pg16` 은 금지한다. 태그만 고정하는 것도 금지한다. 같은 태그가 다른 이미지를 가리킬 수 있기 때문이다. 경위를 남긴다. 최초 결정은 `0.8.1-pg16` 태그 고정이었고 근거는 "back `compose.yaml` 과 일치한다"였다. 그런데 back#31 이 compose 를 `0.8.5-pg16@sha256:1d53…` 로 올리면서 그 근거가 무효가 됐다(운영도 0.8.5 다. infra#41). 어긋난 쪽이 ai 였으므로 `S15P11A705-122` 에서 digest 까지 맞췄다. +- **lock 파일을 도입한다.** 합류자의 환경 재현성을 위해서다. `requirements.txt` 는 사람이 읽는 하한 명세로, lock 은 정확한 버전 고정으로 역할을 나눈다. +- **ai-ci 의 PR 제목 Jira 키 검증.** 형식만 보증하며 티켓이 실제로 존재하는지는 보증하지 않는다. 이 한계는 수용했다. ## 발견 (→ 트러블슈팅) -로컬 코드 리뷰로는 드러나지 않고 CI 러너/멀티 커넥션에서만 터진 경계 이슈 3건을 [troubleshooting/2026-07-24-e3-ci-and-search-path.md](../troubleshooting/2026-07-24-e3-ci-and-search-path.md)에 기록: +로컬 코드 리뷰로는 드러나지 않고 CI 러너 또는 멀티 커넥션 환경에서만 실패하는 경계 이슈 3건을 [troubleshooting/2026-07-24-e3-ci-and-search-path.md](../troubleshooting/2026-07-24-e3-ci-and-search-path.md)에 기록했다. -- **T21 search_path**(최우선): `SET search_path = ai` 단독이 public을 제외해 VECTOR 타입 해석·`register_vector`가 멀티 커넥션에서 실패 → `ai, public` 보정(프로덕션 `app/core/db.py` 유일 변경). -- **T19 lock 플랫폼 종속**: Windows lock의 `pywin32`가 Linux CI 설치를 깨뜨림 → `uv pip compile --universal`. -- **T20 pytest pythonpath**: CI 러너에서 `app`/`tests` import 실패 → `pyproject pythonpath=["."]`. +- **T21 search_path** (최우선): `SET search_path = ai` 단독 설정이 public 스키마를 검색 경로에서 제외해, VECTOR 타입 해석과 `register_vector` 가 멀티 커넥션에서 실패했다. `ai, public` 으로 보정했다. 프로덕션 `app/core/db.py` 의 유일한 변경이다. +- **T19 lock 플랫폼 종속**: Windows 에서 생성한 lock 에 포함된 `pywin32` 가 Linux CI 의 설치를 깨뜨렸다. `uv pip compile --universal` 로 플랫폼 마커를 넣어 해결했다. +- **T20 pytest pythonpath**: CI 러너에서 `app`/`tests` 모듈 import 가 실패했다. `pyproject` 에 `pythonpath=["."]` 를 추가해 해결했다. ## 검증 -- `pytest -q` **27 passed**(단위·저장소·API), `ruff check .` clean, `docker build`(3.12-slim) 성공. -- CI: PR #16이 새 워크플로로 Linux 전체 검증(45s, testcontainers pytest 포함) 그린, main push CI 그린. +- `pytest -q` 27 passed(단위·저장소·API). `ruff check .` 통과. `docker build`(3.12-slim) 성공. +- CI: PR #16 이 새 워크플로로 Linux 전체 검증(45s, testcontainers pytest 포함)을 통과했고, main push CI 도 통과했다. ## E3-PR2 (완료, ai#18) -- `test_pipeline.py` — integration-tests.md §3의 파이프라인 시나리오 20개(취소 거부·검색 경계·Keyword·재개/상태·계약위반/경합), 6·18 공유로 19함수. 동시성은 `on_call` 훅으로 CANCELLED 주입(sleep 금지). -- `pytest tests/ -q` **52 passed** = AI 검증 46(저수준 27 + 파이프라인 19) + CI 계약 6. CI 계약 6개는 ai#25(인프라 `-20`)가 `tests/`에 추가한 CI 이미지 발행 계약이며, E3 자체는 46으로 완결이고 52는 그 위에 **증가**한 수다. ruff clean. +- `test_pipeline.py` — integration-tests.md §3 의 파이프라인 시나리오 20개(취소 거부·검색 경계·Keyword·재개/상태·계약위반/경합)를 구현했다. 시나리오 6·18 이 함수를 공유해 19함수다. 동시성 시나리오는 `on_call` 훅으로 CANCELLED 를 주입해 재현하며 sleep 을 쓰지 않는다. +- `pytest tests/ -q` 52 passed. 구성은 AI 검증 46개(저수준 27 + 파이프라인 19)에 CI 계약 6개를 더한 것이다. CI 계약 6개는 ai#25(인프라 티켓 `-20`)가 `tests/` 에 추가한 CI 이미지 발행 계약 테스트다. 즉 E3 자체는 46개로 완결이고, 52는 그 위에 다른 작업이 더해 늘어난 수다. ruff 통과. diff --git a/docs/implements/2026-07-27-e2e-verification.md b/docs/implements/2026-07-27-e2e-verification.md index ec44878..9d8c771 100644 --- a/docs/implements/2026-07-27-e2e-verification.md +++ b/docs/implements/2026-07-27-e2e-verification.md @@ -1,4 +1,4 @@ -# E2E 검증 — 실제 GMS 경로 전수 확인 +# E2E 검증 — 실제 GMS 경로를 전수 확인했다. 파이프라인·검색은 통과했고, 문서만으로는 기동할 수 없었다 - **상태**: 완료 - **날짜**: 2026-07-27 @@ -9,36 +9,34 @@ ## 무엇을 검증했나 -ai#5·#6·#11·#14·#16·#17·#18로 구현이 끝났으나, 검증된 것은 **Fake 기반 46 테스트(계약 위반·경합 방어)뿐**이었다. 실제 GMS 호출, 프리셋 실적재, 실제 임베딩 기반 검색 품질은 한 번도 실행된 적이 없다. +ai#5·#6·#11·#14·#16·#17·#18 로 구현이 끝났지만, 그 시점까지 검증된 것은 Fake 기반 테스트 46개(계약 위반·경합 방어)뿐이었다. 실제 GMS 호출, 프리셋 실제 적재, 실제 임베딩 기반의 검색 품질은 한 번도 실행된 적이 없었다. -이 세션은 **구현하지 않은 시각**에서 `README.md`와 `docs/spec/`만 보고 로컬 기동을 재현했다. 절차를 미리 알려주지 않은 것은 의도적이며, **문서만으로 기동 가능한지가 검증 대상**이었다. 따라서 막힌 지점 자체가 주 산출물이다. +이 세션은 구현에 참여하지 않은 시각에서 `README.md` 와 `docs/spec/` 만 보고 로컬 기동을 재현했다. 절차를 미리 알려주지 않은 것은 의도적이다. 문서만으로 기동이 가능한지가 검증 대상이었기 때문이다. 따라서 막힌 지점 자체가 이 검증의 주 산출물이다. -범위: ①로컬 환경 기동 ②`/internal/v1/context/process` 실경로 ③`/internal/v1/search` 실경로·품질 ④Docker 빌드·기동. 추가로 매칭 하네스(`tools/keyword_eval/`)와 운영 코드의 동등성을 실측했다. +검증 범위는 네 가지다. ① 로컬 환경 기동, ② `/internal/v1/context/process` 실제 경로, ③ `/internal/v1/search` 실제 경로와 품질, ④ Docker 빌드·기동. 추가로 매칭 하네스(`tools/keyword_eval/`)와 운영 코드의 동등성을 실측했다. -**프로덕션 코드 변경 없음.** 이 PR은 문서와 검증 도구만 추가한다. +프로덕션 코드 변경은 없다. 이 PR 은 문서와 검증 도구만 추가한다. ## 문서 마찰 — 이 세션의 주 산출물 -> **아래는 검증 시점(2026-07-27 오전)의 상태입니다.** F1·F2·F4·F5와 판정 비결정성 명시는 -> 이 검증의 발견을 받아 **-59 세션이 ai#22로 이미 반영**했습니다(F6은 이후 ai#24로 해결). 각 항목에 반영 결과를 병기하며, -> 전체 대응은 [발견 처리](#발견-처리) 표를 참조하십시오. 기록을 남기는 이유는 보존 원칙(정정하되 -> 삭제하지 않음)과, **"문서만으로 기동 가능한가"의 답이 당시 '아니오'였다는 사실 자체가 산출물**이기 -> 때문입니다. +> 아래는 검증 시점(2026-07-27 오전)의 상태다. F1·F2·F4·F5 와 판정 비결정성 명시는 이 검증의 발견을 받아 -59 세션이 ai#22 로 이미 반영했다(F6 은 이후 ai#24 로 해결). 각 항목에 반영 결과를 병기하며, 전체 대응은 [발견 처리](#발견-처리) 표에 있다. 반영됐는데도 기록을 남기는 이유는 두 가지다. 정정하되 삭제하지 않는다는 보존 원칙 때문이고, "문서만으로 기동 가능한가"의 답이 당시 '아니오'였다는 사실 자체가 산출물이기 때문이다. ### F1 — README 2단계에 절차가 없다 (최대 마찰) +README 2단계는 다음과 같이 적혀 있다. + ``` # 2. ai 스키마 생성 — back Flyway(V1/V100/V101)를 적용해 ai.* 테이블 마련 # (ai 레포는 Migration을 실행하지 않는다) ``` -**"적용해"의 방법이 없다.** back 레포 경로도, gradle 명령도, SQL 직접 적용 대안도 없다. 합류자는 여기서 반드시 멈춘다. 이번 검증에서는 `back/src/main/resources/db/migration/`을 직접 찾아 `docker exec psql`로 순차 적용했고, 이 경로 탐색이 전체에서 가장 오래 걸린 판단이었다. +"적용해"의 방법이 없다. back 레포의 경로도, gradle 명령도, SQL 을 직접 적용하는 대안도 적혀 있지 않다. 합류자는 여기서 반드시 멈춘다. 이번 검증에서는 `back/src/main/resources/db/migration/` 을 직접 찾아 `docker exec psql` 로 순차 적용했다. 이 경로를 찾는 데 전체 검증에서 가장 오랜 시간이 걸렸다. -부수 문제: back에는 **`V102__feed_event.sql`도 존재**하는데 README는 V1/V100/V101만 적는다. core 소관이라 제외했으나 문서에 판단 근거가 없어 스스로 결정해야 했다. +부수 문제도 있다. back 에는 `V102__feed_event.sql` 도 존재하는데 README 는 V1/V100/V101 만 적는다. V102 는 core 소관이라 제외했지만, 문서에 판단 근거가 없어 검증자가 스스로 결정해야 했다. > **반영됨(ai#22)**: README 2단계에 back 마이그레이션 파일 위치, 컨테이너 psql 적용 루프, V102 제외 근거가 추가됐다. -### F2 — DSN이 3중으로 어긋난다 +### F2 — DSN 이 3중으로 어긋난다 | 출처 | port | user / password | |---|---|---| @@ -46,42 +44,42 @@ ai#5·#6·#11·#14·#16·#17·#18로 구현이 끝났으나, 검증된 것은 ** | `.env.example` (3단계가 복사하라는 파일) | **5432** | **`ai_app`** / `CHANGME` | | `back/compose.yaml` (1단계가 대안으로 지목) | **15432** | `pinlog` / **`pinlog-local`** | -README 1단계를 실행하고 3단계(`cp .env.example .env`)를 그대로 따르면 **연결 실패**한다. 3단계에 "DSN 주입"이라 적혀 있어 의도된 여지일 수 있으나, 어느 값이 정답인지 문서가 정하지 않는다. +README 1단계를 실행하고 3단계(`cp .env.example .env`)를 그대로 따르면 DB 연결에 실패한다. 3단계에 "DSN 주입"이라고 적혀 있어 값을 채우라는 의도였을 수 있지만, 어느 값이 정답인지는 문서가 정하지 않는다. -> **반영됨(ai#22)**: `pinlog:pinlog@localhost:5433/pinlog`로 통일하고 `.env.example` 기본값을 일치시켰다. back `compose.yaml`과 혼용하지 말라는 경고도 명시됐다. +> **반영됨(ai#22)**: `pinlog:pinlog@localhost:5433/pinlog` 로 통일하고 `.env.example` 기본값을 일치시켰다. back `compose.yaml` 과 혼용하지 말라는 경고도 명시됐다. -### F5 — Docker는 build만 문서화돼 있다 +### F5 — Docker 는 build 만 문서화돼 있다 -README에 `docker build`만 있고 `docker run` 예시가 없다. 이미지는 `.dockerignore`로 `.env`를 제외하므로 **13개 환경변수를 전부 주입해야 하는데 그 목록도, host DB 접근 방법도 없다**. 이번에 구성해 검증한 명령은 아래 [Docker](#docker) 절에 남긴다. +README 에 `docker build` 만 있고 `docker run` 예시가 없다. 이미지는 `.dockerignore` 로 `.env` 를 제외하므로 환경변수 13개를 전부 주입해야 하는데, 그 목록도 host DB 접근 방법도 문서에 없다. 이번에 구성해 검증한 명령은 아래 [Docker](#docker) 절에 남긴다. > **반영됨(ai#22)**: 검증한 `docker run` 명령이 README Docker 절에 그대로 추가됐다. -### F6 — `PRESET_CACHE_TTL_SEC`은 정의만 있고 읽는 곳이 없다 +### F6 — `PRESET_CACHE_TTL_SEC` 은 정의만 있고 읽는 곳이 없다 -`app/core/config.py`·`.env.example`에 존재하고 `spec/architecture.md` §5가 TTL 기반 재적재로 서술하지만, `PresetCache`에 reload 경로가 없다. **프리셋은 프로세스 수명 동안 고정**이다. 문서-구현 불일치. +이 설정은 `app/core/config.py` 와 `.env.example` 에 존재하고 `spec/architecture.md` §5 는 TTL 기반 재적재로 서술한다. 그러나 `PresetCache` 에는 reload 경로가 없다. 프리셋은 프로세스 수명 동안 고정이다. 문서와 구현의 불일치다. -> **해결됨(ai#24)**: `preset_cache_ttl_sec`를 `config.py`·`.env.example`에서 제거(ai#24)하고 `architecture.md §5`를 "재시작으로만"으로 정정(ai#21)했다. 문서-구현 정합. +> **해결됨(ai#24)**: `preset_cache_ttl_sec` 를 `config.py`·`.env.example` 에서 제거하고(ai#24) `architecture.md §5` 를 "재시작으로만"으로 정정했다(ai#21). 문서와 구현이 일치하게 됐다. ### 문서대로 작동한 것 -1·3·4·5단계의 명령 자체, `pytest`(46개 전원 통과), `docker build`. 또한 `tests/schema/ai_snapshot.sql`을 back `V100__ai_tables.sql`과 diff한 결과 **테이블 정의 차이 없음**(주석·V1/V101 병합분만 차이) — 스냅샷이 자체 경고하던 드리프트는 발생하지 않았다. +1·3·4·5단계의 명령 자체, `pytest`(46개 전원 통과), `docker build` 는 문서대로 작동했다. 또한 `tests/schema/ai_snapshot.sql` 을 back 의 `V100__ai_tables.sql` 과 diff 한 결과 테이블 정의 차이가 없었다(주석과 V1/V101 병합분만 다르다). 스냅샷 파일이 스스로 경고하던 드리프트는 발생하지 않았다. ## 판정 기준 결과 ### 프리셋 적재 — 통과 -`python -m app.bootstrap.load_presets` (실제 GMS 임베딩 1배치 호출) +`python -m app.bootstrap.load_presets` 를 실행했다(실제 GMS 임베딩 1배치 호출). ``` total | embedding_null | profile_kinds | profile | dims | active 27 | 0 | 1 | openai-text-embedding-3-small-1536-cosine-v1 | 1536 | 27 ``` -27행 · embedding 전부 NOT NULL · profile 1종이며 `settings.embedding_profile`과 일치. 범주 배분도 설계대로(COMPANION 6 / ACTIVITY 8 / ATMOSPHERE 7 / SITUATION 6). +27행이 적재됐고, embedding 은 전부 NOT NULL 이며, profile 은 1종으로 `settings.embedding_profile` 과 일치한다. 범주 배분도 설계대로다(COMPANION 6 / ACTIVITY 8 / ATMOSPHERE 7 / SITUATION 6). ### 파이프라인 — 통과 -Context 8건 투입, **전부 6.0초에 두 status COMPLETED 도달**. PENDING/PROCESSING 잔류 0. +Context 8건을 투입했고, 전부 6.0초 안에 두 status 가 COMPLETED 에 도달했다. PENDING/PROCESSING 잔류는 0건이다. | ctx | 투입 성격 | 판정 결과 | |---|---|---| @@ -93,23 +91,23 @@ Context 8건 투입, **전부 6.0초에 두 status COMPLETED 도달**. PENDING/P | 1006 | 비·아늑·레트로 | `RAINY_DAY` `COZY` `RETRO` | | 1007 | 5001의 두 번째 Context | `EXHIBITION` | -- **후보 밖 keyword_id: 0건** — `context_keyword LEFT JOIN keyword_preset`으로 검출 -- **keyword 0개 정상 처리** — 1003·1004가 COMPLETED + `context_keyword` 0행 -- **`unmatched_concepts` 저장 확인** — 1003 → `["넓은 주차장","깨끗한 화장실","친절한 직원 응대","합리적인 가격"]`, 1004 → `["반려견 동반"]`. `preset_version=1`, `model_profile=gemini-2.5-flash`도 정상 기록 +- 후보 밖 keyword_id 는 0건이었다. `context_keyword LEFT JOIN keyword_preset` 으로 검출했다. +- 키워드 0개도 정상 처리됐다. 1003·1004 가 COMPLETED 상태로 `context_keyword` 0행을 남겼다. +- `unmatched_concepts` 저장을 확인했다. 1003 은 `["넓은 주차장","깨끗한 화장실","친절한 직원 응대","합리적인 가격"]`, 1004 는 `["반려견 동반"]` 이다. `preset_version=1`, `model_profile=gemini-2.5-flash` 도 정상 기록됐다. -1003이 특히 의미 있다. 부대시설 4개를 **억지로 키워드에 넣지 않고 unmatched로 분류**했다 — `llm_client.SYSTEM`의 부대시설 제외 규칙과 `unmatchedConcepts` 지시가 둘 다 실경로에서 작동한다는 증거다. +1003 이 특히 의미 있다. 부대시설 개념 4개를 억지로 키워드에 넣지 않고 unmatched 로 분류했다. `llm_client.SYSTEM` 의 부대시설 제외 규칙과 `unmatchedConcepts` 지시가 둘 다 실제 경로에서 작동한다는 증거다. -**미검증 1건**: `BLOCKED` 프리셋 제외 로직은 `data/keyword_preset.yaml`에 BLOCKED가 0건(PUBLIC 25 / PRIVATE_ONLY 2)이라 실데이터로 검증 불가하다. Fake 테스트에만 존재한다. +미검증 1건이 남는다. `BLOCKED` 프리셋 제외 로직은 `data/keyword_preset.yaml` 에 BLOCKED 가 0건(PUBLIC 25 / PRIVATE_ONLY 2)이라 실데이터로 검증할 수 없다. Fake 테스트에만 존재한다. ## 검색 품질 ### 계약 방어선 — 3종 통과 -정상 200 · Profile 불일치 **422**(임베딩 미호출) · 시크릿 누락 **401**. +정상 요청 200, Profile 불일치 422(임베딩을 호출하지 않음), 시크릿 누락 401 을 확인했다. -### 관련 질의 6건 전부 의도 Context가 1위 +### 관련 질의 6건 전부 의도한 Context 가 1위 -기준은 "상위 3위 내"였으나 전부 1위였다. +통과 기준은 "상위 3위 내"였으나 실제로는 전부 1위였다. | 질의 | 1위 | similarity | |---|---|---| @@ -120,7 +118,7 @@ Context 8건 투입, **전부 6.0초에 두 status COMPLETED 도달**. PENDING/P | 기념일에 야경 보면서 식사할 곳 | 남산 뷰 레스토랑 (1005) | 0.5266 | | 주차 편한 곳 | 외곽 식당 (1003) | 0.5263 | -### 분리도 — 두 분포가 겹치지 않는다 +### 분리도 — 관련 질의와 무관 질의의 유사도 분포가 겹치지 않는다 ``` 관련 질의 top1 : min 0.5263 · max 0.6740 · avg 0.5989 @@ -128,11 +126,11 @@ Context 8건 투입, **전부 6.0초에 두 status COMPLETED 도달**. PENDING/P 간격(관련 min − 무관 max) = +0.2120 ``` -**검색에 하한을 적용하지 않는 설계가 옳다는 실측 근거를 얻었다.** 무관 질의 "자동차 엔진오일 교환 정비소"의 top1이 **0.3143으로 `SIMILARITY_FLOOR=0.30`을 넘는다.** 즉 검색에 0.30을 걸었더라도 이 무관 질의는 걸러지지 않았다. `spec/personal-search.md` §6의 "기본은 적용하지 않는다"를 지지하는 동시에, **0.30은 검색용 컷오프로 부적절한 값**임을 보여준다(그 값은 Keyword 후보 선정용으로 산출된 것이다 — `tools/keyword_eval/REPORT.md`). 하한을 도입한다면 별도 튜닝이 필요하다. +이 측정에서 검색에 유사도 하한을 적용하지 않는 현행 설계가 옳다는 실측 근거를 얻었다. 무관 질의 "자동차 엔진오일 교환 정비소"의 top1 이 0.3143 으로 `SIMILARITY_FLOOR=0.30` 을 넘는다. 즉 검색에 0.30 하한을 걸었더라도 이 무관 질의는 걸러지지 않았을 것이다. 이 결과는 `spec/personal-search.md` §6 의 "기본은 적용하지 않는다"를 지지한다. 동시에 0.30 이 검색용 컷오프로는 부적절한 값임을 보여준다. 그 값은 Keyword 후보 선정용으로 산출된 것이기 때문이다(`tools/keyword_eval/REPORT.md`). 검색에 하한을 도입하려면 별도 튜닝이 필요하다. ### DISTINCT ON 대표 선택 — 통과 -record 5001은 Context 1001·1007 두 건을 가진다. 질의에 따라 대표가 바뀌고, **두 경우 모두 1회만 등장**한다. +record 5001 은 Context 1001·1007 두 건을 가진다. 질의에 따라 대표 Context 가 바뀌고, 두 경우 모두 검색 결과에 1회만 등장한다. ``` "친구들이랑 시끌벅적하게 놀 만한 곳" → rec=5001 대표 ctx=1001 (0.6414) @@ -141,37 +139,37 @@ record 5001은 Context 1001·1007 두 건을 가진다. 질의에 따라 대표 ### 사용자 격리 — 통과 -user 9001 검색 결과에 9002 소유 ctx 1008 미노출. 9002 검색에는 1008만. +user 9001 의 검색 결과에 9002 소유의 ctx 1008 이 노출되지 않았다. 9002 의 검색에는 1008 만 나왔다. ### 부수 관찰 -"주차 편한 곳"이 ctx 1003을 1위로 찾는다. **키워드는 0개인데 검색은 된다.** 임베딩 검색과 키워드 판정이 독립적으로 동작한다는 뜻이고, 제품상으로도 옳다 — 부대시설은 Keyword가 아니지만 사용자가 그 이유로 다시 찾을 수는 있다. +"주차 편한 곳" 질의가 ctx 1003 을 1위로 찾는다. 1003 은 키워드가 0개인데 검색에는 잡힌다. 임베딩 검색과 키워드 판정이 서로 독립적으로 동작한다는 뜻이다. 제품 관점에서도 옳은 동작이다. 부대시설은 Keyword 대상이 아니지만, 사용자가 그 이유로 장소를 다시 찾을 수는 있다. ## 동등성 실측 — 하네스 ↔ 운영 -### 전제부터 거짓이었다 +### 당초 질문의 전제가 성립하지 않았다 -당초 질문은 "하네스와 운영 코드가 **같은 프롬프트로 같은 결과**를 내는가"였다. 코드 정독 결과 전제가 성립하지 않았다. +당초 질문은 "하네스와 운영 코드가 같은 프롬프트로 같은 결과를 내는가"였다. 코드를 정독한 결과 이 전제가 성립하지 않았다. -- `tools/` 전체에 **`from app...` import가 0건** — 하네스는 운영 코드를 공유하지 않는 복사본이다 -- 프롬프트가 이미 다르다: 운영 `app/client/llm_client.py`의 `SYSTEM`에만 `unmatchedConcepts` 지시 1줄이 있다 -- responseSchema도 다르다: 운영만 `unmatchedConcepts` 속성 보유, 하네스는 `enum` 옵션 보유 +- `tools/` 전체에 `from app...` import 가 0건이다. 하네스는 운영 코드를 공유하지 않는 복사본이다. +- 프롬프트가 이미 다르다. 운영 `app/client/llm_client.py` 의 `SYSTEM` 에만 `unmatchedConcepts` 지시 1줄이 있다. +- responseSchema 도 다르다. 운영만 `unmatchedConcepts` 속성을 갖고, 하네스는 `enum` 옵션을 갖는다. -그래서 질문을 **"그 차이가 결과를 바꾸는가"**로 재정의해 실측했다. +그래서 질문을 "그 차이가 결과를 바꾸는가"로 재정의해 실측했다. ### 설계 — 비결정성 기준선을 먼저 잰다 -LLM 판정은 흔들릴 수 있으므로, 두 경로의 차이를 재기 전에 **운영 경로 자체의 흔들림**을 먼저 측정했다. 이것을 하지 않으면 모든 불일치를 프롬프트 차이로 오귀속하게 된다. +LLM 판정은 같은 입력에도 흔들릴 수 있다. 그래서 두 경로의 차이를 재기 전에 운영 경로 자체의 흔들림을 먼저 측정했다. 이것을 하지 않으면 모든 불일치를 프롬프트 차이의 탓으로 잘못 귀속하게 된다. -1. 운영 판정을 같은 입력으로 2회 → 자체 재현성 -2. 후보 집합 비교 — 운영(DB 적재 벡터 + `_topk`) vs 하네스(자체 임베딩 + `argsort`) -3. 판정 비교 — **동일 후보**를 두 경로에 투입(프롬프트·스키마만 다르게) +1. 운영 판정을 같은 입력으로 2회 실행해 자체 재현성을 잰다. +2. 후보 집합을 비교한다 — 운영(DB 적재 벡터 + `_topk`) 대 하네스(자체 임베딩 + `argsort`). +3. 판정을 비교한다 — 동일한 후보를 두 경로에 투입해 프롬프트·스키마 차이만 남긴다. -샘플 10건(`tools/keyword_eval/samples.yaml`). +샘플은 10건이다(`tools/keyword_eval/samples.yaml`). -### 결과 — 후보 9/10 · 판정 9/10 +### 결과 — 후보 일치 9/10 · 판정 일치 9/10 -불일치 2건을 각각 반복 측정해 원인을 귀속시켰다. +불일치 2건은 각각 반복 측정해 원인을 귀속시켰다. **후보 불일치(샘플 00) — 동점 경계의 임베딩 잡음** @@ -181,11 +179,11 @@ LLM 판정은 흔들릴 수 있으므로, 두 경로의 차이를 재기 전에 차이 0.0004 · 9·12·13위는 양쪽 완전 일치 ``` -두 경로가 프리셋을 각각 따로 임베딩하기 때문에 생기는 **API 미세 잡음**이며, K=10 경계에서만 순위가 뒤집혔다. 선정 로직 자체는 동일하다. +두 경로가 프리셋을 각각 따로 임베딩하기 때문에 생기는 API 의 미세한 잡음이다. K=10 경계에서만 순위가 뒤집혔다. 선정 로직 자체는 동일하다. **판정 불일치(샘플 05) — 양쪽 모두 흔들린다** -각 경로 5회 반복: +각 경로를 5회 반복했다. ``` "팀 회식으로 갔는데 룸 있어서 편했음" 후보 [403,104,405,201,101,204,305,202,205,206] @@ -193,30 +191,30 @@ LLM 판정은 흔들릴 수 있으므로, 두 경로의 차이를 재기 전에 하네스 202(식사) 포함 3/5 ``` -**계통적 차이가 아니라 경계 사례의 확률적 편향이다.** +두 경로 사이의 계통적 차이가 아니라, 경계 사례에서 나타나는 확률적 편향이다. ### 결론 -**프롬프트 1줄 차이가 selected 결과를 바꾼다는 증거는 없다.** 다만 코드가 분리돼 있다는 구조적 위험은 그대로이며, 프롬프트 사본이 3개(운영 `llm_client.py` / 하네스 `test_c_judge.py` / `tools/keyword_eval/prompts/keyword_judgment.md`)이고 그 markdown은 운영이 쓰지 않는 `enum`·`minimum`을 여전히 적고 있다. +프롬프트 1줄 차이가 selected 결과를 바꾼다는 증거는 없다. 다만 코드가 분리되어 있다는 구조적 위험은 그대로 남는다. 프롬프트 사본이 3개(운영 `llm_client.py` / 하네스 `test_c_judge.py` / `tools/keyword_eval/prompts/keyword_judgment.md`)이고, 그 markdown 사본은 운영이 쓰지 않는 `enum`·`minimum` 을 여전히 적고 있다. ### 부수 발견 — 판정은 결정적이지 않다 -`thinkingConfig.thinkingBudget=0`인데도 경계 샘플이 40~60%로 흔들린다. 저장이 delete-insert이므로 **같은 Context를 재판정하면 저장된 키워드가 실제로 바뀐다.** +`thinkingConfig.thinkingBudget=0` 인데도 경계 샘플의 판정이 40~60% 확률로 흔들린다. 키워드 저장이 delete-insert 방식이므로, 같은 Context 를 다시 판정하면 저장된 키워드가 실제로 바뀐다. -다만 **재판정이 일어나는 경로가 실질적으로 거의 없다**: +다만 재판정이 일어나는 경로가 실질적으로 거의 없다. | 경로 | 재판정 여부 | |---|---| -| 부분 재개 | keyword가 COMPLETED면 `try_start`가 0을 반환해 건너뜀 → 재판정 없음 | -| Context 수정 | 새 `context_id` → "바뀐 것"이 아니라 "새로 붙은 것" | -| 재스캔 회수 | 미완료 단계만 대상 → 재판정이 아니라 최초 판정 | -| **운영 재처리(M3)** | Spring의 `COMPLETED → PENDING`(Profile 전환·프리셋 재분류, [state-machine.md](../spec/state-machine.md) §49행) — **입력이 바뀌므로 결과 변화가 당연** | +| 부분 재개 | keyword 가 COMPLETED 면 `try_start` 가 0을 반환해 건너뛴다. 재판정 없음 | +| Context 수정 | 새 `context_id` 가 생긴다. "바뀐 것"이 아니라 "새로 붙은 것" | +| 재스캔 회수 | 미완료 단계만 대상이다. 재판정이 아니라 최초 판정 | +| 운영 재처리(M3) | Spring 의 `COMPLETED → PENDING` 전환(Profile 전환·프리셋 재분류, [state-machine.md](../spec/state-machine.md) §49행). 입력이 바뀌므로 결과 변화가 당연하다 | -따라서 **현행 유지(허용)로 판단**한다. 다만 검증 시점에는 계약 어디에도 "판정이 결정적이지 않다"는 사실이 명시돼 있지 않았다 → **-59가 [keyword-preset.md](../spec/keyword-preset.md) §4.4에 명시**했다(ai#22). +따라서 현행 유지(허용)로 판단한다. 다만 검증 시점에는 계약 어디에도 "판정이 결정적이지 않다"는 사실이 명시되어 있지 않았다. 이 발견을 받아 -59 가 [keyword-preset.md](../spec/keyword-preset.md) §4.4 에 명시했다(ai#22). ## F2b 권한 경계 실증 — `-61` 근거 -F2에서 로컬이 `.env.example`이 처방한 `ai_app`이 아니라 `pinlog`으로 돈다는 것을 발견했고, 그 파급을 실증했다. +F2 에서 로컬이 `.env.example` 이 처방한 `ai_app` 롤이 아니라 `pinlog` 롤로 돈다는 것을 발견했고, 그 파급을 실증했다. ```sql SELECT current_user, (SELECT rolsuper FROM pg_roles WHERE rolname=current_user) AS is_superuser, @@ -227,17 +225,17 @@ CREATE TABLE core.boundary_probe(id int); -- CREATE TABLE INSERT INTO core.boundary_probe VALUES (1); -- INSERT 0 1 ``` -접속 롤이 **슈퍼유저**이고, **`ai_app` 롤은 DB에 존재조차 하지 않는다**(`pg_roles`에 `pinlog` 단독). FastAPI가 사용하는 바로 그 DSN으로 `core`에 DDL·DML이 통과한다. +접속 롤이 슈퍼유저이고, `ai_app` 롤은 DB 에 존재하지도 않는다(`pg_roles` 에 `pinlog` 단독). FastAPI 가 사용하는 바로 그 DSN 으로 `core` 스키마에 DDL·DML 이 통과한다. -> **"FastAPI는 `core.*`에 접근하지 않는다"(README:10, [architecture.md](../spec/architecture.md) §7)는 계약이 로컬에서 검증되지 않는다.** 위반해도 성공하기 때문이다. 코드 리뷰로만 지켜지고 있으며 DB가 강제하지 않는다. +"FastAPI 는 `core.*` 에 접근하지 않는다"(README:10, [architecture.md](../spec/architecture.md) §7)는 계약이 로컬에서 검증되지 않는다는 뜻이다. 위반해도 성공하기 때문이다. 이 계약은 현재 코드 리뷰로만 지켜지고 있으며 DB 가 강제하지 않는다. -`search_path = ai, public`이 `core`를 경로 밖에 두는 1차 방어선이지만, 스키마 한정 참조(`core.foo`)는 그대로 통과한다. **인프라 티켓 `S15P11A705-61`(ai 전용 DB role)의 실증 근거**다. 프로브 테이블은 즉시 DROP했다. +`search_path = ai, public` 이 `core` 를 검색 경로 밖에 두는 1차 방어선이지만, 스키마를 한정한 참조(`core.foo`)는 그대로 통과한다. 인프라 티켓 `S15P11A705-61`(ai 전용 DB role)의 실증 근거다. 프로브 테이블은 즉시 DROP 했다. ## Docker -빌드 360MB, 기동 정상, 기동 로그에 `preset cache loaded: 27 presets`. 컨테이너 경유 실호출까지 확인 — `/search` 200(실제 GMS 임베딩), Profile 불일치 422, 시크릿 누락 401. +빌드 결과는 360MB, 기동은 정상이며, 기동 로그에 `preset cache loaded: 27 presets` 가 찍혔다. 컨테이너 경유 실제 호출까지 확인했다. `/search` 200(실제 GMS 임베딩), Profile 불일치 422, 시크릿 누락 401. -README에 없어 이번에 구성해 검증한 명령(F5 → -59 인계 대상): +README 에 없어 이번에 구성해 검증한 명령이다(F5 발견으로 -59 에 인계했다). ```bash docker run -d --name pinlog-ai -p 8000:8000 \ @@ -247,7 +245,7 @@ docker run -d --name pinlog-ai -p 8000:8000 \ pinlog-ai ``` -`--env-file .env` 뒤에 `-e DATABASE_URL`을 두어 host 접근용으로 덮어쓴다(컨테이너의 `localhost`는 자기 자신이므로 `.env`의 DSN을 그대로 쓰면 연결 실패). +`--env-file .env` 뒤에 `-e DATABASE_URL` 을 두어 host 접근용 DSN 으로 덮어쓴다. 컨테이너 안의 `localhost` 는 컨테이너 자신을 가리키므로, `.env` 의 DSN 을 그대로 쓰면 연결에 실패하기 때문이다. ## 검증 과정에서의 판단 @@ -255,37 +253,37 @@ docker run -d --name pinlog-ai -p 8000:8000 \ ### ① `register_vector` 미등록 — 자기 스크립트 문제임을 어떻게 알았나 -동등성 스크립트가 프리셋 캐시 적재에서 터졌다. +동등성 스크립트가 프리셋 캐시 적재에서 예외로 중단됐다. ``` ValueError: could not convert string to float: '[0.05609131,0.008399963,...]' ``` -**제품 결함으로 오인하기 쉬운 형태**였다. 벡터가 문자열로 넘어오니 `PresetCache._to_array`가 실패한 것이고, 같은 코드가 서버에서는 이미 정상 동작 중이었다. +제품 결함으로 오인하기 쉬운 형태였다. 벡터가 문자열로 넘어와 `PresetCache._to_array` 가 실패한 것인데, 같은 코드가 서버에서는 이미 정상 동작 중이었다. -판단 근거는 **같은 코드가 두 경로에서 다르게 동작한다**는 사실이었다. `app/main.py`의 lifespan은 정상적으로 27건을 적재했는데(기동 로그로 확인됨) 내 스크립트만 실패했다. 그러면 차이는 코드가 아니라 **연결 방식**에 있다. `app/core/db.py`를 보니 커넥션 초기화에서 `SET search_path = ai, public` + `register_vector(conn)`를 수행하는데, 내 스크립트는 `asyncpg.connect`를 직접 호출해 그 초기화를 건너뛰고 있었다. +판단의 근거는 같은 코드가 두 경로에서 다르게 동작한다는 사실이었다. `app/main.py` 의 lifespan 은 27건을 정상 적재했는데(기동 로그로 확인) 검증 스크립트만 실패했다. 그렇다면 차이는 코드가 아니라 연결 방식에 있다. `app/core/db.py` 를 보니 커넥션 초기화에서 `SET search_path = ai, public` 과 `register_vector(conn)` 를 수행하는데, 검증 스크립트는 `asyncpg.connect` 를 직접 호출해 그 초기화를 건너뛰고 있었다. -교훈은 T21과 같은 계열이다 — **pgvector는 커넥션마다 타입 등록이 필요**하고, 앱이 그것을 `Database`에 캡슐화해 두었다. 우회하면 재현된다. 해결은 스크립트가 `Database`를 그대로 쓰도록 바꾼 것이며, 이는 **더 충실한 검증**이기도 하다(앱과 동일한 연결 설정을 쓰게 되므로). 시딩 스크립트에서 재발할 수 있어 [T23](../troubleshooting/2026-07-27-e2e-env-issues.md)으로 기록했다. +교훈은 T21 과 같은 계열이다. pgvector 는 커넥션마다 타입 등록이 필요하고, 앱은 그것을 `Database` 클래스에 캡슐화해 두었다. 이를 우회하면 문제가 재현된다. 해결은 스크립트가 `Database` 를 그대로 쓰도록 바꾼 것이다. 이는 더 충실한 검증이기도 하다. 앱과 동일한 연결 설정을 쓰게 되기 때문이다. 시딩 스크립트에서 재발할 수 있어 [T23](../troubleshooting/2026-07-27-e2e-env-issues.md)으로 기록했다. ### ② 동등성 결론이 뒤집힌 과정 -1차 실행에서 판정 9/10 일치, 불일치 1건(샘플 05에서 하네스만 `202` 추가). 같은 실행에서 **운영 자체 재현성은 10/10**이었다. +1차 실행에서 판정 일치가 9/10 이었고, 불일치 1건은 샘플 05에서 하네스만 `202` 를 추가로 뽑은 것이었다. 같은 실행에서 운영 자체 재현성은 10/10 이었다. -이 두 숫자만 보면 결론은 명확해 보인다 — 운영은 결정적인데 하네스만 다른 답을 냈으니, **차이의 원인은 프롬프트 1줄**이다. 실제로 그렇게 적을 뻔했다. +이 두 숫자만 보면 결론은 명확해 보인다. 운영은 결정적인데 하네스만 다른 답을 냈으니 차이의 원인은 프롬프트 1줄이라는 것이다. 실제로 그렇게 적을 뻔했다. -문제는 재현성 10/10이 **샘플 05를 2회 뽑아 우연히 같은 값이 나온 것**을 포함한다는 점이다. 후속 반복 측정 결과 샘플 05는 운영에서도 202를 2/5로 뽑는다. 2회 추출에서 같은 답이 나올 확률은 약 52%이므로, **10/10은 운이었다.** +문제는 재현성 10/10 에 샘플 05를 2회 뽑아 우연히 같은 값이 나온 경우가 포함된다는 점이다. 후속 반복 측정 결과 샘플 05는 운영에서도 202 를 5회 중 2회(2/5)로 뽑는다. 2회 추출에서 같은 답이 나올 확률은 약 52%다. 즉 10/10 은 우연이었다. -반복 측정 후 실제 분포는 운영 2/5 · 하네스 3/5 — **양쪽 모두 흔들리며 차이는 통계적으로 무의미**하다. 결론이 "프롬프트 1줄 차이 때문"에서 "양쪽 비결정성"으로 뒤집혔다. +반복 측정 후의 실제 분포는 운영 2/5, 하네스 3/5 였다. 양쪽 모두 흔들리며 그 차이는 통계적으로 무의미하다. 결론이 "프롬프트 1줄 차이 때문"에서 "양쪽 모두의 비결정성"으로 뒤집혔다. -> **비결정성 기준선을 먼저 재지 않았다면 오귀속했을 것이다.** 그리고 기준선을 재더라도 **표본이 작으면 기준선 자체가 거짓말을 한다.** 불일치가 나온 사례는 그 사례에 대해 반복 측정해야 한다. +여기서 두 가지 교훈을 남긴다. 비결정성 기준선을 먼저 재지 않았다면 원인을 잘못 귀속했을 것이다. 그리고 기준선을 재더라도 표본이 작으면 기준선 자체가 틀린 결론을 준다. 불일치가 나온 사례는 그 사례에 대해 반복 측정해야 한다. ## 시딩 가능 범위 판단 -- **가능 — `/search` 시연은 `ai` 스키마만으로 100% 성립한다.** 실측으로 확인했다. 검색 쿼리는 `ai.context_embedding` + `ai.context_ai_state`만 조인하고 `core`를 읽지 않으며, 이번 검증이 정확히 그 상태(`core` 테이블 0개)에서 돌았다. -- **가능 — 키워드 파이프라인 시연도 동일**. `placeMeta`는 MVP 미사용이라 Place 데이터가 없어도 무관하다. -- **제약 — 화면에는 id 숫자만 나온다.** 본문 조립은 Spring이 `core`에서 하는 구조다(`spec/personal-search.md` §6). → **매핑 파일 방식**으로 해결한다. 이번 검증에서 [`tools/e2e/e2e_contexts.yaml`](../../tools/e2e/e2e_contexts.yaml)이 `context_id`/`record_id` ↔ 본문·장소명을 들고 있고, 검색 결과 출력에 장소명을 붙이는 데 실제로 사용했다. 그대로 확장하면 된다. -- **불가 — 피드 시연.** core 도메인 미착수(백엔드가 member 엔티티 스캐폴딩 단계, core 마이그레이션 V2~ 미착수). -- **비권장 — core 테이블 직접 생성.** ①계약 위반(README:10·`architecture.md` §7이 core 소유·접근을 배제) ②back의 V2~ 구간과 Flyway 충돌 → 로컬 DB 통째 재생성 위험 ③**이득이 없다** — 매핑 파일로 동일한 시연 효과를 충돌 위험 0으로 얻는다. 이번 세션이 그것을 실증했다. +- **가능 — `/search` 시연은 `ai` 스키마만으로 100% 성립한다.** 실측으로 확인했다. 검색 쿼리는 `ai.context_embedding` 과 `ai.context_ai_state` 만 조인하고 `core` 를 읽지 않는다. 이번 검증이 정확히 그 상태(`core` 테이블 0개)에서 돌았다. +- **가능 — 키워드 파이프라인 시연도 동일하다.** `placeMeta` 는 MVP 에서 사용하지 않으므로 Place 데이터가 없어도 무관하다. +- **제약 — 화면에는 id 숫자만 나온다.** 본문 조립은 Spring 이 `core` 에서 하는 구조이기 때문이다(`spec/personal-search.md` §6). 매핑 파일 방식으로 해결한다. 이번 검증에서 [`tools/e2e/e2e_contexts.yaml`](../../tools/e2e/e2e_contexts.yaml)이 `context_id`/`record_id` 와 본문·장소명의 대응을 들고 있었고, 검색 결과 출력에 장소명을 붙이는 데 실제로 사용했다. 그대로 확장하면 된다. +- **불가 — 피드 시연.** core 도메인이 미착수 상태다(백엔드가 member 엔티티 스캐폴딩 단계이고, core 마이그레이션 V2~ 는 미착수). +- **비권장 — core 테이블 직접 생성.** 이유는 셋이다. ① 계약 위반이다(README:10 과 `architecture.md` §7 이 core 소유·접근을 배제한다). ② back 의 V2~ 구간과 Flyway 충돌이 나면 로컬 DB 를 통째로 재생성해야 할 위험이 있다. ③ 이득이 없다. 매핑 파일로 동일한 시연 효과를 충돌 위험 0으로 얻을 수 있고, 이번 세션이 그것을 실증했다. ## 발견 처리 @@ -298,8 +296,8 @@ ValueError: could not convert string to float: '[0.05609131,0.008399963,...]' | F4 검색 하한 실측 근거 | 근거 보강 | -59 → `personal-search.md` §6에 0.3143·간격 +0.2120 기재 | **반영됨** (ai#22) | | 판정 비결정성 | 계약 명시 필요 | 현행 유지(허용) 결정 + -59 → `keyword-preset.md` §4.4 신설 | **반영됨** (ai#22) | | **F2b 권한 경계 미검증** | 인프라 | **`S15P11A705-61`** (ai 전용 DB role) — 근거는 이 문서 | **미해소** | -| 하네스-운영 코드 분리 | 구조 | 실측상 결과 차이 없음 — 프롬프트 사본 3개는 잔존, 통합은 별건 | 기록 | -| BLOCKED 제외 실데이터 미검증 | 커버리지 갭 | 프리셋에 BLOCKED가 생기면 자연 해소 | 기록 | +| 하네스-운영 코드 분리 | 구조 | 실측상 결과 차이 없음. 프롬프트 사본 3개는 잔존하며 통합은 별건 | 기록 | +| BLOCKED 제외 실데이터 미검증 | 커버리지 갭 | 프리셋에 BLOCKED 가 생기면 자연 해소 | 기록 | | T22~T24 환경 이슈 | 재현 가능 | [troubleshooting](../troubleshooting/2026-07-27-e2e-env-issues.md) | 이 PR | ## 검증 @@ -316,6 +314,6 @@ docker run (위 명령) /health 200, /search 200(실 GM ## 남은 것 -- 시딩 설계(사용자 수·사용자당 맥락 수·취향 분산·매핑 파일 확장) — 별도 진행 -- `BLOCKED` 프리셋 실데이터 검증 — 프리셋에 BLOCKED가 생기면 자연 해소 -- 하네스·운영 프롬프트 단일화 — 결과 차이는 없으나 사본 3개는 남아 있음 +- 시딩 설계(사용자 수·사용자당 맥락 수·취향 분산·매핑 파일 확장)는 별도로 진행한다. +- `BLOCKED` 프리셋의 실데이터 검증은 프리셋에 BLOCKED 가 생기면 자연 해소된다. +- 하네스·운영 프롬프트 단일화. 결과 차이는 없으나 사본 3개는 남아 있다. diff --git a/docs/implements/2026-07-28-s1-implementation-recovery.md b/docs/implements/2026-07-28-s1-implementation-recovery.md index 8d6a65f..ab52701 100644 --- a/docs/implements/2026-07-28-s1-implementation-recovery.md +++ b/docs/implements/2026-07-28-s1-implementation-recovery.md @@ -1,90 +1,142 @@ -# S1 구현 판단 맥락 복원 — FastAPI AI 서버 +# S1 구현 판단 맥락 복원 — 종료된 세션의 설계 선택 19건·불변식·spec 불일치를 기록에서 복원했다 - **상태**: 완료 (복원) - **날짜**: 2026-07-28 - **유형**: 구현 (판단 맥락 복원) - **관련 PR/커밋**: ai#5·#6(E1·E2) · #3(eval) · #7·#8(문서) · #10·#11(contextId) · #14·#16·#17·#18(E3) · #20(주석 정정) · #24(PR2 반영+dead config) -- **근거 원본**: `pinlog/.claude/state/S1-RECOVERY-PACKET.md`(종료 S1 세션 transcript 1,245 이벤트 정독) · `S1-DOC-GAP-TABLE.md`(origin/main 코드·문서 전수 대조) +- **근거 원본**: `pinlog/.claude/state/S1-RECOVERY-PACKET.md`(종료된 S1 세션의 transcript 이벤트 1,245건을 정독한 기록) · `S1-DOC-GAP-TABLE.md`(origin/main 의 코드·문서 전수 대조) -> 종료된 **S1(AI 파트 작업)** 세션이 남긴 구현 판단을 영구 보존한다. 코드·spec으로 드러나지 않는 "왜"가 대상이다. -> **출처 표기**: `기록복원`(transcript 확인) · `직접확인`(origin/main 코드) · `문서` · `추정` · `미복원`. -> 근거가 없으면 문장을 채우지 않고 **`미복원`으로 확정**한다 — 원 구현자의 판단 이유를 창작하지 않는다. +> 종료된 S1 세션(AI 파트 작업)이 남긴 구현 판단을 영구 보존하는 문서다. 코드와 spec 만으로는 드러나지 않는 "왜 그렇게 했는가"가 대상이다. +> 각 서술에는 출처를 표기한다. `기록복원`(transcript 에서 확인) · `직접확인`(origin/main 코드에서 확인) · `문서` · `추정` · `미복원`. +> 근거가 없는 항목은 문장을 채우지 않고 `미복원`으로 확정한다. 원 구현자의 판단 이유를 창작하지 않기 위해서다. ## 1. 구현 범위·최종 구조 -`POST /internal/v1/search`(동기) · `POST /internal/v1/context/process`(202 접수 + 백그라운드) · `/health`. 전부 `/internal/v1/*` 내부 전용, 공유 시크릿 미들웨어. 기동 시 `bootstrap/load_presets`로 keyword_preset 임베딩 27건 적재. `tools/keyword_eval` 오프라인 평가 하네스가 **선행 존재**해 판정 모델을 확정한 뒤 앱으로 포팅. (`기록복원`) +구현된 엔드포인트는 `POST /internal/v1/search`(동기), `POST /internal/v1/context/process`(202 접수 후 백그라운드 처리), `/health` 다. 전부 `/internal/v1/*` 내부 전용이고 공유 시크릿 미들웨어를 거친다. 기동 시 `bootstrap/load_presets` 가 keyword_preset 임베딩 27건을 적재한다. `tools/keyword_eval` 오프라인 평가 하네스가 앱 구현보다 먼저 존재했고, 그 하네스로 판정 모델을 확정한 뒤 앱으로 포팅했다. (`기록복원`) -계층 단방향 `api → service → {repository, cache, client}`. **repository는 rowcount만 돌려주고 중단 판단은 service가 한다.** client는 DB를, repository는 외부 API를 모른다. (`기록복원`; 계약 architecture §3 준수) +계층은 `api → service → {repository, cache, client}` 단방향이다. repository 는 rowcount 만 반환하고 중단 여부의 판단은 service 가 한다. client 는 DB 를 모르고, repository 는 외부 API 를 모른다. (`기록복원`; 계약 architecture §3 준수) ## 2. 비자명한 설계 선택 19건 (전부 `기록복원`) | # | 선택 | 근거 | |---|---|---| -| 1 | asyncpg + 원시 SQL(ORM 미사용) | 계약이 SQL 본문을 정본으로 명시 → 그대로 옮기는 것이 준수에 유리. architecture는 "async 세션"만 규정한 열린 결정이라 `AskUserQuestion`으로 사용자 확정 | -| 2 | 판정 LLM = `gemini-2.5-flash` + `responseSchema` + `thinkingBudget=0` | eval C-2 실측(최속 1.12s·최소 토큰 25,314·스키마 위반 0) | -| 3 | function-calling 대신 native `responseSchema` | gemini-2.5-flash가 function-calling에서 응답 malform | -| 4 | `keywordId` JSON-schema enum 미사용 → 후처리 필터로 멤버십 강제 | | -| 5 | `/search` 집계 `GROUP BY+MAX` → `DISTINCT ON (record_id)` | Record별 최고 유사도 **행 자체**를 골라야 그 행의 context_id를 대표값으로 반환 가능. 계약·시그니처 무변경 | -| 6 | `SET search_path = ai, public` | `vector` 타입이 public에 있어 `ai`만 고정하면 VECTOR 해석·`register_vector` 실패. public을 넣어도 core는 경로 밖 (T21) | -| 7 | pgvector 태그 고정, 롤링 `pg16` 금지 | 롤링은 재빌드 시 마이너가 조용히 바뀌어 CI 비결정적. "당장 깨질 위험은 낮다"고 자평(ANN 없음)하되 통일 비용 0이라 채택. **정본은 운영 이미지·ai 테스트가 따라감** | -| 8 | Python 3.12 + 상한 `<3.13` | 로컬/CI/미래의 **환경 3분할이 최악**. 상한 없으면 합류자가 3.14로 또 갈라짐. 3.12 근거 = **GraphRAG 전제**(torch/transformers/igraph 최신 wheel 수개월 지연). 고정 5곳 | -| 9 | 신규 `ci.yml` 대신 기존 `ai-ci.yml` 수정 | 중복 방지. 이미 3.12라 lock 전환·dev deps·Jira 스텝만 추가 | -| 10 | Jira 키 검증은 PR 제목만 | squash라 PR 제목이 최종 커밋 메시지 | -| 11 | 브랜치 보호 = required check만, 리뷰 0, `enforce_admins:false` | 1인 레포 self-merge + 긴급 여지. **적용을 "CI 그린 후"로 미룬 판단이 옳았음 확인** — 먼저 켰으면 CI 핫픽스 PR 자체가 막힘 | -| 12 | 동시성 테스트 `sleep` 금지, `on_call` 훅 | Fake `on_call`에서 `raw_connect`로 다른 커넥션 열어 CANCELLED 주입 + `asyncio.gather` | -| 13 | Fake는 인터페이스 레벨 + 호출 횟수 기록 | 단언 핵심이 `call_count == 0/1`. 실 GMS 호출 금지 | -| 14 | Profile 문자열 리터럴 금지 — `settings` fixture 경유 | | -| 15 | builders에 "본문 버전" 인자 없음 | Context 불변 → 수정 시나리오는 context_id 다른 두 State로 | -| 16 | `settings` fixture에서 `get_settings()` 캐시 재설정 | `main.py` 모듈 레벨 `app=create_app()`가 import 시점 `.env` 캐시 (→ T26) | -| 17 | conftest placeholder env 선주입 | CI에 `.env` 없어 import 시 죽음 | -| 18 | dead config `preset_cache_ttl_sec` = TTL 구현이 아니라 **제거**(택일 ①) | architecture §5가 "TTL 재적재 없음"으로 확정 → 코드를 문서에 맞춤 (ai#24) | -| 19 | 멀티 세션에서 격리 git worktree 기본 | 단일 워킹트리·인덱스·HEAD 공유로 커밋 오염 (→ T25) | +| 1 | asyncpg + 원시 SQL. ORM 미사용 | 계약이 SQL 본문을 정본으로 명시하므로 그대로 옮기는 것이 계약 준수에 유리하다. architecture 는 "async 세션"만 규정한 열린 결정이었으므로 `AskUserQuestion` 으로 사용자가 확정했다 | +| 2 | 판정 LLM = `gemini-2.5-flash` + `responseSchema` + `thinkingBudget=0` | eval C-2 실측 결과다. 가장 빠르고(1.12s) 토큰이 가장 적고(25,314) 스키마 위반이 0건이었다 | +| 3 | function-calling 대신 native `responseSchema` | gemini-2.5-flash 가 function-calling 에서 malformed 응답을 반환했다 | +| 4 | `keywordId` 를 JSON-schema enum 으로 제약하지 않고 후처리 필터로 멤버십을 강제 | (근거 기록 없음) | +| 5 | `/search` 집계를 `GROUP BY+MAX` 에서 `DISTINCT ON (record_id)` 로 변경 | Record 별 최고 유사도의 행 자체를 골라야 그 행의 context_id 를 대표값으로 반환할 수 있다. 계약과 시그니처는 바꾸지 않았다 | +| 6 | `SET search_path = ai, public` | `vector` 타입이 public 스키마에 있어서 `ai` 만 고정하면 VECTOR 타입 해석과 `register_vector` 가 실패한다. public 을 넣어도 core 는 여전히 경로 밖이다 (T21) | +| 7 | pgvector 이미지 태그 고정, 롤링 태그 `pg16` 금지 | 롤링 태그는 재빌드 시 마이너 버전이 조용히 바뀌어 CI 가 비결정적이 된다. ANN 인덱스를 쓰지 않으므로 "당장 깨질 위험은 낮다"고 자평했지만, 통일 비용이 0이라 고정을 채택했다. 정본은 운영 이미지이고 ai 테스트가 그것을 따라간다 | +| 8 | Python 3.12 + 상한 `<3.13` | 로컬/CI/미래 합류자의 환경이 3개로 갈라지는 것이 최악이다. 상한이 없으면 합류자가 3.14 로 또 갈라진다. 3.12 를 고른 근거는 GraphRAG 전제다(torch/transformers/igraph 는 최신 Python wheel 이 수개월 지연된다). 버전 고정 위치는 5곳이다 | +| 9 | 신규 `ci.yml` 생성 대신 기존 `ai-ci.yml` 수정 | 워크플로 중복 방지. 기존 워크플로가 이미 3.12 였으므로 lock 전환·dev deps·Jira 검증 스텝만 추가했다 | +| 10 | Jira 키 검증은 PR 제목만 대상 | squash 병합이므로 PR 제목이 최종 커밋 메시지가 된다 | +| 11 | 브랜치 보호는 required check 만 두고, 필수 리뷰 0·`enforce_admins:false` | 1인 레포의 self-merge 와 긴급 대응 여지를 위해서다. 적용 시점을 "CI 그린 후"로 미룬 판단이 옳았음이 확인됐다. 먼저 켰다면 CI 를 고치는 핫픽스 PR 자체가 막혔을 것이다 | +| 12 | 동시성 테스트에서 `sleep` 금지, `on_call` 훅 사용 | Fake 의 `on_call` 안에서 `raw_connect` 로 다른 커넥션을 열어 CANCELLED 를 주입하고 `asyncio.gather` 로 경합을 재현한다 | +| 13 | Fake 는 인터페이스 레벨로 만들고 호출 횟수를 기록 | 단언의 핵심이 `call_count == 0/1` 이다. 실제 GMS 호출은 금지다 | +| 14 | Profile 문자열 리터럴 금지, `settings` fixture 경유 | (근거 기록 없음) | +| 15 | builders 에 "본문 버전" 인자를 두지 않음 | Context 는 불변이다. 수정 시나리오는 context_id 가 다른 두 State 로 표현한다 | +| 16 | `settings` fixture 에서 `get_settings()` 캐시 재설정 | `main.py` 의 모듈 레벨 `app=create_app()` 이 import 시점에 `.env` 를 캐시하기 때문이다 (→ T26) | +| 17 | conftest 에서 placeholder 환경 변수를 먼저 주입 | CI 에는 `.env` 가 없어 import 시점에 실행이 중단되기 때문이다 | +| 18 | dead config `preset_cache_ttl_sec` 는 TTL 을 구현하지 않고 제거(택일 ①) | architecture §5 가 "TTL 재적재 없음"으로 확정했으므로 코드를 문서에 맞췄다 (ai#24) | +| 19 | 멀티 세션 작업에서는 격리 git worktree 를 기본으로 | 단일 워킹트리에서 인덱스·HEAD 를 공유하면 커밋이 오염된다 (→ T25) | ## 3. 불변식 — 코드만으로 안 드러나는 것 (전부 `기록복원`) -**상태 전이** — FastAPI는 CANCELLED·PENDING 전이 금지(Spring 전용), `retry_count` 증가·재시도 소진 FAILED·`is_deleted` 변경·`core.*` 읽기도 금지. 실패 전이도 `WHERE ...='PROCESSING'` 가드를 유지해 CANCELLED를 덮지 않음. 완료 전이는 `FOR UPDATE`로 잠갔어도 WHERE 가드를 그대로 둠. PROCESSING 만료 expiry는 **Spring rescan 만료값과 동일 값을 주입받음 — ai가 독자적으로 고르지 않음**. +**상태 전이** -**멱등성·동시성** — guarded UPDATE의 **rowcount 0은 예외가 아니라 정상 종료**. 중복 요청을 이것으로 흡수하며 분산 락·큐를 두지 않음. 사전 점검은 **비용 절감일 뿐 정합성을 보장하지 않음**(통과 직후 Spring이 삭제 가능). 부분 재개 조건은 `embedding_status==COMPLETED` **AND** `embedding_profile==현재`. Context 불변이라 본문 버전 비교 없음. **2-worker 경합 방어로 keyword 단계에 벡터 fallback 로드**(→ `spec/partial-resume.md §3` 개정). +- FastAPI 는 CANCELLED·PENDING 으로의 전이를 하지 않는다. 이 두 전이는 Spring 전용이다. +- `retry_count` 증가, 재시도 소진에 의한 FAILED, `is_deleted` 변경, `core.*` 읽기도 금지다. +- 실패 전이도 `WHERE ...='PROCESSING'` 가드를 유지한다. 가드가 없으면 CANCELLED 상태를 FAILED 로 덮어쓸 수 있다. +- 완료 전이는 `FOR UPDATE` 로 잠갔더라도 WHERE 가드를 그대로 둔다. +- PROCESSING 만료 기준값(expiry)은 Spring rescan 의 만료값과 동일한 값을 주입받는다. ai 가 독자적으로 고르지 않는다. -**트랜잭션 경계** — 저장 불변식: `SELECT ... FOR UPDATE` → **status 재검사** → 쓰기 → COMPLETED. 재검사 실패면 예외가 아니라 **결과 폐기·롤백**. "행 존재"가 아니라 "status가 PROCESSING인가"를 보는 것이 삭제 후 지각 INSERT를 막는 지점. 임베딩 TX와 키워드 TX **분리**(키워드 실패가 COMPLETED 임베딩을 롤백 안 함). `context_embedding` UPSERT의 `SET`에 **`is_deleted`를 절대 포함하지 않음**(넣으면 삭제된 context 임베딩 부활). 키워드 저장은 UPSERT가 아니라 **delete-insert**(재판정 시 집합 통째 교체). INSERT 0행이어도 COMPLETED — **"키워드 없음"≠"미처리"**. `context_keyword_analysis`는 unmatchedConcepts가 비어도 행을 남김. `preset_version`은 요청 전체에 스냅샷 고정. Preset Cache는 기동 1회·재시작으로만 무효화(0행이면 기동 실패). 외부 API 호출은 **트랜잭션 밖·락 없이**. 후보 0건은 정상 완료지만 **context 프로필≠preset 프로필이면 판정 중단(영구 오류)**하며 "키워드 없음"으로 완료하지 않음. +**멱등성·동시성** + +- guarded UPDATE 의 rowcount 0 은 예외가 아니라 정상 종료다. 중복 요청을 이것으로 흡수하며, 분산 락이나 큐를 두지 않는다. +- 사전 점검은 비용 절감 수단일 뿐 정합성을 보장하지 않는다. 점검 통과 직후에 Spring 이 삭제할 수 있다. +- 부분 재개 조건은 `embedding_status==COMPLETED` 그리고 `embedding_profile==현재 Profile` 두 가지 모두다. Context 가 불변이므로 본문 버전 비교는 없다. +- 2-worker 경합 방어로 keyword 단계에 벡터 fallback 로드를 뒀다(이에 맞춰 `spec/partial-resume.md §3` 을 개정했다). + +**트랜잭션 경계** + +- 저장 불변식은 `SELECT ... FOR UPDATE` → status 재검사 → 쓰기 → COMPLETED 순서다. 재검사에 실패하면 예외를 던지는 것이 아니라 결과를 폐기하고 롤백한다. +- 재검사가 보는 것은 "행이 존재하는가"가 아니라 "status 가 PROCESSING 인가"다. 이것이 삭제 후 뒤늦게 도착한 INSERT 를 막는 지점이다. +- 임베딩 트랜잭션과 키워드 트랜잭션은 분리한다. 키워드 저장이 실패해도 COMPLETED 된 임베딩을 롤백하지 않는다. +- `context_embedding` UPSERT 의 `SET` 절에 `is_deleted` 를 절대 포함하지 않는다. 포함하면 삭제된 context 의 임베딩이 되살아난다. +- 키워드 저장은 UPSERT 가 아니라 delete-insert 다. 재판정 시 키워드 집합을 통째로 교체하기 위해서다. +- INSERT 가 0행이어도 COMPLETED 다. "키워드 없음"과 "미처리"는 다른 상태다. +- `context_keyword_analysis` 는 unmatchedConcepts 가 비어도 행을 남긴다. +- `preset_version` 은 요청 전체에 스냅샷으로 고정한다. +- Preset Cache 는 기동 시 1회 적재하고 재시작으로만 무효화한다. 적재 결과가 0행이면 기동에 실패한다. +- 외부 API 호출은 트랜잭션 밖에서, 락 없이 수행한다. +- 후보 0건은 정상 완료다. 그러나 context 의 프로필과 preset 의 프로필이 다르면 판정을 중단하고 영구 오류로 처리한다. "키워드 없음"으로 완료하지 않는다. ## 4. 테스트 확인 / 미확인 범위 -**확인**(`기록복원`): 저수준 27(unit·repo·api) + 파이프라인 19함수/20시나리오 = 46 passed(이후 CI 계약 6 추가로 52). 단언 축 = **호출 횟수**. 동시성 두 커넥션 + `asyncio.gather`, `sleep` 없음. Testcontainers 실 pgvector + `ai_snapshot.sql` + TRUNCATE 격리. E1 로컬 end-to-end 실 GMS(모임 0.56·가족 0.72, 타 유저 차단, 422/401/health). E2 4시나리오(정상/후보0→COMPLETED/CANCELLED 거부/**부분 재개 시 임베딩 호출 0회**). DISTINCT ON 대표 선택 실증(record 40에 context 300·301 → 300). +**확인** (`기록복원`) + +- 저수준 27개(unit·repo·api)와 파이프라인 19함수/20시나리오, 합계 46 passed. 이후 CI 계약 6개가 추가되어 52가 됐다. +- 단언의 축은 호출 횟수다. 동시성은 두 커넥션과 `asyncio.gather` 로 재현했고 `sleep` 은 없다. +- Testcontainers 로 실제 pgvector 를 띄우고 `ai_snapshot.sql` 적용, TRUNCATE 격리. +- E1 은 로컬 end-to-end 를 실제 GMS 로 확인했다("모임" 질의 유사도 0.56, "가족" 0.72, 타 유저 차단, 422/401/health). +- E2 는 4시나리오를 확인했다(정상 / 후보 0건도 COMPLETED / CANCELLED 거부 / 부분 재개 시 임베딩 호출 0회). +- DISTINCT ON 의 대표 선택을 실증했다(record 40 에 context 300·301 이 있을 때 300 을 대표로 반환). -**미확인 — 세션이 인지한 것**(`기록복원`): 시나리오 21은 BE 소관 제외 · **스냅샷 드리프트의 침묵 구간**(back이 스냅샷 안 고치면 ai가 그 컬럼 안 쓰는 동안 조용히 통과) · **워크플로 변경 PR은 병합 전 검증 불가**(GitHub이 pull_request에 base 워크플로 사용 → 병합 후 첫 실행이 첫 검증. PR 본문에 적었으나 실제로 깨짐) · **로컬 환경이 CI 조건 차이를 가림**(PYTHONPATH가 대표) · **크로스레포 사실 드리프트를 어떤 CI도 안 잡음**(`e3-test-harness.md:36` "back과 일치"가 back 변경 순간 거짓이 됐으나 감지 수단 전무) · CI 러너 digest pull network policy 미검증 · **`unmatchedConcepts`는 eval 하네스가 한 번도 실행한 적 없음**(하네스 스키마에 없고 구현에서 처음 추가). 실 GMS를 자동 테스트가 호출 안 하므로(Fake만) **프로바이더 응답 계약 변화를 테스트가 못 잡음** (`추정`). +**미확인 — 세션이 인지한 것** (`기록복원`) + +- 시나리오 21 은 BE 소관이라 제외했다. +- 스냅샷 드리프트의 침묵 구간이 있다. back 이 스키마를 바꾸고 스냅샷을 안 고쳐도, ai 가 그 컬럼을 쓰지 않는 동안은 테스트가 조용히 통과한다. +- 워크플로를 변경하는 PR 은 병합 전에 검증할 수 없다. GitHub 이 pull_request 이벤트에 base 브랜치의 워크플로를 사용하므로, 병합 후 첫 실행이 첫 검증이 된다. PR 본문에 이 한계를 적어 뒀는데 실제로 깨졌다. +- 로컬 환경이 CI 와의 조건 차이를 가린다. PYTHONPATH 가 대표적인 예다. +- 크로스레포 사실 드리프트를 어떤 CI 도 잡지 않는다. `e3-test-harness.md:36` 의 "back 과 일치한다"는 서술이 back 의 변경 순간 거짓이 됐지만 감지 수단이 없었다. +- CI 러너의 digest pull 이 network policy 에 걸리는지 검증하지 않았다. +- `unmatchedConcepts` 는 eval 하네스가 한 번도 실행한 적이 없다. 하네스 스키마에 없었고 구현에서 처음 추가된 필드다. +- 자동 테스트는 Fake 만 쓰고 실제 GMS 를 호출하지 않으므로, 프로바이더의 응답 계약 변화를 테스트가 잡지 못한다. (`추정`) ## 5. spec ↔ 구현 불일치 — 구현 결함 (정상화 아님, 별도 Bug Task 대상) -> 아래는 **spec이 계약으로 명시한 것을 구현이 지키지 않는** 상태다. "왜 그렇게 했는가"는 근거가 없어 `미복원`이다. **이 문서는 사실만 기록하며, 결함을 설계 판단으로 서술하지 않는다.** 실제 수정은 별도 Bug Task **`S15P11A705-121`**(외부 API 오류 재시도 및 분류 계약 수정)에서 다룬다. +> 아래는 spec 이 계약으로 명시한 것을 구현이 지키지 않는 상태다. "왜 그렇게 했는가"는 근거가 없어 `미복원`이다. 이 문서는 사실만 기록하며 결함을 설계 판단으로 서술하지 않는다. 실제 수정은 별도 Bug Task `S15P11A705-121`(외부 API 오류 재시도 및 분류 계약 수정)에서 다룬다. | # | spec 계약 | 실제 구현 | 출처 | 왜(판단) | |---|---|---|---|---| -| A-1 | `failure-recovery.md §3.1`: 최대 2회 재시도(지수 백오프+jitter, 대상=타임아웃·429·5xx·연결실패) | **재시도 없음.** 두 클라이언트 httpx 1회. app 전체 retry/backoff/429 매치 0건 | `직접확인`(client, grep 0건) | `미복원` (의도적 유예인지 누락인지, Spring 재스캔 10분으로 충분하다는 판단이었는지) | -| A-2 | `§2.1`: `429`=Transient | `status>=500`만 Transient, 그 외 non-200 전부 Permanent → **429가 영구 오류로 단계 FAILED** | `직접확인`(`embedding_client._embed_batch`) | `미복원` | -| A-3 | `§2.2`: `400`·`401/403`=Permanent | **모든 non-200을 Transient**(401·400 포함). 인증 실패가 10분마다 무한 재시도 | `직접확인`(`llm_client.judge`) | `미복원` (Embedding과 정반대 비대칭의 근거) | -| A-4 | `§2.2`: "재시도 후에도 스키마 위반"을 Permanent | 파싱 실패→Transient. **재시도가 없어 도달 경로 자체가 없음**(분류표 사문화) | `직접확인`(`llm_client._parse`) | `미복원` | -| A-5 | `§3.2`: 연결·읽기 각각 타임아웃, "두 호출 합+재시도 < PROCESSING 만료 600s" | `60.0`/`90.0` 단일 total | `직접확인` | 값 근거 `미복원` | -| A-6 | `§2.2`: Circuit Breaker | 없음 | `직접확인` | `미복원`(MVP 유예 여부) | -| F-1 | `integration-tests.md §3-8`: 일시적 오류→PROCESSING 유지 단언 | **테스트 없음.** `fakes.py`의 `raise_exc`가 어느 테스트에서도 미사용(grep 0건) → **Transient/Permanent 파이프라인 경로가 한 번도 실행된 적 없음** | `직접확인` | `미복원`(의도적 제외인지 누락인지) | -| F-2 | `§2.2`: 영구 오류→단계 FAILED | 파이프라인 테스트 없음(repo SQL만). service가 PermanentError→`fail()` 부르는 결선 미검증 | `직접확인` | — | -| F-3 | `§4.2`: client는 인터페이스 Fake | client HTTP 계층 **테스트 0건**(상태코드→오류타입 매핑·차원 불일치·`_parse` 미검증). **A절 429 오분류가 잡히지 않은 직접 이유** | `직접확인` | 공백 수용 판단인지 `미복원` | - -## 6. 실행 인프라 판단 — 현재상태 `직접확인` + 최초이유 `미복원` - -> 아래는 **코드에 결과만 있고 근거가 PR·커밋·문서 어디에도 없다.** 최초 선택 이유를 창작하지 않는다. - -- **BackgroundTasks** (`api/internal/v1/context.py:24`) — FastAPI `BackgroundTasks`. **큐·워커풀·동시 실행 상한 없음**(요청 수만큼 태스크가 떠 외부 API 동시 타격). 왜 BackgroundTasks인지·Celery/RQ/asyncio 큐 기각 근거·재시작 시 in-flight 유실과 §3.3의 연결: `미복원`. -- **커넥션 풀** (`core/db.py:25-26`) — `min_size=1, max_size=10` **하드코딩**(설정값 아님). 10의 근거·무제한 백그라운드 동시성과의 풀 고갈 지점·설정 미분리 이유: `미복원`. -- **`core/logging.py`** — `basicConfig`+`getLogger`만. 구조화·상관ID·레벨 주입 없음(§2.1은 "WARN으로 context_id·stage·원인 포함" 규정). 미도입이 판단인지 미착수인지: `미복원`. -- **오류 타입 4종** — `PermanentError`/`TransientError`/`PersistDiscarded`/`ProfileMismatchError`. spec은 "두 종류"만. 폐기를 예외 제어흐름으로 구현한 판단: `미복원`. -- **`SharedSecretMiddleware`** (`core/security.py:22`) — `!=` 단순 비교(**상수시간 아님**, `hmac.compare_digest` 미사용), `/health` 무인증. 타이밍 공격을 내부망 전용이라 수용한 판단인지 미인지인지: `미복원`. -- **Profile 정합 검사** — `token not in self.embedding_profile`(**부분 문자열 포함**). `15`가 `1536`에 포함되는 오탐/미탐 인지 여부: `미복원`. +| A-1 | `failure-recovery.md §3.1`: 최대 2회 재시도(지수 백오프+jitter, 대상은 타임아웃·429·5xx·연결실패) | 재시도가 없다. 두 클라이언트 모두 httpx 1회 호출이다. app 전체에서 retry/backoff/429 매치가 0건이다 | `직접확인`(client 코드, grep 0건) | `미복원`. 의도적 유예인지 누락인지, Spring 재스캔 10분이면 충분하다는 판단이었는지 알 수 없다 | +| A-2 | `§2.1`: `429`=Transient | `status>=500` 만 Transient 이고 그 외 non-200 은 전부 Permanent 다. 결과적으로 429 가 영구 오류로 분류되어 단계가 FAILED 가 된다 | `직접확인`(`embedding_client._embed_batch`) | `미복원` | +| A-3 | `§2.2`: `400`·`401/403`=Permanent | 모든 non-200 을 Transient 로 분류한다(401·400 포함). 인증 실패가 10분마다 무한 재시도된다 | `직접확인`(`llm_client.judge`) | `미복원`. Embedding 클라이언트와 정반대인 비대칭의 근거를 알 수 없다 | +| A-4 | `§2.2`: "재시도 후에도 스키마 위반"이면 Permanent | 파싱 실패를 Transient 로 분류한다. 재시도 자체가 없어 이 조항에 도달하는 경로가 없다(분류표가 사문화됐다) | `직접확인`(`llm_client._parse`) | `미복원` | +| A-5 | `§3.2`: 연결·읽기 타임아웃을 각각 두고, "두 호출 합+재시도 < PROCESSING 만료 600s" | `60.0`/`90.0` 의 단일 total 타임아웃뿐이다 | `직접확인` | 값의 근거 `미복원` | +| A-6 | `§2.2`: Circuit Breaker | 없다 | `직접확인` | `미복원`. MVP 유예 여부를 알 수 없다 | +| F-1 | `integration-tests.md §3-8`: 일시적 오류 시 PROCESSING 유지 단언 | 테스트가 없다. `fakes.py` 의 `raise_exc` 가 어느 테스트에서도 사용되지 않는다(grep 0건). Transient/Permanent 파이프라인 경로가 한 번도 실행된 적이 없다 | `직접확인` | `미복원`. 의도적 제외인지 누락인지 알 수 없다 | +| F-2 | `§2.2`: 영구 오류 시 단계 FAILED | 파이프라인 테스트가 없다(repo SQL 만 검증). service 가 PermanentError 를 받아 `fail()` 을 부르는 결선이 미검증이다 | `직접확인` | — | +| F-3 | `§4.2`: client 는 인터페이스 Fake 로 대체 | client 의 HTTP 계층 테스트가 0건이다. 상태코드→오류타입 매핑·차원 불일치·`_parse` 가 미검증이다. A절의 429 오분류가 잡히지 않은 직접적인 이유다 | `직접확인` | 공백을 수용한 판단인지 `미복원` | + +## 6. 실행 인프라 판단 — 현재 상태는 `직접확인`, 최초 이유는 `미복원` + +> 아래 항목들은 코드에 결과만 있고 그 근거가 PR·커밋·문서 어디에도 없다. 최초 선택 이유를 창작하지 않는다. + +- **BackgroundTasks** (`api/internal/v1/context.py:24`) — FastAPI `BackgroundTasks` 를 쓴다. 큐·워커풀·동시 실행 상한이 없어 요청 수만큼 태스크가 떠서 외부 API 를 동시에 호출한다. 왜 BackgroundTasks 인지, Celery/RQ/asyncio 큐를 기각한 근거, 재시작 시 in-flight 작업 유실과 §3.3 의 관계: `미복원`. +- **커넥션 풀** (`core/db.py:25-26`) — `min_size=1, max_size=10` 하드코딩이다(설정값이 아니다). 10 의 근거, 상한 없는 백그라운드 동시성에서 풀이 고갈되는 지점, 설정으로 분리하지 않은 이유: `미복원`. +- **`core/logging.py`** — `basicConfig`+`getLogger` 만 있다. 구조화 로깅·상관 ID·레벨 주입이 없다(§2.1 은 "WARN 으로 context_id·stage·원인 포함"을 규정한다). 미도입이 판단인지 미착수인지: `미복원`. +- **오류 타입 4종** — `PermanentError`/`TransientError`/`PersistDiscarded`/`ProfileMismatchError`. spec 은 "두 종류"만 규정한다. 폐기를 예외 제어흐름으로 구현한 판단: `미복원`. +- **`SharedSecretMiddleware`** (`core/security.py:22`) — `!=` 단순 비교다. 상수시간 비교(`hmac.compare_digest`)를 쓰지 않았고 `/health` 는 무인증이다. 타이밍 공격을 내부망 전용이라는 이유로 수용한 것인지 인지하지 못한 것인지: `미복원`. +- **Profile 정합 검사** — `token not in self.embedding_profile` 로 부분 문자열 포함을 검사한다. `15` 가 `1536` 에 포함되는 식의 오탐/미탐 가능성을 인지했는지: `미복원`. ## 7. 기술 부채·임시 처리 (`기록복원`) -`ai_snapshot.sql` 수동 동기화(자동 diff는 스택 확대 후 재검토) · back PR 템플릿 체크는 **제안만**(back 소관) · CONTRIBUTING 미작성(합류 직전으로) · 기능별 폴더 분리 보류(architecture §9에 규칙만) · Python 상한 `<3.13` 잠정 · Jira 상태 전환 수동(-19 미완) · ~~**`conftest.py:23`이 `0.8.1`인 채로 남음**(back·운영은 0.8.5)~~ → **해소**: `S15P11A705-122`에서 `0.8.5-pg16` + digest 고정으로 정합화. 사전 검사가 **두 번의 별도 커넥션 조회**(TOCTOU 창 2개)로 나뉜 사실도 spec(§4.1 1회)과 어긋나나 근거 `미복원`. - -## 8. 미복원 항목 (창작 금지, 여기 확정) - -E3 계획 파일 본문(20시나리오→19함수 매핑 기준) · `AskUserQuestion` 선택지 원문(asyncpg 대안이 **SQLAlchemy였다는 것도 `추정`**) · stacked PR(#5→#6) 선택 이유(사용자 선택 사실만) · Bash 도구 간헐 차단 원인 · `.handoff/e3.md`가 `chore/handoff-docs`에 커밋된 경위 · A/B/F절 각 판단 이유 · 타임아웃 60/90·다중 워커 스냅샷 스큐 검토 여부·상수시간 비교·풀 10. +- `ai_snapshot.sql` 은 수동 동기화다. 자동 diff 는 스택 확대 후 재검토한다. +- back PR 템플릿 체크는 제안만 했다(back 소관). +- CONTRIBUTING 은 미작성이다(합류 직전이라 미룸). +- 기능별 폴더 분리는 보류했다. architecture §9 에 규칙만 남겼다. +- Python 상한 `<3.13` 은 잠정이다. +- Jira 상태 전환은 수동이다(-19 미완). +- ~~`conftest.py:23` 이 `0.8.1` 인 채로 남았다(back·운영은 0.8.5)~~ → 해소됨. `S15P11A705-122` 에서 `0.8.5-pg16` + digest 고정으로 정합화했다. +- 사전 검사가 두 번의 별도 커넥션 조회로 나뉘어 있다(TOCTOU 창이 2개). spec §4.1 의 "1회 조회"와 어긋나지만 근거는 `미복원`이다. + +## 8. 미복원 항목 (창작 금지, 여기서 확정) + +- E3 계획 파일 본문(20시나리오→19함수 매핑의 기준) +- `AskUserQuestion` 선택지 원문. asyncpg 의 대안이 SQLAlchemy 였다는 것도 `추정`이다. +- stacked PR(#5→#6) 선택 이유(사용자가 선택했다는 사실만 남아 있다) +- Bash 도구 간헐 차단의 원인 +- `.handoff/e3.md` 가 `chore/handoff-docs` 브랜치에 커밋된 경위 +- §5 A/B/F 각 항목의 판단 이유 +- 타임아웃 60/90 의 근거, 다중 워커 스냅샷 스큐 검토 여부, 상수시간 비교, 풀 크기 10 의 근거 diff --git a/docs/implements/2026-07-29-demo-seeding.md b/docs/implements/2026-07-29-demo-seeding.md index 65030f7..c123fb9 100644 --- a/docs/implements/2026-07-29-demo-seeding.md +++ b/docs/implements/2026-07-29-demo-seeding.md @@ -10,90 +10,56 @@ ## 무엇을 했나 -① `tools/e2e/`가 현행 코드에서 도는지 재확인하고 ② 발표 시연용 데이터를 -**다시 만들 수 있는 형태로** 만들었다. 프로덕션 코드 변경은 없다. +두 가지다. ① `tools/e2e/` 가 현행 코드에서 도는지 재확인했다. ② 발표 시연용 데이터를 다시 만들 수 있는 형태로 만들었다. 프로덕션 코드 변경은 없다. -I21(2026-07-27) 이후 back에 `-102`(Context 생성 시 AI 처리 접수)·`-120`(Feed)· -`-124`(삭제 정합)가, ai에 `-96`(`/ready`·smoke)이 들어왔다. **I21이 "불가"로 -판정했던 피드 시연이 가능해진 것**이 이 작업의 전제 변화다. +I21(2026-07-27) 이후 back 에 `-102`(Context 생성 시 AI 처리 접수)·`-120`(Feed)·`-124`(삭제 정합)가, ai 에 `-96`(`/ready`·smoke)이 들어왔다. I21 이 "불가"로 판정했던 피드 시연이 가능해진 것이 이 작업의 전제 변화다. -> I21 §시딩 가능 범위: *"불가 — 피드 시연. core 도메인 미착수(백엔드가 member -> 엔티티 스캐폴딩 단계, core 마이그레이션 V2~ 미착수)."* +> I21 §시딩 가능 범위: *"불가 — 피드 시연. core 도메인 미착수(백엔드가 member 엔티티 스캐폴딩 단계, core 마이그레이션 V2~ 미착수)."* -지금은 `V2__member`·`V3__core_domain`·`V4__social_account`·`V5`가 모두 있고 -Feed 런타임도 있다. 그래서 I21이 권했던 **매핑 파일 방식(`e2e_contexts.yaml`)을 -데모로 확장하지 않았다.** 그 방식은 core가 없다는 제약의 산물이었고, 제약이 -사라졌으므로 판단을 다시 했다. +지금은 `V2__member`·`V3__core_domain`·`V4__social_account`·`V5` 가 모두 있고 Feed 런타임도 있다. 그래서 I21 이 권했던 매핑 파일 방식(`e2e_contexts.yaml`)을 데모로 확장하지 않았다. 그 방식은 core 가 없다는 제약의 산물이었고, 제약이 사라졌으므로 판단을 다시 했다. --- ## 판단 1 — 시딩을 어디에 두고 어떻게 만드는가 -### 결론: `tools/demo_seed/`, back API 호출 +### 결론: `tools/demo_seed/` 에 두고, back API 를 호출해 만든다 두 축의 결정이다. -**위치는 `tools/`다.** `app/bootstrap/load_presets.py`는 제품이 항상 필요로 하는 -데이터를 적재하는 **운영 부트스트랩**이다. 데모 시딩은 운영에서 절대 돌면 안 -되는 코드이므로 같은 자리에 두면 안 된다. `tools/`에는 이미 `e2e/`(검증 드라이버)와 -`keyword_eval/`(평가 하네스)이 있고, "실행해서 무언가를 확인하는 로컬 도구"라는 -성격이 같다. +**위치는 `tools/` 다.** `app/bootstrap/load_presets.py` 는 제품이 항상 필요로 하는 데이터를 적재하는 운영 부트스트랩이다. 데모 시딩은 운영에서 절대 돌면 안 되는 코드이므로 같은 자리에 두면 안 된다. `tools/` 에는 이미 `e2e/`(검증 드라이버)와 `keyword_eval/`(평가 하네스)이 있고, "실행해서 무언가를 확인하는 로컬 도구"라는 성격이 같다. -**만드는 방법은 back API 호출이다.** 갈림길은 이랬다. +**만드는 방법은 back API 호출이다.** 선택지는 다음과 같았다. | | SQL 직접 INSERT | back API 호출 | |---|---|---| | 필요 스택 | DB만 | DB + back + FastAPI | | `-102` PENDING 생성 | 안 탄다 (직접 써야 함) | 탄다 | | FastAPI `/context/process` 호출 | 안 탄다 | 탄다 | -| 실패 모드 | **데이터는 있는데 파이프라인은 안 돈 상태** | 파이프라인이 실패하면 드러난다 | -| 시딩의 부가 가치 | 없음 | **통합 검증을 겸한다** | +| 실패 모드 | 데이터는 있는데 파이프라인은 안 돈 상태 | 파이프라인이 실패하면 드러난다 | +| 시딩의 부가 가치 | 없음 | 통합 검증을 겸한다 | -SQL 직접 INSERT의 위험은 비용이 아니라 **거짓 성공**이다. 화면에 데이터가 보이면 -잘 된 것처럼 보이는데, 정작 시연에서 "새 Context를 지금 추가하면 Keyword가 -붙는다"를 보여주는 순간 그 경로가 처음 실행된다. 발표 당일에 처음 실행되는 -경로를 남기지 않는 것이 이 작업의 목적에 부합한다. +SQL 직접 INSERT 의 위험은 비용이 아니라 거짓 성공이다. 화면에 데이터가 보이면 잘 된 것처럼 보이는데, 정작 시연에서 "새 Context 를 지금 추가하면 Keyword 가 붙는다"를 보여주는 순간 그 경로가 처음 실행된다. 발표 당일에 처음 실행되는 경로를 남기지 않는 것이 이 작업의 목적에 부합한다. -실제로 이번 시딩이 그 값을 했다 — Record 14건을 만드는 동안 -`core.record`·`core.context` INSERT → `ai.context_ai_state` PENDING INSERT → -`POST /internal/v1/context/process` → 임베딩·판정 저장까지가 매 건 실행됐다. +실제로 이번 시딩이 그 역할을 했다. Record 14건을 만드는 동안 `core.record`·`core.context` INSERT → `ai.context_ai_state` PENDING INSERT → `POST /internal/v1/context/process` → 임베딩·판정 저장까지가 매 건 실행됐다. -### SQL을 쓰는 두 지점과 그 근거 +### SQL 을 쓰는 두 지점과 그 근거 -"전부 API"는 성립하지 않는다. 두 곳에서 SQL이 불가피하다. +"전부 API"는 성립하지 않는다. 두 곳에서 SQL 이 불가피하다. -**① member 생성.** back의 유일한 회원 생성 경로가 소셜 OAuth 콜백이다 -(`SocialLoginService`). 스크립트가 부를 API가 없고, 실제 Google 계정 5개로 -브라우저 로그인을 하는 것은 재현 가능한 절차가 아니다. 다행히 `core.member`는 -`id`·`created_at`·`deleted_at` 셋뿐인 익명 테이블이라(익명 서비스 설계) 이 INSERT가 -우회하는 도메인 규칙이 없다. 추적을 위해 `core.social_account`에 provider -`demo-seed`를 함께 남겼다 — **이 표식이 `--reset`의 삭제 범위이자 "이건 시딩 -데이터"의 판별 근거**다. +**① member 생성.** back 의 유일한 회원 생성 경로가 소셜 OAuth 콜백이다(`SocialLoginService`). 스크립트가 부를 API 가 없고, 실제 Google 계정 5개로 브라우저 로그인을 하는 것은 재현 가능한 절차가 아니다. 다행히 `core.member` 는 `id`·`created_at`·`deleted_at` 셋뿐인 익명 테이블이라(익명 서비스 설계) 이 INSERT 가 우회하는 도메인 규칙이 없다. 추적을 위해 `core.social_account` 에 provider `demo-seed` 를 함께 남겼다. 이 표식이 `--reset` 의 삭제 범위이자 "이것은 시딩 데이터"라는 판별 근거다. -**② `--reset` 삭제.** API의 삭제는 전부 soft delete다. "시연 직전에 DB를 비우고 -다시"를 재현하려면 hard delete가 필요한데 그에 해당하는 API가 없다. 삭제 범위는 -provider `demo-seed`로 식별된 member의 데이터로 한정했다. +**② `--reset` 삭제.** API 의 삭제는 전부 soft delete 다. "시연 직전에 DB 를 비우고 다시"를 재현하려면 hard delete 가 필요한데 그에 해당하는 API 가 없다. 삭제 범위는 provider `demo-seed` 로 식별된 member 의 데이터로 한정했다. -그 외 Record·Context·Collection·Follow는 전부 API로 만든다. +그 외 Record·Context·Collection·Follow 는 전부 API 로 만든다. -> **`core`에 쓰는 것이 계약 위반 아닌가.** 계약이 금지하는 것은 *FastAPI 런타임이* -> `core`를 읽고 쓰는 것이다(README:10, [architecture.md](../spec/architecture.md) §7). -> `tools/`의 로컬 스크립트는 런타임이 아니고, 앱 코드에는 `core` 참조가 한 줄도 -> 늘지 않았다. I21이 실증한 대로 로컬 접속 롤은 슈퍼유저라 DB가 이 경계를 강제하지 -> 않는다(`S15P11A705-61` 미해소) — 그래서 **범위를 표식으로 좁히고 문서에 남기는 -> 것이 현재 쓸 수 있는 유일한 방어선**이다. +> **`core` 에 쓰는 것이 계약 위반 아닌가.** 계약이 금지하는 것은 *FastAPI 런타임이* `core` 를 읽고 쓰는 것이다(README:10, [architecture.md](../spec/architecture.md) §7). `tools/` 의 로컬 스크립트는 런타임이 아니고, 앱 코드에는 `core` 참조가 한 줄도 늘지 않았다. I21 이 실증한 대로 로컬 접속 롤은 슈퍼유저라 DB 가 이 경계를 강제하지 않는다(`S15P11A705-61` 미해소). 그래서 쓰기 범위를 표식으로 좁히고 문서에 남기는 것이 현재 쓸 수 있는 유일한 방어선이다. ### back 변경은 필요하지 않았다 -`Forbidden`에 걸린 항목이다. 결과적으로 back 코드는 한 줄도 건드리지 않았고 -`Need Decision`으로 올릴 사유도 생기지 않았다. 두 가지가 그것을 가능하게 했다. +`Forbidden` 에 걸린 항목이다. 결과적으로 back 코드는 한 줄도 건드리지 않았고 `Need Decision` 으로 올릴 사유도 생기지 않았다. 두 가지가 그것을 가능하게 했다. -- **JWT 서명 키 주입 경로가 이미 있다.** `JwtKeyProvider`가 `JWT_PRIVATE_KEY`를 - 받고, 없으면 임시 키쌍을 만든다. 시딩은 back에 준 것과 **같은 개인키**로 Access - 토큰을 서명한다. 인증 *우회*가 아니라 키 *공급*이며, 서명·발급자·만료·용도 - 클레임을 back의 `JwtTokenProvider`가 그대로 검증한다. -- **CSRF도 통과시킨다.** 우회 수단이 없기도 하고, 프론트가 밟을 경로를 그대로 - 밟는 편이 낫다. +- **JWT 서명 키 주입 경로가 이미 있다.** `JwtKeyProvider` 가 `JWT_PRIVATE_KEY` 를 받고, 없으면 임시 키쌍을 만든다. 시딩은 back 에 준 것과 같은 개인키로 Access 토큰을 서명한다. 인증을 *우회*하는 것이 아니라 키를 *공급*하는 것이며, 서명·발급자·만료·용도 클레임을 back 의 `JwtTokenProvider` 가 그대로 검증한다. +- **CSRF 도 통과시킨다.** 우회 수단이 없기도 하고, 프론트가 밟을 경로를 그대로 밟는 편이 낫다. --- @@ -105,17 +71,15 @@ provider `demo-seed`로 식별된 member의 데이터로 한정했다. | 시연 | 필요한 것 | 수 | 근거 | |---|---|---|---| -| 자연어 검색 | 주인공 1명의 Context | 6 | 정답 1 + 경쟁 5. I21이 분리도 +0.2120을 실측한 규모 | -| 탐색 피드 | 타 소유자와 그 Context | 4명 × 2 = 8 | `max-per-owner=2`라 소유자 4명이 카드 8장의 상한 | -| Keyword | (위에 자연히 붙는다) | 0 | 별도 Context를 만들지 않는다 | +| 자연어 검색 | 주인공 1명의 Context | 6 | 정답 1 + 경쟁 5. I21 이 분리도 +0.2120 을 실측한 규모다 | +| 탐색 피드 | 타 소유자와 그 Context | 4명 × 2 = 8 | `max-per-owner=2` 라 소유자 4명이 카드 8장의 상한이다 | +| Keyword | (위에 자연히 붙는다) | 0 | 별도 Context 를 만들지 않는다 | -Collection 9개와 Follow 2건은 **Record를 재사용**하므로 GMS 호출이 늘지 않는다. -소유자당 Collection을 2개로 둔 것도 그래서다 — 화면은 채워지고 비용은 그대로다. +Collection 9개와 Follow 2건은 Record 를 재사용하므로 GMS 호출이 늘지 않는다. 소유자당 Collection 을 2개로 둔 것도 같은 이유다. 화면은 채워지고 비용은 그대로다. ### 진짜 제약은 돈이 아니라 429였다 -비용 산정보다 먼저 부딪힌 것은 **GMS 게이트웨이의 Gemini 429**다. 판정 호출을 -15초 간격으로 12회 던져 측정했다. +비용 산정보다 먼저 부딪힌 것은 GMS 게이트웨이의 Gemini 429 다. 판정 호출을 15초 간격으로 12회 던져 측정했다. ``` 1.8s OK 81.4s OK 146.4s OK @@ -126,61 +90,47 @@ Collection 9개와 Follow 2건은 **Record를 재사용**하므로 GMS 호출이 → 3분에 6건 통과 (분당 약 2건) ``` -`RESOURCE_EXHAUSTED`. 임베딩(OpenAI 경로)에서는 관측되지 않았고 판정(Gemini -경로)에서만 났다. 공용 게이트웨이라 다른 사용자와 쿼터를 나눠 쓰는 것으로 보인다. +오류는 `RESOURCE_EXHAUSTED` 다. 임베딩(OpenAI 경로)에서는 관측되지 않았고 판정(Gemini 경로)에서만 났다. 공용 게이트웨이라 다른 사용자와 쿼터를 나눠 쓰는 것으로 보인다. -이것이 건수 판단을 바꿨다. **"많을수록 좋은 것이 아니다"의 이유가 비용이 아니라 -시간**이다. Context 1건이 판정 1회를 쓰고 판정이 분당 2건이면, 시딩 시간은 건수에 -선형으로 늘고 재현(다시 돌리기)마다 그만큼 든다. 시연에 보탬이 되지 않는 Context는 -그 시간을 값 없이 쓴다. +이것이 건수 판단을 바꿨다. "많을수록 좋은 것이 아니다"의 이유가 비용이 아니라 시간이다. Context 1건이 판정 1회를 쓰고 판정이 분당 2건이면, 시딩 시간은 건수에 선형으로 늘고 재현(다시 돌리기)마다 그만큼 든다. 시연에 보탬이 되지 않는 Context 는 그 시간을 값 없이 쓴다. -### 429가 10분을 태우는 경로 — 이것이 설계를 바꿨다 +### 429가 10분을 소모하게 만드는 경로 — 이것이 설계를 바꿨다 -429가 나면 `keyword_service`는 상태를 `PROCESSING`으로 둔 채 조용히 돌아온다 -(재스캔 회수 전제). 그런데 `ai_state_repo.try_start`는 stale `PROCESSING`을 -**`PROCESSING_EXPIRY_SEC`(기본 600초) 뒤에만** 재선점한다. +429 가 나면 `keyword_service` 는 상태를 `PROCESSING` 으로 둔 채 조용히 돌아온다(재스캔 회수 전제). 그런데 `ai_state_repo.try_start` 는 stale `PROCESSING` 을 `PROCESSING_EXPIRY_SEC`(기본 600초) 뒤에만 재선점한다. ```sql WHERE {col} IN ('PENDING','PROCESSING') AND ({col} = 'PENDING' OR updated_at < now() - $2::interval) ``` -즉 **429 한 번이 그 Context를 10분간 얼린다.** 로컬에는 Spring 재스캔이 없으므로 -그냥 두면 영영 미완료다. 세 가지 선택지가 있었다. +즉 429 한 번이 그 Context 를 10분간 처리 불가 상태로 묶는다. 로컬에는 Spring 재스캔이 없으므로 그냥 두면 영영 미완료다. 세 가지 선택지가 있었다. | 안 | 내용 | 채택 | |---|---|---| -| A | `.env`의 `PROCESSING_EXPIRY_SEC`를 낮춘다 | ✗ FastAPI 재기동이 필요하고, 운영과 다른 값을 로컬에 남긴다 | -| B | 429가 안 날 만큼 느리게 던진다 | ✗ 15초 간격에도 429가 났다. 회피가 불가능 | -| C | 시딩이 `PROCESSING` → `PENDING`으로 되돌리고 재호출 | **✓** | +| A | `.env` 의 `PROCESSING_EXPIRY_SEC` 를 낮춘다 | ✗ FastAPI 재기동이 필요하고, 운영과 다른 값을 로컬에 남긴다 | +| B | 429 가 안 날 만큼 느리게 던진다 | ✗ 15초 간격에도 429 가 났다. 회피가 불가능하다 | +| C | 시딩이 `PROCESSING` → `PENDING` 으로 되돌리고 재호출한다 | **✓** | -C를 골랐다. 쓰기가 `ai` 스키마 안에서 끝나고, 운영의 M3 재처리 -(`COMPLETED → PENDING`, [state-machine.md](../spec/state-machine.md))와 같은 성격의 -상태 되돌림이며, 설정 변경도 재기동도 필요 없다. `tools/e2e/run_pipeline.py`가 -이미 `PENDING`을 직접 넣어 Spring을 대행하는 것과 같은 자세다. +C 를 골랐다. 쓰기가 `ai` 스키마 안에서 끝나고, 운영의 M3 재처리(`COMPLETED → PENDING`, [state-machine.md](../spec/state-machine.md))와 같은 성격의 상태 되돌림이며, 설정 변경도 재기동도 필요 없다. `tools/e2e/run_pipeline.py` 가 이미 `PENDING` 을 직접 넣어 Spring 을 대행하는 것과 같은 방식이다. -결과적으로 `seed.py`는 **로컬에 없는 재스캔 워커를 대신한다** — 미완료 건을 -한 건씩(`RECOVER_INTERVAL_SEC=20`) 되살린다. 이 구조 덕에 429가 나도 시딩이 -실패하지 않고 느려질 뿐이다. +결과적으로 `seed.py` 는 로컬에 없는 재스캔 워커를 대신한다. 미완료 건을 한 건씩(`RECOVER_INTERVAL_SEC=20`) 되살린다. 이 구조 덕에 429 가 나도 시딩이 실패하지 않고 느려질 뿐이다. --- -## E2E 재확인 — `tools/e2e/`는 현행 코드에서 그대로 돈다 +## E2E 재확인 — `tools/e2e/` 는 현행 코드에서 그대로 돈다 -I21 이후 ai `app/`에 들어온 변경은 `-96` 둘(`/ready`·`GMS_BASE_URL` fail-fast -추가, 공개 설정 기본값을 코드로 이동)이다. 드라이버가 쓰는 `get_settings()`· -`Database`·API 계약은 그대로였고, **깨진 곳이 없었다.** +I21 이후 ai `app/` 에 들어온 변경은 `-96` 의 둘(`/ready`·`GMS_BASE_URL` fail-fast 추가, 공개 설정 기본값을 코드로 이동)이다. 드라이버가 쓰는 `get_settings()`·`Database`·API 계약은 그대로였고, 깨진 곳이 없었다. | 드라이버 | 결과 | |---|---| -| `run_pipeline.py` | Context 8건 → **6.0초에 두 status 전부 COMPLETED** (I21과 동일) | -| `run_search.py` | 계약 3종(200·422·401) 통과, 관련 질의 6건 전부 1위, 분리도 **+0.2120** | -| `run_equivalence.py` | **GMS 429로 중단** (아래) | +| `run_pipeline.py` | Context 8건 → 6.0초에 두 status 전부 COMPLETED (I21 과 동일) | +| `run_search.py` | 계약 3종(200·422·401) 통과, 관련 질의 6건 전부 1위, 분리도 +0.2120 | +| `run_equivalence.py` | GMS 429 로 중단 (아래) | | `run_attribution.py` | 미실행 — 위와 같은 사유 | ### 검색 수치가 소수점 넷째 자리까지 같다 -I21과 이번 실행이 완전히 같은 값을 냈다. +I21 과 이번 실행이 완전히 같은 값을 냈다. ``` 관련 top1 : min 0.5263 · max 0.6740 · avg 0.5989 (I21) @@ -189,24 +139,16 @@ I21과 이번 실행이 완전히 같은 값을 냈다. 간격 : +0.2120 (양쪽 동일) ``` -**임베딩은 결정적이다.** I21이 "판정은 결정적이지 않다"를 실측한 것과 대비되는 -사실이며, 검색 시연이 매번 같은 순위를 낸다는 뜻이라 시연 안정성에도 의미가 있다. +임베딩은 결정적이라는 뜻이다. I21 이 "판정은 결정적이지 않다"를 실측한 것과 대비되는 사실이며, 검색 시연이 매번 같은 순위를 낸다는 뜻이라 시연 안정성에도 의미가 있다. -### `run_equivalence.py`의 중단은 회귀가 아니다 +### `run_equivalence.py` 의 중단은 회귀가 아니다 두 가지가 겹쳤다. -1. **키 설정이 별도다.** 이 드라이버는 `tools/keyword_eval/` 하네스를 import하고, - 그쪽은 `GMS_API_KEY`를 환경변수나 `tools/keyword_eval/.env`에서 읽는다. 레포 - `.env`를 보지 않는다. `tools/e2e/README.md`에 이미 적혀 있는 전제다. -2. **키를 준 뒤에는 429로 죽었다.** 샘플 10건 × (운영 2회 + 하네스 1회) 판정을 - 간격 없이 던지므로 분당 2건 쿼터에서 완주가 불가능하다. 샘플 06까지 진행한 뒤 - `TransientError: llm error: 429`로 중단됐다. +1. **키 설정이 별도다.** 이 드라이버는 `tools/keyword_eval/` 하네스를 import 하고, 그쪽은 `GMS_API_KEY` 를 환경변수나 `tools/keyword_eval/.env` 에서 읽는다. 레포 `.env` 를 보지 않는다. `tools/e2e/README.md` 에 이미 적혀 있는 전제다. +2. **키를 준 뒤에는 429 로 중단됐다.** 샘플 10건 × (운영 2회 + 하네스 1회) 판정을 간격 없이 던지므로 분당 2건 쿼터에서 완주가 불가능하다. 샘플 06까지 진행한 뒤 `TransientError: llm error: 429` 로 중단됐다. -여기까지의 결과 자체는 **I21과 일치**한다 — 후보 집합·순서가 샘플 00~06 전부 -Jaccard 1.00으로 일치했고, 운영 판정 2회 반복도 전부 동일했다. 코드가 바뀌어 깨진 -것이 아니라 **외부 쿼터가 완주를 막은 것**이므로 수정 대상은 드라이버의 재시도· -간격 설계이고, 이번 티켓 범위(-58)가 아니다. → [남은 것](#남은-것) +중단 전까지의 결과 자체는 I21 과 일치한다. 후보 집합·순서가 샘플 00~06 전부 Jaccard 1.00 으로 일치했고, 운영 판정 2회 반복도 전부 동일했다. 코드가 바뀌어 깨진 것이 아니라 외부 쿼터가 완주를 막은 것이므로, 수정 대상은 드라이버의 재시도·간격 설계이고 이번 티켓 범위(-58)가 아니다. → [남은 것](#남은-것) --- @@ -221,14 +163,11 @@ member 5 · Record·Context 14 · Collection 9 · Follow 2 Context 14건 전부 COMPLETED · ai.context_keyword 32행 · 총 소요 약 6분 ``` -`--pace 25`의 1회차에서는 **회수가 한 번도 필요하지 않았다** — 25초 간격이 판정 -쿼터(분당 약 2건) 안에 들어와 14건이 모두 첫 시도에 통과했다. **그럼에도 회수 -루프는 필요하다** — 쿼터가 공용이라 다른 사용자 부하에 따라 달라지고, 실제로 -2회차에서는 1건이 429로 막혀 회수가 발동했다. +`--pace 25` 의 1회차에서는 회수가 한 번도 필요하지 않았다. 25초 간격이 판정 쿼터(분당 약 2건) 안에 들어와 14건이 모두 첫 시도에 통과했다. 그럼에도 회수 루프는 필요하다. 쿼터가 공용이라 다른 사용자 부하에 따라 달라지고, 실제로 2회차에서는 1건이 429 로 막혀 회수가 발동했다. ### 시연 3종 — 전부 통과 -**A. 자연어 검색** — 시연 질의 4건 전부 의도한 Record가 1위. +**A. 자연어 검색** — 시연 질의 4건 전부 의도한 Record 가 1위였다. | 질의 | 1위 | similarity | |---|---|---| @@ -237,9 +176,7 @@ Context 14건 전부 COMPLETED · ai.context_keyword 32행 · 총 소요 약 6 | 친구들이랑 시끌벅적하게 놀 만한 곳 | [데모] 연남 골목 선술집 | 0.6414 | | 기념일에 야경 보면서 식사할 곳 | [데모] 언덕 위 야경 식당 | 0.5267 | -2위와의 간격이 가장 좁은 것이 "비 오는 날"(0.5021 → 0.3490, 간격 0.153)이고 -가장 넓은 것이 "친구들이랑"(0.6414 → 0.4069, 간격 0.235)이다. 넷 다 시연에서 -1위가 뒤집힐 여지가 없다. +2위와의 간격이 가장 좁은 것이 "비 오는 날"(0.5021 → 0.3490, 간격 0.153)이고 가장 넓은 것이 "친구들이랑"(0.6414 → 0.4069, 간격 0.235)이다. 넷 다 시연에서 1위가 뒤집힐 여지가 없다. **B. 탐색 피드** — 카드 8장, 소유자 4명, 본인 것 0건. @@ -254,7 +191,7 @@ Context 14건 전부 COMPLETED · ai.context_keyword 32행 · 총 소요 약 6 [7] 데모 디저트 지도 owner=4 [DESSERT, TRENDY] ``` -**C. Keyword** — Context 6건(주인공)에 17행, 피드 카드 8/8장에 표시. +**C. Keyword** — Context 6건(주인공)에 17행이 붙었고, 피드 카드 8/8장에 표시됐다. ``` [데모] 골목 안 다방 COZY, RAINY_DAY, RETRO @@ -265,15 +202,11 @@ Context 14건 전부 COMPLETED · ai.context_keyword 32행 · 총 소요 약 6 [데모] 강변 산책로 초입 EXHIBITION, WALK ``` -의도한 대로 붙었다. `ANNIVERSARY`는 `PRIVATE_ONLY`라 본인 Context에는 있고 -타인이 보는 피드 카드 8장 어디에도 나오지 않는다 — `FeedKeywordRepository`의 -비대칭이 실데이터에서 작동한다. +의도한 대로 붙었다. `ANNIVERSARY` 는 `PRIVATE_ONLY` 라서 본인 Context 에는 있고 타인이 보는 피드 카드 8장 어디에도 나오지 않는다. `FeedKeywordRepository` 의 비대칭이 실데이터에서 작동한다는 뜻이다. ### 재현성 — 처음부터 다시 돌려 확인했다 -`seed.py --reset`을 **두 번 완주**하고 두 번 다 `verify.py`를 통과시켰다. -한 번 성공은 재현이 아니므로, 2회차는 1회차 데이터를 hard delete한 **빈 상태에서 -다시** 만들었다(member id가 2~6에서 7~11로 옮겨간 것이 그 증거다). +`seed.py --reset` 을 두 번 완주하고 두 번 다 `verify.py` 를 통과시켰다. 한 번의 성공은 재현이 아니므로, 2회차는 1회차 데이터를 hard delete 한 빈 상태에서 다시 만들었다(member id 가 2~6에서 7~11로 옮겨간 것이 그 증거다). **같은 것 — 시연이 성립하는 조건 전부** @@ -286,7 +219,7 @@ Context 14건 전부 COMPLETED · ai.context_keyword 32행 · 총 소요 약 6 | 본인 Collection 노출 | 0 | 0 | | `PRIVATE_ONLY` 누출 | 0 | 0 | -similarity도 사실상 같다. +similarity 도 사실상 같다. ``` 비 오는 날 … 0.5021 / 0.5021 @@ -303,33 +236,19 @@ similarity도 사실상 같다. | `ai.context_keyword` 행 수 | 32 | 34 | | 회수 루프 발동 | 0회 | 1회 (ctx=29, 21초에 회수) | -- **id는 재현의 조건이 아니다.** `GENERATED ALWAYS AS IDENTITY`라 삭제해도 - 시퀀스가 되감기지 않는다. 그래서 `verify.py`는 id를 하드코딩하지 않고 place - 이름과 `demo-seed` 표식으로 대상을 찾는다. -- **Keyword 행 수 차이는 알려진 비결정성이다.** I21이 실측한 그대로다 - ([keyword-preset.md](../spec/keyword-preset.md) §4.4). 2회차에서 "넓은 한상 - 식당"에 `GATHERING`이 하나 더 붙었다. 시연 3종의 판정에는 영향이 없다 — - 판정 기준이 "특정 code가 정확히 몇 개"가 아니라 "Keyword가 붙어 화면에 - 나오는가"이기 때문이고, 그 기준이 옳은 이유가 바로 이 비결정성이다. -- **2회차의 회수 1회는 설계가 작동한 증거다.** ctx=29가 `kw=PROCESSING`으로 - 남았고(429), 시딩이 `PENDING`으로 되돌려 21초 만에 회수했다. 회수 루프가 - 없었다면 `PROCESSING_EXPIRY_SEC` 600초를 기다려야 했다. - -> **재현의 정의를 여기서 못박는다** — 같은 행이 나오는 것이 아니라 **같은 시연이 -> 성립하는 것**이다. 임베딩은 결정적이라 검색 순위가 고정되고, 판정은 비결정적이라 -> Keyword 조합이 흔들린다. 후자를 고정하려 들면 시딩이 판정 결과를 박아 넣어야 -> 하고, 그건 파이프라인을 우회하는 것이라 이 도구가 피하려던 바로 그 상태다. +- **id 는 재현의 조건이 아니다.** `GENERATED ALWAYS AS IDENTITY` 라서 삭제해도 시퀀스가 되감기지 않는다. 그래서 `verify.py` 는 id 를 하드코딩하지 않고 place 이름과 `demo-seed` 표식으로 대상을 찾는다. +- **Keyword 행 수 차이는 알려진 비결정성이다.** I21 이 실측한 그대로다([keyword-preset.md](../spec/keyword-preset.md) §4.4). 2회차에서 "넓은 한상 식당"에 `GATHERING` 이 하나 더 붙었다. 시연 3종의 판정에는 영향이 없다. 판정 기준이 "특정 code 가 정확히 몇 개"가 아니라 "Keyword 가 붙어 화면에 나오는가"이기 때문이고, 그 기준이 옳은 이유가 바로 이 비결정성이다. +- **2회차의 회수 1회는 설계가 작동한 증거다.** ctx=29 가 `kw=PROCESSING` 으로 남았고(429), 시딩이 `PENDING` 으로 되돌려 21초 만에 회수했다. 회수 루프가 없었다면 `PROCESSING_EXPIRY_SEC` 600초를 기다려야 했다. + +> **재현의 정의를 여기서 확정한다.** 같은 행이 나오는 것이 아니라 같은 시연이 성립하는 것이다. 임베딩은 결정적이라 검색 순위가 고정되고, 판정은 비결정적이라 Keyword 조합이 흔들린다. 후자를 고정하려 들면 시딩이 판정 결과를 직접 써 넣어야 하고, 그것은 파이프라인을 우회하는 것이라 이 도구가 피하려던 바로 그 상태다. --- ## 발견 — 시연 화면에 드러나는 Feed 배치 역전 (back 소관, `Need Decision`) -피드 결과에서 **팔로우한 소유자(walker·dessert)가 아래쪽(4~7)에, 팔로우하지 않은 -소유자(rainy·host_party)가 위쪽(0~3)에** 배치된다. 점수 공식만 보면 나올 수 없는 -순서다 — `w_follow=0.5`이고 `followSignal`은 팔로우 채널 출처면 1.0이므로, -`keywordAffinity`(가중치 0.375)가 아무리 높아도 뒤집을 수 없다. +피드 결과에서 팔로우한 소유자(walker·dessert)가 아래쪽(4~7)에, 팔로우하지 않은 소유자(rainy·host_party)가 위쪽(0~3)에 배치된다. 점수 공식만 보면 나올 수 없는 순서다. `w_follow=0.5` 이고 `followSignal` 은 팔로우 채널 출처면 1.0 이므로, `keywordAffinity`(가중치 0.375)가 아무리 높아도 뒤집을 수 없다. -원인은 점수가 아니라 **배치**다. +원인은 점수가 아니라 배치다. ```java // ScoredCandidate @@ -340,39 +259,24 @@ List exploration = take(ranked, used, ownerCounts, Math.min(slots, size), ScoredCandidate::isExploration); ``` -`take`는 **점수 순으로** 집는다. 그런데 데모처럼 Collection 총수가 적으면 -(`random-limit=20` ≥ 후보 8) 무작위 채널이 후보 전체를 훑어 **모든 후보가 -`fromRandom=true`**가 된다. 그러면 탐색 슬롯 4개가 *최고점 후보 4개*를 먼저 -가져가고, 그것들이 하위 절반에 놓인다. +`take` 는 점수 순으로 집는다. 그런데 데모처럼 Collection 총수가 적으면(`random-limit=20` ≥ 후보 8) 무작위 채널이 후보 전체를 훑어 모든 후보가 `fromRandom=true` 가 된다. 그러면 탐색 슬롯 4개가 *최고점 후보 4개* 를 먼저 가져가고, 그것들이 하위 절반에 놓인다. -의도와 반대다 — `feed-scoring` 4.2(AI 소유 명세, 실물은 back 레포 -`docs/ai/spec/feed-scoring.md`)가 탐색을 하위 절반에 -두는 이유는 *"상단을 무작위로 채우면 첫인상이 나빠지므로"*인데, 여기서는 상단이 -차점자로 채워지고 최고점이 내려간다. +의도와 반대다. `feed-scoring` 4.2(AI 소유 명세, 실물은 back 레포 `docs/ai/spec/feed-scoring.md`)가 탐색을 하위 절반에 두는 이유는 *"상단을 무작위로 채우면 첫인상이 나빠지므로"* 인데, 여기서는 상단이 차점자로 채워지고 최고점이 내려간다. -- **운영 규모에서는 잘 드러나지 않는다.** Collection이 수백 개면 `random-limit=20`이 - 전체의 일부만 훑으므로 대부분의 후보는 `fromRandom=false`다. -- **시연 화면에는 그대로 나온다.** 발표에서 "팔로우한 사람의 컬렉션"을 짚으면 - 아래쪽을 가리켜야 한다. +- **운영 규모에서는 잘 드러나지 않는다.** Collection 이 수백 개면 `random-limit=20` 이 전체의 일부만 훑으므로 대부분의 후보는 `fromRandom=false` 다. +- **시연 화면에는 그대로 나온다.** 발표에서 "팔로우한 사람의 컬렉션"을 짚으면 아래쪽을 가리켜야 한다. -**AI 파트가 판단할 계약 질문이다** — 후보·필터·탐색의 *의미*는 AI 파트 소유이고 -(CONTRIBUTING «Feed 협업 경계»), 구현은 백엔드 소유다. 물어야 할 것은 하나다. +AI 파트가 판단할 계약 질문이다. 후보·필터·탐색의 *의미* 는 AI 파트 소유이고(CONTRIBUTING «Feed 협업 경계»), 구현은 백엔드 소유다. 물어야 할 것은 하나다. -> 탐색 슬롯이 **최고점 후보를 흡수해도 되는가**, 아니면 탐색은 "점수로는 뽑히지 -> 않았을 후보"에서만 채워야 하는가. +> 탐색 슬롯이 최고점 후보를 흡수해도 되는가, 아니면 탐색은 "점수로는 뽑히지 않았을 후보"에서만 채워야 하는가. -후자라면 `take(exploration)`이 점수 하위부터 집거나 exploit 선발 후 남은 것에서 -집어야 한다. 이 티켓(-58)에서 임의로 정하지 않는다. `Forbidden`이 back 코드 변경을 -막고 있기도 하지만, 그보다 **계약을 정하지 않고 구현만 바꾸면 같은 문제가 다시 -난다.** +후자라면 `take(exploration)` 이 점수 하위부터 집거나, exploit 선발 후 남은 것에서 집어야 한다. 이 티켓(-58)에서 임의로 정하지 않는다. `Forbidden` 이 back 코드 변경을 막고 있기도 하지만, 그보다 계약을 정하지 않고 구현만 바꾸면 같은 문제가 다시 나기 때문이다. -시딩 데이터로 이 배치를 감추지 않았다. Collection을 21개 이상으로 늘리면 무작위 -채널이 전체를 덮지 못해 증상이 사라지지만, 그건 결함을 데이터로 가리는 것이고 -시연 결과를 예측 불가능하게 만든다. +시딩 데이터로 이 배치를 감추지 않았다. Collection 을 21개 이상으로 늘리면 무작위 채널이 전체를 덮지 못해 증상이 사라지지만, 그것은 결함을 데이터로 가리는 것이고 시연 결과를 예측 불가능하게 만든다. --- -## 발견 — Record 상세 API는 Keyword를 돌려주지 않는다 (back 미구현) +## 발견 — Record 상세 API 는 Keyword 를 돌려주지 않는다 (back 미구현) ```java // RecordDetailResponse.of @@ -382,13 +286,9 @@ return new RecordDetailResponse( record.getCreatedAt(), addedToCollectionAt); ``` -`GET /v1/records/{id}`와 `POST /v1/records`의 `keywords`는 **항상 빈 배열**이다. -`ai.context_keyword`에 행이 있어도 그렇다(위 C절에서 17행을 확인했다). +`GET /v1/records/{id}` 와 `POST /v1/records` 의 `keywords` 는 항상 빈 배열이다. `ai.context_keyword` 에 행이 있어도 그렇다(위 C절에서 17행을 확인했다). -시연 영향: **Record 상세 화면에는 Keyword가 나오지 않는다.** Keyword를 보여줄 수 -있는 화면은 현재 피드 카드뿐이다. `verify.py`는 이것을 통과 조건이 아니라 -**관측 항목**으로 출력한다 — back의 미구현 때문에 시딩 검증이 붉게 뜨면 정작 -시딩의 문제를 못 보게 된다. +시연 영향: Record 상세 화면에는 Keyword 가 나오지 않는다. Keyword 를 보여줄 수 있는 화면은 현재 피드 카드뿐이다. `verify.py` 는 이것을 통과 조건이 아니라 관측 항목으로 출력한다. back 의 미구현 때문에 시딩 검증이 실패로 뜨면, 정작 시딩 자체의 문제를 못 보게 되기 때문이다. 발표 시연 구성에 영향이 있으므로 중앙 조정 세션에 함께 올린다. @@ -396,39 +296,31 @@ return new RecordDetailResponse( ## 데이터 설계 -`demo_data.yaml` 하나가 시딩의 유일한 입력이다. 내용을 바꾸고 `--reset`으로 다시 -돌리면 그대로 재구성된다 — **스크립트를 고쳐야 한다면 그건 결함**이다. +`demo_data.yaml` 하나가 시딩의 유일한 입력이다. 내용을 바꾸고 `--reset` 으로 다시 돌리면 그대로 재구성된다. 스크립트를 고쳐야 한다면 그것은 결함이다. ### 시연용임이 드러나게 만들었다 -`Forbidden`의 *"실제 인물·상호를 오해하게 만드는 데이터"* 항목에 대한 처리다. +`Forbidden` 의 *"실제 인물·상호를 오해하게 만드는 데이터"* 항목에 대한 처리다. -- 장소명에 `[데모]` 접두사 — 화면에 그대로 보인다 -- `kakao_place_id`는 `demo-seed-*` — DB에서 판별된다 -- 주소는 `서울특별시 OO구 (데모 시딩 데이터)` -- 실재 상호를 쓰지 않았다. 시연 화면이 특정 가게에 대한 실제 후기처럼 보이면 안 된다 +- 장소명에 `[데모]` 접두사를 붙였다. 화면에 그대로 보인다. +- `kakao_place_id` 는 `demo-seed-*` 다. DB 에서 판별된다. +- 주소는 `서울특별시 OO구 (데모 시딩 데이터)` 다. +- 실재 상호를 쓰지 않았다. 시연 화면이 특정 가게에 대한 실제 후기처럼 보이면 안 된다. -사람 이름은 애초에 저장되지 않는다 — `core.member`에 이름 컬럼이 없다(익명 서비스). -`demo_data.yaml`의 `key`는 파일과 로그에서만 쓰는 식별자다. +사람 이름은 애초에 저장되지 않는다. `core.member` 에 이름 컬럼이 없다(익명 서비스). `demo_data.yaml` 의 `key` 는 파일과 로그에서만 쓰는 식별자다. ### 시연 서사를 데이터에 넣었다 기능이 도는 것과 시연이 설득력 있는 것은 다르다. 두 가지를 의도적으로 배치했다. -- **`rainy` 소유자의 취향을 주인공과 겹쳐 놓았다.** 주인공이 비 오는 날·아늑한 - 곳을 저장해 두었고, `rainy`의 Collection도 그렇다. Feed 점수의 - `keywordAffinity`(가중치 0.375)가 실제로 순위에 반영되는 것이 화면에서 보인다. -- **`WITH_PARTNER`·`ANNIVERSARY`가 붙을 Context를 주인공에게 넣었다.** - `ANNIVERSARY`는 `PRIVATE_ONLY`라 본인 프로파일에는 반영되고 타인 Collection - 카드에는 나오지 않는다(`FeedKeywordRepository`의 비대칭). `verify.py`가 - 이 미노출을 판정 항목으로 확인한다. +- **`rainy` 소유자의 취향을 주인공과 겹쳐 놓았다.** 주인공이 비 오는 날·아늑한 곳을 저장해 두었고, `rainy` 의 Collection 도 그렇다. Feed 점수의 `keywordAffinity`(가중치 0.375)가 실제로 순위에 반영되는 것이 화면에서 보인다. +- **`WITH_PARTNER`·`ANNIVERSARY` 가 붙을 Context 를 주인공에게 넣었다.** `ANNIVERSARY` 는 `PRIVATE_ONLY` 라 본인 프로파일에는 반영되고 타인 Collection 카드에는 나오지 않는다(`FeedKeywordRepository` 의 비대칭). `verify.py` 가 이 미노출을 판정 항목으로 확인한다. --- ## 검증 방식 — "만들었다"와 "보인다"를 분리했다 -`verify.py`는 DB를 세어 통과시키지 않는다. **시연에서 실제로 호출될 두 API를 -그대로 호출하고 그 응답만으로 판정**한다. +`verify.py` 는 DB 를 세어 통과시키지 않는다. 시연에서 실제로 호출될 두 API 를 그대로 호출하고 그 응답만으로 판정한다. ``` A. 자연어 검색 POST /internal/v1/search (FastAPI, 주인공 userId, 실 GMS 임베딩) @@ -436,20 +328,15 @@ B. 탐색 피드 GET /v1/feed/collections (back, 주인공 인증) C. Keyword B의 응답 keywords + GET /v1/records/{id} ``` -DB 수치로 통과시키면 "행은 있는데 API가 안 준다"를 놓친다. Feed는 특히 그렇다 — -`record_count > 0`·`is_published`·소유자 미탈퇴·본인 제외를 전부 WHERE 절에 걸고 -있어서, 행이 있어도 응답이 빌 수 있다. +DB 수치로 통과시키면 "행은 있는데 API 가 안 준다"를 놓친다. Feed 가 특히 그렇다. `record_count > 0`·`is_published`·소유자 미탈퇴·본인 제외를 전부 WHERE 절에 걸고 있어서, 행이 있어도 응답이 빌 수 있다. -`load_ids()`가 매핑을 `seed.py`의 출력 파일이 아니라 **DB에서 복원**하는 것도 -같은 이유다. 파일로 넘기면 파일과 DB가 어긋날 수 있고, 그러면 검증이 거짓 -통과한다. +`load_ids()` 가 매핑을 `seed.py` 의 출력 파일이 아니라 DB 에서 복원하는 것도 같은 이유다. 파일로 넘기면 파일과 DB 가 어긋날 수 있고, 그러면 검증이 거짓으로 통과한다. --- ## 재현 절차 -전제(스택 셋)와 실행은 [`tools/demo_seed/README.md`](../../tools/demo_seed/README.md)에 -있다. 요지만 옮긴다. +전제(스택 셋)와 실행은 [`tools/demo_seed/README.md`](../../tools/demo_seed/README.md)에 있다. 요지만 옮긴다. ```bash cd ../back && docker compose -p pinlog-demo up -d # Flyway가 V1~V102 전부 적용 @@ -460,30 +347,21 @@ python tools/demo_seed/seed.py --reset python tools/demo_seed/verify.py ``` -### I21의 마찰 F1이 이 경로에서는 사라진다 +### I21 의 마찰 F1 이 이 경로에서는 사라진다 -I21이 최대 마찰로 꼽은 것은 *"README 2단계 `ai` 스키마 생성 — '적용해'의 방법이 -없다"*였다. ai#22가 psql 루프를 문서에 넣어 해소했지만, **데모 경로에서는 그 -루프 자체가 필요 없다** — back을 띄우면 Flyway가 `V1`~`V102`를 전부 적용한다. +I21 이 최대 마찰로 꼽은 것은 *"README 2단계 `ai` 스키마 생성 — '적용해'의 방법이 없다"* 였다. ai#22 가 psql 루프를 문서에 넣어 해소했지만, 데모 경로에서는 그 루프 자체가 필요 없다. back 을 띄우면 Flyway 가 `V1`~`V102` 를 전부 적용하기 때문이다. -다만 그 대가로 **DB가 갈린다.** 레포 `README.md`는 5433 단독 컨테이너를 -처방하는데(user `pinlog`/`pinlog`), 데모는 back의 compose(15432, `pinlog`/ -`pinlog-local`)를 쓴다. back과 ai가 같은 DB를 봐야 시딩이 성립하기 때문이다. -I21의 F2(DSN 3중 불일치)가 남긴 함정과 같은 종류라 `tools/demo_seed/README.md`에 -"같은 DB가 아니다"를 명시했다. +다만 그 대가로 DB 가 갈린다. 레포 `README.md` 는 5433 단독 컨테이너를 처방하는데(user `pinlog`/`pinlog`), 데모는 back 의 compose(15432, `pinlog`/`pinlog-local`)를 쓴다. back 과 ai 가 같은 DB 를 봐야 시딩이 성립하기 때문이다. I21 의 F2(DSN 3중 불일치)가 남긴 함정과 같은 종류라서 `tools/demo_seed/README.md` 에 "같은 DB 가 아니다"를 명시했다. -컨테이너 프로젝트 이름을 `pinlog-demo`로 둔 것은 다른 세션이 쓰는 `back` 프로젝트 -컨테이너를 `docker compose`가 재생성하지 않게 하려는 것이다. +컨테이너 프로젝트 이름을 `pinlog-demo` 로 둔 것은, 다른 세션이 쓰는 `back` 프로젝트 컨테이너를 `docker compose` 가 재생성하지 않게 하려는 것이다. --- ## 범위 밖으로 둔 것 -- **dev·운영 DB 시딩** — `Forbidden`. 로컬과 재현 절차까지가 이 작업이다. -- **테스트 픽스처와의 통합** — `tests/`는 Testcontainers로 자체 DB를 쓴다. 섞지 않는다. -- **`tools/e2e/`와의 통합** — 두 데이터가 같은 DB에 공존해도 서로 보이지 않는다. - e2e 데이터는 `core`에 대응 행이 없어 Feed 조인에 걸리지 않고, `/search`는 - `userId`로 갈린다. 실제로 이번에 같은 DB에서 둘을 함께 돌렸다. +- **dev·운영 DB 시딩** — `Forbidden` 항목이다. 로컬과 재현 절차까지가 이 작업이다. +- **테스트 픽스처와의 통합** — `tests/` 는 Testcontainers 로 자체 DB 를 쓴다. 섞지 않는다. +- **`tools/e2e/` 와의 통합** — 두 데이터가 같은 DB 에 공존해도 서로 보이지 않는다. e2e 데이터는 `core` 에 대응 행이 없어 Feed 조인에 걸리지 않고, `/search` 는 `userId` 로 갈린다. 실제로 이번에 같은 DB 에서 둘을 함께 돌렸다. ## 검증 @@ -498,14 +376,11 @@ python -m compileall app tools OK pytest --cov=app --cov-branch 69 passed · TOTAL 77% ``` -`app/` 변경이 없으므로 pytest 결과와 커버리지는 `main`과 같다. +`app/` 변경이 없으므로 pytest 결과와 커버리지는 `main` 과 같다. ## 남은 것 -- `run_equivalence.py`·`run_attribution.py`의 **429 대응**(호출 간격·재시도). - 현행 설계로는 분당 2건 쿼터에서 완주가 불가능하다. 별도 티켓 대상. +- `run_equivalence.py`·`run_attribution.py` 의 429 대응(호출 간격·재시도). 현행 설계로는 분당 2건 쿼터에서 완주가 불가능하다. 별도 티켓 대상이다. - **어디에 시딩할 것인가** — dev 환경 시딩 여부·시점은 배포 담당과 별도로 정한다. -- `BLOCKED` 프리셋 실데이터 검증 — I21에서 이월. 프리셋에 BLOCKED가 생기면 자연 해소. -- **back 소관 2건** — 탐색 슬롯이 최고점 후보를 흡수하는 배치(계약 질문은 AI 파트 - 소유), `RecordDetailResponse`의 `keywords` 미구현. 이 PR에서 고치지 않고 중앙 - 조정 세션에 `Need Decision`으로 올린다. +- `BLOCKED` 프리셋 실데이터 검증 — I21 에서 이월. 프리셋에 BLOCKED 가 생기면 자연 해소된다. +- **back 소관 2건** — 탐색 슬롯이 최고점 후보를 흡수하는 배치(계약 질문은 AI 파트 소유), `RecordDetailResponse` 의 `keywords` 미구현. 이 PR 에서 고치지 않고 중앙 조정 세션에 `Need Decision` 으로 올린다. diff --git a/docs/implements/2026-07-29-dev-deployment-gates.md b/docs/implements/2026-07-29-dev-deployment-gates.md index 5748dbd..ba8942a 100644 --- a/docs/implements/2026-07-29-dev-deployment-gates.md +++ b/docs/implements/2026-07-29-dev-deployment-gates.md @@ -1,4 +1,4 @@ -# dev 배포 게이트 3종 — `/ready` · `GMS_BASE_URL` fail-fast · GMS 양방향 스모크 +# dev 배포 게이트 3종 — `/ready` 프로브, `GMS_BASE_URL` 기동 검증, GMS 양방향 스모크를 구현했다 - **상태**: 완료 - **날짜**: 2026-07-29 @@ -8,7 +8,7 @@ ## 무엇을 만들었나 -인프라가 dev 배포 activation 조건으로 요청한 3종이다. 셋은 **같은 결함 하나를 서로 다른 시점에서 막는다** — "틀린 설정으로도 서버가 정상 기동하고, 첫 실사용 요청에서야 실패한다"([ai#32 리뷰 ①·②](https://github.com/Team-PinLog/ai/pull/32)). +인프라가 dev 배포의 activation 조건으로 요청한 게이트 3종이다. 셋은 같은 결함 하나를 서로 다른 시점에서 막는다. 그 결함이란 "틀린 설정으로도 서버가 정상 기동하고, 첫 실사용 요청에서야 실패한다"는 것이다([ai#32 리뷰 ①·②](https://github.com/Team-PinLog/ai/pull/32)). | 시점 | 게이트 | 잡는 것 | |---|---|---| @@ -25,32 +25,32 @@ preset 현재 Embedding Profile 기준 캐시 ≥ 1건 실패 503 {"status": "not_ready"} ``` -**판단 4건.** +설계 판단 4건을 남긴다. -- **GMS를 호출하지 않는다.** 준비 판정에 외부 게이트웨이 가용성을 섞으면 자기 책임 밖의 장애로 인스턴스가 트래픽에서 빠진다. GMS 도달성은 배포 시점 스모크가 따로 증명한다(계약 §2 요청과 일치). -- **Profile 조건을 재조회하지 않는다.** 캐시는 lifespan이 `settings.embedding_profile`로 조회한 행만 담으므로(`main.py`), 건수 ≥ 1이 곧 "현재 Profile 기준 ≥ 1건"이다. 별도 쿼리는 같은 사실을 두 번 묻는 것이다. -- **무인증.** `SharedSecretMiddleware`가 `/internal/`만 가로채므로 `/ready`는 헤더 없이 호출된다. 프로브가 시크릿을 들고 다니지 않게 하려는 계약상 의도다. -- **응답에 값이 없다.** 이 경로는 무인증 노출이라 `{"status": ...}` 한 필드만 싣는다. 예외 처리 로그도 **예외 타입 이름만** 남긴다 — asyncpg 예외 메시지에는 DSN이 섞여 들어올 수 있다. +- **GMS 를 호출하지 않는다.** 준비 판정에 외부 게이트웨이의 가용성을 섞으면, 자기 책임 밖의 장애 때문에 인스턴스가 트래픽에서 빠지게 된다. GMS 도달성은 배포 시점의 스모크가 따로 증명한다(계약 §2 의 요청과 일치한다). +- **Profile 조건을 재조회하지 않는다.** 캐시는 lifespan 이 `settings.embedding_profile` 로 조회한 행만 담는다(`main.py`). 따라서 캐시 건수가 1건 이상이라는 것이 곧 "현재 Profile 기준 1건 이상"이다. 별도 쿼리를 두는 것은 같은 사실을 두 번 묻는 일이다. +- **무인증으로 연다.** `SharedSecretMiddleware` 는 `/internal/` 경로만 가로채므로 `/ready` 는 헤더 없이 호출된다. 프로브가 시크릿을 들고 다니지 않게 하려는 계약상의 의도다. +- **응답에 내부 값을 싣지 않는다.** 무인증으로 노출되는 경로이므로 `{"status": ...}` 한 필드만 싣는다. 실패 시 로그에도 예외 타입 이름만 남긴다. asyncpg 예외 메시지에는 DSN 이 섞여 들어올 수 있기 때문이다. -`GET /health`는 **정적 `{"status":"ok"}` 그대로 유지**했다(liveness·startup 전용). 합의사항이라 회귀 테스트(`test_health_stays_ok_while_not_ready`)로 고정했다. +`GET /health` 는 정적 `{"status":"ok"}` 를 그대로 유지했다(liveness·startup 전용). 합의사항이므로 회귀 테스트(`test_health_stays_ok_while_not_ready`)로 고정했다. ## 2. `GMS_BASE_URL` 형식 fail-fast (`app/core/config.py`) -한 변수를 두 클라이언트가 다르게 소비한다 — 임베딩은 `{URL}/embeddings`를 그대로 붙이고, 판정은 `URL.split("/gmsapi/")[0] + "/gmsapi"`로 root를 **파생**한다. 세그먼트가 빠지면 **임베딩은 정상 동작하고 judge만 조용히 실패**한다. `_gms_base_url_shape` model_validator가 이를 기동 시점에 막는다. +한 변수를 두 클라이언트가 다르게 소비한다. 임베딩 클라이언트는 `{URL}/embeddings` 를 그대로 붙이고, 판정 클라이언트는 `URL.split("/gmsapi/")[0] + "/gmsapi"` 로 root 를 파생시킨다. 따라서 URL 에 `/gmsapi/` 세그먼트가 빠지면 임베딩은 정상 동작하는데 judge 만 조용히 실패한다. `_gms_base_url_shape` model_validator 가 이 상태를 기동 시점에 막는다. -**`SettingsError`(RuntimeError)를 쓰고 `ValueError`를 쓰지 않는다.** pydantic은 `ValueError`/`AssertionError`만 가로채 `ValidationError`로 감싸는데, 그때 `input_value`를 메시지에 실어 넣는다. 실측(pydantic 2.13.4): +예외 타입은 `SettingsError`(RuntimeError)를 쓰고 `ValueError` 를 쓰지 않는다. pydantic 은 `ValueError`/`AssertionError` 만 가로채 `ValidationError` 로 감싸는데, 그때 `input_value` 를 메시지에 실어 넣기 때문이다. 실측 결과는 다음과 같다(pydantic 2.13.4). | 검증 방식 | 메시지에 실리는 것 | |---|---| -| `field_validator` + `ValueError` | `input_value='https://…'` — **endpoint 전체 노출** | -| `model_validator(after)` + `ValueError` | `input_value={'secret': 'SENTINEL-SECR…` — **원시 입력 dict 앞부분 노출** | -| `model_validator(after)` + `SettingsError` | 우리가 쓴 문장만 | +| `field_validator` + `ValueError` | `input_value='https://…'` — endpoint 전체가 노출된다 | +| `model_validator(after)` + `ValueError` | `input_value={'secret': 'SENTINEL-SECR…` — 원시 입력 dict 의 앞부분이 노출된다 | +| `model_validator(after)` + `SettingsError` | 우리가 쓴 문장만 실린다 | -기동 실패 메시지는 배포 파이프라인 로그에 남으므로 세 번째를 택했다. `test_gms_base_url_error_carries_no_values`가 이 성질을 고정한다. +기동 실패 메시지는 배포 파이프라인 로그에 남으므로 세 번째 방식을 택했다. `test_gms_base_url_error_carries_no_values` 가 이 성질을 고정한다. -> **범위 밖 관찰**: 기존 `_profile_consistency`는 여전히 `ValueError` 경로라, Profile 불일치로 기동이 깨지면 같은 방식으로 원시 입력 dict 앞부분이 로그에 남는다. 이번 티켓 범위가 아니라 손대지 않았다. 별도 티켓 후보. +> **범위 밖 관찰**: 기존 `_profile_consistency` 는 여전히 `ValueError` 경로다. Profile 불일치로 기동이 깨지면 같은 방식으로 원시 입력 dict 앞부분이 로그에 남는다. 이번 티켓 범위가 아니라 손대지 않았다. 별도 티켓 후보다. -검증 대상은 **`/gmsapi/` 세그먼트 포함 여부 하나**다. scheme·host 형식은 검사하지 않는다 — 그쪽 오류는 스모크가 실호출로 잡는 편이 확실하고, 검증을 늘리면 정상 값을 막을 위험만 커진다. +검증 대상은 `/gmsapi/` 세그먼트 포함 여부 하나다. scheme·host 형식은 검사하지 않는다. 그쪽 오류는 스모크가 실제 호출로 잡는 편이 확실하고, 검증 규칙을 늘리면 정상 값을 잘못 막을 위험만 커지기 때문이다. ## 3. GMS 양방향 스모크 (`app/smoke/gms_roundtrip.py`) @@ -58,18 +58,18 @@ preset 현재 Embedding Profile 기준 캐시 ≥ 1건 python -m app.smoke.gms_roundtrip ``` -**판단 4건.** +설계 판단 4건을 남긴다. -- **한쪽이 실패해도 나머지를 건너뛰지 않는다.** 이 스모크가 존재하는 이유가 비대칭 장애라, 한 번 실행으로 어느 쪽이 죽었는지 알아야 한다. 종료 코드는 마지막에 한 번 판정한다. -- **DB를 건드리지 않는다.** 판정 후보를 모듈 안 고정 리터럴로 두어 Preset 적재 여부와 무관하다 — 읽기조차 없으므로 부트스트랩 Job 전후 어디서 돌려도 된다. -- **판정 내용을 단언하지 않는다.** 판정은 비결정적이라(`spec/keyword-preset.md`) 선택이 비어도 성공이다. 증명 대상은 인증·경로·모델의 생존이지 판정 품질이 아니다. -- **출력에 값이 없다.** 검사 이름과 `ok`/`failed (예외타입)`뿐이다. 클라이언트 예외 메시지에는 응답 본문 일부(`resp.text[:200]`)와 요청 URL이 섞여 오므로 **타입 이름으로 환원**한다. 추가로 `httpx`·`httpcore` 로거를 `CRITICAL`로 올린다 — httpx는 요청마다 INFO로 전체 URL을 남기고, 이 명령의 출력은 배포 로그에 남는다. `configure_logging()`을 부르지 않는 이유도 같다(root를 INFO로 열면 그 로그가 켜진다). +- **한쪽이 실패해도 나머지를 건너뛰지 않는다.** 이 스모크가 존재하는 이유가 임베딩·판정의 비대칭 장애다. 한 번의 실행으로 어느 쪽이 실패했는지 알아야 하므로, 종료 코드는 마지막에 한 번만 판정한다. +- **DB 를 건드리지 않는다.** 판정 후보를 모듈 안의 고정 리터럴로 두어 Preset 적재 여부와 무관하게 동작한다. 읽기조차 하지 않으므로 부트스트랩 Job 전후 어느 시점에 돌려도 된다. +- **판정 내용을 단언하지 않는다.** 판정은 비결정적이므로(`spec/keyword-preset.md`) 선택 결과가 비어 있어도 성공이다. 증명 대상은 인증·경로·모델이 살아 있는지이지 판정 품질이 아니다. +- **출력에 값을 싣지 않는다.** 검사 이름과 `ok`/`failed (예외타입)` 만 출력한다. 클라이언트 예외 메시지에는 응답 본문 일부(`resp.text[:200]`)와 요청 URL 이 섞여 오므로 예외 타입 이름으로 바꿔 출력한다. 추가로 `httpx`·`httpcore` 로거 레벨을 `CRITICAL` 로 올렸다. httpx 는 요청마다 INFO 레벨로 전체 URL 을 남기는데, 이 명령의 출력은 배포 로그에 남기 때문이다. `configure_logging()` 을 부르지 않는 이유도 같다. root 로거를 INFO 로 열면 그 로그가 켜진다. -`get_settings()`가 단일 Settings를 강제하므로 실행에 서버와 동일한 env 전체가 필요하다 — 부트스트랩 Job과 같은 알려진 제약이며(ai#32 §4) 그대로 둔다. +`get_settings()` 가 단일 Settings 를 강제하므로, 실행에는 서버와 동일한 env 전체가 필요하다. 부트스트랩 Job 과 같은 알려진 제약이며(ai#32 §4) 그대로 둔다. ## 검증 -기준 커밋 기준 실측이다. +기준 커밋에서의 실측이다. | 항목 | 명령 | 결과 | |---|---|---| @@ -80,25 +80,25 @@ python -m app.smoke.gms_roundtrip | `/health` 실기동 | `curl /health` | `200 {"status":"ok"}` — 형태 불변 | | 스모크 정상 | `python -m app.smoke.gms_roundtrip` (실 GMS) | `embedding: ok` / `judge: ok` / exit **0** | | 스모크 비대칭 실패 | 위 명령 + `PINLOG_JUDGE_MODEL=<미존재 모델>` | `embedding: ok` / `judge: failed (TransientError)` / exit **1** | -| URL fail-fast | `GMS_BASE_URL=<세그먼트 없는 값> python -c "import app.main"` | `SettingsError` 기동 중단, 메시지에 값 없음 | +| URL fail-fast | `GMS_BASE_URL=<세그먼트 없는 값> python -c "import app.main"` | `SettingsError` 로 기동 중단. 메시지에 값 없음 | -추가된 테스트 14개: +추가된 테스트는 14개다. | 파일 | 수 | 내용 | |---|---|---| | `tests/test_api.py` | 6 | `/ready` 200·503(캐시 0건)·503(DB 끊김)·무인증·값 미노출·`/health` 불변 회귀 | | `tests/test_unit.py` | 8 | `GMS_BASE_URL` 거부 2·수용 1·값 미노출 1 · 스모크 집계 3·출력 1 | -**스모크는 단위 테스트로 실호출을 덮지 않는다.** 외부 의존이라 `_CHECKS`를 스텁으로 교체해 **집계·종료 코드·값 미노출 규약**만 검증하고, 실제 GMS 왕복은 위 표의 수동 실측으로 대신했다. 실호출 자체를 CI에 넣으면 GMS 가용성이 CI 성패에 들어온다. +스모크의 실제 호출은 단위 테스트로 덮지 않았다. 외부 의존이므로 `_CHECKS` 를 스텁으로 교체해 집계·종료 코드·값 미노출 규약만 검증하고, 실제 GMS 왕복은 위 표의 수동 실측으로 대신했다. 실제 호출을 CI 에 넣으면 GMS 가용성이 CI 성패에 들어오기 때문이다. ## 인프라에 전달할 것 -1. 이 PR 병합 후 **immutable source SHA + image digest** (ai#32 §5 — AI CI가 main push에서 1회 build/publish) -2. readiness probe 경로를 `/ready`로, startup/liveness는 `/health`로 배선 -3. activation 전 컨테이너에서 `python -m app.smoke.gms_roundtrip` 실행 → exit 0 확인 +1. 이 PR 병합 후의 immutable source SHA 와 image digest (ai#32 §5 — AI CI 가 main push 에서 1회 build/publish 한다) +2. readiness probe 경로를 `/ready` 로, startup/liveness 는 `/health` 로 배선한다 +3. activation 전에 컨테이너에서 `python -m app.smoke.gms_roundtrip` 을 실행해 exit 0 을 확인한다 ## 남은 것 -- `_profile_consistency`의 `ValueError` 경로 값 노출(위 §2 관찰) — 별도 티켓 -- 외부 API retry/error classification — `S15P11A705-121`, dev 배포 blocker 아님(ai#32 합의) -- `llm_client`의 `/gmsapi/` 리터럴과 `config.GMS_PATH_SEGMENT`가 각자 리터럴 — 통합은 client 계층 변경이라 범위 밖 +- `_profile_consistency` 의 `ValueError` 경로 값 노출(§2 의 범위 밖 관찰) — 별도 티켓으로 다룬다. +- 외부 API retry/error classification — `S15P11A705-121`. dev 배포의 blocker 는 아니다(ai#32 합의). +- `llm_client` 의 `/gmsapi/` 리터럴과 `config.GMS_PATH_SEGMENT` 가 각자 리터럴로 존재한다. 통합은 client 계층 변경이라 이번 범위 밖이다. diff --git a/docs/implements/2026-07-29-sealed-secret-handoff.md b/docs/implements/2026-07-29-sealed-secret-handoff.md index 9311267..19b94be 100644 --- a/docs/implements/2026-07-29-sealed-secret-handoff.md +++ b/docs/implements/2026-07-29-sealed-secret-handoff.md @@ -6,21 +6,21 @@ > > 이 문서의 이전 판(`S15P11A705-96`, 레포 자체 workflow `seal-ai-secrets.yml`)이 기록한 > 구현은 `S15P11A705-154`에서 Infra 공용 action -> `Team-PinLog/infra/.github/actions/sealedsecret-infra-pr`으로 **대체됐다.** 아래 +> `Team-PinLog/infra/.github/actions/sealedsecret-infra-pr`으로 대체됐다. 아래 > [대체된 구현의 설계 근거](#대체된-구현의-설계-근거-s15p11a705-96--보존)의 판단은 -> 폐기된 것이 아니라 **해당 action에 반영돼 있다.** +> 폐기된 것이 아니라 해당 action 에 반영되어 있다. > > `docs/implements/README.md`의 보존 원칙("완료된 항목도 삭제하지 않고 상태 표시만 > 갱신합니다")에 따라 이전 판을 삭제하지 않는다. 이 복원 방식은 -> [ai#39](https://github.com/Team-PinLog/ai/pull/39) 코멘트에서 Infra와 합의했다. +> [ai#39](https://github.com/Team-PinLog/ai/pull/39) 코멘트에서 Infra 와 합의했다. ## 계약 -정본 workflow는 `.github/workflows/seal-runtime-secrets.yml` 하나다. 수동 -`workflow_dispatch`로만 실행하며 GitHub Environment `pinlog-secrets-dev`의 보호 규칙과 -Secret을 사용한다. 일반 `ai-ci` workflow는 이 런타임 Secret을 읽지 않는다. +정본 workflow 는 `.github/workflows/seal-runtime-secrets.yml` 하나다. 수동 +`workflow_dispatch` 로만 실행하며 GitHub Environment `pinlog-secrets-dev` 의 보호 규칙과 +Secret 을 사용한다. 일반 `ai-ci` workflow 는 이 런타임 Secret 을 읽지 않는다. -workflow가 Infra 공용 action에 전달하는 Environment Secret 이름은 다음 네 개로 +workflow 가 Infra 공용 action 에 전달하는 Environment Secret 이름은 다음 네 개로 고정한다. - `GMS_API_KEY` @@ -28,75 +28,75 @@ workflow가 Infra 공용 action에 전달하는 Environment Secret 이름은 다 - `INTERNAL_SHARED_SECRET` - `PINLOG_INFRA_SECRET_PR_TOKEN` -값, placeholder, base64 표현 또는 평문 파일은 레포와 로그에 남기지 않는다. 현재 Infra의 -7-key runtime placeholder를 AI 레포의 source contract로 복제하지 않으며 공개 앱 설정도 -이 workflow가 소유하지 않는다. +값, placeholder, base64 표현 또는 평문 파일은 레포와 로그에 남기지 않는다. 현재 Infra 의 +7-key runtime placeholder 를 AI 레포의 source contract 로 복제하지 않으며, 공개 앱 설정도 +이 workflow 가 소유하지 않는다. ## 공급망 경계 -- checkout은 실행 commit인 `${{ github.sha }}`를 사용하고 credential을 보존하지 않는다. -- workflow 권한은 `contents: read`, `id-token: write`뿐이다. -- Infra action은 commit - `16bfae0da4e1091df597fb89f6acf914391e11b9`으로 고정한다. 이는 - [infra#82](https://github.com/Team-PinLog/infra/pull/82) 병합 commit이며 `infra` `main`과 - 동일하다. 이전 pin `84458bf3`은 병합 전 브랜치 commit이라 `main`과 diverged 상태였다. -- action 입력은 policy `ai-dev`와 revision `${{ github.sha }}`뿐이다. -- 별도 artifact upload나 `repository_dispatch` handoff를 두지 않는다. Infra PR 생성은 +- checkout 은 실행 commit 인 `${{ github.sha }}` 를 사용하고 credential 을 보존하지 않는다. +- workflow 권한은 `contents: read`, `id-token: write` 뿐이다. +- Infra action 은 commit + `16bfae0da4e1091df597fb89f6acf914391e11b9` 으로 고정한다. 이는 + [infra#82](https://github.com/Team-PinLog/infra/pull/82) 병합 commit 이며 `infra` `main` 과 + 동일하다. 이전 pin `84458bf3` 은 병합 전 브랜치 commit 이라 `main` 과 diverged 상태였다. +- action 입력은 policy `ai-dev` 와 revision `${{ github.sha }}` 뿐이다. +- 별도의 artifact upload 나 `repository_dispatch` handoff 를 두지 않는다. Infra PR 생성은 공용 action 계약에 위임한다. ## 운영과 검증 범위 -Environment Secret을 회전한 뒤 workflow를 수동 실행하고, 생성된 Infra Draft PR의 source -revision과 변경 대상을 검토한다. 실패 또는 rollback은 Infra PR을 병합하지 않거나 기존 -GitOps revision으로 되돌리는 방식으로 처리한다. 이 변경은 live Secret, GitHub 설정 또는 -클러스터 workload를 직접 수정하지 않는다. +Environment Secret 을 회전한 뒤 workflow 를 수동 실행하고, 생성된 Infra Draft PR 의 source +revision 과 변경 대상을 검토한다. 실패 또는 rollback 은 Infra PR 을 병합하지 않거나 기존 +GitOps revision 으로 되돌리는 방식으로 처리한다. 이 변경은 live Secret, GitHub 설정 또는 +클러스터 workload 를 직접 수정하지 않는다. -레포에서는 static contract test로 경로, trigger, environment, permission, checkout SHA, -action SHA, 입력과 exact key set을 검증한다. 실제 Secret 값과 live sealing 결과는 이 +레포에서는 static contract test 로 경로, trigger, environment, permission, checkout SHA, +action SHA, 입력과 exact key set 을 검증한다. 실제 Secret 값과 live sealing 결과는 이 변경에서 조회하거나 실행하지 않는다. ## 대체된 구현의 설계 근거 (`S15P11A705-96` · 보존) -이 절은 봉인을 AI 레포 workflow가 직접 수행했을 때의 판단 기록이다. **실행 주체는 Infra -공용 action으로 옮겼고, 아래 근거는 그 action이 이어받았다.** 같은 함정을 다시 밟지 않기 +이 절은 봉인을 AI 레포 workflow 가 직접 수행했을 때의 판단 기록이다. 실행 주체는 Infra +공용 action 으로 옮겼고, 아래 근거는 그 action 이 이어받았다. 같은 실수를 다시 하지 않기 위해 근거를 남긴다. ### 무엇을 풀던 문제인가 -**GitHub Actions Secret은 Kubernetes Pod에 자동으로 전달되지 않는다.** 값을 클러스터까지 -보내려면 누군가 한 번은 평문을 만져야 하는데, Infra는 *"key/token 값과 앱 호환성 설계를 -AI owner 소유로 두고, 값을 열람하지 않은 채 암호화 Secret과 컨테이너만 배포하겠다"*는 -경계를 세웠다. SealedSecret이 그 경계를 성립시킨다 — controller의 **공개키로 암호화한 -manifest**만 넘기므로 Infra는 평문을 볼 수 없고, 복호화는 클러스터 안의 controller만 할 +GitHub Actions Secret 은 Kubernetes Pod 에 자동으로 전달되지 않는다. 값을 클러스터까지 +보내려면 누군가 한 번은 평문을 만져야 한다. 그런데 Infra 는 *"key/token 값과 앱 호환성 설계를 +AI owner 소유로 두고, 값을 열람하지 않은 채 암호화 Secret 과 컨테이너만 배포하겠다"* 는 +경계를 세웠다. SealedSecret 이 그 경계를 성립시킨다. controller 의 공개키로 암호화한 +manifest 만 넘기므로 Infra 는 평문을 볼 수 없고, 복호화는 클러스터 안의 controller 만 할 수 있다. -### 평문 YAML을 만들지 않는다 — `kubeseal --raw` +### 평문 YAML 을 만들지 않는다 — `kubeseal --raw` -흔한 경로는 `kubectl create secret generic --dry-run=client -o yaml | kubeseal`인데, 이 -파이프의 **중간 산출물이 평문 base64 Secret YAML**이다. 파이프 안에만 있다 해도 한 단계 +흔한 경로는 `kubectl create secret generic --dry-run=client -o yaml | kubeseal` 인데, 이 +파이프의 중간 산출물이 평문 base64 Secret YAML 이다. 파이프 안에만 있다 해도 한 단계의 실수(리다이렉트, `tee`, 디버깅용 `cat`)로 파일이 된다. -`--raw`는 값 하나를 받아 암호문 한 줄을 돌려준다. 평문은 셸 변수와 파이프에만 존재하고 -파일·인자·로그 어디에도 남지 않는다. 변수 확장은 셸 내부에서 끝나므로 `ps`로도 보이지 +`--raw` 는 값 하나를 받아 암호문 한 줄을 돌려준다. 평문은 셸 변수와 파이프에만 존재하고 +파일·인자·로그 어디에도 남지 않는다. 변수 확장은 셸 내부에서 끝나므로 `ps` 로도 보이지 않는다. -대신 SealedSecret manifest를 손으로 조립해야 한다. `echo`로 쌓는다 — **heredoc은 쓸 수 -없다.** 이 스크립트는 workflow YAML의 블록 스칼라(`run: |`) 안이라 들여쓰기가 곧 블록의 -경계다. heredoc 본문을 왼쪽 끝에 붙이면 workflow YAML 자체가 끊기고, 들여쓴 채 두면 그 -공백이 산출 파일에 들어가 SealedSecret이 깨진다. 실제로 둘 다 겪고서 `echo` 조립으로 +대신 SealedSecret manifest 를 손으로 조립해야 한다. `echo` 로 쌓는다. heredoc 은 쓸 수 +없다. 이 스크립트는 workflow YAML 의 블록 스칼라(`run: |`) 안에 있어서 들여쓰기가 곧 블록의 +경계이기 때문이다. heredoc 본문을 왼쪽 끝에 붙이면 workflow YAML 자체가 끊기고, 들여쓴 채 +두면 그 공백이 산출 파일에 들어가 SealedSecret 이 깨진다. 실제로 둘 다 겪고서 `echo` 조립으로 바꿨다. -### scope는 strict +### scope 는 strict -암호문이 `pinlog-dev/ai-owner-secrets`라는 **이름·네임스페이스 조합에 묶인다.** 다른 +암호문이 `pinlog-dev/ai-owner-secrets` 라는 이름·네임스페이스 조합에 묶인다. 다른 이름으로 옮겨 쓰려면 다시 봉인해야 한다. 봉인된 값이 엉뚱한 리소스로 복사되는 것을 -controller가 거부하므로, 유출된 manifest 하나가 다른 네임스페이스에서 복호화되는 경로가 +controller 가 거부하므로, 유출된 manifest 하나가 다른 네임스페이스에서 복호화되는 경로가 없다. ### 배포 전에 실패시킬 수 있는 것은 배포 전에 실패시킨다 (기동 검증) -앱이 기동 시 fail-fast로 거르는 검사를 **봉인 시점에 미리** 돌린다. 그러지 않으면 -배포하고 Pod이 죽어야 알게 된다. +앱이 기동 시 fail-fast 로 거르는 검사를 봉인 시점에 미리 돌린다. 그러지 않으면 +배포하고 Pod 이 죽은 뒤에야 알게 된다. ```text GMS_BASE_URL /gmsapi/ 포함 (embedding·judge 양쪽이 요구) @@ -105,38 +105,38 @@ PINLOG_EMBEDDING_PROFILE 나머지 셋을 부분 문자열로 포함 (app/core/config.py 의 profile 정합 검사와 같은 규칙) ``` -profile이 어긋난 채 배포되면 **기존 임베딩이 전부 조회 대상에서 빠진다** — 조용히 검색 -결과가 비는 종류의 실패라 배포 후에 알아채기 가장 어렵다. +profile 이 어긋난 채 배포되면 기존 임베딩이 전부 조회 대상에서 빠진다. 오류 없이 검색 +결과만 비는 종류의 실패라서, 배포 후에 알아채기가 가장 어렵다. -같은 이유로 인증서를 **SHA-256 지문으로 고정**했고 -(`deploy/sealed-secrets/pinlog-dev-cert.pem`, Infra가 controller에서 제공한 공개 인증서), -만료도 함께 검사했다. **엉뚱한 공개키로 봉인하면 controller가 복호화하지 못하고, 그 -실패는 배포 시점에야 드러나기 때문**이다. +같은 이유로 인증서를 SHA-256 지문으로 고정했고 +(`deploy/sealed-secrets/pinlog-dev-cert.pem`, Infra 가 controller 에서 제공한 공개 인증서), +만료도 함께 검사했다. 엉뚱한 공개키로 봉인하면 controller 가 복호화하지 못하는데, 그 +실패는 배포 시점에야 드러나기 때문이다. -산출물은 두 방향으로 검증했다. (1) 모든 `encryptedData` 값이 `Ag`로 시작하는 100자 이상 -base64인가 — 봉인이 빈 문자열이나 평문을 흘렸다면 여기서 걸린다. (2) 평문이 그대로 새지 -않았는가 — 단 **12자 이상만 본다.** `1536`·`cosine` 같은 짧은 값은 base64 암호문에 우연히 -포함될 수 있어 거짓 양성을 만들고, 그 길이대는 (1)이 이미 덮는다. +산출물은 두 방향으로 검증했다. (1) 모든 `encryptedData` 값이 `Ag` 로 시작하는 100자 이상의 +base64 인가. 봉인이 빈 문자열이나 평문을 흘렸다면 여기서 걸린다. (2) 평문이 그대로 새지 +않았는가. 단, 12자 이상의 값만 본다. `1536`·`cosine` 같은 짧은 값은 base64 암호문에 우연히 +포함될 수 있어 거짓 양성을 만들고, 그 길이대는 (1)이 이미 덮기 때문이다. ### 봉인 대상이 7키에서 3키로 줄어든 경위 -이전 판은 `GMS_API_KEY`·`GMS_BASE_URL`·`INTERNAL_SHARED_SECRET`에 +이전 판은 `GMS_API_KEY`·`GMS_BASE_URL`·`INTERNAL_SHARED_SECRET` 에 `PINLOG_EMBEDDING_MODEL`·`_DIMENSION`·`_DISTANCE`·`_PROFILE` 넷을 더한 7키를 봉인했다. -**EMBEDDING 넷은 비밀이 아니다** — 모델명·차원·거리함수·프로필 식별자이고 정본은 공개 -문서 [P32](../proposals/README.md)에 있다. 당시 Secret 경로로 다룬 것은 Infra가 주입 -경로를 하나로 요구했고, 앱이 이 넷에 기본값을 두지 않아 누락되면 Pod이 아예 뜨지 않았기 -때문이다. +그런데 EMBEDDING 넷은 비밀이 아니다. 모델명·차원·거리함수·프로필 식별자이고 정본은 공개 +문서 [P32](../proposals/README.md)에 있다. 당시 이 넷을 Secret 경로로 다룬 이유는 두 가지였다. +Infra 가 주입 경로를 하나로 요구했고, 앱이 이 넷에 기본값을 두지 않아 누락되면 Pod 이 아예 +뜨지 않았기 때문이다. -이후 [ai#36](https://github.com/Team-PinLog/ai/pull/36)과 P45에서 넷을 +이후 [ai#36](https://github.com/Team-PinLog/ai/pull/36)과 P45 에서 넷을 `app/core/config.py` 기본값으로 옮겨 주입 대상에서 뺐다. 그래서 현재 계약의 이름 넷은 -앱 Secret 3개 + Infra PR 토큰 1개이며, 앱이 읽는 런타임 Secret은 3개다. `DATABASE_URL`은 -이전 판에서도 여기 없었다 — Infra가 별도 `ai-db-credentials`로 관리하고 `envFrom`으로 +앱 Secret 3개 + Infra PR 토큰 1개이며, 앱이 읽는 런타임 Secret 은 3개다. `DATABASE_URL` 은 +이전 판에서도 여기 없었다. Infra 가 별도의 `ai-db-credentials` 로 관리하고 `envFrom` 으로 함께 연결한다. ### 이전 판이 남긴 미결 — 현재 상태 | 이전 판의 미결 | 현재 | |---|---| -| `PINLOG_AI_INFRA_PR_TOKEN` — 앱이 읽지 않으며 전달 방식이 artifact가 아니라 PR이어야 함 | 해소. `PINLOG_INFRA_SECRET_PR_TOKEN`으로 확정되고 공용 action이 Infra Draft PR을 연다. artifact·`repository_dispatch` 경로는 제거했다 | +| `PINLOG_AI_INFRA_PR_TOKEN` — 앱이 읽지 않으며 전달 방식이 artifact 가 아니라 PR 이어야 함 | 해소. `PINLOG_INFRA_SECRET_PR_TOKEN` 으로 확정되고 공용 action 이 Infra Draft PR 을 연다. artifact·`repository_dispatch` 경로는 제거했다 | | 회전 절차 — 클러스터의 기존 Secret 교체 시점 | 미해소. Environment Secret 회전 후 수동 `workflow_dispatch` → Infra Draft PR 검토가 현재 절차이고, 클러스터 반영은 Infra 몫이다 | -| prod — 이 인증서는 `pinlog-dev` 전용 | 미해소. prod는 별도 controller·인증서가 필요하다. 공용 action의 policy `back-prod`와 별개로 AI prod policy는 아직 없다 | +| prod — 이 인증서는 `pinlog-dev` 전용 | 미해소. prod 는 별도 controller·인증서가 필요하다. 공용 action 의 policy `back-prod` 와 별개로 AI prod policy 는 아직 없다 | diff --git a/docs/implements/2026-07-30-coverage-gate.md b/docs/implements/2026-07-30-coverage-gate.md index 22fcac4..cd796ff 100644 --- a/docs/implements/2026-07-30-coverage-gate.md +++ b/docs/implements/2026-07-30-coverage-gate.md @@ -1,14 +1,12 @@ -# app coverage 게이트 활성화 (S15P11A705-110) +# app coverage 게이트 활성화 — line·branch 각각 80% 이상을 병합 차단 조건으로 전환했다 (S15P11A705-110) 상태: 완료 · 유형: 구현 · 근거: [CONTRIBUTING.md](../../CONTRIBUTING.md) 검증 절, [integration-tests.md](../spec/integration-tests.md) §4.2 · §5 -`S15P11A705-108`이 비차단으로 도입한 `app` line·branch coverage 측정을 **병합 차단 게이트**로 -전환했다. 티켓 완료 조건은 *"line과 branch 각각 80% 이상"*이다. +`S15P11A705-108` 이 비차단으로 도입한 `app` line·branch coverage 측정을 병합 차단 게이트로 전환했다. 티켓 완료 조건은 *"line 과 branch 각각 80% 이상"* 이다. ## 1. 기준선이 낡아 있었다 -티켓 본문의 수치는 2026-07-28(52 tests) 관측이다. 그 사이 `S15P11A705-121`(ai#44, `b45aa93`)이 -client 재시도·오류 분류 테스트 58개를 추가해 상황이 바뀌었다. **착수 시점에 재측정했다.** +티켓 본문의 수치는 2026-07-28(52 tests) 관측이다. 그 사이 `S15P11A705-121`(ai#44, `b45aa93`)이 client 재시도·오류 분류 테스트 58개를 추가해 상황이 바뀌었다. 그래서 착수 시점에 재측정했다. | | 티켓 기재 (07-28) | 재측정 (`b45aa93` 시점) | 최종 | |---|---|---|---| @@ -16,20 +14,13 @@ client 재시도·오류 분류 테스트 58개를 추가해 상황이 바뀌었 | line | 76.95% (474/616) | 88.80% (674/759) | **99.74% (757/759)** | | branch | 62.50% (55/88) | 82.08% (87/106) | **98.11% (104/106)** | -티켓이 지목한 미검증 4개 영역 중 **둘은 이미 `-121`이 덮은 뒤**였다 -(`client/embedding_client.py` 96%, `client/llm_client.py` 100%). 남은 것은 티켓의 추정대로 -`bootstrap/load_presets.py`(**0%**)와 `main.py`(58%)였고, 재측정으로 `smoke/gms_roundtrip.py`(76%)가 -추가로 드러났다. +티켓이 지목한 미검증 4개 영역 중 둘은 이미 `-121` 이 덮은 뒤였다(`client/embedding_client.py` 96%, `client/llm_client.py` 100%). 남은 것은 티켓의 추정대로 `bootstrap/load_presets.py`(0%)와 `main.py`(58%)였고, 재측정으로 `smoke/gms_roundtrip.py`(76%)가 추가로 드러났다. -기준선만 놓고 보면 두 지표 모두 이미 80%를 넘겨 게이트를 그대로 켤 수 있었다. 그러지 않은 -이유는 **branch 82.08%가 2 포인트 여유뿐**이었기 때문이다. 그 상태로 켜면 다음 기능 PR 하나가 -분기를 몇 개 추가하는 것만으로 무관한 이유로 붉어진다. 게이트는 회귀를 막아야지 정상 작업을 -막으면 안 된다. +기준선만 놓고 보면 두 지표 모두 이미 80% 를 넘겨 게이트를 그대로 켤 수 있었다. 그러지 않은 이유는 branch 82.08% 가 임계값 대비 2 포인트 여유뿐이었기 때문이다. 그 상태로 켜면 다음 기능 PR 하나가 분기를 몇 개 추가하는 것만으로, 그 PR 과 무관한 이유로 CI 가 실패한다. 게이트는 회귀를 막아야지 정상 작업을 막으면 안 된다. -## 2. 왜 `--cov-fail-under`가 아닌가 +## 2. 왜 `--cov-fail-under` 가 아닌가 -`--cov-branch`를 켜면 coverage.py의 `percent_covered`는 **statement와 branch를 합산한 하나의 -비율**이 된다. 이 레포 기준선이 그 함정을 그대로 보여 준다. +`--cov-branch` 를 켜면 coverage.py 의 `percent_covered` 는 statement 와 branch 를 합산한 하나의 비율이 된다. 이 레포의 기준선이 그 함정을 그대로 보여 준다. ```text 합산 (pytest 터미널 표시) 88% ← --cov-fail-under=80 통과 @@ -37,125 +28,86 @@ line 88.80% branch 82.08% ``` -statement 759개가 branch 106개를 압도하므로, branch가 60%대로 떨어져도 합산값은 80을 넘긴다. -완료 조건이 "각각"인 이상 합산 게이트는 조건을 검사하지 못한다. 그래서 -`tools/check_coverage_gate.py`가 `coverage.json`의 `totals`를 두 지표로 나눠 판정한다. +statement 759개가 branch 106개를 압도하므로, branch 가 60%대로 떨어져도 합산값은 80 을 넘긴다. 완료 조건이 "각각"인 이상 합산 게이트는 조건을 검사하지 못한다. 그래서 `tools/check_coverage_gate.py` 가 `coverage.json` 의 `totals` 를 두 지표로 나눠 판정한다. -**임계값은 스크립트 상수이며 CLI로 덮을 수 없다.** 덮을 수 있게 두면 CI가 조용히 낮은 값을 -넘겨 게이트를 무력화할 수 있다. `test_ci_image_publish_contract.py`가 워크플로에 -`--cov-fail-under`가 없고 게이트 step이 인자 없이 호출되는지를 계약으로 고정한다. +임계값은 스크립트 상수이며 CLI 인자로 덮을 수 없다. 덮을 수 있게 두면 CI 가 조용히 낮은 값을 넘겨 게이트를 무력화할 수 있기 때문이다. `test_ci_image_publish_contract.py` 가 워크플로에 `--cov-fail-under` 가 없고 게이트 step 이 인자 없이 호출되는지를 계약으로 고정한다. -`--cov-branch` 없이 만든 리포트는 `coverage.json`에 branch 키 자체가 없다(실측). 그 상태를 -통과로 처리하면 branch 게이트가 사라진 것과 같으므로 **판정 불가로 끊는다** — "측정하지 -못했다"는 통과가 아니다. +`--cov-branch` 없이 만든 리포트는 `coverage.json` 에 branch 키 자체가 없다(실측). 그 상태를 통과로 처리하면 branch 게이트가 사라진 것과 같으므로 판정 불가로 실패시킨다. "측정하지 못했다"는 통과가 아니다. ## 3. 게이트가 실제로 막는 것을 관측했다 -통과만 확인하면 아무것도 강제하지 않는 게이트를 놓친다. 네 가지 RED를 실측했다. +통과만 확인하면 아무것도 강제하지 않는 게이트를 놓친다. 네 가지 실패(RED)를 실측했다. | 드릴 | 방법 | 결과 | |---|---|---| -| 테스트 제거 | `pytest tests/test_unit.py`만 실행 | `line 42.82% / branch 34.91%` → **exit 1** | -| 임계값 상향 | 상수를 `99.9`로 잠깐 변경 후 전량 실행 | `line 99.74% / branch 98.11%` → **exit 1** (되돌림) | +| 테스트 제거 | `pytest tests/test_unit.py` 만 실행 | `line 42.82% / branch 34.91%` → **exit 1** | +| 임계값 상향 | 상수를 `99.9` 로 잠깐 변경 후 전량 실행 | `line 99.74% / branch 98.11%` → **exit 1** (되돌림) | | 측정 플래그 누락 | `--cov-branch` 없이 리포트 생성 | 판정 불가 → **exit 1** | | 리포트 부재 | `coverage.json` 없이 실행 | 판정 불가 → **exit 1** | -임계값 상향 드릴에서는 `test_gate_thresholds_are_the_ticket_completion_criteria`도 함께 붉어졌다 — -임계값을 조용히 내리면 게이트는 남고 의미만 사라지므로 그 값을 테스트로 못박아 두었다. +임계값 상향 드릴에서는 `test_gate_thresholds_are_the_ticket_completion_criteria` 도 함께 실패했다. 임계값을 조용히 내리면 게이트는 남고 의미만 사라지므로, 그 값을 테스트로 고정해 두었다. ## 4. 보강한 테스트 (146 → 181, +35) -전부 **계약과 실패 경로** 중심이다. 커버리지를 채우려고 만든 테스트가 아니라, 재측정으로 -드러난 "한 번도 실행된 적 없는 경로"에 단언을 붙인 것이다. +전부 계약과 실패 경로 중심이다. 커버리지 수치를 채우려고 만든 테스트가 아니라, 재측정으로 드러난 "한 번도 실행된 적 없는 경로"에 단언을 붙인 것이다. ### 4.1 부트스트랩 — `tests/test_bootstrap.py` (신설, 10) -`load_presets.py`는 line·branch 모두 0%였다. `/search`·`/context/process` 이전에 반드시 1회 도는 -경로인데 한 줄도 검증되지 않았다. +`load_presets.py` 는 line·branch 모두 0% 였다. `/search`·`/context/process` 이전에 반드시 1회 도는 경로인데 한 줄도 검증되지 않았다. -- Preset 적재 결과: 행 수·`embedding_profile`·`is_active`·벡터 차원. **Profile과 `is_active`는 - YAML이 아니라 적재가 채우는 값**이라 어긋나면 서버가 Preset 0건으로 기동 실패한다. -- `embed()` **정확히 1회** 호출과 입력 텍스트 값 대조(§4.2 호출 횟수 기록). -- 멱등성 + `ON CONFLICT DO UPDATE` SET 절: 행을 손으로 흔든 뒤 재실행이 YAML로 되돌리는지. -- 커넥션 반납(`finally: disconnect()`) — 성공 경로와 **UPSERT 실패 경로** 둘 다. 후자가 - `finally`의 존재 이유다(차원이 어긋난 벡터로 DB 레벨에서 깨뜨리고, 예외를 삼키지 않는 - 것도 함께 단언한다). `pg_stat_activity` 백엔드 수를 세지 않는다 — 풀을 닫아도 서버가 - 백엔드를 정리하는 시점은 비동기라 그 대조는 간헐 실패한다. 지킬 계약은 "적재가 반납을 - 호출한다"이고 그건 값으로 셀 수 있다. -- `python -m app.bootstrap.load_presets` 실행 경로. +- Preset 적재 결과를 단언한다. 행 수·`embedding_profile`·`is_active`·벡터 차원. Profile 과 `is_active` 는 YAML 이 아니라 적재 코드가 채우는 값이라, 어긋나면 서버가 Preset 0건으로 기동에 실패한다. +- `embed()` 가 정확히 1회 호출되는지와 입력 텍스트 값을 대조한다(§4.2 의 호출 횟수 기록 방식). +- 멱등성과 `ON CONFLICT DO UPDATE` 의 SET 절을 검증한다. 행을 손으로 바꾼 뒤 재실행이 YAML 값으로 되돌리는지 본다. +- 커넥션 반납(`finally: disconnect()`)을 성공 경로와 UPSERT 실패 경로 둘 다에서 검증한다. 후자가 `finally` 의 존재 이유다. 차원이 어긋난 벡터로 DB 레벨에서 실패를 일으키고, 예외를 삼키지 않는 것도 함께 단언한다. `pg_stat_activity` 의 백엔드 수는 세지 않는다. 풀을 닫아도 서버가 백엔드를 정리하는 시점은 비동기라 그 대조는 간헐적으로 실패한다. 지킬 계약은 "적재가 반납을 호출한다"이고 그것은 값으로 셀 수 있다. +- `python -m app.bootstrap.load_presets` 실행 경로도 검증한다. ### 4.2 기동 — `tests/test_lifespan.py` (신설, 4) -`test_api.py`는 lifespan을 **우회하고** `app.state`에 Fake를 직접 꽂는다. 그래서 실제 기동 -경로가 통째로 미검증이었다. +`test_api.py` 는 lifespan 을 우회하고 `app.state` 에 Fake 를 직접 꽂는다. 그래서 실제 기동 경로가 통째로 미검증이었다. -- 조립 결과와 Preset 캐시 적재, 풀이 실제로 살아 있는지(`SELECT 1`). -- Preset 0건 기동 중단 두 경로: **Profile 불일치**와 **전부 BLOCKED**. 후자는 행은 있지만 - 적재 건수가 0인 경우로, 조건이 `rows`가 아니라 `loaded`여야 하는 이유다. -- 종료 시 풀 반납. +- 조립 결과와 Preset 캐시 적재, 풀이 실제로 살아 있는지(`SELECT 1`)를 확인한다. +- Preset 0건으로 기동이 중단되는 두 경로를 검증한다. Profile 불일치와 전부 BLOCKED. 후자는 행은 있지만 적재 건수가 0인 경우로, 판정 조건이 `rows` 가 아니라 `loaded` 여야 하는 이유다. +- 종료 시 풀 반납을 확인한다. -여기서는 **진짜 클라이언트를 조립하는지**를 단언하므로 Fake로 바꾸지 않았다. 생성자는 IO를 -하지 않으므로 실호출 금지 규칙과 충돌하지 않고, Fake로 바꾸면 이 단언 자체가 사라진다. +여기서는 진짜 클라이언트를 조립하는지를 단언하므로 Fake 로 바꾸지 않았다. 생성자는 IO 를 하지 않으므로 실호출 금지 규칙과 충돌하지 않고, Fake 로 바꾸면 이 단언 자체가 사라진다. ### 4.3 Keyword 단계 내부 경로 — `tests/test_pipeline.py` (+4) -- **후보 0개 → LLM 미호출**로 정상 COMPLETED. 시나리오 14(LLM이 빈 `selected` 반환)와 다른 - 경로다 — 저쪽은 호출한 뒤 0개고 이쪽은 아예 부르지 않는다. 유사도 하한이 사라지면 이 - 테스트만 깨진다. -- `embedding_status`는 COMPLETED인데 벡터 행이 없는 경우 → 해당 단계만 FAILED. -- 다른 워커가 embedding을 끝내 벡터를 들고 있지 않은 경합 경로의 fallback 조회. -- **사전 검사와 재개 판정 사이에 State가 삭제되는 창**. 두 조회가 서로 다른 `acquire()`라 - 그 사이가 열려 있다. `sleep` 대신 커넥션 반납 시점을 훅으로 잡아 순서를 고정했다(§4.4의 - `on_call` 훅과 같은 기법, 창의 위치만 다르다). +- 후보 0개면 LLM 을 호출하지 않고 정상 COMPLETED 로 끝나는 경로. 시나리오 14(LLM 이 빈 `selected` 반환)와 다른 경로다. 저쪽은 호출한 뒤 0개이고 이쪽은 아예 부르지 않는다. 유사도 하한이 사라지면 이 테스트만 깨진다. +- `embedding_status` 는 COMPLETED 인데 벡터 행이 없는 경우, 해당 단계만 FAILED 가 되는 경로. +- 다른 워커가 embedding 을 끝내 현재 워커가 벡터를 들고 있지 않은 경합 경로의 fallback 조회. +- 사전 검사와 재개 판정 사이에 State 가 삭제되는 시간 창. 두 조회가 서로 다른 `acquire()` 라서 그 사이가 열려 있다. `sleep` 대신 커넥션 반납 시점을 훅으로 잡아 순서를 고정했다(§4.4 의 `on_call` 훅과 같은 기법이고, 창의 위치만 다르다). ### 4.4 단위 (+16), CI 계약 (+1) -캐시 스냅샷 노출 필드, 적재 전 조회 오류, pgvector↔파이썬 변환 양쪽 분기, 중복 confidence -접기의 내림차순·`None` 짝 케이스, `_silence_http_logging`, 스모크 스크립트 실행(성공/실패 -양쪽, 실패 시 값 미노출), 게이트 판정 로직의 임계값 경계. +캐시 스냅샷 노출 필드, 적재 전 조회 오류, pgvector↔파이썬 변환의 양쪽 분기, 중복 confidence 접기의 내림차순·`None` 짝 케이스, `_silence_http_logging`, 스모크 스크립트 실행(성공/실패 양쪽, 실패 시 값 미노출), 게이트 판정 로직의 임계값 경계. ### 4.5 테스트 인프라 -`conftest.py`의 `_schema`를 async → **sync fixture**로 바꿨다. 스크립트 엔트리포인트는 자기가 -`asyncio.run()`을 부르므로 그것을 검증하는 테스트도 sync여야 하고, sync 테스트는 async fixture를 -받을 수 없다. 스키마 적용은 세션당 1회라 비용은 같다. 동기 테스트용 `clean_dsn`도 함께 추가했다. +`conftest.py` 의 `_schema` 를 async 에서 sync fixture 로 바꿨다. 스크립트 엔트리포인트는 자기가 `asyncio.run()` 을 부르므로 그것을 검증하는 테스트도 sync 여야 하고, sync 테스트는 async fixture 를 받을 수 없다. 스키마 적용은 세션당 1회라 비용은 같다. 동기 테스트용 `clean_dsn` 도 함께 추가했다. -`if __name__ == "__main__"` 아래는 import로 한 줄도 실행되지 않으므로 `runpy.run_module(..., -run_name="__main__")`로 검증한다. runpy는 **새 네임스페이스**에서 모듈을 다시 실행하므로 캐시된 -모듈에 건 패치가 보이지 않는다 — 원본 모듈(`app.client.embedding_client`)의 속성을 갈아 끼워야 -새 네임스페이스의 `from ... import`가 그것을 집는다. 이 함정을 `tests/README.md`에 적었다. +`if __name__ == "__main__"` 아래는 import 로는 한 줄도 실행되지 않으므로 `runpy.run_module(..., run_name="__main__")` 로 검증한다. runpy 는 새 네임스페이스에서 모듈을 다시 실행하므로 캐시된 모듈에 건 패치가 보이지 않는다. 원본 모듈(`app.client.embedding_client`)의 속성을 갈아 끼워야 새 네임스페이스의 `from ... import` 가 그것을 집는다. 이 함정을 `tests/README.md` 에 적었다. ## 5. 제외하지 않고 미달로 남긴 분기 -**`# pragma: no cover`도 `omit`도 쓰지 않았다.** 남은 미달은 2 line · 2 branch다. +`# pragma: no cover` 도 `omit` 도 쓰지 않았다. 남은 미달은 2 line · 2 branch 다. | 위치 | 내용 | 판단 | |---|---|---| | `service/embedding_service.py:80` | `complete()` rowcount 0 → `PersistDiscarded` | 도달 불가 | | `service/keyword_service.py:184` | 〃 (keyword 단계) | 도달 불가 | -둘 다 `db.transaction()` 안에서 `lock_state()`가 `SELECT ... FOR UPDATE`로 상태를 잠그고 -`PROCESSING`임을 확인한 **직후**의 재검사다. 같은 트랜잭션에서 행 잠금을 쥔 채 조건이 뒤집힐 -수 없으므로 현재 구조에서는 실행될 수 없다. +둘 다 `db.transaction()` 안에서 `lock_state()` 가 `SELECT ... FOR UPDATE` 로 상태를 잠그고 `PROCESSING` 임을 확인한 직후의 재검사다. 같은 트랜잭션에서 행 잠금을 쥔 채 조건이 뒤집힐 수 없으므로 현재 구조에서는 실행될 수 없다. -**제외하지 않은 이유**는 도달 불가가 구조에 딸린 성질이기 때문이다. 저장 로직이 잠금 밖으로 -나가거나 `lock_state`와 `complete`의 조건이 갈라지면 이 줄은 도달 가능해진다. `pragma`를 -붙여 두면 그때 아무도 모른다. 미달로 남겨 두면 커버리지 리포트가 계속 그 두 줄을 가리킨다. -게이트 여유가 18 포인트라 이 선택에 비용이 없다. +제외하지 않은 이유는, 도달 불가가 구조에 딸린 성질이기 때문이다. 저장 로직이 잠금 밖으로 나가거나 `lock_state` 와 `complete` 의 조건이 갈라지면 이 줄은 도달 가능해진다. `pragma` 를 붙여 두면 그때 아무도 모른다. 미달로 남겨 두면 커버리지 리포트가 계속 그 두 줄을 가리킨다. 게이트 여유가 18 포인트라 이 선택에 비용이 없다. ## 6. 함께 처리한 것 — `integration-tests.md` §4.2 계층 구분 명문화 -`-121`의 후속이다. §4.2가 *"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake를 씁니다"*라고만 적어, -`test_client_retry.py`의 HTTP mock이 명세 위반인지가 `-121` 작업 중 **두 번** 질문으로 올라왔다. -판정은 충돌이 아니라 **층이 다름**이었고, 명세에 없었다는 것이 같은 질문이 반복된 이유다. +`-121` 의 후속이다. §4.2 가 *"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake 를 씁니다"* 라고만 적어, `test_client_retry.py` 의 HTTP mock 이 명세 위반인지가 `-121` 작업 중 두 번이나 질문으로 올라왔다. 판정은 충돌이 아니라 층이 다르다는 것이었고, 그 구분이 명세에 없었다는 것이 같은 질문이 반복된 이유다. -§4.2 앞에 적용 범위 절을 넣고 경계를 한 문장으로 고정했다 — **`app/client/` 밖의 코드는 HTTP를 -몰라야 하고, 따라서 그 코드를 검증하는 테스트도 HTTP를 몰라야 한다.** HTTP 목이 파이프라인 -테스트에 나타나면 계층 위반의 신호이고, client 단위 테스트에 인터페이스 Fake가 나타나면 -아무것도 검증하지 않는 테스트다. §5 계층 표에도 client 단위·부트스트랩·기동 세 행을 더했다. +§4.2 앞에 적용 범위 절을 넣고 경계를 한 문장으로 고정했다. `app/client/` 밖의 코드는 HTTP 를 몰라야 하고, 따라서 그 코드를 검증하는 테스트도 HTTP 를 몰라야 한다는 것이다. HTTP 목이 파이프라인 테스트에 나타나면 계층 위반의 신호이고, client 단위 테스트에 인터페이스 Fake 가 나타나면 아무것도 검증하지 않는 테스트다. §5 계층 표에도 client 단위·부트스트랩·기동 세 행을 더했다. -`docs/spec/` 수정은 이번 작업에서 허용됐다. `-121`에서 금지한 것은 그 작업이 *구현을 계약에 -맞추는 것*이었기 때문이고, 이번은 **계약 자체의 공백을 메우는 것**이다. +`docs/spec/` 수정은 이번 작업에서 허용됐다. `-121` 에서 금지한 이유는 그 작업이 *구현을 계약에 맞추는 것* 이었기 때문이고, 이번은 계약 자체의 공백을 메우는 것이기 때문이다. ## 7. 검증 @@ -166,14 +118,10 @@ pytest --cov=app --cov-branch --cov-report=term-missing --cov-report=json:covera python tools/check_coverage_gate.py # exit 0 ``` -`181 passed`, Testcontainers pgvector `0.8.5-pg16@sha256:1d53…` 전량. Python 3.12.10 / win32. +`181 passed`. Testcontainers pgvector `0.8.5-pg16@sha256:1d53…` 로 전량 실행했다. Python 3.12.10 / win32. ## 8. 범위 밖 -- **coverage 임계값 상향.** 현재 실측이 line 99.74% / branch 98.11%라 80%는 헐겁다. 다만 - 임계값은 "지금 어디에 있나"가 아니라 "어디 아래로 내려가면 막을 것인가"이고, 후자는 티켓이 - 80으로 정했다. 상향은 별건 판단이다. -- **`tools/`의 커버리지.** 게이트 대상은 `--cov=app`이므로 게이트 스크립트 자신은 측정되지 - 않는다. 대신 판정 로직을 `evaluate()`로 분리해 임계값 경계를 단위 테스트로 고정했다. -- **branch protection 설정.** 중앙 소관이다. `ai-ci / check` 안의 step으로 들어가므로 required - status check 목록은 바뀌지 않는다. +- **coverage 임계값 상향.** 현재 실측이 line 99.74% / branch 98.11% 라 80% 는 여유가 크다. 다만 임계값은 "지금 어디에 있나"가 아니라 "어디 아래로 내려가면 막을 것인가"이고, 후자는 티켓이 80 으로 정했다. 상향은 별건 판단이다. +- **`tools/` 의 커버리지.** 게이트 대상은 `--cov=app` 이므로 게이트 스크립트 자신은 측정되지 않는다. 대신 판정 로직을 `evaluate()` 로 분리해 임계값 경계를 단위 테스트로 고정했다. +- **branch protection 설정.** 중앙 소관이다. `ai-ci / check` 안의 step 으로 들어가므로 required status check 목록은 바뀌지 않는다. diff --git a/docs/implements/2026-07-30-judge-vendor-fallback.md b/docs/implements/2026-07-30-judge-vendor-fallback.md index 3905176..f32df6b 100644 --- a/docs/implements/2026-07-30-judge-vendor-fallback.md +++ b/docs/implements/2026-07-30-judge-vendor-fallback.md @@ -1,16 +1,14 @@ -# 판정 LLM 벤더 폴백 (S15P11A705-175) +# 판정 LLM 벤더 폴백 — 일시적 오류에서 OpenAI·Gemini·Anthropic 체인으로 넘어가게 했다 (S15P11A705-175) 상태: 완료 · 근거 계약: [failure-recovery.md §3.4](../spec/failure-recovery.md) · 관련: [S15P11A705-121 재시도·오류 분류](2026-07-30-retry-and-error-classification.md), [S15P11A705-174 실데이터 E2E](2026-07-30-real-data-e2e.md) -판정 LLM 호출이 Gemini 한 경로에 묶여 있던 것을 벤더 어댑터 구조로 풀고, 일시적 오류일 때 -다음 벤더로 넘어가게 했다. 도메인 코드·DB 스키마 변경은 없다. +판정 LLM 호출이 Gemini 한 경로에 묶여 있던 것을 벤더 어댑터 구조로 바꾸고, 일시적 오류일 때 다음 벤더로 넘어가게 했다. 도메인 코드·DB 스키마 변경은 없다. -## 1. 왜 — 429는 게이트웨이 전역이 아니라 Gemini 경로에만 있었다 +## 1. 왜 — 429 는 게이트웨이 전역이 아니라 Gemini 경로에만 있었다 -2026-07-30 실측이다. 같은 시각·같은 GMS 키·같은 판정 작업(Context 5건 × 모델당 12~15회)을 -던졌다. 도구는 `tools/keyword_eval/probe_vendors.py`. +2026-07-30 실측이다. 같은 시각·같은 GMS 키·같은 판정 작업(Context 5건 × 모델당 12~15회)을 던졌다. 도구는 `tools/keyword_eval/probe_vendors.py` 다. | 모델 | 성공률 | 평균 응답 | prompt | output | 현행과 일치도 | |---|---|---|---|---|---| @@ -21,41 +19,36 @@ | gemini-2.5-flash (현행) | 92% | 1.23s | 732 | 21 | 기준 | | gemini-2.5-flash-lite | **58%** | 1.09s | 732 | 48 | 0.90 | -**OpenAI·Anthropic 경로는 한 번도 막히지 않았다.** 임베딩(OpenAI 호환 경로) 49회가 안 막힌 -것과 같은 그림이다 — 쿼터가 게이트웨이 전역이 아니라 **프로바이더 경로별로** 걸린다. +OpenAI·Anthropic 경로는 한 번도 막히지 않았다. 임베딩(OpenAI 호환 경로) 49회가 안 막힌 것과 같은 양상이다. 즉 쿼터가 게이트웨이 전역이 아니라 프로바이더 경로별로 걸린다. -429가 나면 `keyword_status`가 `PROCESSING`에 남고 `AiProcessClient`가 실패를 삼킨다. 화면에는 -Context가 정상 생성된 것으로 보이는데 키워드도 검색도 안 된다. 재스캔(`S15P11A705-159`)이 5분 -뒤 복구하지만 시연 중 5분은 없는 시간과 같다. +429 가 나면 `keyword_status` 가 `PROCESSING` 에 남고 `AiProcessClient` 가 실패를 삼킨다. 화면에는 Context 가 정상 생성된 것으로 보이는데 키워드도 검색도 안 된다. 재스캔(`S15P11A705-159`)이 5분 뒤 복구하지만, 시연 중의 5분은 없는 시간과 같다. ## 2. 확정한 폴백 순서 | 순위 | 벤더:모델 | 근거 | |---|---|---| -| 1 | `openai:gpt-4o-mini` | 100% · 0.91s로 가장 빠르다 | -| 2 | `gemini:gemini-2.5-flash` | 현행. 결과 기준선이므로 남긴다 | +| 1 | `openai:gpt-4o-mini` | 성공률 100% 이고 0.91s 로 가장 빠르다 | +| 2 | `gemini:gemini-2.5-flash` | 현행 모델. 결과 기준선이므로 남긴다 | | 3 | `anthropic:claude-haiku-4-5-20251001` | 프로바이더가 셋째라 동시 장애 가능성이 가장 낮다 | -기각한 후보: +기각한 후보와 사유는 다음과 같다. -- `gpt-4.1-nano` — 일치도 0.53. 빠르지만 다른 답을 낸다. -- `gemini-2.5-flash-lite` — 58%. 가장 많이 막혔다. -- `gpt-4.1-mini` — 일치도 1위(0.93)지만 응답 1.37s이고 최대 4.67s까지 튄 관측이 있다. 그 차이는 - 판정 비결정성 범위 안이라 속도를 택했다. +- `gpt-4.1-nano` — 일치도 0.53. 빠르지만 현행과 다른 답을 낸다. +- `gemini-2.5-flash-lite` — 성공률 58%. 가장 많이 막혔다. +- `gpt-4.1-mini` — 일치도는 1위(0.93)지만 응답이 1.37s 이고 최대 4.67s 까지 튄 관측이 있다. 일치도 차이는 판정 비결정성 범위 안이라 속도를 택했다. ## 3. 구조 | 파일 | 역할 | |---|---| | `app/client/vendors.py` (신설) | 벤더별 요청 생성·응답 봉투 해석. 어댑터 레지스트리와 체인 해석 | -| `app/client/llm_client.py` | 어느 벤더로 부를지 고르고, 오류를 분류하고, 선택 결과를 `JudgeResult`로 옮긴다 | +| `app/client/llm_client.py` | 어느 벤더로 부를지 고르고, 오류를 분류하고, 선택 결과를 `JudgeResult` 로 옮긴다 | | `app/core/config.py` | `PINLOG_JUDGE_CHAIN` — 형식 검증과 기동 시 fail-fast | -| `app/client/_usage.py` | 벤더별 토큰 필드 추출 + 행에 `vendor`·`model` | -| `app/schema/llm.py` | `JudgeResult.model` — **실제로 답한** 모델 | -| `app/service/keyword_service.py` | `model_profile`에 답한 모델을 저장 | +| `app/client/_usage.py` | 벤더별 토큰 필드 추출. 행에 `vendor`·`model` 을 남긴다 | +| `app/schema/llm.py` | `JudgeResult.model` — 실제로 답한 모델 | +| `app/service/keyword_service.py` | `model_profile` 에 답한 모델을 저장한다 | -경로와 인증이 벤더마다 다르다. `root`는 `GMS_BASE_URL`에서 `/gmsapi/` 앞을 잘라 만들고, -키는 셋 다 같은 `GMS_API_KEY`다 — 헤더 이름만 다르다. +경로와 인증이 벤더마다 다르다. `root` 는 `GMS_BASE_URL` 에서 `/gmsapi/` 앞을 잘라 만들고, 키는 셋 다 같은 `GMS_API_KEY` 다. 헤더 이름만 다르다. ```text OpenAI {root}/api.openai.com/v1/chat/completions @@ -66,53 +59,32 @@ Anthropic {root}/api.anthropic.com/v1/messages x-api-key + anthropic-version · tools + tool_choice(강제 호출) ``` -작동 확인된 형태는 `probe_vendors.py`에서 옮겼다. **스키마만은 그대로가 아니다** — 프로브는 -`keywordId` 하나만 요구하는 축약본이었고(속도·일치도 측정용), 운영은 `confidence`와 -`unmatchedConcepts`까지 받아야 한다. OpenAI strict 모드는 모든 객체에 -`additionalProperties: false`와 전 property `required`를 요구하므로 Gemini 스키마를 그대로 -재사용할 수 없다. 빠지면 400이고, 400은 영구 오류라 **폴백 없이 판정이 죽는다.** +작동이 확인된 요청 형태는 `probe_vendors.py` 에서 옮겼다. 다만 스키마는 그대로 옮기지 않았다. 프로브는 `keywordId` 하나만 요구하는 축약본이었고(속도·일치도 측정용), 운영은 `confidence` 와 `unmatchedConcepts` 까지 받아야 한다. OpenAI 의 strict 모드는 모든 객체에 `additionalProperties: false` 와 전 property `required` 를 요구하므로 Gemini 스키마를 그대로 재사용할 수 없다. 이 요구가 빠지면 400 이 나고, 400 은 영구 오류라서 폴백 없이 판정이 실패로 끝난다. ## 4. 판단 — 시도 예산을 체인 길이에 곱하지 않는다 -`RetryPolicy.attempts`(기본 3)를 **판정 호출 1건의 총 HTTP 시도 횟수**로 두고, n번째 시도가 -체인의 n번째 벤더를 쓴다. 체인이 짧으면 마지막 벤더를 반복한다. +`RetryPolicy.attempts`(기본 3)를 판정 호출 1건의 총 HTTP 시도 횟수로 두고, n 번째 시도가 체인의 n 번째 벤더를 쓴다. 체인이 짧으면 마지막 벤더를 반복한다. -벤더마다 3회씩 재시도하는 안을 기각한 이유는 §3.2의 상한이다 — "두 호출의 타임아웃 합 + -재시도 시간 < PROCESSING 만료 600s". 벤더별 재시도면 최악이 3벤더 × 3시도 × 90s = 810s로 -만료를 넘고, 그러면 재스캔이 아직 살아 있는 판정을 중복 실행해 비용이 배가 된다. 이 설계에서는 -최악이 3시도 × 90s = 270s로 폴백 이전과 같고, `test_retry_budget_fits_processing_expiry`가 -그 상한을 그대로 지킨다. +벤더마다 3회씩 재시도하는 안은 기각했다. 이유는 §3.2 의 상한("두 호출의 타임아웃 합 + 재시도 시간 < PROCESSING 만료 600s")이다. 벤더별 재시도면 최악이 3벤더 × 3시도 × 90s = 810s 로 만료를 넘고, 그러면 재스캔이 아직 살아 있는 판정을 중복 실행해 비용이 배가 된다. 이 설계에서는 최악이 3시도 × 90s = 270s 로 폴백 이전과 같고, `test_retry_budget_fits_processing_expiry` 가 그 상한을 그대로 지킨다. -부수 효과가 오히려 본질에 가깝다 — 같은 벤더에 백오프를 걸고 다시 던지는 것보다 **막히지 않은 -다른 경로로 즉시 넘어가는 편이 성공 확률이 높다.** 429는 경로별로 걸리기 때문이다(§1). +부수 효과가 오히려 본질에 가깝다. 같은 벤더에 백오프를 걸고 다시 던지는 것보다, 막히지 않은 다른 경로로 즉시 넘어가는 편이 성공 확률이 높다. 429 가 경로별로 걸리기 때문이다(§1). -그리고 **체인을 벤더 하나로 줄이면 시도 배분이 그 벤더로 모여 폴백 이전과 정확히 같아진다.** -롤백이 설정 한 줄인 것이 이 설계의 결과다. +그리고 체인을 벤더 하나로 줄이면 시도 배분이 그 벤더로 모여 폴백 이전과 정확히 같아진다. 롤백이 설정 한 줄로 끝나는 것이 이 설계의 결과다. ## 5. 넘어가는 조건과 넘어가지 않는 조건 -`TransientError`(429·5xx·타임아웃·연결 실패·구조화 출력 위반)만 다음 벤더로 넘어간다. -`PermanentError`(400·401·403)는 넘어가지 않는다 — 키·설정 문제는 다른 벤더에서도 같은 답이고, -넘어가면 GMS 호출만 3배가 된다(`S15P11A705-121` 결함 3의 재발 형태). +`TransientError`(429·5xx·타임아웃·연결 실패·구조화 출력 위반)만 다음 벤더로 넘어간다. `PermanentError`(400·401·403)는 넘어가지 않는다. 키·설정 문제는 다른 벤더에서도 같은 답이 나오고, 넘어가면 GMS 호출만 3배가 되기 때문이다(`S15P11A705-121` 결함 3의 재발 형태다). -구조화 출력 위반을 폴백 사유에 넣은 이유는 벤더마다 구조화 출력 방식이 다르기 때문이다 — -한쪽이 절단·안전 차단으로 깨져도 다른 쪽은 성공할 수 있다. 소진 후에는 기존과 같이 영구 -오류로 승격한다(§2.2). +구조화 출력 위반을 폴백 사유에 넣은 이유는 벤더마다 구조화 출력 방식이 다르기 때문이다. 한쪽이 응답 절단이나 안전 차단으로 깨져도 다른 쪽은 성공할 수 있다. 재시도가 소진된 후에는 기존과 같이 영구 오류로 승격한다(§2.2). -체인이 소진되면 기존과 같은 분류로 끝난다. 상태는 PROCESSING에 남고 재스캔이 회수한다 — -재스캔이 집을 수 있는 상태를 유지하는 것이 완료 조건이었다. +체인이 소진되면 기존과 같은 분류로 끝난다. 상태는 PROCESSING 에 남고 재스캔이 회수한다. 재스캔이 집을 수 있는 상태를 유지하는 것이 완료 조건이었다. ## 6. 어느 벤더가 답했는지 두 곳에 남는다. -- **토큰 로그**(`PINLOG_TOKEN_LOG` JSONL) — `vendor`·`model` 필드. 벤더마다 토큰 필드 이름이 - 달라(`usageMetadata` camelCase / `usage.prompt_tokens` / `usage.input_tokens`) 추출도 벤더별로 - 갈랐다. Anthropic은 합계를 주지 않아 입력+출력을 더해 남긴다. -- **`ai.context_keyword_analysis.model_profile`** — 설정 1순위가 아니라 **답한 모델**이다. - `model_profile`의 용도가 "어떤 모델의 판단이었는지 구분"(keyword-preset.md §5.2)이므로, - 폴백이 생긴 뒤에 설정값을 그대로 쓰면 그 구분이 거짓이 된다. 티켓의 명시 범위는 토큰 로그 - 하나였지만, 폴백을 넣는 순간 이 필드가 조용히 틀려지므로 같은 PR에서 고쳤다. +- **토큰 로그**(`PINLOG_TOKEN_LOG` JSONL) — `vendor`·`model` 필드를 남긴다. 벤더마다 토큰 필드 이름이 달라(`usageMetadata` camelCase / `usage.prompt_tokens` / `usage.input_tokens`) 추출도 벤더별로 나눴다. Anthropic 은 합계를 주지 않아 입력과 출력을 더해 남긴다. +- **`ai.context_keyword_analysis.model_profile`** — 설정의 1순위가 아니라 실제로 답한 모델을 저장한다. `model_profile` 의 용도가 "어떤 모델의 판단이었는지 구분"(keyword-preset.md §5.2)이므로, 폴백이 생긴 뒤에 설정값을 그대로 쓰면 그 구분이 거짓이 된다. 티켓의 명시 범위는 토큰 로그 하나였지만, 폴백을 넣는 순간 이 필드가 조용히 틀려지므로 같은 PR 에서 고쳤다. ## 7. 설정 @@ -120,17 +92,9 @@ Anthropic {root}/api.anthropic.com/v1/messages PINLOG_JUDGE_CHAIN=openai:gpt-4o-mini,gemini:gemini-2.5-flash,anthropic:claude-haiku-4-5-20251001 ``` -`PINLOG_JUDGE_MODEL`을 대체했다. 모델 하나로는 폴백 순서를 표현할 수 없고, 벤더 이름 없이는 -어느 경로·어느 인증 헤더로 부를지 알 수 없다. **옛 키는 이제 무시된다** — 배포 설정에는 -주입되지 않았고(봉인 대상은 `GMS_API_KEY`·`GMS_BASE_URL`·`INTERNAL_SHARED_SECRET` 셋뿐), -`.env.example`과 테스트 픽스처에서 함께 제거했다. 로컬 `.env`에 남아 있으면 조용히 무시되므로 -이 문서와 `.env.example`이 그 안내를 겸한다. +`PINLOG_JUDGE_MODEL` 을 대체했다. 모델 하나로는 폴백 순서를 표현할 수 없고, 벤더 이름 없이는 어느 경로·어느 인증 헤더로 부를지 알 수 없기 때문이다. 옛 키는 이제 무시된다. 배포 설정에는 주입되지 않았고(봉인 대상은 `GMS_API_KEY`·`GMS_BASE_URL`·`INTERNAL_SHARED_SECRET` 셋뿐이다), `.env.example` 과 테스트 픽스처에서 함께 제거했다. 로컬 `.env` 에 남아 있으면 조용히 무시되므로 이 문서와 `.env.example` 이 그 안내를 겸한다. -정본은 코드(`app/core/config.py`)에 두고 주입은 덮어쓰기다(P45). 형식 오류·빈 체인은 기동에서 -막는다 — 판정 경로는 첫 Context 요청까지 실행되지 않으므로, 미루면 서버는 정상으로 보이는데 -Keyword만 통째로 생성되지 않는 비대칭 장애가 된다(`GMS_BASE_URL` 세그먼트 누락과 같은 종류). -형식은 config가 보고, **지원 벤더인지는 어댑터 레지스트리만 알 수 있어** 클라이언트 생성이 -본다(둘 다 lifespan startup이다). +정본은 코드(`app/core/config.py`)에 두고 주입은 덮어쓰기다(P45). 형식 오류·빈 체인은 기동에서 막는다. 검증을 미루면 판정 경로는 첫 Context 요청까지 실행되지 않으므로, 서버는 정상으로 보이는데 Keyword 만 통째로 생성되지 않는 비대칭 장애가 된다(`GMS_BASE_URL` 세그먼트 누락과 같은 종류다). 형식은 config 가 검사하고, 지원 벤더인지는 어댑터 레지스트리만 알 수 있으므로 클라이언트 생성 시점에 검사한다(둘 다 lifespan startup 이다). ## 8. 검증 @@ -145,28 +109,16 @@ RED 드릴 2회를 실제로 관측했다. | 드릴 | 주입한 결함 | 관측 | |---|---|---| -| 1 | `_call_for`가 항상 1순위를 반환(폴백 무력화) | 6건 실패 — 429 폴백, 시도별 벤더 순서, 오류 메시지의 벤더, 스키마 위반 폴백, 전송 실패 폴백, 토큰 로그 | -| 2 | 재시도 드라이버가 `PermanentError`도 다음 벤더로 넘김 | 14건 실패 — 이 PR 신규 3건(`401` 폴백 금지, `400`·`403` 폴백 금지)과 기존 재시도 계약 11건이 함께 무너진다. 폴백이 §2.2 분류를 재정의하지 않았다는 증거다 | +| 1 | `_call_for` 가 항상 1순위를 반환(폴백 무력화) | 6건 실패 — 429 폴백, 시도별 벤더 순서, 오류 메시지의 벤더, 스키마 위반 폴백, 전송 실패 폴백, 토큰 로그 | +| 2 | 재시도 드라이버가 `PermanentError` 도 다음 벤더로 넘김 | 14건 실패 — 이 PR 신규 3건(`401` 폴백 금지, `400`·`403` 폴백 금지)과 기존 재시도 계약 11건이 함께 실패한다. 폴백이 §2.2 분류를 재정의하지 않았다는 증거다 | -**실 GMS 호출은 하지 않았다.** 이 PR의 테스트는 `httpx.MockTransport`로 세 벤더의 응답 봉투를 -직접 만든다(tests/README.md — 실호출을 CI에 넣지 않는다). §1의 실측은 이 PR 이전에 -`probe_vendors.py`로 수행한 것이고, 어댑터가 실제 GMS에서 왕복하는지는 -`python -m app.smoke.gms_roundtrip`으로 배포 절차에서 확인한다. +실제 GMS 호출은 하지 않았다. 이 PR 의 테스트는 `httpx.MockTransport` 로 세 벤더의 응답 봉투를 직접 만든다(tests/README.md — 실호출을 CI 에 넣지 않는다). §1 의 실측은 이 PR 이전에 `probe_vendors.py` 로 수행한 것이고, 어댑터가 실제 GMS 에서 왕복하는지는 `python -m app.smoke.gms_roundtrip` 으로 배포 절차에서 확인한다. ## 9. 남는 위험·미검증 -- **표본은 Context 5건 · 하루 오후 한 시점이다.** 시간대가 바뀌면 성공률 분포가 달라질 수 있다. - 1순위가 바뀌어야 할 상황이 오면 설정 한 줄이며, 그것이 이 구조의 목적이다. -- **일치도 0.83~0.93의 차이에는 판정 자체의 비결정성이 섞여 있다.** 현행 모델도 같은 Context를 - 반복하면 흔들린다. 벤더가 바뀌면 Keyword가 바뀔 수 있고 그것은 사용자에게 보이는 값이다. -- **Anthropic은 prompt가 1973으로 2.4배다.** 토큰 기준 과금이면 3순위가 비싸다. GMS가 - 프로바이더별 과금을 어떻게 처리하는지 모르므로 확인 전에는 판단하지 않는다 - ([cost-estimate.md](../spec/cost-estimate.md) §3 주석과 같은 이유). -- **스모크는 체인 전체를 증명하지 않는다.** 1순위가 429면 2순위로 넘어가 통과하므로, 통과가 - "세 벤더가 모두 살아 있다"를 뜻하지 않는다. 3순위가 죽어 있는데 모르는 상태가 가능하다. - 벤더별 가용성은 `probe_vendors.py`로 수동 확인한다 — 스모크에서 벤더마다 실호출하면 배포 - 게이트가 GMS 쿼터에 3배로 묶인다. 상시 관측은 `S15P11A705-96` 범위다. -- **1순위 모델명에 오타가 있으면 400/404 → 영구 오류로 폴백 없이 죽는다.** 폴백 이전과 같은 - 실패 양상이지만, 체인이 길어질수록 오타 지점도 늘어난다. 기동 시 검증은 형식과 벤더 이름까지이며 - **모델명이 실제로 존재하는지는 실호출만이 안다**(스모크의 몫이다). -- 벤더별 프롬프트 튜닝은 하지 않았다. 같은 프롬프트로 일치도 0.83~0.93이 나왔고, 별건이다. +- **표본은 Context 5건 · 하루 오후 한 시점이다.** 시간대가 바뀌면 성공률 분포가 달라질 수 있다. 1순위를 바꿔야 할 상황이 오면 설정 한 줄로 되고, 그것이 이 구조의 목적이다. +- **일치도 0.83~0.93 의 차이에는 판정 자체의 비결정성이 섞여 있다.** 현행 모델도 같은 Context 를 반복하면 결과가 달라진다. 벤더가 바뀌면 Keyword 가 바뀔 수 있고 그것은 사용자에게 보이는 값이다. +- **Anthropic 은 prompt 가 1973 토큰으로 다른 벤더의 2.4배다.** 토큰 기준 과금이면 3순위가 비싸다. GMS 가 프로바이더별 과금을 어떻게 처리하는지 모르므로 확인 전에는 판단하지 않는다([cost-estimate.md](../spec/cost-estimate.md) §3 주석과 같은 이유다). +- **스모크는 체인 전체를 증명하지 않는다.** 1순위가 429 면 2순위로 넘어가 통과하므로, 통과가 "세 벤더가 모두 살아 있다"를 뜻하지 않는다. 3순위가 죽어 있는데 모르는 상태가 가능하다. 벤더별 가용성은 `probe_vendors.py` 로 수동 확인한다. 스모크에서 벤더마다 실호출하면 배포 게이트가 GMS 쿼터에 3배로 묶이기 때문이다. 상시 관측은 `S15P11A705-96` 범위다. +- **1순위 모델명에 오타가 있으면 400/404 영구 오류로, 폴백 없이 판정이 실패한다.** 폴백 이전과 같은 실패 양상이지만, 체인이 길어질수록 오타 지점도 늘어난다. 기동 시 검증은 형식과 벤더 이름까지이며, 모델명이 실제로 존재하는지는 실제 호출만이 안다(스모크의 몫이다). +- 벤더별 프롬프트 튜닝은 하지 않았다. 같은 프롬프트로 일치도 0.83~0.93 이 나왔고, 별건이다. diff --git a/docs/implements/2026-07-30-real-data-e2e.md b/docs/implements/2026-07-30-real-data-e2e.md index 6dd3ae2..79dee09 100644 --- a/docs/implements/2026-07-30-real-data-e2e.md +++ b/docs/implements/2026-07-30-real-data-e2e.md @@ -1,17 +1,17 @@ -# 실사용자 데이터 E2E 검증 — 정확도·소요 시간·토큰 실측 +# 실사용자 데이터 E2E 검증 — 정확도·소요 시간·토큰을 실측했다 - **티켓**: S15P11A705-174 - **날짜**: 2026-07-30 - **선행**: [데모 시딩](2026-07-29-demo-seeding.md) (`S15P11A705-58`) · [E2E 검증](2026-07-27-e2e-verification.md) -- **함정**: [T27·T28](../troubleshooting/2026-07-30-seeding-quota-and-encoding.md) +- **측정 중 발견한 문제**: [T27·T28](../troubleshooting/2026-07-30-seeding-quota-and-encoding.md) -> **2026-07-30 개정 (`S15P11A705-176`).** 초판의 두 서술이 틀려 고쳤다 — -> 판정 후보 수(27 → **10**, §6)와 시딩 소요(15분 → **42초**, §2). 후자는 GMS 가 -> 느렸던 것이 아니라 우리가 `--pace 25` 로 쉬었기 때문이다. +> **2026-07-30 개정 (`S15P11A705-176`).** 초판의 두 서술이 틀려서 고쳤다. +> 판정 후보 수(27 → **10**, §6)와 시딩 소요(15분 → **42초**, §2)다. 후자는 GMS 가 +> 느렸던 것이 아니라 우리가 `--pace 25` 로 호출 간격을 벌렸기 때문이다. ## 요약 -가공 데모 14건에 **실사용자 기록 23건**을 더해 37건으로 전체 통합 경로를 돌렸다. +가공 데모 14건에 실사용자 기록 23건을 더해 37건으로 전체 통합 경로를 돌렸다. ``` 검색 정확도 10 / 12 (83.3%) 가공 4/4 · 실데이터 6/8 · 2회차도 동일 @@ -21,11 +21,11 @@ Keyword PASS 74행 · 피드 카드 15/15 표시 토큰 32,912 판정이 임베딩의 17배 ``` -**실패 2건은 원인이 서로 다르고, 하나는 설계 관측이다**(아래 §3.2). +검색 실패 2건은 원인이 서로 다르고, 그중 하나는 버그가 아니라 설계에 대한 관측이다(§3.2). ### 데이터를 세 층으로 읽어야 한다 -값마다 출처가 다르고, **어느 검증이 어느 층에 걸려 있는지가 다르다.** +값마다 출처가 다르고, 어느 검증이 어느 층에 걸려 있는지가 다르다. | 층 | 필드 | 이 층에 걸린 검증 | |---|---|---| @@ -33,13 +33,9 @@ Keyword PASS 74행 · 피드 카드 15/15 표시 | ② 계약 자리채움 | `kakaoPlaceId` · `address` · `lat` · `lng` | **없다.** 읽는 코드가 없다 | | ③ 시연 구성 | `collections` · `follows` | **탐색 피드**(§4) | -②는 back `PlacePayload` 가 `@NotBlank`·`@NotNull` 로 요구해 채운 값이다. 임베딩은 -`context` 만 받고 Feed MVP 는 region 을 제외했으므로(`S15P11A705-119`) 어디에도 -쓰이지 않는다. 실데이터 23건은 전 건이 같은 좌표와 "미확인" 주소를 쓴다. +②는 back 의 `PlacePayload` 가 `@NotBlank`·`@NotNull` 로 요구해서 채운 값이다. 임베딩은 `context` 만 입력으로 받고 Feed MVP 는 region 을 제외했으므로(`S15P11A705-119`) 이 필드들은 어디에도 쓰이지 않는다. 실데이터 23건은 전 건이 같은 좌표와 "미확인" 주소를 쓴다. -**③은 AI 파트가 만들었다.** 원본은 `장소명 · 맥락` 두 컬럼뿐이었고 Collection -묶음과 팔로우 관계는 시연 3종이 성립하도록 우리가 구성했다. §4 의 PASS 는 그 -구조 위의 결과이며, 실제 사용자가 그렇게 묶었을지는 알 수 없다. +③은 AI 파트가 만들었다. 원본은 `장소명 · 맥락` 두 컬럼뿐이었고, Collection 묶음과 팔로우 관계는 시연 3종이 성립하도록 우리가 구성했다. §4 의 PASS 는 그 구조 위의 결과이며, 실제 사용자가 그렇게 묶었을지는 알 수 없다. ## 1. 실행 환경 @@ -52,8 +48,7 @@ Keyword PASS 74행 · 피드 카드 15/15 표시 | Judge | `gemini-2.5-flash`, `thinkingBudget=0` | | 데이터 | member 7 · Record 37 · Collection 16 · Follow 5 | -실데이터는 두 사람의 실제 방문 기록이다(김가현 11건 · 이정헌 12건). 가공 데이터와 달리 -`[데모]` 접두사를 붙이지 않았다 — 실제 기록을 가공으로 위장하면 오히려 오해를 만든다. +실데이터는 두 사람의 실제 방문 기록이다(김가현 11건 · 이정헌 12건). 가공 데이터와 달리 `[데모]` 접두사를 붙이지 않았다. 실제 기록을 가공으로 위장하면 오히려 오해를 만들기 때문이다. ## 2. 소요 시간 — 느렸던 것은 GMS 가 아니었다 @@ -64,13 +59,11 @@ Keyword PASS 74행 · 피드 카드 15/15 표시 | `--pace 25` | 15분 8초 | 0회 | 37/37 | 32,912 | | `--pace 1` | **42초** | 0회 | 37/37 | 32,967 | -**21.6배.** 결과도 토큰도 같다(0.2% 차이는 판정 output 의 비결정성). -**15분 중 14분 20초가 우리가 스스로 쉰 시간이었다.** 검증은 두 경우 모두 11초다. +21.6배 차이다. 결과도 토큰도 같다(토큰 0.2% 차이는 판정 output 의 비결정성이다). 즉 15분 중 14분 20초가 우리가 스스로 쉰 시간이었다. 검증(`verify.py`)은 두 경우 모두 11초다. ### 왜 `--pace 25` 였나 — 한 시점의 측정을 상수로 읽었다 -초판은 GMS Gemini 쿼터를 "분당 약 2건"으로 두고 그에 맞춰 간격을 벌렸다. -**다음 날 같은 코드가 분당 30건 이상을 통과시켰다.** +초판은 GMS Gemini 쿼터를 "분당 약 2건"으로 두고 그에 맞춰 간격을 벌렸다. 그런데 다음 날 같은 코드가 분당 30건 이상을 통과시켰다. ``` 간격 5s × 24회 (prompt 257·86·41) 전부 200. 프롬프트 크기와 무관 @@ -78,15 +71,12 @@ Keyword PASS 74행 · 피드 카드 15/15 표시 동시 10건 10/10 · 1.7초 ``` -GMS 는 SSAFY **공용** 게이트웨이라 쿼터가 우리 전용 할당이 아니고, 프로바이더 -경로별로 따로 걸린다 — 같은 시각 OpenAI·Anthropic 경로는 12/12 로 막히지 않았다. -자세한 것은 [T27 정정본](../troubleshooting/2026-07-30-seeding-quota-and-encoding.md)과 -`S15P11A705-175`(벤더 폴백)에 있다. +GMS 는 SSAFY 공용 게이트웨이라서 쿼터가 우리 전용 할당이 아니고, 프로바이더 경로별로 따로 걸린다. 같은 시각 OpenAI·Anthropic 경로는 12/12 로 막히지 않았다. 자세한 것은 [T27 정정본](../troubleshooting/2026-07-30-seeding-quota-and-encoding.md)과 `S15P11A705-175`(벤더 폴백)에 있다. | 단계 | 값 | |---|---| | 임베딩 | 병목이 아니다. 호출당 1초 미만 | -| 판정 | 응답 자체는 평균 1.1초. **간격은 우리가 정한 값이었다** | +| 판정 | 응답 자체는 평균 1.1초. 간격은 우리가 정한 값이었다 | | 회수 루프 | 0회 — 두 실행 모두 `retry_count > 0` 인 행이 0 | | 429 | 0건 | @@ -115,22 +105,18 @@ GMS 는 SSAFY **공용** 게이트웨이라 쿼터가 우리 전용 할당이 실데이터 6/8 75% ``` -**6번은 0.0087 차이로 통과했다.** 사루카메(미슐랭 라멘)와 쿠로코식당(라멘) 둘 다 라멘집이라 -분리가 어려운 것이 정상이며, 이 정도 차이는 다음 실행에서 뒤집힐 수 있다. 성공으로 세되 -안정적이라고 보지 않는다. +6번은 0.0087 차이로 통과했다. 사루카메(미슐랭 라멘)와 쿠로코식당(라멘)은 둘 다 라멘집이라 분리가 어려운 것이 정상이며, 이 정도 차이는 다음 실행에서 뒤집힐 수 있다. 성공으로 세되 안정적이라고 보지 않는다. ### 3.2 실패 원인 — 둘이 다르다 -**7번 「친구들이랑 피자에 맥주 마신 곳」 — 축약어가 안 이어졌다** +**7번 「친구들이랑 피자에 맥주 마신 곳」 — 축약어가 연결되지 않았다** ``` 기대 뉴오더클럽 연남 3위 0.3642 "…신한 친구들이랑 피맥했고 … 같이 가서 피맥함" 실제 카츠요 1위 0.4512 "6개월 동안 신한 부트캠프 친구들과 자주 먹었던 돈카츠 집" ``` -본문의 **「피맥」이 질의의 「피자에 맥주」와 임베딩상 멀다.** 반면 질의의 「친구들이랑」이 -두 본문 모두에 강하게 걸려, 그 축이 승부를 갈랐다. 축약어·은어가 임베딩에서 풀리지 않는 -전형적 사례다. +본문의 「피맥」이 질의의 「피자에 맥주」와 임베딩 공간에서 멀다. 반면 질의의 「친구들이랑」은 두 본문 모두에 강하게 걸렸고, 그 축이 순위를 정했다. 축약어·은어를 임베딩이 일반 표현과 연결하지 못하는 전형적인 사례다. **8번 「밥 먹고 산책하면서 쉬어가는 공원」 — 장소명이 임베딩에 없다** @@ -139,27 +125,22 @@ GMS 는 SSAFY **공용** 게이트웨이라 쿼터가 우리 전용 할당이 실제 치킨버거 이스트사이드 1위 0.5263 "치킨버거 맛있더라. 이거 사들고 그네 공원 갔음" ``` -본문만 보면 동교어린이공원이 질의와 거의 같은 문구(「밥먹고 산책하면서」)를 담고 있는데도 -2위다. 원인은 **질의의 「공원」이 본문에 없다**는 것이다 — 그 단어는 장소명에만 있고, -치킨버거 쪽은 본문에 「공원」이 들어 있다. +본문만 보면 동교어린이공원이 질의와 거의 같은 문구(「밥먹고 산책하면서」)를 담고 있는데도 2위다. 원인은 질의의 「공원」이라는 단어가 그 본문에 없다는 것이다. 그 단어는 장소명에만 있고, 오히려 치킨버거 쪽 본문에 「공원」이 들어 있다. -임베딩 입력이 **Context 본문 하나**이기 때문이다. +이렇게 되는 이유는 임베딩 입력이 Context 본문 하나이기 때문이다. ```python # tools/demo_seed/seed.py:334 — back 도 같은 필드를 보낸다 "text": rec["context"] ``` -**이것은 버그가 아니라 현재 설계다.** 다만 사용자는 장소명을 검색어에 쓴다는 것이 -실데이터에서 처음 드러났다. 개선 후보는 §6에 적는다. +이것은 버그가 아니라 현재 설계다. 다만 사용자는 장소명을 검색어에 쓴다는 사실이 실데이터에서 처음 드러났다. 개선 후보는 §6 에 적는다. ### 3.3 이 정확도를 어떻게 읽어야 하는가 -**기대값은 내가 정했다.** 질의 8건과 정답을 이 검증을 위해 작성했으므로, 83.3% 는 -"사용자가 실제로 그 질의를 했을 때의 만족도"가 아니라 **"내가 의도한 매칭이 재현되는 비율"**이다. +기대값은 검증 작성자가 정했다. 질의 8건과 정답을 이 검증을 위해 작성했으므로, 83.3% 는 "사용자가 실제로 그 질의를 했을 때의 만족도"가 아니라 "작성자가 의도한 매칭이 재현되는 비율"이다. -두 실패 모두 기대한 Record 가 1~3위 안에는 들어 있다. 순위가 아니라 **1위 일치**를 -기준으로 세었기 때문에 FAIL 이며, top-3 기준이면 12/12 다. +두 실패 모두 기대한 Record 가 1~3위 안에는 들어 있다. 판정 기준을 순위권이 아니라 1위 일치로 세었기 때문에 FAIL 이며, top-3 기준이면 12/12 다. ## 4. 탐색 피드 — PASS @@ -170,14 +151,9 @@ requestId=a1f45a33… items=15 hasNext=False 본인 Collection 제외 PASS 0건 ``` -실데이터 Collection 7개(가보고 싶은 카페·다녀온 맛집·분위기 좋은 곳·연남 단골·부캠 시절 -밥집·그네팟 코스·면 요리)가 가공 Collection 과 섞여 나왔다. `max-per-owner` 상한이 -소유자별로 작동하는 것이 소유자 6명 분포로 확인된다. +실데이터 Collection 7개(가보고 싶은 카페·다녀온 맛집·분위기 좋은 곳·연남 단골·부캠 시절 밥집·그네팟 코스·면 요리)가 가공 Collection 과 섞여 나왔다. `max-per-owner` 상한이 소유자별로 작동하는 것이 소유자 6명 분포로 확인된다. -**이 PASS 는 ③ 시연 구성 층 위의 결과다**(요약 참조). 원본은 `장소명 · 맥락` 두 -컬럼뿐이었고 위 Collection 7개와 팔로우 5건은 AI 파트가 묶고 이은 것이다. 검증된 -것은 **"Collection 이 있으면 피드가 그것을 섞어 낸다"** 이지 "사용자가 이렇게 묶는다"가 -아니다. +이 PASS 는 ③ 시연 구성 층 위의 결과다(요약 참조). 원본은 `장소명 · 맥락` 두 컬럼뿐이었고 위 Collection 7개와 팔로우 5건은 AI 파트가 묶고 이은 것이다. 검증된 것은 "Collection 이 있으면 피드가 그것을 섞어 낸다"이지 "사용자가 이렇게 묶는다"가 아니다. ## 5. Keyword — PASS @@ -187,27 +163,23 @@ Context 부착 74행 (주인공 6 Record 에 18행) PRIVATE_ONLY 미노출 PASS (ANNIVERSARY* 가 타인 화면에 없음) ``` -**관측 1건** — `GET /v1/records/{id}` 의 `keywords` 가 `[]` 다. back 의 -`RecordDetailResponse.of` 가 `List.of()` 를 고정 반환한다(미구현). Record 상세 화면에는 -Keyword 가 나오지 않는다. `S15P11A705-58` 에서 이미 발견된 back 소관 결함이며 이 티켓 -범위가 아니다. +관측 1건을 남긴다. `GET /v1/records/{id}` 의 `keywords` 가 `[]` 다. back 의 `RecordDetailResponse.of` 가 `List.of()` 를 고정 반환한다(미구현). 따라서 Record 상세 화면에는 Keyword 가 나오지 않는다. `S15P11A705-58` 에서 이미 발견된 back 소관 결함이며 이 티켓 범위가 아니다. ## 6. 토큰 사용량 ``` [embedding] 49회 1,845 토큰 건당 37.7 [judge] 37회 31,067 토큰 건당 839.6 prompt 790.2 + output 49.5 - thoughts 0 thinkingBudget=0 이 실제로 먹힌다 + thoughts 0 thinkingBudget=0 이 실제로 적용된다 ───────────────────────────────────────────────── 총 86회 32,912 토큰 15.6분 ``` -임베딩 49회 = 시딩 37 + 검색 질의 12. 별도로 **Keyword Preset 적재 1배치 2,157 토큰**이 -시딩 전에 들었다(27건 한 번에). +임베딩 49회 = 시딩 37 + 검색 질의 12 다. 별도로 Keyword Preset 적재 1배치에 2,157 토큰이 시딩 전에 들었다(27건 한 번에). ### 판정이 임베딩의 17배를 쓴다 -건당 839.6 대 37.7 이다. 원인은 후보 **수**가 아니라 후보 **표현**이다. +건당 839.6 대 37.7 이다. 원인은 후보의 수가 아니라 후보의 표현이다. ```python # app/client/llm_client.py — 후보 하나가 이만큼을 차지한다 @@ -215,16 +187,13 @@ f"- id={p['id']} | {p['display_name']} ({p['category']}) | " f"의미: {p['description']} | 예: {examples}" ``` -`KEYWORD_CANDIDATE_TOP_K=10` 이 실제로 작동해 후보는 **10개**다(초판이 27개라고 -적은 것은 프리셋 총 개수와 혼동한 오류다). 그 10개가 각각 `description` 전문과 -`examples` 전체를 싣는다 — 후보당 약 70토큰 × 10 + Context 50 ≈ 790. +`KEYWORD_CANDIDATE_TOP_K=10` 이 실제로 작동해 후보는 10개다(초판이 27개라고 적은 것은 프리셋 총 개수와 혼동한 오류였다). 그 10개가 각각 `description` 전문과 `examples` 전체를 싣는다. 후보당 약 70토큰 × 10 + Context 50 ≈ 790 이다. ``` Context 1건 처리 = 임베딩 37.7 + 판정 839.6 ≈ 877 토큰 ``` -**프리셋을 늘려도 판정 prompt 는 늘지 않는다** — top-K 가 상한이다. 늘어나는 것은 -후보 선택의 폭이고, prompt 를 줄이려면 `description`·`examples` 표현을 손봐야 한다. +프리셋을 늘려도 판정 prompt 는 늘지 않는다. top-K 가 상한이기 때문이다. 프리셋이 늘어나면 커지는 것은 후보 선택의 폭이고, prompt 를 줄이려면 `description`·`examples` 표현을 손봐야 한다. ### 벤더 비교 — 판정 결과는 벤더 간에 대체로 일치한다 @@ -239,12 +208,9 @@ Context 1건 처리 = 임베딩 37.7 + 판정 839.6 ≈ 877 토큰 | gemini-2.5-flash (현행) | 92% | 1.23s | 732 | 21 | 기준 | | gemini-2.5-flash-lite | **58%** | 1.09s | 732 | 48 | 0.90 | -**429 는 Gemini 경로에만 났다.** 임베딩이 안 막힌 것과 같은 그림이며, 쿼터가 -게이트웨이 전역이 아니라 프로바이더별로 걸린다는 뜻이다. +429 는 Gemini 경로에만 났다. 임베딩이 안 막힌 것과 같은 양상이며, 쿼터가 게이트웨이 전역이 아니라 프로바이더별로 걸린다는 뜻이다. -임베딩이 후보를 이미 걸러 주므로 LLM 은 확인 작업만 한다 — 그래서 체급이 달라도 -같은 답이 나온다. 남는 차이는 대부분 **판정 자체의 비결정성**이다(현행 모델도 -같은 Context 를 반복하면 흔들린다). `gpt-4.1-nano` 만 0.53 으로 확연히 낮다. +임베딩이 후보를 이미 걸러 주므로 LLM 은 확인 작업만 한다. 그래서 모델 체급이 달라도 같은 답이 나온다. 남는 차이는 대부분 판정 자체의 비결정성이다(현행 모델도 같은 Context 를 반복하면 결과가 달라진다). `gpt-4.1-nano` 만 0.53 으로 확연히 낮다. ### 개선 후보 (이 티켓 범위 밖) @@ -252,7 +218,7 @@ Context 1건 처리 = 임베딩 37.7 + 판정 839.6 ≈ 877 토큰 |---|---|---|---| | 벤더 폴백 | Gemini 혼잡을 다른 경로로 흡수 | 결과가 미세하게 달라진다 | `-175` | | 임베딩 입력에 장소명 포함 | §3.2 8번 유형 해소 | 공용 계약 `static/05` §7 변경 + 기존 임베딩 전량 재생성 | 미발행 | -| 후보 표현 경량화(`examples` 축약) | prompt 790 → 절반 이하 | 판정 근거가 줄어 품질이 떨어질 수 있다. **속도 문제는 이것으로 풀리지 않는다**(프롬프트 크기와 429가 무관함이 실측됐다) | 미발행 | +| 후보 표현 경량화(`examples` 축약) | prompt 790 → 절반 이하 | 판정 근거가 줄어 품질이 떨어질 수 있다. 속도 문제는 이것으로 풀리지 않는다(프롬프트 크기와 429 가 무관함이 실측됐다) | 미발행 | | 축약어·은어 사전 | §3.2 7번 유형 | 유지 비용. MVP 범위 아님 | 미발행 | ## 7. 재현 @@ -279,15 +245,11 @@ DATABASE_URL="..." python tools/demo_seed/verify.py python tools/demo_seed/token_report.py .demo/token-usage.jsonl ``` -`PYTHONIOENCODING` 은 더 이상 필요 없다 — 두 스크립트가 stdout 을 스스로 UTF-8 로 -재구성한다([T28](../troubleshooting/2026-07-30-seeding-quota-and-encoding.md)). -`--pace` 도 기본값이 1 이라 붙이지 않는다. +`PYTHONIOENCODING` 은 더 이상 필요 없다. 두 스크립트가 stdout 을 스스로 UTF-8 로 재구성한다([T28](../troubleshooting/2026-07-30-seeding-quota-and-encoding.md)). `--pace` 도 기본값이 1 이라 붙이지 않는다. ### 시연 당일에는 시딩하지 말고 스냅샷을 복구한다 -**GMS 가 그날 혼잡하면 시딩이 42초가 아니라 15분이 된다.** 쿼터는 우리가 통제할 수 -없으므로 의존 시점을 통제한다 — 전날 데이터를 확정하고 스냅샷을 떠 두면 복구는 -**GMS 를 한 번도 부르지 않는다.** +GMS 가 그날 혼잡하면 시딩이 42초가 아니라 15분이 된다. 쿼터는 우리가 통제할 수 없으므로 의존하는 시점을 통제한다. 전날 데이터를 확정하고 스냅샷을 떠 두면, 복구는 GMS 를 한 번도 부르지 않는다. ```bash # 데이터가 좋을 때 (전날) @@ -297,8 +259,7 @@ docker exec pinlog-demo-postgres-1 pg_dump -U pinlog -Fc pinlog > .demo/demo-sna docker exec -i pinlog-demo-postgres-1 pg_restore -U pinlog -d pinlog --clean --if-exists < .demo/demo-snapshot.dump ``` -`.demo/` 는 gitignore 대상이라 스냅샷이 레포에 들어가지 않는다. **실사용자 기록이 -들어 있으므로 공유하지 마라.** +`.demo/` 는 gitignore 대상이라 스냅샷이 레포에 들어가지 않는다. 실사용자 기록이 들어 있으므로 공유하면 안 된다. 실시간 생성 시연을 넣을지는 30초 헬스체크로 판단한다. @@ -313,10 +274,7 @@ python tools/keyword_eval/probe_quota.py --n 3 --gap 1 ## 8. 검증하지 않은 것 -- **`tools/e2e/` 드라이버 4종** — 이번에는 `demo_seed` 경로만 돌렸다 -- **재스캔 Scheduler 실동작** — `#104` 가 포함된 jar 로 띄웠지만 429가 0건이라 - 스케줄러가 회수할 대상이 없었다. **동작을 관측하지 못했다** -- **반복 측정** — 2회 실행했고 검색 정확도가 **10/12 로 동일**했다(실패 2건도 같음). - 임베딩이 결정적이라 검색 순위는 재현된다. 다만 6번(차이 0.0087)은 여전히 아슬아슬하고, - 판정(Keyword)은 비결정적이라 Keyword 행 수가 58 → 59 로 달라졌다 -- **프론트 화면** — API 응답까지만 확인했다 +- **`tools/e2e/` 드라이버 4종** — 이번에는 `demo_seed` 경로만 돌렸다. +- **재스캔 Scheduler 실동작** — `#104` 가 포함된 jar 로 띄웠지만 429 가 0건이라 스케줄러가 회수할 대상이 없었다. 동작을 관측하지 못했다. +- **반복 측정** — 2회 실행했고 검색 정확도가 10/12 로 동일했다(실패 2건도 같다). 임베딩이 결정적이라 검색 순위는 재현된다. 다만 6번(차이 0.0087)은 여전히 근소하고, 판정(Keyword)은 비결정적이라 Keyword 행 수가 58 → 59 로 달라졌다. +- **프론트 화면** — API 응답까지만 확인했다. diff --git a/docs/implements/2026-07-30-retry-and-error-classification.md b/docs/implements/2026-07-30-retry-and-error-classification.md index befc96a..07a4861 100644 --- a/docs/implements/2026-07-30-retry-and-error-classification.md +++ b/docs/implements/2026-07-30-retry-and-error-classification.md @@ -1,90 +1,60 @@ -# 외부 API 재시도·오류 분류 정합화 (S15P11A705-121) +# 외부 API 재시도·오류 분류 정합화 — 두 클라이언트의 반대 분류를 spec 대로 고쳤다 (S15P11A705-121) 상태: 완료 · 유형: 구현 · 근거 명세: [failure-recovery.md](../spec/failure-recovery.md) §2.1 · §2.2 · §3.1 · §3.2 -`docs/implements/2026-07-28-s1-implementation-recovery.md` §5가 기록한 구현 결함 A-1~A-4·F-1~F-3 중 -Circuit Breaker(A-6)와 타임아웃 값 재산정(A-5)을 제외한 전부를 수정했다. 문서화 누락이 아니라 -**두 클라이언트가 상태 코드를 정반대로 분류하던** 구현 결함이다. +`docs/implements/2026-07-28-s1-implementation-recovery.md` §5 가 기록한 구현 결함 A-1~A-4·F-1~F-3 중 Circuit Breaker(A-6)와 타임아웃 값 재산정(A-5)을 제외한 전부를 수정했다. 문서화 누락이 아니라, 두 클라이언트가 상태 코드를 정반대로 분류하던 구현 결함이다. ## 1. 무엇이 반대였나 | | spec | 수정 전 구현 | 결과 | |---|---|---|---| -| `429` | Transient (§2.1) | `>= 500`만 Transient → 나머지 non-200 전부 Permanent | rate limit 한 번에 해당 Context가 **영구 실패** | -| LLM `400`·`401`·`403` | Permanent (§2.2) | **모든** non-200을 Transient | 인증 실패가 재스캔 주기(5분)마다 GMS 호출을 생성 | +| `429` | Transient (§2.1) | `>= 500` 만 Transient. 나머지 non-200 전부 Permanent | rate limit 한 번에 해당 Context 가 영구 실패로 남는다 | +| LLM `400`·`401`·`403` | Permanent (§2.2) | 모든 non-200 을 Transient | 인증 실패가 재스캔 주기(5분)마다 GMS 호출을 만든다 | -두 클라이언트가 각자 상태 코드 표를 들고 있었던 것이 원인이다. 그래서 매핑을 -`app/core/errors.py`의 `classify_http_status` 한 곳으로 모았다 — spec §2가 "`errors.py`에서 -분류한다"고 지목한 지점이다. `429`를 `>= 500`보다 **먼저** 판정해야 4xx로 떨어지지 않는다. +두 클라이언트가 각자 상태 코드 표를 들고 있었던 것이 원인이다. 그래서 매핑을 `app/core/errors.py` 의 `classify_http_status` 한 곳으로 모았다. spec §2 가 "`errors.py` 에서 분류한다"고 지목한 지점이다. `429` 를 `>= 500` 보다 먼저 판정해야 4xx 로 떨어지지 않는다. ## 2. 재시도 (§3.1) -`app/client/retry.py`의 `RetryPolicy` + `call_with_retry`. 총 3회 시도(최초 1회 + 재시도 2회), -지수 백오프 `0.5s → 1.0s`(상한 4.0s)에 full jitter. +`app/client/retry.py` 의 `RetryPolicy` 와 `call_with_retry` 를 도입했다. 총 3회 시도(최초 1회 + 재시도 2회), 지수 백오프 `0.5s → 1.0s`(상한 4.0s)에 full jitter 를 적용한다. -**재시도 대상은 `TransientError` 하나다.** §3.1의 대상 목록(타임아웃·429·5xx·연결 실패)이 §2.1의 -Transient 집합과 같고 비대상 목록이 곧 `PermanentError`이므로, 재시도 여부를 판정하는 두 번째 -상태 코드 표를 만들지 않았다. 표가 둘이면 갈라지고, 실제로 갈라진 것이 위 §1이다. +재시도 대상은 `TransientError` 하나다. §3.1 의 대상 목록(타임아웃·429·5xx·연결 실패)이 §2.1 의 Transient 집합과 같고, 비대상 목록이 곧 `PermanentError` 다. 그래서 재시도 여부를 판정하는 두 번째 상태 코드 표를 만들지 않았다. 표가 둘이면 갈라지고, 실제로 갈라진 결과가 위 §1 이다. -재시도 단위는 **API 호출 1회**다 — Embedding은 배치 1건 단위로 재시도해 성공한 앞 배치를 다시 -보내지 않는다(`test_embedding_retry_does_not_resend_successful_batch`). +재시도 단위는 API 호출 1회다. Embedding 은 배치 1건 단위로 재시도해, 성공한 앞 배치를 다시 보내지 않는다(`test_embedding_retry_does_not_resend_successful_batch`). -**백오프 값을 `Settings`(환경변수)로 열지 않았다.** §3.2의 상한("두 호출의 타임아웃 합 + 재시도 -시간 < PROCESSING 만료 600s")에 묶인 값이라 env로 열면 그 상한이 배포마다 달라진다. 현재 최악값은 -`3 × (60 + 90) + 2 × 1.5 = 453s < 600s`이고 이 부등식을 테스트가 지킨다 -(`test_retry_budget_fits_processing_expiry`). 대신 `RetryPolicy`를 생성자 인자로 받아 **테스트가 -sleep·jitter를 주입**한다 — 실제로 잠드는 테스트를 만들지 않기 위한 설계다. +백오프 값을 `Settings`(환경변수)로 열지 않았다. §3.2 의 상한("두 호출의 타임아웃 합 + 재시도 시간 < PROCESSING 만료 600s")에 묶인 값이라, env 로 열면 그 상한이 배포마다 달라지기 때문이다. 현재 최악값은 `3 × (60 + 90) + 2 × 1.5 = 453s < 600s` 이고 이 부등식을 테스트가 지킨다(`test_retry_budget_fits_processing_expiry`). 대신 `RetryPolicy` 를 생성자 인자로 받아 테스트가 sleep·jitter 를 주입한다. 실제로 잠드는 테스트를 만들지 않기 위한 설계다. ## 3. 구조화 출력 위반 (§2.2 "재시도 후에도") -`SchemaViolationError(TransientError)`를 도입했다. 하위 타입인 것이 설계의 핵심이다. +`SchemaViolationError(TransientError)` 를 도입했다. `TransientError` 의 하위 타입이라는 것이 설계의 핵심이다. -- 재시도 중에는 Transient로 동작한다 — 같은 요청에 대한 LLM 출력은 결정론적이지 않으므로 - 재요청이 성공할 여지가 있다(`test_llm_schema_violation_recovers_within_retry`). -- **소진되면 `judge`가 `PermanentError`로 승격한다.** 그래서 service가 보는 분류는 여전히 두 - 종류이고 §2의 체계가 깨지지 않는다. 하위 타입인 채로 새어 나가면 `except TransientError`가 - 먼저 잡아 무한 재판정이 된다 — 그것을 `test_schema_violation_does_not_escape_as_transient`가 막는다. +- 재시도 중에는 Transient 로 동작한다. 같은 요청에 대한 LLM 출력은 결정론적이지 않으므로 재요청이 성공할 여지가 있다(`test_llm_schema_violation_recovers_within_retry`). +- 재시도가 소진되면 `judge` 가 `PermanentError` 로 승격한다. 그래서 service 가 보는 분류는 여전히 두 종류이고 §2 의 체계가 깨지지 않는다. 하위 타입인 채로 새어 나가면 `except TransientError` 가 먼저 잡아 무한 재판정이 된다. 그것을 `test_schema_violation_does_not_escape_as_transient` 가 막는다. -Embedding 응답 형식 위반은 이 타입을 쓰지 않고 곧바로 Permanent다. 프로바이더가 같은 요청에 같은 -형식으로 답하므로 재시도가 무의미하다. +Embedding 의 응답 형식 위반은 이 타입을 쓰지 않고 곧바로 Permanent 다. 프로바이더가 같은 요청에 같은 형식으로 답하므로 재시도가 무의미하기 때문이다. ## 4. service 결선 — 결함 3의 나머지 절반 -`keyword_service.run`은 `judge` 호출을 `except TransientError`로만 감싸고 있었고 `PermanentError` -핸들러가 없었다. 호출 경로 위쪽도 무방비였다(`context_processing`에 광범위 except 없음, -`context.py`는 `BackgroundTasks.add_task`로 넘김). +`keyword_service.run` 은 `judge` 호출을 `except TransientError` 로만 감싸고 있었고 `PermanentError` 핸들러가 없었다. 호출 경로 위쪽도 처리가 없었다(`context_processing` 에 광범위 except 없음, `context.py` 는 `BackgroundTasks.add_task` 로 넘긴다). -따라서 `llm_client`만 고치면 401이 **BackgroundTasks까지 새어 트레이스백만 남기고 단계는 -PROCESSING에 머문다** — 만료 후 재스캔이 같은 호출을 반복하므로, 고치려던 무한 재시도가 경로만 -바꿔 그대로 남는다. `PermanentError → _fail()` 결선을 추가했다. 되돌려서 실패를 확인했다(§6). +따라서 `llm_client` 만 고치면 401 이 BackgroundTasks 까지 새어 트레이스백만 남기고 단계는 PROCESSING 에 머문다. 만료 후 재스캔이 같은 호출을 반복하므로, 고치려던 무한 재시도가 경로만 바꿔 그대로 남는 것이다. `PermanentError → _fail()` 결선을 추가했다. 실제로 되돌려서 실패를 확인했다(§6). -`embedding_service`는 이 결선을 이미 갖고 있었다. 비대칭이었던 쪽만 맞췄고 상태 전이 규칙은 -건드리지 않았다. +`embedding_service` 는 이 결선을 이미 갖고 있었다. 비대칭이었던 쪽만 맞췄고 상태 전이 규칙은 건드리지 않았다. -로그 레벨도 명세에 맞췄다 — 일시 오류 `WARN` + `context_id`·`stage`·원인(§2.1), 영구 오류 -`ERROR`(§2.2). 둘 다 `INFO`/`WARNING`이어서 일시 장애가 묻히고 배포 설정 문제가 알림에 오르지 -않았다. +로그 레벨도 명세에 맞췄다. 일시 오류는 `WARN` 에 `context_id`·`stage`·원인을 포함하고(§2.1), 영구 오류는 `ERROR` 다(§2.2). 수정 전에는 둘 다 `INFO`/`WARNING` 이어서 일시 장애가 묻히고 배포 설정 문제가 알림에 오르지 않았다. ## 5. 테스트 — 결함 5(근본 원인) 해소 -`tests/fakes.py`의 `raise_exc`가 **어느 테스트에서도 쓰이지 않아** Transient/Permanent 파이프라인 -경로가 한 번도 실행된 적이 없었다. 그것이 위 §1의 두 오분류가 잡히지 않은 직접 원인이다. +`tests/fakes.py` 의 `raise_exc` 가 어느 테스트에서도 쓰이지 않아, Transient/Permanent 파이프라인 경로가 한 번도 실행된 적이 없었다. 그것이 위 §1 의 두 오분류가 잡히지 않은 직접 원인이다. | 파일 | 계층 | 추가 | |---|---|---| | `tests/test_unit.py` | 순수 단위 | 상태 코드 분류 표 대조 · 백오프 수열·jitter 범위 · §3.2 예산 부등식 | | `tests/test_client_retry.py` (신설) | client HTTP | 상태 코드→오류 타입 · 재시도 횟수·간격 · 스키마 위반 승격 · 배치 재전송 안 함 | -| `tests/test_pipeline.py` | 파이프라인 | `raise_exc`로 4경로 + CANCELLED 경합 2건 | +| `tests/test_pipeline.py` | 파이프라인 | `raise_exc` 로 4경로 + CANCELLED 경합 2건 | -파이프라인 단언은 **transient 동안 `PROCESSING` 유지**(다른 단계 불변, `retry_count` 불변, 부분 -결과 없음)와 **permanent 시 해당 단계만 `FAILED`**(COMPLETED 단계 보존)다. +파이프라인 단언의 내용은 두 가지다. transient 동안 `PROCESSING` 이 유지되는가(다른 단계 불변, `retry_count` 불변, 부분 결과 없음), permanent 시 해당 단계만 `FAILED` 가 되는가(COMPLETED 단계 보존). -`test_client_retry.py`는 `integration-tests.md` §5의 4계층 밖이다. §4.2("HTTP 레벨 목이 아니라 -인터페이스 레벨 Fake")는 *파이프라인이 client를 무엇으로 대체하는가*의 규칙이고, 여기서 검증하는 -것은 그 대체물이 아니라 **실제 client 자신의 HTTP 계층**이다. 인터페이스 Fake로는 -`_embed_batch`가 429를 어떻게 분류하는지 볼 수 없다 — 그 공백이 F-3이었다. `httpx.MockTransport`를 -쓰며 DB·Docker·네트워크가 필요 없고, 주입한 sleep이 실제로 잠들지 않는다. 이 예외는 -`tests/README.md`에 기록했다. **`docs/spec/`은 수정하지 않았다.** +`test_client_retry.py` 는 `integration-tests.md` §5 의 4계층 밖이다. §4.2("HTTP 레벨 목이 아니라 인터페이스 레벨 Fake")는 *파이프라인이 client 를 무엇으로 대체하는가* 의 규칙이고, 여기서 검증하는 것은 그 대체물이 아니라 실제 client 자신의 HTTP 계층이다. 인터페이스 Fake 로는 `_embed_batch` 가 429 를 어떻게 분류하는지 볼 수 없다. 그 공백이 F-3 이었다. `httpx.MockTransport` 를 쓰며 DB·Docker·네트워크가 필요 없고, 주입한 sleep 이 실제로 잠들지 않는다. 이 예외는 `tests/README.md` 에 기록했다. `docs/spec/` 은 수정하지 않았다. ## 6. 검증 @@ -93,26 +63,14 @@ ruff check . exit 0 pytest 131 passed (착수 시 baseline 74 → +57) ``` -Docker 29.6.1 가동 상태에서 Testcontainers 실 pgvector 포함 전량 실행. 미실행 범위 없음. +Docker 29.6.1 가동 상태에서 Testcontainers 실 pgvector 를 포함해 전량 실행했다. 미실행 범위는 없다. -**결함을 실제로 잡는지 확인**: `app/service/keyword_service.py`의 `PermanentError` 핸들러만 -되돌려(`git stash`) `test_judge_permanent_fails_keyword_stage`를 돌리면 -`app.core.errors.PermanentError: llm error: 401`이 파이프라인 밖으로 새며 실패한다. 예측한 -경로와 같다. +결함을 실제로 잡는지도 확인했다. `app/service/keyword_service.py` 의 `PermanentError` 핸들러만 되돌리고(`git stash`) `test_judge_permanent_fails_keyword_stage` 를 돌리면 `app.core.errors.PermanentError: llm error: 401` 이 파이프라인 밖으로 새며 실패한다. 예측한 경로와 같다. ## 7. 남은 것 -- **Circuit Breaker** (spec §2.2 말미) — 티켓 제외 범위. MVP 밖으로 유예. 인증 실패·모델명 오류 - 같은 전 서비스 영향 오류를 개별 Context의 FAILED로 누적시키는 문제는 그대로 남는다. -- **연결·읽기 타임아웃 분리와 60/90 재산정** (§3.2) — 티켓 제외 범위(A-5). 현재는 단일 total이며, - 재시도 도입으로 최악값이 150s → 453s가 되었다. 600s 상한 안이지만 여유가 줄었고 그 부등식은 - 테스트가 지킨다. -- **`integration-tests.md` §4.2의 계층 구분 명문화** — 후속 별건. §4.2는 "HTTP 레벨 목이 아니라 - 인터페이스 레벨 Fake"라고만 적어 *파이프라인 시나리오*와 *client 단위* 두 층을 구분하지 - 않는다. 중앙과 "층이 다르므로 충돌이 아니다"로 합의했고 근거를 `tests/README.md`에 적었으나, - 명세가 그 구분을 담지 않는 한 같은 의문이 다시 나온다. **`docs/spec/` 수정은 이 티켓의 금지 - 범위**이므로 중앙이 별건으로 처리한다. - -`docs/WORKLOG.md`는 반영했다. 계약이 금지 범위로 지정한 이유는 당시 `ai#41`이 같은 파일을 -건드리고 있었기 때문이며, 그 PR이 CLOSED되고 대체 PR(`ai#42`)이 병합되어 전제가 사라졌음을 -중앙이 확인했다. +- **Circuit Breaker** (spec §2.2 말미) — 티켓 제외 범위다. MVP 밖으로 유예했다. 인증 실패·모델명 오류 같은 전 서비스 영향 오류가 개별 Context 의 FAILED 로 누적되는 문제는 그대로 남는다. +- **연결·읽기 타임아웃 분리와 60/90 재산정** (§3.2) — 티켓 제외 범위다(A-5). 현재는 단일 total 타임아웃이며, 재시도 도입으로 최악값이 150s 에서 453s 가 되었다. 600s 상한 안이지만 여유가 줄었고, 그 부등식은 테스트가 지킨다. +- **`integration-tests.md` §4.2 의 계층 구분 명문화** — 후속 별건이다. §4.2 는 "HTTP 레벨 목이 아니라 인터페이스 레벨 Fake"라고만 적어 *파이프라인 시나리오* 와 *client 단위* 두 층을 구분하지 않는다. 중앙과 "층이 다르므로 충돌이 아니다"로 합의했고 근거를 `tests/README.md` 에 적었으나, 명세가 그 구분을 담지 않는 한 같은 의문이 다시 나온다. `docs/spec/` 수정은 이 티켓의 금지 범위이므로 중앙이 별건으로 처리한다. + +`docs/WORKLOG.md` 는 반영했다. 계약이 이 파일을 금지 범위로 지정했던 이유는 당시 `ai#41` 이 같은 파일을 건드리고 있었기 때문인데, 그 PR 이 CLOSED 되고 대체 PR(`ai#42`)이 병합되어 전제가 사라졌음을 중앙이 확인했다. diff --git a/docs/implements/2026-07-31-candidate-threshold.md b/docs/implements/2026-07-31-candidate-threshold.md index dac9cfb..7cf9a08 100644 --- a/docs/implements/2026-07-31-candidate-threshold.md +++ b/docs/implements/2026-07-31-candidate-threshold.md @@ -1,75 +1,86 @@ -# 후보 유사도 임계값 τ — 실데이터로 재검증하고 현행 0.30 을 유지한다 +# 후보 유사도 임계값 τ — 실데이터로 재검증한 결과 어느 값도 이득이 손실을 넘지 못해 현행 0.30 을 유지한다 - **티켓**: S15P11A705-210 - **날짜**: 2026-07-31 - **선행**: [실데이터 E2E](2026-07-30-real-data-e2e.md) (`S15P11A705-174`) · `tools/keyword_eval/REPORT.md` -- **함정**: [T37~T39](../troubleshooting/2026-07-31-tau-measurement.md) +- **측정 중 발견한 문제**: [T37~T39](../troubleshooting/2026-07-31-tau-measurement.md) - **하네스**: `tools/tau_grid/` ## 요약 -**τ 를 올리지 않는다.** 실데이터 42건을 라벨링해 τ 를 0.30~0.45 로 훑은 결과, 어느 값에서도 -오분류 감소가 정상 판정 누락을 안정적으로 넘지 못했다. +**τ 를 올리지 않는다.** 실데이터 42건을 라벨링해 τ 를 0.30~0.45 범위로 훑은 결과, 어느 +값에서도 오분류 감소가 정상 판정 누락을 안정적으로 넘지 못했다. -``` -현행 τ = 0.30 유지 SIMILARITY_FLOOR 변경 없음 · 코드 변경 없음 -최선 후보 τ = 0.34 오분류 9/10 제거 · 정상 11 유실 · 교환비 1.22 - Context 단위로 정당하게 빔 5 · 부당하게 빔 5 (동률) -순이득 낙관 +2 / 비관 -6 — 부호가 갈린다 -``` - -**티켓의 전제 하나가 사실과 달랐다.** 「top-10 에 임계값이 없다」고 되어 있으나 -`keyword_service._topk` 는 처음부터 `sims[i] >= floor` 를 걸고 있었고 단위 테스트 -(`test_topk_excludes_below_floor`)와 파이프라인 테스트 -(`test_no_candidate_above_floor_completes_without_calling_llm`)도 있었다. 증상은 실재하나 -원인이 다르다 — 자세한 것은 [T37](../troubleshooting/2026-07-31-tau-measurement.md) 에 있다. +| 항목 | 확인한 내용 | +|---|---| +| 결정 | 현행 τ = 0.30 유지. `SIMILARITY_FLOOR` 설정과 코드를 변경하지 않는다 | +| 최선 후보 | τ = 0.34. 오분류 10건 중 9건을 제거하지만 정상 판정 11건을 함께 잃는다(교환비 1.22) | +| Context 단위 | τ = 0.34 에서 정당하게 비는 Context 5건, 부당하게 비는 Context 5건으로 동률이다 | +| 순이득 | 낙관 계산 +2, 비관 계산 -6. 판정이 갈리는 라벨을 어느 쪽으로 계산하느냐에 따라 부호가 바뀐다 | + +여기서 「정당하게 비는 Context」는 부적합 키워드만 붙어 있던 카드가 빈 카드가 되는 +경우이고(이 티켓이 의도한 효과다), 「부당하게 비는 Context」는 적합 키워드가 있었는데 +카드가 통째로 비는 경우다(그 대가다). + +**티켓의 전제 하나가 사실과 달랐다.** 티켓에는 「top-10 에 임계값이 없다」고 되어 +있다. 그러나 `keyword_service._topk` 는 처음부터 `sims[i] >= floor` 조건을 걸고 +있었고, 단위 테스트(`test_topk_excludes_below_floor`)와 파이프라인 테스트 +(`test_no_candidate_above_floor_completes_without_calling_llm`)도 이미 있었다. +티켓이 지적한 증상 자체는 실재하지만 원인이 다르다. 자세한 경위는 +[T37](../troubleshooting/2026-07-31-tau-measurement.md) 에 있다. ## 1. 무엇을 어떻게 쟀나 -### 1.1 τ 를 조건으로 두면 emb_grid 를 쓸 수 있는가 — 못 쓴다 +### 1.1 기존 emb_grid 하네스를 쓰지 않고 별도 하네스를 만든 이유 -`tools/emb_grid`(`S15P11A705-191`)는 조건마다 **전량 재시딩**한다. 임베딩 모델·차원이 -조건이므로 벡터를 다시 만들 수밖에 없기 때문이다. **τ 는 임베딩을 바꾸지 않는다** — -후보 선정에만 걸린다. 그대로 쓰면 조건마다 GMS 임베딩 42회를 아무 이유 없이 다시 쓴다. +`tools/emb_grid`(`S15P11A705-191`)는 조건마다 데이터를 전량 재시딩한다. 그 하네스는 +임베딩 모델과 차원이 실험 조건이므로 조건이 바뀔 때마다 벡터를 다시 만들 수밖에 없다. +반면 τ 는 임베딩을 바꾸지 않고 후보 선정 단계에만 걸리는 값이다. emb_grid 를 그대로 +쓰면 조건마다 GMS 임베딩 호출 42회를 아무 이유 없이 다시 쓰게 된다. -그래서 별도 하네스를 짰다. 구조(조건 정본 한 파일·사전 검증·JSON 산출)는 `emb_grid` 를 따랐다. +그래서 별도 하네스를 작성했다. 구조(조건 정본을 한 파일에 두는 방식 · 사전 검증 · +JSON 산출)는 `emb_grid` 를 따랐다. -### 1.2 재판정 없이 τ 를 훑는다 +### 1.2 LLM 재판정 없이 τ 를 훑는 방법 -벡터가 고정이므로 `(Context, Preset)` 유사도 42×27 을 한 번 떠 두면 임의의 τ 에 대한 -후보 집합을 **LLM 호출 없이** 재구성할 수 있다. +벡터가 고정이므로 `(Context, Preset)` 유사도 42×27 행렬을 한 번 떠 두면, 임의의 τ 에 +대한 후보 집합을 LLM 호출 없이 재구성할 수 있다. ``` 재구성 selected(τ) = { k ∈ selected(0.30) : sim(ctx, k) ≥ τ } ``` -τ 를 올려 빠지는 것은 유사도 하위 후보이고, 그중 **현행 판정이 고른 것**만 결과에서 -사라진다. 고르지 않은 후보가 빠지는 것은 결과를 바꾸지 않는다. +τ 를 올릴 때 빠지는 것은 유사도 하위 후보이고, 그중 현행 판정이 실제로 고른 것만 +결과에서 사라진다. 판정이 고르지 않은 후보가 빠지는 것은 결과를 바꾸지 않는다. -**이 재구성이 근사임을 §4 에서 실측으로 검증했다.** +이 재구성은 근사다. 근사가 실제 판정과 얼마나 어긋나는지는 §4 에서 실측으로 검증했다. ### 1.3 「오분류」를 무엇으로 셌나 -정답 라벨이 없다. 현행 τ=0.30 판정이 붙인 **83행 전부**를 사람이 판정해 -`tools/tau_grid/labels.yaml` 에 고정했다. 기준은 그 파일 머리말에 있고 요지는 셋이다. +정답 라벨이 없다. 그래서 현행 τ=0.30 판정이 붙인 83행 전부를 사람이 판정해 +`tools/tau_grid/labels.yaml` 에 고정했다. 판정 기준은 그 파일 머리말에 있고 요지는 +세 가지다. -``` -fit 프리셋의 description 이 본문에 명시되거나 직접 함의된다 -unfit 본문에 근거가 없다. 장소 종류·통념에서 온 연상이다 -unclear 통념상 그럴듯하나 본문이 말하지 않는다. 판정자에 따라 갈린다 -``` +| 라벨 | 기준 | +|---|---| +| `fit` | 프리셋의 `description` 이 본문에 명시되거나 직접 함의된다 | +| `unfit` | 본문에 근거가 없다. 장소 종류나 통념에서 온 연상이다 | +| `unclear` | 통념상 그럴듯하나 본문이 말하지 않는다. 판정자에 따라 갈릴 수 있다 | -**표시명이 아니라 `description` 을 기준으로 삼는다.** `QUICK_STOP 간단하게` 의 정의는 -"짧게 들르는 방문. 테이크아웃·잠깐 요기"이므로 「간단히 맛있었다」 같은 어감은 근거가 -아니다. 부대시설 규칙과 COMPANION 실동행 규칙은 판정 프롬프트와 같게 두었다 — 라벨이 -프롬프트보다 엄격하면 판정을 부당하게 깎는다. +라벨 기준은 표시명이 아니라 `description` 이다. 예를 들어 `QUICK_STOP 간단하게` 의 +정의는 "짧게 들르는 방문. 테이크아웃·잠깐 요기"이므로, 본문의 「간단히 맛있었다」 +같은 어감은 이 프리셋의 근거가 아니다. 부대시설 규칙과 COMPANION 실동행 규칙은 판정 +프롬프트와 같게 두었다. 라벨이 프롬프트보다 엄격하면 판정을 부당하게 깎게 되기 +때문이다. -**`unclear` 를 따로 둔 이유.** fit 으로 접으면 τ 상향의 손실이 부풀고 unfit 으로 접으면 -이득이 부푼다. 어느 쪽으로 접든 결론이 그쪽으로 기울므로 따로 세고 두 극단을 함께 낸다. +`unclear` 를 따로 둔 이유는 다음과 같다. `unclear` 를 `fit` 으로 접으면 τ 상향의 +손실이 부풀고, `unfit` 으로 접으면 이득이 부푼다. 어느 쪽으로 접든 결론이 그쪽으로 +기울므로, 따로 세고 두 극단의 계산을 함께 낸다. -라벨은 판정자 한 명이고 교차 검증이 없다. **이 리포트의 모든 수치가 그 라벨 위에 있다.** +라벨은 판정자 한 명이 붙였고 교차 검증이 없다. 이 리포트의 모든 수치가 그 라벨 위에 +있다는 한계를 먼저 밝혀 둔다. -## 2. 분포 — 유사도가 적합성을 가르지 못한다 +## 2. 유사도 분포 — 적합 판정과 부적합 판정의 유사도 구간이 겹친다 ``` n min p25 p50 p75 max @@ -78,22 +89,23 @@ unfit 10 0.3100 0.3143 0.3224 0.3303 0.4225 unclear 10 0.3199 0.3258 0.3694 0.3911 0.4429 ``` -**unfit 의 최댓값(0.4225) 아래에 fit 이 34/63 건 있다.** τ 를 오분류 위로 올리면 그 아래 -정상 판정을 전부 함께 자른다. 이 한 줄이 아래 모든 결과를 지배한다. +`unfit` 의 최댓값(0.4225) 아래에 `fit` 이 63건 중 34건 있다. 따라서 τ 를 오분류 +위로 올리면 그 아래에 있는 정상 판정을 전부 함께 자르게 된다. 아래 모든 결과는 이 +분포 겹침에서 나온다. -부수로 나온 것 둘. +부수적으로 확인된 사실이 둘 있다. -- **Context 42건 전부가 τ=0.30 에서 후보를 갖는다**(top-1 최솟값 0.3138). 「후보 0개 → - LLM 미호출」 경로가 실데이터에서 한 번도 타지 않는다는 뜻이다. 경로는 살아 있으나 - **실데이터가 그 문을 열지 않는다.** -- 후보는 평균 **7.4개**다. `KEYWORD_CANDIDATE_TOP_K=10` 상한이 아니라 0.30 이 이미 자른 - 결과다 — 27개 중 19.6개가 이미 걸러지고 있다. +- Context 42건 전부가 τ=0.30 에서 후보를 갖는다(top-1 유사도 최솟값 0.3138). 「후보 + 0개면 LLM 을 호출하지 않는다」는 경로가 코드에 있지만, 실데이터에서는 후보 0개 + 조건이 한 번도 발생하지 않았다는 뜻이다. +- 후보는 평균 7.4개다. `KEYWORD_CANDIDATE_TOP_K=10` 상한에 걸린 것이 아니라 τ=0.30 + 이 이미 자른 결과다. 27개 프리셋 중 평균 19.6개가 이 단계에서 걸러지고 있다. ## 3. τ 격자 -`sim ≥ τ` 인 후보만 남겼을 때 현행 판정 83행이 어떻게 되는가. +`sim ≥ τ` 인 후보만 남겼을 때 현행 판정 83행이 어떻게 되는지를 τ 값별로 쟀다. -| τ | unfit 제거 | fit 유실 | unclear 유실 | 교환비 | 순이득 낙관/비관 | 정당하게 빔 | 부당하게 빔 | +| τ | unfit 제거 | fit 유실 | unclear 유실 | 교환비 | 순이득 낙관/비관 | 정당하게 빈 Context | 부당하게 빈 Context | |---|---|---|---|---|---|---|---| | 0.30 | 0 | 0 | 0 | — | +0 / +0 | 0 | 0 | | 0.31 | 0 | 6 | 0 | ∞ | -6 / -6 | 0 | 2 | @@ -105,64 +117,68 @@ unclear 10 0.3199 0.3258 0.3694 0.3911 0.4429 | 0.40 | 9 | 30 | 8 | 3.33 | -13 / -29 | 5 | 15 | | 0.45 | 10 | 44 | 10 | 4.40 | -24 / -44 | 5 | 22 | -- **교환비** = unfit 1건을 지우는 데 버리는 fit 수 -- **순이득** = 제거한 오분류 − 잃은 정상. 낙관은 unclear 를 전부 오분류로, 비관은 전부 정상으로 셈 -- **정당하게 빔** = 붙어 있던 것이 전부 unfit 이던 Context 가 0건이 됨 (이 티켓이 노리는 것) -- **부당하게 빔** = fit 이 있었는데 전부 사라져 카드가 빔 (그 대가) +각 열의 뜻은 다음과 같다. -**Context 단위를 행 단위와 함께 봐야 한다.** 행으로만 세면 「키워드가 하나 줄었다」와 -「카드가 통째로 빈다」가 같은 1 로 잡히는데 사용자에게는 전혀 다르다. +- **교환비** — unfit 1건을 지우는 데 버리는 fit 판정 수 +- **순이득** — 제거한 오분류에서 잃은 정상 판정을 뺀 값. 낙관은 `unclear` 를 전부 + 오분류로, 비관은 전부 정상으로 계산한 것이다 +- **정당하게 빈 Context** — 붙어 있던 키워드가 전부 unfit 이던 Context 가 0건이 됨. + 이 티켓이 의도한 효과다 +- **부당하게 빈 Context** — fit 키워드가 있었는데 전부 사라져 카드가 빔. 그 대가다 + +행 단위 수치와 Context 단위 수치를 함께 봐야 한다. 행으로만 세면 「키워드가 하나 +줄었다」와 「카드가 통째로 빈다」가 같은 1 로 잡히는데, 사용자에게 이 둘은 전혀 다른 +경험이다. ### 판독 -**τ=0.34 가 최선이고, 그 최선이 채택 기준을 넘지 못한다.** +τ=0.34 가 최선이고, 그 최선이 채택 기준을 넘지 못한다. -- 0.34 위로는 unfit 제거가 9에서 늘지 않는다. fit 손실만 는다 -- 0.34 에서도 Context 단위 정당 5 · 부당 5 로 **동률**이다 -- 순이득의 부호가 unclear 처리에 따라 갈린다(+2 / -6). 라벨 10건이 결론을 뒤집는다 +- 0.34 위로는 unfit 제거가 9에서 더 늘지 않는다. fit 손실만 늘어난다. +- 0.34 에서도 Context 단위로는 정당하게 빈 것 5건, 부당하게 빈 것 5건으로 동률이다. +- 순이득의 부호가 `unclear` 처리에 따라 갈린다(+2 / -6). 라벨 10건의 해석이 결론을 + 뒤집을 수 있다는 뜻이다. -τ=0.31 은 특히 나쁘다 — **오분류를 하나도 못 지우면서 정상 6건을 잃는다.** unfit 최솟값이 -0.3100 이고 fit 이 0.3001 부터 있기 때문이다. +τ=0.31 은 특히 나쁘다. 오분류를 하나도 지우지 못하면서 정상 판정 6건을 잃는다. +unfit 유사도의 최솟값이 0.3100 인데 fit 은 0.3001 부터 있기 때문이다. ### 기각한 값의 근거 | 값 | 기각 이유 | |---|---| | 0.31 | 오분류 제거 0 · 정상 유실 6. 순손실만 남는다 | -| 0.32 | 「가지튀김이 미쳤음」 2건을 정확히 잡으나 부당하게 비는 Context 가 4로 더 많다 | -| 0.33 | 교환비 1.57 · 낙관 순이득도 0 | -| 0.35 | 0.34 와 이득이 같은데 후보 0개 Context 가 3→5 로 는다. 더 자르고 같은 값을 얻는다 | -| 0.40 이상 | 교환비 3 이상. 부당하게 비는 Context 가 15건(36%) | +| 0.32 | 「가지튀김이 미쳤음」 2건을 정확히 잡으나, 부당하게 비는 Context 가 4건으로 더 많다 | +| 0.33 | 교환비 1.57. 낙관 계산으로도 순이득이 0 이다 | +| 0.35 | 0.34 와 이득이 같은데 후보 0개 Context 가 3건에서 5건으로 늘어난다. 더 잘라내고 같은 값을 얻는 셈이다 | +| 0.40 이상 | 교환비 3 이상. 부당하게 비는 Context 가 15건(36%)에 이른다 | ## 4. 재구성이 실제와 얼마나 어긋나는가 — 검증 -§1.2 의 재구성은 「후보가 줄어도 남은 후보의 판정은 그대로」를 가정한다. 후보 목록은 -프롬프트의 일부이므로 이 가정은 틀릴 수 있다. **대조군을 함께 돌려 τ 효과와 판정 -비결정성을 갈랐다.** - -``` -대조군 τ=0.30 을 그대로 다시 판정 vs DB 저장값 - 어긋난 Context 11/42 · 예측에 없던 선택 +9 · 있다던 선택 -6 (LLM 42회) +§1.2 의 재구성은 「후보가 줄어도 남은 후보에 대한 판정은 그대로」라는 가정 위에 +있다. 후보 목록은 프롬프트의 일부이므로 이 가정은 틀릴 수 있다. 그래서 대조군을 함께 +돌려, τ 변경의 효과와 판정 비결정성을 분리했다. -본실험 τ=0.34 로 잘라 판정 vs 재구성 예측 - 어긋난 Context 11/42 · 예측에 없던 선택 +14 · 있다던 선택 -1 (LLM 39회) -``` +| 실험 | 조건 | 어긋난 Context | 예측에 없던 선택 | 있다던 선택이 사라짐 | LLM 호출 | +|---|---|---|---|---|---| +| 대조군 | τ=0.30 을 그대로 다시 판정, DB 저장값과 비교 | 11/42 | +9 | -6 | 42회 | +| 본실험 | τ=0.34 로 잘라 판정, 재구성 예측과 비교 | 11/42 | +14 | -1 | 39회 | -**τ 변경이 만드는 추가 어긋남이 0 이다.** 재구성을 근거로 쓸 수 있다. +τ 를 바꾸지 않고 다시 판정만 해도 42건 중 11건이 어긋났고, τ 를 바꾼 실험도 같은 +11건 수준이었다. 즉 τ 변경이 만드는 추가 어긋남은 0 이다. 재구성을 근거로 쓸 수 있다. -두 가지가 더 나왔다. +이 검증에서 두 가지가 더 확인됐다. -- **`+14` 는 후보가 줄자 남은 후보를 더 골랐다는 뜻이다.** 재구성은 fit 손실을 - **과대평가**하는 쪽으로 틀린다 — §3 의 손실 수치는 보수적이다. 그런데도 순이득이 - 양수가 아니다 -- **같은 τ 로 다시 판정만 해도 Context 11/42(26%)에서 결과가 달라진다.** τ 를 0.30→0.34 로 - 바꿔 얻는 효과(unfit 9 제거)와 **판정 비결정성이 만드는 변동(+9/-6)이 같은 크기**다. - τ 튜닝의 효과가 노이즈에 묻힌다 +- 본실험의 `+14` 는 후보가 줄자 LLM 이 남은 후보를 더 골랐다는 뜻이다. 따라서 + 재구성은 fit 손실을 실제보다 크게 계산하는 쪽으로 틀린다. §3 의 손실 수치는 + 보수적인 값인데, 그런데도 순이득이 양수가 되지 않았다. +- 같은 τ 로 다시 판정만 해도 Context 11/42(26%)에서 결과가 달라진다. τ 를 + 0.30→0.34 로 바꿔 얻는 효과(unfit 9건 제거)와 판정 비결정성이 만드는 변동(+9/-6)이 + 같은 크기다. τ 튜닝의 효과가 판정 변동에 묻힌다. -### 데이터 자체가 같은 것을 한 번 더 보여준다 +### 데이터 자체가 같은 사실을 한 번 더 보여준다 -42건 중 5쌍이 **같은 본문**이다(오늘 생성분이 기존 본문을 복제했다). 본문이 같으면 -벡터도 후보도 같으므로 판정만 두 번 한 셈이 된다. +42건 중 5쌍이 같은 본문이다(오늘 생성분이 기존 본문을 복제했다). 본문이 같으면 +벡터도 후보도 같으므로, 이 5쌍은 같은 입력을 두 번 판정한 것과 같다. ``` 불일치 3/5 268/293 MEAL vs MEAL + CELEBRATION @@ -171,54 +187,53 @@ unclear 10 0.3199 0.3258 0.3694 0.3911 0.4429 일치 2/5 269/291 · 274/289 ``` -**같은 입력에 같은 후보인데 3/5 가 다르게 판정됐다.** 그리고 갈린 셋에서 한쪽에만 -나타난 `CELEBRATION`·`VIEW_GOOD` 은 §1.3 라벨에서 **전부 unfit** 이다 — 오분류가 -「항상 붙는 것」이 아니라 **흔들릴 때 붙는 것**임을 보여준다. τ 로 잡을 수 있는 성질이 -아니다. +같은 입력에 같은 후보를 주었는데 5쌍 중 3쌍이 다르게 판정됐다. 그리고 판정이 갈린 세 +쌍에서 한쪽에만 나타난 `CELEBRATION`·`VIEW_GOOD` 은 §1.3 라벨에서 전부 unfit 이다. +즉 오분류는 항상 붙는 것이 아니라 판정이 흔들릴 때 붙는다. 이것은 고정 임계값 τ 로 +잡을 수 있는 성질이 아니다. ## 5. Context 게이트 γ 를 검토하고 기각했다 -τ 가 두 일을 겸하는 것이 문제로 보였다. +τ 가 두 가지 일을 겸하는 것이 문제로 보였다. ``` -(a) 무관한 Context 를 통째로 후보 0개로 만든다 ← 노리는 것 +(a) 무관한 Context 를 통째로 후보 0개로 만든다 ← 이 티켓이 의도한 효과 (b) 정상 Context 안의 저유사도 후보를 자른다 ← 부수 피해 ``` -둘을 나누면 (a)만 얻을 수 있다 — **top-1 유사도가 γ 미만이면 판정을 건너뛴다.** 별도 -분기는 필요 없다. `_topk` 가 빈 목록을 내면 기존 「후보 0개」 경로가 그대로 탄다. +둘을 나누면 (a)만 얻을 수 있다. top-1 유사도가 γ 미만이면 판정을 건너뛰는 방식이다. +별도 분기도 필요 없다. `_topk` 가 빈 목록을 반환하면 기존 「후보 0개」 경로가 그대로 +동작한다. -실데이터에서 γ=0.32 는 완벽해 보였다. - -``` -스킵 Context 2건 (274·289, 둘 다 「가지튀김이 미쳤음」) -정당 2 · 부당 0 · 잃는 fit 0 · 잃는 unfit 2 -``` +실데이터 기준으로 γ=0.32 는 이상적으로 보였다. 스킵되는 Context 는 2건(274·289, 둘 +다 「가지튀김이 미쳤음」)이고, 정당하게 비는 것 2건, 부당하게 비는 것 0건, 잃는 fit +0건, 잃는 unfit 2건이었다. -**그러나 이 값은 표본 2건에 맞춘 것이다.** 게이트가 막아야 할 것 — 프리셋 어디에도 -걸리지 않는 입력 — 을 직접 만들어 재웠다(`probe_gate.py`, 임베딩만 호출). +그러나 이 값은 표본 2건에 맞춘 것이다. 게이트가 실제로 막아야 할 입력, 즉 프리셋 +어디에도 해당하지 않는 입력을 직접 만들어 쟀다(`probe_gate.py`, 임베딩만 호출). ``` irrelevant 8건 min 0.2558 p50 0.3163 max 0.4091 (휴대폰 액정 수리 → QUICK_STOP 0.409) terse 8건 min 0.2968 p50 0.4725 max 0.5847 (티라미수 최고 → GATHERING 0.297) ``` -**겹친다.** γ 를 어디에 두든 무관 입력이 통과하거나 짧은 정상 입력이 잘린다. γ=0.32 는 -무관 3/8 만 막고 정상 1/8 을 잘못 자른다. +두 분포가 겹친다. 따라서 γ 를 어디에 두든 무관한 입력이 통과하거나 짧은 정상 입력이 +잘린다. γ=0.32 는 무관 입력 8건 중 3건만 막으면서 정상 입력 8건 중 1건을 잘못 자른다. -「티라미수 최고」의 top-1 이 `DESSERT` 가 아니라 `GATHERING`(0.297)이라는 것이 이유를 -보여준다 — **짧은 본문에서 임베딩이 불안정하다.** 게이트는 무관함이 아니라 본문 길이를 -재게 된다. +「티라미수 최고」의 top-1 이 `DESSERT` 가 아니라 `GATHERING`(0.297)이라는 결과가 +이유를 보여준다. 짧은 본문에서는 임베딩 유사도가 불안정하다. 그래서 이 게이트는 +본문이 무관한지가 아니라 본문이 짧은지를 재는 장치가 되어 버린다. ## 6. 그래서 무엇이 원인인가 -τ 도 γ 도 듣지 않는 이유는 하나다. **유사도는 「이 프리셋이 이 본문과 얼마나 가까운가」를 -재지 「이 프리셋을 붙여도 되는가」를 재지 않는다.** 「가지튀김이 미쳤음」과 `DRINK 술자리`가 -0.3138 인 것은 임베딩이 틀린 게 아니라 튀김과 술자리가 실제로 그 정도로 가깝기 때문이다. +τ 와 γ 가 모두 효과를 내지 못하는 이유는 하나다. 유사도는 「이 프리셋이 이 본문과 +얼마나 가까운가」를 재는 값이지, 「이 프리셋을 붙여도 되는가」를 재는 값이 아니다. +「가지튀김이 미쳤음」과 `DRINK 술자리` 의 유사도가 0.3138 인 것은 임베딩이 틀려서가 +아니라, 튀김과 술자리가 의미상 실제로 그 정도로 가깝기 때문이다. -붙여도 되는지를 재는 층은 **판정 프롬프트**다. `keyword_eval/REPORT.md` 의 C-1 이 같은 -유형을 이미 그 층에서 고쳤다 — 「주차가 넓어서 → SPACIOUS」 오탐을 부대시설 제외 규칙으로 -기각시켰다. 이번 unfit 10건도 같은 성격이다. +붙여도 되는지를 재는 층은 판정 프롬프트다. `keyword_eval/REPORT.md` 의 C-1 이 같은 +유형의 문제를 이미 그 층에서 고쳤다. 「주차가 넓어서 → SPACIOUS」 오탐을 부대시설 +제외 규칙으로 기각시킨 사례다. 이번에 확인한 unfit 10건도 같은 성격이다. | unfit | 유사도 | 성격 | |---|---|---| @@ -230,8 +245,8 @@ terse 8건 min 0.2968 p50 0.4725 max 0.5847 (티라미수 최고 → | 보쌈+전골 → `QUICK_STOP` | 0.3261 | 정의와 반대 | | 언덕 카페 → `VIEW_GOOD` | 0.4225 | 접근 난이도를 전망으로 읽음 | -**「본문에 근거가 없으면 고르지 마라」가 규칙으로 서면 유사도와 무관하게 걸린다.** -후속으로 제안한다(§8). +「본문에 근거가 없으면 고르지 마라」가 판정 규칙으로 서면, 유사도 값과 무관하게 이 +유형이 걸러진다. 이를 후속으로 제안한다(§8). ## 7. 검증 @@ -252,30 +267,31 @@ DATABASE_URL="..." .venv/Scripts/python.exe tools/tau_grid/probe_gate.py DATABASE_URL="..." .venv/Scripts/python.exe tools/tau_grid/verify_reconstruction.py --tau 0.34 ``` -`matrix.py` 는 `.env` 의 `:5433` 을 그대로 쓰면 **데이터가 없는 DB 를 재고 0건을 결론으로 -낸다.** 그래서 `:15432` 가 아니면 재지 않고 멈춘다(T33). +`matrix.py` 는 `.env` 의 `:5433` 을 그대로 쓰면 데이터가 없는 DB 를 재고 0건을 +결론으로 내는 문제가 있었다. 그래서 지금은 `:15432` 가 아니면 측정하지 않고 +멈추도록 되어 있다(T33). ### 검증하지 않은 것 -- **라벨의 교차 검증** — 판정자가 한 명이다. unfit 10건은 장소 종류와 정면으로 어긋나 - 뒤집힐 여지가 작다고 보나, `unclear` 10건은 판정자가 바뀌면 갈릴 수 있고 그 10건이 - 순이득의 부호를 바꾼다 +- **라벨의 교차 검증** — 판정자가 한 명이다. unfit 10건은 장소 종류와 정면으로 + 어긋나므로 판정자가 바뀌어도 뒤집힐 여지가 작다고 본다. 그러나 `unclear` 10건은 + 판정자가 바뀌면 갈릴 수 있고, 그 10건이 순이득의 부호를 바꾼다. - **τ 를 실제 서버에 넣은 E2E** — 설정값을 바꾸지 않기로 했으므로 돌리지 않았다. - `_topk` 가 floor 를 쓰는 것은 기존 단위 테스트가 이미 덮는다 -- **중복 5쌍의 영향** — 42건 중 5쌍이 같은 본문이다(오늘 생성분이 기존 본문을 복제). - 분포·격자를 42건 기준으로 냈고 37건 기준으로 다시 내지 않았다. 중복된 본문이 두 번 - 세어지므로 §2 의 분포는 그만큼 그 본문 쪽으로 기운다 -- **프리셋 자체의 적합성** — `WITH_KIDS` 는 42건에서 한 번도 선택되지 않았다. τ 와 무관한 - 별개 문제다 + `_topk` 가 floor 를 적용하는 것은 기존 단위 테스트가 이미 덮는다. +- **중복 5쌍의 영향** — 42건 중 5쌍이 같은 본문이다(오늘 생성분이 기존 본문을 + 복제했다). 분포와 격자를 42건 기준으로 냈고 37건 기준으로 다시 내지 않았다. + 중복된 본문이 두 번 세어지므로 §2 의 분포는 그만큼 그 본문 쪽으로 기운다. +- **프리셋 자체의 적합성** — `WITH_KIDS` 는 42건에서 한 번도 선택되지 않았다. τ 와 + 무관한 별개 문제다. ## 8. 후속 제안 | 제안 | 근거 | 비고 | |---|---|---| -| 판정 프롬프트에 「본문에 근거 없으면 미선택」 규칙 강화 | §6 의 unfit 10건이 전부 이 유형 | 티켓 미발행. **이 티켓의 실질적 후속** | -| 판정 비결정성 측정 | 같은 입력에 Context 11/42 가 흔들린다(§4) | 티켓 미발행. 튜닝의 전제 조건 | -| 라벨 교차 검증 | 결론이 라벨 한 명에 걸려 있다 | `labels.yaml` 에 이의를 달면 재계산은 `score.py` 한 번 | +| 판정 프롬프트에 「본문에 근거 없으면 미선택」 규칙 강화 | §6 의 unfit 10건이 전부 이 유형 | 티켓 미발행. 이 티켓의 실질적 후속 | +| 판정 비결정성 측정 | 같은 입력에서 Context 11/42 의 판정이 달라진다(§4) | 티켓 미발행. 튜닝의 전제 조건 | +| 라벨 교차 검증 | 결론이 판정자 한 명의 라벨에 걸려 있다 | `labels.yaml` 에 이의를 달면 재계산은 `score.py` 한 번이다 | | 저빈도 프리셋 점검 | `WITH_KIDS` 0회 · `CELEBRATION` 1회(그마저 오분류) | τ 와 무관 | -τ 는 **데이터가 늘면 다시 볼 값**이다. 하네스와 라벨이 남아 있으므로 재측정은 -`matrix.py` → `score.py` 두 번이고, 새 데이터의 라벨만 더하면 된다. +τ 는 데이터가 늘면 다시 볼 값이다. 하네스와 라벨이 남아 있으므로 재측정은 +`matrix.py` → `score.py` 두 번 실행이고, 새 데이터의 라벨만 더하면 된다. diff --git a/docs/implements/2026-07-31-db-error-classification.md b/docs/implements/2026-07-31-db-error-classification.md index 52380a2..4fe80c0 100644 --- a/docs/implements/2026-07-31-db-error-classification.md +++ b/docs/implements/2026-07-31-db-error-classification.md @@ -1,4 +1,4 @@ -# DB 실패의 오류 분류 — 검색 중 DB 가 죽으면 503 이 나간다 +# DB 실패의 오류 분류 — 검색 중 DB 접속이 실패하면 500 대신 503 을 반환하게 했다 - **티켓**: S15P11A705-221 - **상태**: 완료 @@ -9,30 +9,33 @@ ## 이 문서가 다루는 것 -`-220` 이 남긴 항목 하나다. 그 티켓은 `TransientError→503` · `PermanentError→502` 핸들러를 -넣으면서 **500 을 「우리 코드의 결함」 전용으로 비워 두는 설계**를 택했는데, -**DB 실패는 아무도 분류하지 않아 그대로 500 으로 나갔다.** +`-220` 이 남긴 항목 하나를 처리했다. 그 티켓은 `TransientError→503` · +`PermanentError→502` 핸들러를 넣으면서 500 을 「우리 코드의 결함」 전용으로 비워 두는 +설계를 택했다. 그런데 DB 실패는 어느 쪽으로도 분류되지 않아 그대로 500 으로 나가고 +있었다. ``` 검색 중 DB 접속 실패 → 분류 없음 → uvicorn → HTTP 500 ``` -그러면 커넥션 풀 고갈이나 DB 재기동처럼 **기다리면 낫는 상황**이 「코드가 깨졌다」와 같은 -코드를 쓴다. `-220` 이 500 에 부여한 뜻이 그만큼 다시 흐려진다. +이 상태에서는 커넥션 풀 고갈이나 DB 재기동처럼 기다리면 회복되는 상황이 「코드가 +깨졌다」와 같은 상태 코드를 쓴다. `-220` 이 500 에 부여한 의미가 그만큼 다시 흐려진다. -명세는 이미 이쪽 편이었다. `failure-recovery.md` §2.1 이 *"DB 연결 실패, 잠금 타임아웃, -직렬화 실패"* 를 일시적 오류로 못박아 두었고, **코드가 그 표를 따르지 않고 있었다.** -이 티켓은 새 정책을 만든 것이 아니라 **명세와 코드의 간극을 메운 것**이다. +명세는 이미 분류를 요구하고 있었다. `failure-recovery.md` §2.1 이 *"DB 연결 실패, +잠금 타임아웃, 직렬화 실패"* 를 일시적 오류로 규정해 두었는데, 코드가 그 표를 따르지 +않고 있었다. 따라서 이 티켓은 새 정책을 만든 것이 아니라 명세와 코드의 간극을 메운 +것이다. --- ## 1. 조사 — asyncpg 는 실제로 무엇을 던지는가 -짐작하지 않고 실측했다(asyncpg 0.31.0, Python 3.12). **가장 중요한 발견 셋.** +짐작하지 않고 실측했다(asyncpg 0.31.0, Python 3.12). 가장 중요한 발견은 세 가지다. ### 1.1 접속 실패는 asyncpg 예외가 아니다 -이 티켓의 제목이 「DB 연결 실패」인데, 정작 그 실패는 `asyncpg.*` 로 오지 않는다. +이 티켓의 제목이 「DB 연결 실패」인데, 정작 그 실패는 `asyncpg.*` 타입으로 오지 +않는다. | 상황 | 실제 타입 | 계층 | |---|---|---| @@ -41,32 +44,36 @@ | 접속 타임아웃 | `TimeoutError` | stdlib `OSError` | | **풀 획득 타임아웃** | `TimeoutError` | stdlib `OSError` | -**`asyncpg` 예외만 분류했다면 이 티켓의 본체를 통째로 놓쳤을 것이다.** 티켓 본문이 -*"repository 층에서 asyncpg 예외를 분류"* 라고 적은 대로만 했으면 접속 실패는 여전히 -500 이었고, 테스트는 초록이었을 것이다. +`asyncpg` 예외만 분류했다면 이 티켓이 다루려는 실패의 본체를 통째로 놓쳤을 것이다. +티켓 본문의 *"repository 층에서 asyncpg 예외를 분류"* 라는 문구 그대로 구현했다면 +접속 실패는 여전히 500 으로 나갔을 것이고, 그런데도 테스트는 통과했을 것이다. -풀 획득 타임아웃이 `OSError` 로 잡히는 것은 **Python 버전 사실 하나**에 걸려 있다 — -3.11 부터 `asyncio.TimeoutError` 가 내장 `TimeoutError` 와 같은 객체이고 그것이 -`OSError` 의 하위 타입이다. 조용히 깨질 수 있는 전제라 테스트로 못박았다 +풀 획득 타임아웃이 `OSError` 로 잡히는 것은 Python 버전에 따라 달라지는 사실 하나에 +걸려 있다. Python 3.11 부터 `asyncio.TimeoutError` 가 내장 `TimeoutError` 와 같은 +객체이고, 그 타입이 `OSError` 의 하위 타입이다. 버전이 바뀌면 조용히 깨질 수 있는 +전제이므로 테스트로 고정했다 (`test_pool_acquire_timeout_is_an_os_error_on_this_python`). -### 1.2 `InterfaceError` 하나가 정반대 둘을 함께 쓴다 +### 1.2 `InterfaceError` 하나가 성격이 정반대인 두 실패를 함께 쓴다 -``` -connection is closed / pool is closed 수명주기 -the server expects 2 arguments, 1 was passed 우리 결함 -``` +| 메시지 | 성격 | +|---|---| +| `connection is closed` / `pool is closed` | 커넥션 수명주기 문제 | +| `the server expects 2 arguments, 1 was passed` | 우리 코드의 결함 | -같은 타입이다. **일시 오류로 두면 인자 개수 오류가 503 뒤에 영구히 숨는다** — 「일시적으로 -사용할 수 없습니다」가 뜨고, 재시도해도 안 되고, 알림은 울리지 않는다. 티켓이 경고한 -바로 그 모양이다. +두 실패가 같은 예외 타입으로 온다. 이 타입을 일시 오류로 분류하면 인자 개수 오류 +같은 코드 결함이 503 뒤에 영구히 숨는다. 사용자에게는 「일시적으로 사용할 수 +없습니다」가 뜨고, 재시도해도 실패가 반복되고, 코드 결함인데도 알림은 울리지 +않는다. 티켓이 경고한 바로 그 실패 모드다. -### 1.3 DB 가 죽는 순간의 질의는 `08003` 이다 +### 1.3 DB 가 중단되는 순간의 질의는 `08003` 이다 -백엔드를 실제로 죽여 보면 `ConnectionDoesNotExistError`(`08003`, -*connection was closed in the middle of operation*) 다. **같은 커넥션에 한 번 더 질의하면** -그때는 `InterfaceError('connection is closed')` 다. 즉 §1.2 의 수명주기 쪽은 정상 경로에서 -나오지 않는다 — 나온다면 우리가 닫힌 것을 재사용하고 있다는 뜻이다. +백엔드 프로세스를 실제로 종료해 보면 질의 중이던 커넥션은 +`ConnectionDoesNotExistError`(`08003`, *connection was closed in the middle of +operation*)를 받는다. 같은 커넥션에 한 번 더 질의하면 그때는 +`InterfaceError('connection is closed')` 다. 즉 §1.2 의 수명주기 쪽 메시지는 정상 +경로에서 나오지 않는다. 만약 나온다면 우리 코드가 닫힌 커넥션을 재사용하고 있다는 +뜻이다. --- @@ -74,13 +81,14 @@ the server expects 2 arguments, 1 was passed 우리 결함 ### 2.1 경계 — 어디까지가 일시적인가 -**이 티켓의 핵심 산출물이다.** 기준 한 줄로 그었다. +이 티켓의 핵심 산출물이다. 기준을 한 줄로 그었다. -> **서버·연결의 상태 때문에 실패했으면 일시적이고, 우리가 보낸 질의 때문에 실패했으면 -> 우리 결함이다.** +> 서버나 연결의 상태 때문에 실패했으면 일시적이고, 우리가 보낸 질의 때문에 +> 실패했으면 우리 결함이다. -SQLSTATE **군 단위**로 긋되, 군 안에서 성격이 갈리는 셋(`25xxx`·`55xxx`·`42xxx`)만 잎으로 -집었다. 전체 표는 [`failure-recovery.md` §2.5 「DB 축」](../spec/failure-recovery.md)과 +SQLSTATE 군 단위로 경계를 긋되, 군 안에서 성격이 갈리는 세 군(`25xxx`·`55xxx`· +`42xxx`)만 개별 코드 단위로 지정했다. 전체 표는 +[`failure-recovery.md` §2.5 「DB 축」](../spec/failure-recovery.md)과 [`app/core/db_errors.py`](../../app/core/db_errors.py) 모듈 도크에 있다. ``` @@ -89,55 +97,60 @@ Permanent(502) 28xxx 인증 · 3D000 없는 DB · 42501 권한 미분류(500) 42xxx 나머지 · 22xxx · 23xxx · XX000 · InterfaceError ``` -**미분류 목록이 분류 목록만큼 중요하다.** 넓게 잡으면 버그가 503 뒤에 숨고, 그것이 -이 경계를 그을 때 가장 경계한 실패 모드다. +미분류 목록이 분류 목록만큼 중요하다. 일시 오류의 범위를 넓게 잡으면 코드 결함이 +503 뒤에 숨는다. 그것이 이 경계를 그을 때 가장 경계한 실패 모드다. -### 2.2 `OSError` 는 **접속 단계에서만** 번역한다 +### 2.2 `OSError` 는 접속 단계에서만 번역한다 -`OSError` 는 DB 와 무관한 코드도 던진다. 그래서 커넥션을 얻는 동안에만 번역하고, 커넥션을 -넘겨준 뒤의 블록(호출부 코드가 함께 들어온다)에서는 `asyncpg` 예외만 번역한다. +`OSError` 는 DB 와 무관한 코드도 던지는 광범위한 타입이다. 그래서 커넥션을 얻는 +동안에만 DB 오류로 번역하고, 커넥션을 넘겨준 뒤의 블록(호출부 코드가 함께 +들어온다)에서는 `asyncpg` 예외만 번역한다. -이 구분이 없으면 무관한 파일·소켓 오류가 「DB 가 일시적으로 안 된다」로 둔갑한다. +이 구분이 없으면 DB 와 무관한 파일·소켓 오류까지 「DB 가 일시적으로 안 된다」로 +잘못 분류된다. -### 2.3 분류를 붙이는 지점은 **세션 경계**다 (티켓 문구와 다르다) +### 2.3 분류를 붙이는 지점은 세션 경계다 (티켓 문구와 다르다) -티켓은 *"repository 층에서"* 라고 적었지만 `app/core/db.py` 의 `acquire()`/`transaction()` -에 넣었다. **이유는 §1.1 이다** — repository 함수에 데코레이터를 걸면 `pool.acquire()` 가 -그 밖이라 **접속 실패를 놓친다.** 세션 경계에 두면 획득과 질의가 한 번에 덮이고, 저장소를 -새로 만들어도 분류가 따라온다. repository 보다 아래 층이므로 *"service 위로는 분류된 -예외만 올라간다"* 는 티켓의 취지는 그대로다. +티켓은 *"repository 층에서"* 라고 적었지만, 실제로는 `app/core/db.py` 의 +`acquire()`/`transaction()` 에 넣었다. 이유는 §1.1 에 있다. repository 함수에 +데코레이터를 걸면 `pool.acquire()` 호출이 데코레이터 바깥에 있어 접속 실패를 +놓친다. 세션 경계에 두면 커넥션 획득과 질의가 한 번에 덮이고, repository 를 새로 +만들어도 분류가 자동으로 따라온다. 세션 경계는 repository 보다 아래 층이므로, +*"service 위로는 분류된 예외만 올라간다"* 는 티켓의 취지는 그대로 유지된다. -### 2.4 `-220` 의 핸들러를 고치지 않는다 — 대신 **하위 타입**을 쓴다 +### 2.4 `-220` 의 핸들러를 고치지 않는다 — 대신 하위 타입을 쓴다 -`DatabaseTransientError(TransientError)` · `DatabasePermanentError(PermanentError)` 로 -정의했다. 기존 핸들러가 `isinstance` 로 그대로 받으므로 **`main.py` 를 한 줄도 건드리지 -않았고**, 그러면서 핸들러 로그의 `type(exc).__name__` 이 「게이트웨이가 아니라 DB」임을 -말해 준다. +`DatabaseTransientError(TransientError)` · `DatabasePermanentError(PermanentError)` +로 정의했다. 기존 핸들러가 `isinstance` 검사로 하위 타입을 그대로 받으므로 +`main.py` 는 한 줄도 바뀌지 않았다. 그러면서 핸들러 로그의 `type(exc).__name__` 이 +실패한 하위 시스템이 게이트웨이가 아니라 DB 라는 것을 알려 준다. ``` WARNING app.main upstream transient failure: DatabaseTransientError ``` -### 2.5 예외 메시지에 **타입 이름과 SQLSTATE만** 담는다 +### 2.5 예외 메시지에 타입 이름과 SQLSTATE 만 담는다 -DSN 에는 **DB 비밀번호**가 들어 있고 접속 실패 예외에는 host·port 가 섞일 수 있다. -`-220` 이 게이트웨이 응답 200자 누출을 발견하고 세운 기준을 그대로 적용한다. +DSN 에는 DB 비밀번호가 들어 있고, 접속 실패 예외 메시지에는 host·port 가 섞일 수 +있다. `-220` 이 게이트웨이 응답 200자 누출을 발견하고 세운 기준을 그대로 적용했다. ```python DatabaseTransientError("TooManyConnectionsError[53300]") # 원본 메시지는 옮기지 않는다 ``` -원본은 `__cause__` 로만 매달아 둔다. 대신 **SQLSTATE 는 남긴다** — 공개 어휘이면서 -*"풀이 고갈됐다"* 와 *"DB 가 재기동 중이다"* 를 가르는 유일한 값이고, 핸들러 로그는 타입 -이름만 찍으므로 그 값이 남을 곳이 여기뿐이다. 분류가 붙는 순간 `app.core.db` 로거가 -한 줄 남긴다(일시 `WARNING`, 영구 `ERROR` — §2.4). +원본 예외는 `__cause__` 로만 매달아 둔다. 대신 SQLSTATE 는 메시지에 남긴다. +SQLSTATE 는 공개 어휘이면서 *"풀이 고갈됐다"* 와 *"DB 가 재기동 중이다"* 를 구분하는 +유일한 값인데, 핸들러 로그는 타입 이름만 찍으므로 이 값이 남을 곳이 예외 메시지뿐이다. +분류가 붙는 순간 `app.core.db` 로거가 한 줄 남긴다(일시 오류는 `WARNING`, 영구 +오류는 `ERROR` — §2.4). ### 2.6 재시도를 넣지 않는다 -티켓이 확정한 대로다. `-121` 이 정한 짧은 재시도는 GMS 호출 대상이고, DB 재시도는 커넥션 -풀 동작과 얽혀 별건이다. 지금 구조에서 DB 일시 오류는 `503` 으로 나가고 `back` 은 검색을 -재시도하지 않으므로(`AiSearchClient`), **사용자 화면은 이 티켓 전후로 같다.** 바뀌는 것은 -관측이다 — `-220` 과 같은 값어치다. +티켓이 확정한 대로다. `-121` 이 정한 짧은 재시도는 GMS 호출 대상이고, DB 재시도는 +커넥션 풀 동작과 얽혀 있어 별개 문제다. 지금 구조에서 DB 일시 오류는 `503` 으로 +나가고 `back` 은 검색을 재시도하지 않으므로(`AiSearchClient`), 사용자 화면은 이 티켓 +전후로 같다. 바뀌는 것은 운영자가 보는 관측 정보이고, 이는 `-220` 과 같은 성격의 +개선이다. --- @@ -145,16 +158,17 @@ DatabaseTransientError("TooManyConnectionsError[53300]") # 원본 메시지는 ### 3.1 계약 테스트 — `tests/test_api_error_contract.py` DB 절 8건 -`-220` 이 세운 원칙을 그대로 지켰다. **DB 도 Fake 로 바꾸지 않는다.** 예외를 만들어 -주입하면 분류 경로(`db.acquire()` → `db_errors.py`)를 건너뛰고, 그러면 경계가 바뀌어도 -파일이 통과한다 — `ai#69` 를 놓친 구멍과 같은 모양이다. +`-220` 이 세운 원칙을 그대로 지켰다. DB 도 Fake 로 바꾸지 않는다. 예외를 만들어 +주입하면 분류 경로(`db.acquire()` → `db_errors.py`)를 건너뛰게 되고, 그러면 분류 +경계가 바뀌어도 테스트가 계속 통과한다. `ai#69` 결함을 테스트가 놓친 것과 같은 +구조의 구멍이다. -대신 **실제 `asyncpg` 풀을 실제로 실패시켰다.** +대신 실제 `asyncpg` 풀을 실제로 실패시켰다. | 무엇을 | 어떻게 실패시켰나 | 단언 | |---|---|---| | DB 에 닿지 않는다 | 닫힌 포트를 가리키는 `min_size=0` 풀 | `503`, 임베딩은 1회 성공 | -| 질의 도중 끊긴다 | 서버가 실제로 백엔드를 죽인다(`08003`) | `503` | +| 질의 도중 끊긴다 | 서버가 실제로 백엔드를 종료한다(`08003`) | `503` | | 서버가 질의를 취소한다 | `statement_timeout`(`57014`) | `503` | | 없는 데이터베이스를 가리킨다 | 실제 서버가 `3D000` 으로 거절 | `502` | | 없는 컬럼을 참조한다 | 실제 서버가 `42703` | **`500` 유지** | @@ -162,31 +176,34 @@ DatabaseTransientError("TooManyConnectionsError[53300]") # 원본 메시지는 | 오류 응답 본문 | 접속 불가 | DSN·비밀번호·host:port **없음** | | 정상 DB | — | `200` | -`min_size=0` 풀이 이 절의 장치다. 운영 `Database.connect()` 는 `min_size=1` 이라 접속이 -깨지면 **기동**이 실패한다 — 그건 lifespan 이지 요청 경로가 아니다. `min_size=0` 이 접속 -시점을 첫 `acquire()` 로 미뤄, *서버가 떠 있는 동안 DB 가 닿지 않게 되는* 순간(풀 재충전 -실패·DB 재기동)을 요청 안으로 옮긴다. 풀·드라이버·예외는 전부 실물이다. +`min_size=0` 풀이 이 테스트를 성립시키는 장치다. 운영 `Database.connect()` 는 +`min_size=1` 이라 접속이 깨지면 기동 자체가 실패한다. 그것은 lifespan 의 문제이지 +요청 경로의 문제가 아니다. `min_size=0` 은 접속 시점을 첫 `acquire()` 로 미루므로, +서버가 떠 있는 동안 DB 가 닿지 않게 되는 순간(풀 재충전 실패·DB 재기동)을 요청 +경로 안으로 옮길 수 있다. 풀·드라이버·예외는 전부 실물이다. ### 3.2 분류표 단위 테스트 — `tests/test_db_error_classification.py` 40건 -관통 테스트는 **실제 DB 로 낼 수 있는 실패만** 볼 수 있어서 표의 대부분(`40xxx` 직렬화, -`53xxx` 자원, `28xxx` 인증 …)을 덮지 못한다. 그래서 표 자체를 따로 고정한다. DB 를 띄우지 -않는다 — `classify_db_error` 를 던지지 않고 **반환**하도록 만든 이유가 이것이다 -(`classify_http_status` 와 같은 판단). +관통 테스트는 실제 DB 로 만들 수 있는 실패만 확인할 수 있어서, 분류표의 +대부분(`40xxx` 직렬화, `53xxx` 자원, `28xxx` 인증 등)을 덮지 못한다. 그래서 표 +자체를 별도 단위 테스트로 고정했다. 이 테스트는 DB 를 띄우지 않는다. +`classify_db_error` 를 예외를 던지는 함수가 아니라 분류 결과를 반환하는 함수로 +만든 이유가 이것이다(`classify_http_status` 와 같은 판단). -### 3.3 RED → GREEN +### 3.3 구현 전 실패 확인 → 구현 후 통과 -`app/core/db.py` 를 `origin/dev` 판으로 되돌려 **4건 RED** 를 확인했다. 실패 형태가 -「500 이 나왔다」가 아니라 **`ConnectionRefusedError` 가 응답 계층으로 그대로 샜다**는 -것이었고, 그것이 운영에서 uvicorn 이 500 을 만드는 바로 그 지점이다. +`app/core/db.py` 를 `origin/dev` 판으로 되돌려 신규 테스트 4건이 실패하는 것을 +먼저 확인했다. 실패 형태는 「500 이 나왔다」가 아니라 `ConnectionRefusedError` 가 +응답 계층까지 그대로 새어 나가는 것이었고, 그것이 운영에서 uvicorn 이 500 을 만드는 +바로 그 지점이다. -GREEN 후 전체 **356건 통과**, coverage line 99.82% / branch 98.84% (게이트 80/80). +구현 후 전체 356건 통과, coverage line 99.82% / branch 98.84% (게이트 80/80). ### 3.4 실서버 대조 — DB 를 실제로 멈춰 봤다 -테스트만으로는 「분류를 넣었다」에 가깝다. 전용 pgvector 컨테이너(스키마 + preset 27건)와 -임베딩 스텁에 uvicorn 두 대를 물려 대조했다. **시연 DB 를 건드리지 않도록 격리된 컨테이너를 -따로 띄웠다.** +테스트만으로는 「분류 코드를 넣었다」까지만 확인된다. 실제 동작을 보기 위해 전용 +pgvector 컨테이너(스키마 + preset 27건)와 임베딩 스텁에 uvicorn 두 대를 물려 +대조했다. 시연 DB 를 건드리지 않도록 격리된 컨테이너를 따로 띄웠다. | 상황 | `origin/dev`(43d9c02) | 이 브랜치 | |---|---|---| @@ -194,7 +211,7 @@ GREEN 후 전체 **356건 통과**, coverage line 99.82% / branch 98.84% (게이 | **DB 중단** | **`500`** `Internal Server Error` + 트레이스백 | **`503`** `{"detail":"embedding upstream unavailable"}` | | DB 중단, `/ready` | — | `503` `{"status":"not_ready"}` (기존대로) | -이 브랜치 로그. +이 브랜치의 로그는 다음과 같다. ``` WARNING app.core.db db transient failure: ConnectionRefusedError @@ -202,33 +219,35 @@ WARNING app.main upstream transient failure: DatabaseTransientError INFO: "POST /internal/v1/search HTTP/1.1" 503 Service Unavailable ``` -두 줄이 각각 **원인**(`ConnectionRefusedError`)과 **하위 시스템**(`Database...`)을 말한다. -대조군은 트레이스백만 남았다. +두 줄이 각각 원인(`ConnectionRefusedError`)과 실패한 하위 +시스템(`Database...`)을 알려 준다. 대조군인 `origin/dev` 는 트레이스백만 남았다. --- -## 4. 남긴 것 - -- **응답 본문이 여전히 `embedding upstream ...` 이다.** DB 에서 비롯한 `503`·`502` 도 - 임베딩을 가리키는 문구를 답한다. 상태 코드와 로그는 정확하고 `back` 은 이 본문을 읽지 - 않으므로(`AiSearchClient` — `serverProfile` 을 보는 422 말고는 버린다) 이번 범위에서 - 고치지 않았다. 본문을 하위 시스템 중립 문구로 바꾸는 것은 `-220` 이 정한 계약의 개정이고 - 티켓이 *"핸들러를 바꾸지 않는다"* 로 확정했다. **후속 티켓 후보다.** -- **`53100` 디스크 가득 참을 일시적으로 두었다.** 재시도로 낫지 않으므로 논쟁적이다. - 그러나 우리 질의의 결함이 아니고 분류 체계에 세 번째 칸이 없다 — `500` 은 「우리 코드가 - 깨졌다」를 잘못 가리킨다. 표에서 가장 약한 칸이며 명세에 그렇게 적었다. -- **Context 처리 경로(`/context/process`)의 동작은 바뀌지 않는다.** 202 를 먼저 반환하고 - 백그라운드로 도는 구조라 예외가 응답이 되지 않는다. 다만 그 경로의 DB 실패도 이제 - `TransientError` 로 **분류되어** 올라오므로, §2.1 의 *"상태를 PROCESSING 으로 둔 채 - 종료"* 를 나중에 그 경로에 붙일 때 분류가 이미 준비돼 있다. 두 서비스의 - `except TransientError` 블록은 **클라이언트 호출만** 감싸고 있어 이번 변경의 영향을 - 받지 않는다(확인함). -- **DB 재시도를 넣지 않았다**(§2.6). 커넥션 풀 동작과 얽혀 별건이다. +## 4. 남은 문제 + +- **응답 본문이 여전히 `embedding upstream ...` 이다.** DB 에서 비롯한 `503`·`502` + 응답도 임베딩을 가리키는 문구를 답한다. 상태 코드와 로그는 정확하고, `back` 은 이 + 본문을 읽지 않으므로(`AiSearchClient` 는 `serverProfile` 을 보는 422 외에는 본문을 + 버린다) 이번 범위에서 고치지 않았다. 본문을 하위 시스템 중립 문구로 바꾸는 것은 + `-220` 이 정한 계약의 개정에 해당하고, 티켓이 *"핸들러를 바꾸지 않는다"* 로 + 확정했다. 후속 티켓 후보다. +- **`53100` 디스크 가득 참을 일시 오류로 두었다.** 재시도로 낫지 않는 상황이므로 + 논쟁의 여지가 있다. 그러나 우리 질의의 결함이 아니고, 현재 분류 체계에 세 번째 + 칸이 없다. `500` 으로 두면 「우리 코드가 깨졌다」를 잘못 가리키게 된다. 분류표에서 + 가장 근거가 약한 칸이며, 명세에도 그렇게 적었다. +- **Context 처리 경로(`/context/process`)의 동작은 바뀌지 않는다.** 202 를 먼저 + 반환하고 백그라운드로 도는 구조라 예외가 응답이 되지 않는다. 다만 그 경로의 DB + 실패도 이제 `TransientError` 로 분류되어 올라오므로, §2.1 의 *"상태를 PROCESSING + 으로 둔 채 종료"* 를 나중에 그 경로에 붙일 때 분류가 이미 준비되어 있다. 두 + 서비스의 `except TransientError` 블록은 클라이언트 호출만 감싸고 있어 이번 변경의 + 영향을 받지 않는다(확인함). +- **DB 재시도를 넣지 않았다**(§2.6). 커넥션 풀 동작과 얽혀 있어 별개 문제다. - **Circuit breaker 는 여전히 미구현이다.** `failure-recovery.md` §2.2 가 언급하지만 이 티켓이 다루는 문제가 아니다. -## 관련 함정 +## 관련 장애 기록 -경계를 긋는 동안 걸린 셋을 +경계를 긋는 동안 발견한 문제 세 건을 [2026-07-31-db-error-pitfalls.md](../troubleshooting/2026-07-31-db-error-pitfalls.md) (T53~T55)에 남겼다. diff --git a/docs/implements/2026-07-31-docs-index-check.md b/docs/implements/2026-07-31-docs-index-check.md index d5b894d..6b9bcb5 100644 --- a/docs/implements/2026-07-31-docs-index-check.md +++ b/docs/implements/2026-07-31-docs-index-check.md @@ -1,32 +1,32 @@ -# 문서 색인 정합을 CI 로 옮긴다 — 규칙을 더하지 않고 사람 몫에서 뺀다 +# 문서 색인 정합 검사를 CI 로 옮긴다 — 사람이 세던 번호 중복·색인 누락을 기계가 잡는다 - **티켓**: 없음 (중앙이 결과를 보고 발행 여부를 정한다) - **날짜**: 2026-07-31 -- **함정**: [T64·T65](../troubleshooting/2026-07-31-docs-index-check.md) +- **작업 중 발견한 문제**: [T64·T65](../troubleshooting/2026-07-31-docs-index-check.md) - **산출**: [`tools/check_docs_index.py`](../../tools/check_docs_index.py) · [`tests/test_docs_index.py`](../../tests/test_docs_index.py) · `ai-ci / check` 의 `Docs index integrity` 스텝 ## 요약 -2026-07-31 하루에 문서 색인 사고가 **여섯 번** 났고 대응이 여섯 번 다 「문서에 문장 -추가」였다. 네 번째는 첫 번째의 대응을 그대로 따랐는데도 났다. 그중 **기계가 셀 수 -있는 것**을 CI 로 옮겼다. +2026-07-31 하루에 문서 색인 사고가 여섯 번 났고, 대응이 여섯 번 모두 「문서에 문장 +추가」였다. 네 번째 사고는 첫 번째 사고의 대응 문구를 그대로 따랐는데도 났다. 그래서 +그중 기계가 셀 수 있는 것을 CI 검사로 옮겼다. -``` -① 번호 중복 전수 표에서 T## · I## 이 같은 번호를 두 번 쓰면 실패 -③ 고아 · 누락 파일 표가 가리키는 파일이 없거나, 있는 파일이 파일 표에 없으면 실패 -④ 표 누락 전수 표가 가리키는 문서가 파일 표에 없으면 실패 -``` +| 검사 | 실패 조건 | +|---|---| +| ① 번호 중복 | 전수 표에서 `T##`·`I##` 이 같은 번호를 두 번 쓴다 | +| ③ 고아·누락 | 파일 표가 가리키는 파일이 없거나, 있는 파일이 파일 표에 없다 | +| ④ 표 누락 | 전수 표가 가리키는 문서가 파일 표에 없다 | -착수 시점의 `dev` 에 **③ 위반이 3건 있었다.** 검사를 켜기 전에 별도 커밋으로 정리했다. +착수 시점의 `dev` 에 ③ 위반이 3건 있었다. 검사를 켜기 전에 별도 커밋으로 정리했다. -**그리고 이 PR 을 만드는 동안 여섯 번째가 났다** — 병렬 티켓 `-205` 가 내가 번호를 센 -뒤에 병합되면서 `T61`·`T62`·`I34` 를 가져갔다. 사고 4 와 글자 그대로 같은 모양이고, -이번에는 **사람이 아니라 검사가 잡았다**(§4.1). +그리고 이 PR 을 만드는 동안 여섯 번째 사고가 실제로 났다. 병렬 티켓 `-205` 가 내가 +번호를 센 뒤에 병합되면서 `T61`·`T62`·`I34` 를 가져갔다. 사고 4 와 완전히 같은 +유형인데, 이번에는 사람이 아니라 새로 만든 검사가 잡았다(§4.1). -이 작업의 절반은 읽을거리를 줄이는 것이다 — §8 에 워커 계약에서 뺄 수 있는 문단 셋을 -적었다. +이 작업의 절반은 워커가 기억해야 할 규칙을 줄이는 것이다. §8 에 워커 계약에서 뺄 수 +있는 문단 셋을 적었다. ## 1. 무엇이 났나 — 같은 사고가 여섯 번 @@ -34,43 +34,44 @@ | # | 사고 | 원인 | |---|---|---| -| 1 | T43 중복 | `-219`·`-220` 이 **분기 시점** 기준으로 같은 번호를 잡았다 | -| 2 | 색인 행 중복 | `-219` 가 자기 행을 **갱신**했더니 `merge=union` 이 두 판을 남겼다 | +| 1 | T43 중복 | `-219`·`-220` 이 분기 시점 기준으로 같은 번호를 잡았다 | +| 2 | 색인 행 중복 | `-219` 가 자기 행을 갱신했더니 `merge=union` 이 두 판을 남겼다 | | 3 | 파일 표 미갱신 | 번호 재조정 때 전수 표만 고쳐졌다 | -| 4 | T53 중복 | `-223` 이 본문에서 셌는데, 세는 35분 사이 `-221` 이 병합됐다 | +| 4 | T53 중복 | `-223` 이 본문에서 번호를 셌는데, 세는 35분 사이 `-221` 이 병합됐다 | | 5 | 색인 누락 | `-223` 산출물이 전수 표 `I33` 에만 있고 파일 표에 없었다 (`-205` 가 발견) | | 6 | 하네스 소실 | `.prompt_ab/` 가 `.gitignore` 에 없어 수명을 정한 사람이 없었다 | -**`4` 는 `1` 의 대응(「PR 직전에 다시 확인하라」, T48)을 따랐는데도 났다.** 따를 수 -없는 규칙이어서가 아니라, *"내가 세고 있는 35분 사이에 남이 병합했는가"* 를 사람이 -알 방법이 없기 때문이다. 레포의 `.claude/README.md` 가 이미 적어 둔 원리다. +사고 4 는 사고 1 의 대응(「PR 직전에 다시 확인하라」, T48)을 따랐는데도 났다. 규칙을 +따를 수 없어서가 아니다. *"내가 세고 있는 35분 사이에 남이 병합했는가"* 를 사람이 알 +방법이 없기 때문이다. 레포의 `.claude/README.md` 가 이미 적어 둔 원리가 여기에 +해당한다. > 차이는 성실성이 아니라 강제 장치였다. 규칙을 만들 때 먼저 묻는다 — 이걸 검사할 수 > 있는가. -`6`(산출물 수명)은 셀 수 있는 것이 아니라 옮기지 않았다. `1`~`5` 는 전부 파일 -시스템과 색인만 보면 판정된다. +사고 6(산출물 수명)은 기계가 셀 수 있는 문제가 아니라서 CI 로 옮기지 않았다. 사고 +1~5 는 전부 파일 시스템과 색인만 보면 판정할 수 있다. ## 2. 무엇을 검사하지 않나 — 이유가 있다 -**(a) 연속성을 검사하지 않는다.** `T9`·`T10` 은 백엔드 아티팩트라 `back` 레포에 있고 -결번은 정상 상태다. 결번을 오류로 만들면 이 검사가 **없던 규칙을 만들어 낸다.** +**(a) 번호의 연속성을 검사하지 않는다.** `T9`·`T10` 은 백엔드 아티팩트라 `back` 레포에 +있고, 결번은 정상 상태다. 결번을 오류로 만들면 이 검사가 지금까지 없던 규칙을 새로 +만들어 내는 셈이 된다. `tests/test_docs_index.py::test_정상_상태와_결번을_통과시킨다` 가 이것을 고정한다. -**(b) 두 표가 서로 **일치**하는지 검사하지 않는다. 누락만 본다(④).** 표가 둘로 나뉜 -것 자체가 사고 `3` 의 원인이므로, 설명 문구나 번호 표기까지 맞추라고 하면 **문제를 -규칙으로 승격시키는** 셈이 된다. 누락은 다르다 — 한쪽에만 적힌 문서는 편집 재량이 -아니라 사고다(사고 `5`). +**(b) 두 표가 서로 일치하는지 검사하지 않는다. 누락만 본다(④).** 표가 둘로 나뉜 것 +자체가 사고 3 의 원인이므로, 설명 문구나 번호 표기까지 맞추라고 요구하면 문제의 +원인을 규칙으로 승격시키는 셈이 된다. 누락은 다르다. 한쪽에만 적힌 문서는 편집 +재량이 아니라 사고다(사고 5). -``` -검사한다 전수 표가 가리키는 문서가 파일 표에 없다 -검사하지 않는다 설명 문구·T 범위 표기가 서로 다른가 -검사할 수 없다 파일 표에 있는데 전수 표가 안 가리킨다 ← §6.1 -``` +- 검사한다 — 전수 표가 가리키는 문서가 파일 표에 없다 +- 검사하지 않는다 — 설명 문구·T 범위 표기가 서로 다른가 +- 검사할 수 없다 — 파일 표에 있는데 전수 표가 안 가리킨다 (§6.1) -**구조가 해소되면 ④ 는 지운다.** 표를 하나로 줄이거나 파일 표에 번호 컬럼을 두면 -「두 곳에 같은 사실을 적는다」가 사라진다. 그 한 줄을 `tools/check_docs_index.py` 의 -docstring 에 남겨 뒀다 — 남겨 두면 나중에 왜 있는지 모르는 검사가 된다. +표 이중화 구조가 해소되면 ④ 는 지운다. 표를 하나로 줄이거나 파일 표에 번호 컬럼을 +두면 「두 곳에 같은 사실을 적는다」는 상태가 사라지기 때문이다. 이 조건을 +`tools/check_docs_index.py` 의 docstring 에 남겨 뒀다. 적어 두지 않으면 나중에 왜 +있는지 모르는 검사가 된다. **(c) 문서 내용을 보지 않는다.** 「T 행의 증상이 실제 증상인가」는 기계가 셀 수 없다. @@ -87,19 +88,20 @@ docstring 에 남겨 뒀다 — 남겨 두면 나중에 왜 있는지 모르는 | 누락 | 파일 표가 가리키는 파일이 없음 | (개명·소실) | | 표 누락 | 전수 표는 가리키는데 파일 표에 없음 | 5 | -**한 문제에 한 줄만 낸다.** 전수 표가 가리키는 문서가 파일 표에 없으면 「고아」에도 -해당하는데, 둘 다 내면 고칠 곳이 둘인 것처럼 읽힌다. 더 구체적인 쪽(「표 누락」 — -전수 표의 몇 번째 줄이 가리키는지까지 말한다)만 낸다. +한 문제에는 한 줄만 낸다. 전수 표가 가리키는 문서가 파일 표에 없으면 「고아」에도 +해당하는데, 둘 다 내면 고칠 곳이 두 군데인 것처럼 읽힌다. 더 구체적인 쪽(「표 누락」 +— 전수 표의 몇 번째 줄이 가리키는지까지 말한다)만 낸다. -**「색인에 있는가」의 판정 범위를 파일 표 섹션으로 좁힌 것이 핵심이다.** README 전체의 -링크를 모으면 사고 `3` 을 못 잡는다 — 착수 시점 `dev` 의 위반 3건 중 2건이 정확히 -「전수 표에는 링크가 있고 파일 표에는 없는」 상태였다(T65). +「색인에 있는가」의 판정 범위를 파일 표 섹션으로 좁힌 것이 핵심이다. README 전체의 +링크를 모으면 사고 3 을 잡지 못한다. 착수 시점 `dev` 의 위반 3건 중 2건이 정확히 +「전수 표에는 링크가 있고 파일 표에는 없는」 상태였기 때문이다(T65). -**섹션 제목을 못 찾으면 통과가 아니라 실패다.** 0건을 세고 통과하면 게이트는 이름만 -남는다 — `check_embedding_profile_parity.py` 가 조회 실패를 통과로 두지 않는 것과 같은 -판단이다. +섹션 제목을 찾지 못하면 통과가 아니라 실패로 처리한다. 0건을 세고 통과시키면 +게이트는 이름만 남는다. `check_embedding_profile_parity.py` 가 조회 실패를 통과로 +두지 않는 것과 같은 판단이다. -성공 출력에 **다음 번호**를 찍는다. 번호를 세는 일 자체가 사고 `1`·`4` 의 원인이었다. +성공 출력에는 다음 번호를 찍는다. 번호를 세는 일 자체가 사고 1·4 의 원인이었기 +때문이다. ``` 트러블슈팅 문서 15건 · 전수 T1~T65 (다음 번호 T66) @@ -111,12 +113,12 @@ docstring 에 남겨 뒀다 — 남겨 두면 나중에 왜 있는지 모르는 ### 4.1 실전에서 먼저 잡혔다 — 같은 사고가 여섯 번째로 났다 -**심은 것이 아니다. 이 PR 을 만드는 동안 실제로 났다.** +일부러 심은 것이 아니다. 이 PR 을 만드는 동안 실제로 났다. -PR 을 연 뒤 `origin/dev` 를 병합했더니 그 사이 `S15P11A705-205` 가 병합되면서 -`T61`·`T62`·`T63` 과 `I34` 를 가져갔다. 나는 병합 **전**에 검사를 돌려 「다음 번호 -`T61`·`I34`」를 받아 그대로 썼다. **사고 4 와 글자 그대로 같은 모양이다** — 세는 -시점이 병합보다 앞이었다. +PR 을 연 뒤 `origin/dev` 를 병합했더니, 그 사이 `S15P11A705-205` 가 병합되면서 +`T61`·`T62`·`T63` 과 `I34` 를 가져간 상태였다. 나는 병합 전에 검사를 돌려 「다음 번호 +`T61`·`I34`」를 받아 그대로 썼다. 세는 시점이 병합보다 앞이었다는 점에서 사고 4 와 +완전히 같은 유형이다. ``` ::error::[트러블슈팅] 번호 중복 — T61 이 전수 표에 2번 있다 (docs/troubleshooting/README.md:97, docs/troubleshooting/README.md:99) @@ -132,24 +134,24 @@ PR 을 연 뒤 `origin/dev` 를 병합했더니 그 사이 `S15P11A705-205` 가 exit=1 ``` -**이것이 이 티켓의 가장 값진 관측이다.** 07-31 의 다섯 사고는 전부 **사람이 뒤늦게** -발견했다. 여섯 번째는 **기계가 즉시** 잡았고, 메시지가 지목한 `T64` 이후로 옮기라는 -처방을 그대로 따라 `T64`·`T65`·`I35` 로 재번호했다(T48 대로 매핑 한 번으로, 남의 행은 -건드리지 않고). +이것이 이 티켓의 가장 값진 관측이다. 07-31 의 다섯 사고는 전부 사람이 뒤늦게 +발견했다. 여섯 번째는 기계가 즉시 잡았다. 메시지가 지목한 대로 `T64` 이후로 옮기라는 +처방을 그대로 따라 `T64`·`T65`·`I35` 로 재번호했다(T48 이 정한 대로 매핑 한 번으로, +다른 티켓의 행은 건드리지 않고). -**주의 — 검사가 있어도 충돌 자체는 안 없어진다.** 없어지는 것은 *"충돌한 채로 -병합되는 것"* 이다. 재번호 작업은 남는다. §8 에서 「번호는 병합 뒤에 확정한다」를 -권고로 남기자고 한 이유가 이것이다. +주의할 점이 있다. 검사가 있어도 번호 충돌 자체는 없어지지 않는다. 없어지는 것은 +충돌한 채로 병합되는 것이다. 재번호 작업은 여전히 남는다. §8 에서 「번호는 병합 뒤에 +확정한다」를 권고로 남기자고 한 이유가 이것이다. ### 4.2 통제된 재현 — 사고 1·2·3 을 심어서 -**실제 `docs/` 사본에 07-31 의 사고 셋을 그대로 심어** CLI 를 돌린 결과다(사고 4 는 -사고 1 과 같은 모양이므로 한 케이스로 재현된다 — 둘 다 「전수 표에 같은 번호 두 줄」). -아래 출력의 「T61 이후로」는 그때 실제로 찍힌 값이다(§4.1 의 병합 전이라 최대 번호가 +실제 `docs/` 사본에 07-31 의 사고 셋을 그대로 심어 CLI 를 돌린 결과다. 사고 4 는 +사고 1 과 같은 유형(전수 표에 같은 번호 두 줄)이므로 한 케이스로 재현된다. 아래 +출력의 「T61 이후로」는 그때 실제로 찍힌 값이다(§4.1 의 병합 전이라 최대 번호가 `T60` 이었다). -심은 것: `T57` 행의 번호를 `T53` 으로 바꿈(사고 1·4) · `judge-vote` 파일 표 행을 낡은 -판과 함께 두 줄로 둠(사고 2) · 색인에 없는 새 문서 추가(사고 3). +심은 것은 세 가지다. `T57` 행의 번호를 `T53` 으로 바꿈(사고 1·4) · `judge-vote` +파일 표 행을 낡은 판과 함께 두 줄로 둠(사고 2) · 색인에 없는 새 문서 추가(사고 3). ``` ::error::[트러블슈팅] 번호 중복 — T53 이 전수 표에 2번 있다 (docs/troubleshooting/README.md:88, docs/troubleshooting/README.md:92) @@ -167,10 +169,11 @@ exit=1 exit=1 ``` -**메시지가 「무엇을 어떻게 고칠지」를 말하는 것이 완료 조건이었다.** 세 가지를 지켰다 — -① 두 줄의 **위치를 모두** 지목해 어느 행을 고를지 사람이 고민하지 않게 하고, ② 번호 -중복에는 **다음 빈 번호**를 계산해 주고, ③ 고아에는 **붙여 넣을 수 있는 표 행**을 준다. -위반이 여러 건이면 첫 건에서 멈추지 않고 전부 낸다(왕복이 위반 수만큼 늘지 않도록). +메시지가 「무엇을 어떻게 고칠지」를 말하는 것이 완료 조건이었다. 세 가지를 지켰다. +① 중복된 두 줄의 위치를 모두 지목해 어느 행을 고를지 사람이 고민하지 않게 한다. +② 번호 중복에는 다음 빈 번호를 계산해 준다. ③ 고아에는 그대로 붙여 넣을 수 있는 표 +행을 준다. 위반이 여러 건이면 첫 건에서 멈추지 않고 전부 낸다(수정-재실행 왕복이 +위반 수만큼 늘지 않도록). ### 4.3 통과시키는 것도 확인했다 @@ -181,47 +184,50 @@ exit=1 | 파일 표에 있는데 전수 표가 안 가리키는 문서 | 통과 (§6.1 — 켤 수 없다) | | 현재 `dev` + 이 브랜치 (정리·재번호 후) | 통과 | -**검사가 자기 자신에게도 걸렸다.** §4.1 앞에 이미 한 번 — 이 리포트와 트러블슈팅 -문서를 만들고 번호를 정하기 전에 돌렸더니 그 둘의 색인 누락(고아)을 먼저 잡았다. +검사가 자기 자신의 작업에도 걸렸다. §4.1 이전에 이미 한 번, 이 리포트와 트러블슈팅 +문서를 만들고 번호를 정하기 전에 돌렸더니 그 두 문서의 색인 누락(고아)을 먼저 +잡아냈다. `ruff check .` 통과 · `pytest` 전량 통과 · line 99.8% · branch 99.0% (구체 수치는 §「Regression」 대신 PR 본문 참조). ## 5. 현재 `dev` 의 위반 — 무엇이 어긋나 있었나 -검사를 만든 뒤 **켜기 전에** `origin/dev` 에 돌렸다. 순서가 반대였으면 무관한 PR 이 -전부 막혔을 것이다. +검사를 만든 뒤 CI 에 켜기 전에 `origin/dev` 에 돌렸다. 순서가 반대였으면 무관한 PR +이 전부 막혔을 것이다. -**고아 3건** — 파일은 있는데 「개별 리포트」 표에 없었다. +고아 3건이 나왔다. 파일은 있는데 「개별 리포트」 표에 없는 상태였다. | 문서 | 티켓 | 상태 | |---|---|---| | `2026-07-31-gms-call-observability.md` | `-197` | 전수 표 `I28` 에만 링크가 있었다 | -| `2026-07-31-judge-prompt-rule.md` | `-219` | **어느 표에도 없었다** | +| `2026-07-31-judge-prompt-rule.md` | `-219` | 어느 표에도 없었다 | | `2026-07-31-judge-vote.md` | `-223` | 전수 표 `I33` 에만 링크가 있었다 | -셋 다 `WORKLOG` 에는 있다. 사고 `3` 과 같은 모양이다 — 전수 표에 행을 더할 때 파일 표를 -잊는다. 트러블슈팅 색인에는 위반이 없었다. +셋 다 `WORKLOG` 에는 있다. 사고 3 과 같은 유형이다. 전수 표에 행을 더할 때 파일 +표를 잊는 것이다. 트러블슈팅 색인에는 위반이 없었다. -**함께 고친 것 — 표 안의 빈 줄 6개.** 검사 대상은 아니지만 `merge=union` 이 각 브랜치의 -「빈 줄 + 새 행」을 그대로 이어 붙여 `I27` 이후 6곳에 빈 줄이 남아 있었고, **GFM 은 빈 -줄에서 표를 끊으므로** 그 뒤 행들이 표가 아니라 파이프 문자열 문단으로 렌더되고 있었다 -(T64). 색인이 눈에 안 보이면 사람이 갱신 여부를 확인할 수 없다. +함께 고친 것이 있다. 표 안의 빈 줄 6개다. 검사 대상은 아니지만 `merge=union` 이 각 +브랜치의 「빈 줄 + 새 행」을 그대로 이어 붙여 `I27` 이후 6곳에 빈 줄이 남아 있었다. +GFM 은 빈 줄에서 표를 끊으므로 그 뒤 행들이 표가 아니라 파이프 문자열 문단으로 +렌더되고 있었다(T64). 색인이 눈에 보이지 않으면 사람이 갱신 여부를 확인할 수 없다. -**남겨 둔 것** — `-219` 의 산출물(`tools/prompt_ab/`, 리포트)에 **`I` 번호가 없다.** -이것은 ④ 의 **반대 방향**(파일 표에 있는데 전수 표가 안 가리킨다)이라 검사가 잡지 -못한다 — 켤 수 없는 이유는 §6.1 이고, 같은 상태인 문서가 구현 리포트에만 7건이다. -남의 티켓에 번호를 붙이는 것은 판단이 필요하므로 중앙 판단 항목으로 올린다. +남겨 둔 것도 있다. `-219` 의 산출물(`tools/prompt_ab/`, 리포트)에 `I` 번호가 없다. +이것은 ④ 의 반대 방향(파일 표에 있는데 전수 표가 안 가리킨다)이라 검사가 잡지 +못한다. 반대 방향을 켤 수 없는 이유는 §6.1 에 있고, 같은 상태인 문서가 구현 +리포트에만 7건이다. 다른 티켓의 산출물에 번호를 붙이는 것은 판단이 필요하므로 중앙 +판단 항목으로 올린다. -**사고 `5` 는 중앙이 독립으로 발견했다** — `-205` 병합 중 `judge-vote` 리포트가 파일 -표에 없는 것을 확인해 검사 ④ 를 요구했다. 그 시점 `dev` 에서는 사실이었고, 이 브랜치는 -「위반 정리」 커밋에서 이미 고쳐 둔 상태였다(위 표의 셋째 줄). **같은 것을 사람과 검사가 -각각 찾았고 결과가 일치했다** — ④ 는 그 발견을 다음 번에는 사람 없이 하기 위한 것이다. +사고 5 는 중앙이 독립적으로 발견했다. `-205` 병합 중 `judge-vote` 리포트가 파일 표에 +없는 것을 확인해 검사 ④ 를 요구했다. 그 시점의 `dev` 에서는 사실이었고, 이 브랜치는 +「위반 정리」 커밋에서 이미 고쳐 둔 상태였다(위 표의 셋째 줄). 같은 문제를 사람과 +검사가 각각 찾았고 결과가 일치했다. ④ 는 그 발견을 다음 번에는 사람 없이 하기 위한 +것이다. ## 6. 표 이중화 조사 — 줄일 수 있는가 계약이 「전수 표가 파일 표를 포함한다면 파일 표는 생성 가능할 수 있다」를 조사하라고 -했다. **포함하지 않는다. 생성할 수 없다.** 착수 시점 `dev` 기준 실측이다. +했다. 결론부터 적으면, 포함하지 않고 생성할 수 없다. 착수 시점 `dev` 기준 실측이다. | | 트러블슈팅 | 구현 리포트 | |---|---|---| @@ -232,85 +238,84 @@ exit=1 | 파일 표 행이 번호를 언급 | 13 / 13 (`(T16~T18)`) | **0 / 24** | | 전수 표 행 중 문서 링크 없음 | 58 | 13 | -**트러블슈팅은 전수 표에 문서 링크가 하나도 없다.** T↔문서 매핑은 파일 표 「내용」 -컬럼의 `(T16~T18)` 표기가 **유일한** 출처이고 방향이 반대다(문서→번호). 전수 표에서 +트러블슈팅은 전수 표에 문서 링크가 하나도 없다. T↔문서 매핑은 파일 표 「내용」 +컬럼의 `(T16~T18)` 표기가 유일한 출처이고, 방향도 반대다(문서→번호). 전수 표에서 파일 표를 만들 수 없다. -**구현 리포트는 두 표의 집합이 다르다.** 파일 표 24개 중 7개가 전수 표에서 링크되지 +구현 리포트는 두 표의 집합이 다르다. 파일 표 24개 중 7개가 전수 표에서 링크되지 않고, 전수 표 13행은 애초에 문서가 아닌 산출이다(`docs#2`, 로컬 메모리, `spec/`, `이 트리`). 어느 쪽도 다른 쪽을 포함하지 않는다. -**결론 — 두 표는 같은 것의 두 판이 아니라 서로 다른 두 축이다.** +결론 — 두 표는 같은 내용의 두 판이 아니라 서로 다른 두 축이다. -``` -파일 표 "어떤 문서가 있는가" 문서 단위, 1문서 1행 -전수 표 "무엇을 겪었나 / 만들었나" 사건·산출 단위, 1문서에 T 여럿, 문서 없는 산출도 있음 -``` +- **파일 표** — "어떤 문서가 있는가". 문서 단위, 1문서 1행. +- **전수 표** — "무엇을 겪었나 / 만들었나". 사건·산출 단위, 1문서에 T 여럿, 문서 + 없는 산출도 있음. -그래서 **한쪽을 지우는 축소는 불가능하다.** +그래서 한쪽을 지우는 축소는 불가능하다. -- 파일 표를 지우려면 전수 표 모든 행에 문서 링크를 강제해야 하는데 문서 없는 산출이 - 13행이다. -- 전수 표를 지우려면 `T` 번호를 문서 안으로 내려야 한다 — 계약이 「별건」으로 못 박은 +- 파일 표를 지우려면 전수 표 모든 행에 문서 링크를 강제해야 하는데, 문서 없는 + 산출이 13행이다. +- 전수 표를 지우려면 `T` 번호를 문서 안으로 내려야 한다. 계약이 「별건」으로 못 박은 파일명 기반 전환이 바로 그것이다. -**대신 사고 `3` 의 원인은 이중화 자체가 아니라 「둘 중 하나만 고쳐도 아무도 모르는 -것」이었고, 이 PR 이 그 절반을 닫았다** — 파일 표는 이제 파일 시스템에 묶여 있다. -남은 구멍은 반대쪽이다: **전수 표에 행을 더하지 않아도 아무도 모른다**(`-219` 가 -그 상태다). +사고 3 의 원인은 이중화 자체가 아니라 「둘 중 하나만 고쳐도 아무도 모르는 것」이었고, +이 PR 이 그 절반을 닫았다. 파일 표는 이제 파일 시스템에 묶여 있다. 남은 구멍은 +반대쪽이다. 전수 표에 행을 더하지 않아도 아무도 모른다(`-219` 가 그 상태다). ### 6.1 그래서 ④ 는 한 방향뿐이다 — 반대 방향은 검사할 수 없다 -중앙이 「한 표에만 있는 문서」를 검사하라고 했다(사고 `5`). **한 방향은 됐고 한 방향은 -안 된다.** 이 브랜치 기준 실측이다. +중앙이 「한 표에만 있는 문서」를 검사하라고 했다(사고 5). 한 방향은 가능했고 한 +방향은 불가능하다. 이 브랜치 기준 실측이다. | 방향 | 트러블슈팅 | 구현 리포트 | 판정 | |---|---|---|---| | (a) 전수 표에 있는데 파일 표에 없다 | 0건 | 0건 | **검사한다** — 켜도 현재 통과 | | (b) 파일 표에 있는데 전수 표에 없다 | **15건** | **7건** | 검사 못 한다 | -**(b) 의 22건은 사고가 아니다.** +(b) 의 22건은 사고가 아니다. -- 트러블슈팅 전수 표는 **설계상 문서를 가리키지 않는다**(문서 링크 0종, §6 표). T↔문서 - 매핑은 파일 표의 `(T16~T18)` 표기가 유일한 출처다. (b) 를 켜면 **정상 문서 15건이 - 전부 위반**으로 나온다. -- 구현 리포트의 7건은 전수 표에 `I` 번호가 없는 문서다(`-219`·`-198`·`-210` 등). 번호를 - 붙일지는 판단이 필요하고 남의 티켓이다. +- 트러블슈팅 전수 표는 설계상 문서를 가리키지 않는다(문서 링크 0종, §6 표). T↔문서 + 매핑은 파일 표의 `(T16~T18)` 표기가 유일한 출처다. (b) 를 켜면 정상 문서 15건이 + 전부 위반으로 나온다. +- 구현 리포트의 7건은 전수 표에 `I` 번호가 없는 문서다(`-219`·`-198`·`-210` 등). + 번호를 붙일지는 판단이 필요하고 다른 티켓 소관이다. -**(b) 를 켜려면 먼저 구조를 바꿔야 한다** — 트러블슈팅 파일 표에 번호 컬럼을 명시해 -매핑을 기계 판독 가능하게 하고, 번호 없는 구현 문서 7건에 번호를 붙이거나 「번호 없음」을 -명시적 상태로 두어야 한다. **이 PR 범위 밖이고 판단은 중앙 몫이다.** +(b) 를 켜려면 먼저 구조를 바꿔야 한다. 트러블슈팅 파일 표에 번호 컬럼을 명시해 +매핑을 기계가 읽을 수 있게 하고, 번호 없는 구현 문서 7건에 번호를 붙이거나 「번호 +없음」을 명시적 상태로 두어야 한다. 이것은 이 PR 범위 밖이고 판단은 중앙 몫이다. -닫고 싶다면 선택지는 구조 변경이 아니라 **파일 표에 번호 컬럼을 명시**하는 것이다. -그러면 (b) 도 검사할 수 있고, 「두 표가 일치해야 한다」가 아니라 여전히 **누락만** -보는 것이므로 §2-b 를 어기지 않는다. +이 구멍을 닫고 싶다면 선택지는 구조 변경이 아니라 파일 표에 번호 컬럼을 명시하는 +것이다. 그러면 (b) 도 검사할 수 있고, 「두 표가 일치해야 한다」가 아니라 여전히 +누락만 보는 것이므로 §2-b 의 원칙을 어기지 않는다. ## 7. 판단이 갈린 지점 -**(a) Python 인가 셸인가 — Python.** CI 스텝과 `pytest` 가 **같은 코드**를 부른다. +**(a) Python 인가 셸인가 — Python.** CI 스텝과 `pytest` 가 같은 코드를 부른다. 셸이면 CI 전용이 되어 「검사를 고쳤는데 CI 에서만 다르게 돈다」가 가능해진다. `tests/test_docs_index.py` 가 `check_repository()` 를 직접 부르므로 로컬에서 -`pytest` 만 돌려도 같은 신호가 나온다. 부수 이득으로 재현 케이스를 테스트로 고정할 수 -있었다 — 셸이었다면 이 리포트 §4 가 일회성 관측으로 끝났을 것이다. +`pytest` 만 돌려도 같은 신호가 나온다. 부수 이득으로 재현 케이스를 테스트로 고정할 +수 있었다. 셸이었다면 이 리포트 §4 가 일회성 관측으로 끝났을 것이다. **(b) 새 잡인가 `check` 안의 스텝인가 — 스텝.** 필수 상태 검사는 두 이름뿐이다 -(CONTRIBUTING 「PR과 리뷰」). 잡을 늘리면 그 이름이 required 목록에 없어 **실패해도 -병합을 막지 못한다.** 이름을 추가하려면 branch protection 을 고쳐야 하고 그것은 AI +(CONTRIBUTING 「PR과 리뷰」). 잡을 늘리면 그 이름이 required 목록에 없어 실패해도 +병합을 막지 못한다. 이름을 추가하려면 branch protection 을 고쳐야 하고 그것은 AI 파트 단독 권한이 아니다. -**(c) 스텝 위치 — 테스트 앞.** 파일 시스템만 보는 1초짜리 판정이고 색인 정합은 테스트 -결과와 무관하다. **트레이드오프를 감수했다** — 여기서 끊기면 그 PR 은 테스트 신호를 못 -받는다. 고치고 다시 돌리는 비용보다 Testcontainers 한 판을 아끼는 쪽을 택했다. +**(c) 스텝 위치 — 테스트 앞.** 파일 시스템만 보는 1초짜리 판정이고 색인 정합은 +테스트 결과와 무관하다. 트레이드오프를 감수했다. 여기서 끊기면 그 PR 은 테스트 +신호를 받지 못한다. 고치고 다시 돌리는 비용보다 Testcontainers 한 판을 아끼는 쪽을 +택했다. -**(d) `main` 에도 올릴 것인가 — 이번엔 안 올린다.** `Validate PR base` 는 `dev` 에만 -있어 `main` 기반 PR 에 안 걸리는 함정이 있었다(T36). 그것은 **검사 대상이 base 에 따라 -갈리는** 검사였기 때문이다. 이 검사는 base 와 무관하게 같은 판정을 내므로 다음 릴리스로 -`main` 에 올라가면 충분하다. `hotfix/` 를 낼 이유가 없다. +**(d) `main` 에도 올릴 것인가 — 이번엔 안 올린다.** `Validate PR base` 는 `dev` +에만 있어 `main` 기반 PR 에 안 걸리는 문제가 있었다(T36). 그것은 검사 대상이 base +에 따라 갈리는 검사였기 때문이다. 이 검사는 base 와 무관하게 같은 판정을 내므로 +다음 릴리스로 `main` 에 올라가면 충분하다. `hotfix/` 를 낼 이유가 없다. ## 8. 계약에서 뺄 수 있는 문단 — 이 작업의 절반 -검사가 생겼으므로 워커 계약(`.claude/CONTRACT.md`)의 아래 문단들은 **사람이 기억할 -이유가 없어졌다.** +검사가 생겼으므로 워커 계약(`.claude/CONTRACT.md`)의 아래 문단들은 사람이 기억할 +이유가 없어졌다. ``` "T##·I## 은 분기 시점이 아니라 origin/dev 를 당긴 뒤의 마지막 번호 다음으로 잡는다" @@ -318,33 +323,33 @@ exit=1 "색인 행을 고칠 때는 병합 뒤 중복을 확인하라" ``` -셋 다 「사람이 세고 사람이 확인한다」를 요구하는데, 그것이 정확히 사고 `1`·`2`·`4` 가 -난 지점이다. 이제는 **틀린 채로 PR 을 내면 CI 가 어디를 어떻게 고칠지까지 말해 준다.** -번호를 미리 맞추는 것은 여전히 편하지만 **의무는 아니다.** +셋 다 「사람이 세고 사람이 확인한다」를 요구하는데, 그것이 정확히 사고 1·2·4 가 난 +지점이다. 이제는 번호가 틀린 채로 PR 을 내면 CI 가 어디를 어떻게 고칠지까지 말해 +준다. 번호를 미리 맞추는 것은 여전히 편하지만 의무는 아니다. -**빼지 말아야 할 것** — 「번호는 병합 뒤에 확정한다」는 *권고*로는 남기는 편이 낫다. -검사는 충돌을 사후에 잡을 뿐 재번호 작업을 없애 주지는 않는다. 다만 **"PR 내기 직전에 -다시 확인하라"** 는 지시는 뺀다. 확인은 이제 기계가 한다. +빼지 말아야 할 것도 있다. 「번호는 병합 뒤에 확정한다」는 권고로는 남기는 편이 낫다. +검사는 충돌을 사후에 잡을 뿐 재번호 작업을 없애 주지는 않는다. 다만 "PR 내기 직전에 +다시 확인하라" 는 지시는 뺀다. 그 확인은 이제 기계가 한다. ## 9. 한계 — 이 검사가 놓치는 것 | 놓치는 것 | 왜 | |---|---| | 전수 표에 행을 아예 안 더한 경우 | 반대 방향은 검사할 수 없다(§6.1) — 정상 문서 22건이 위반으로 나온다 | -| 번호 충돌 자체 | 검사는 **충돌한 채로 병합되는 것**만 막고 재번호 작업은 남는다(§4.1) | -| 번호는 다른데 **내용이 같은** 중복 행 | 텍스트 동일성은 기준을 세울 수 없다 | +| 번호 충돌 자체 | 검사는 충돌한 채로 병합되는 것만 막고 재번호 작업은 남는다(§4.1) | +| 번호는 다른데 내용이 같은 중복 행 | 텍스트 동일성은 기준을 세울 수 없다 | | 파일 표 행의 요약이 문서 내용과 어긋남 | 기계가 셀 수 없다 | | `back/docs/ai/` 의 같은 구조 | 다른 레포다(이 PR 범위 밖) | | `docs/proposals/README.md` | 표 성격이 다르다 — `M##` 행이 그 파일에만 있고 제자리 상태 전이가 용도다(`.gitattributes` 가 union 대상에서 뺀 이유와 같다) | -**잘못 잡을 수 있는 지점**: 파일 표 섹션 제목(`개별 문서` / `개별 리포트`)이나 전수 표 -제목을 바꾸면 검사가 **실패한다**(통과가 아니라). 의도적으로 그렇게 뒀지만, 제목을 -고치는 사람은 `tools/check_docs_index.py` 의 `INDEXES` 를 함께 고쳐야 한다 — 오류 -메시지가 그 파일 경로를 지목한다. +잘못 잡을 수 있는 지점도 있다. 파일 표 섹션 제목(`개별 문서` / `개별 리포트`)이나 +전수 표 제목을 바꾸면 검사가 통과가 아니라 실패한다. 의도적으로 그렇게 뒀지만, +제목을 고치는 사람은 `tools/check_docs_index.py` 의 `INDEXES` 를 함께 고쳐야 한다. +오류 메시지가 그 파일 경로를 지목한다. ## 10. 후속 -- **중앙 판단**: 계약에서 뺄 문단(§8) · `-219` 산출물의 `I` 번호 부재(§5) · 파일 표에 - 번호 컬럼을 두어 단방향 검사를 붙일지(§6) +- **중앙 판단**: 계약에서 뺄 문단(§8) · `-219` 산출물의 `I` 번호 부재(§5) · 파일 + 표에 번호 컬럼을 두어 단방향 검사를 붙일지(§6) - **범위 밖**: `back/docs/ai/` 도 같은 구조다. 이 검사를 그대로 옮길 수 있으나 다른 레포·다른 파트 소관이다 diff --git a/docs/implements/2026-07-31-embedding-grid.md b/docs/implements/2026-07-31-embedding-grid.md index 260e4fa..e374280 100644 --- a/docs/implements/2026-07-31-embedding-grid.md +++ b/docs/implements/2026-07-31-embedding-grid.md @@ -1,4 +1,4 @@ -# 임베딩 4조건 실경로 측정 — 입력 구성 × 모델 +# 임베딩 4조건 실경로 측정 — 합계는 동률이고, 조건마다 실패하는 질의가 다르다 - **티켓**: S15P11A705-191 - **날짜**: 2026-07-31 @@ -7,16 +7,16 @@ ## 요약 -``` -1위 일치 A 10/12 · B 9/12 · C 10/12 · D 10/12 -top-3 네 조건 모두 12/12 -저장 small 6,148 B/건 → large 12,292 B/건 (정확히 2배) -토큰 조건 간 차이 없음 — 임베딩 토큰은 입력 텍스트가 정한다 -시딩 42.5 ~ 44.2초. 조건이 아니라 GMS 왕복이 지배한다 -``` +| 항목 | 결과 | +|---|---| +| 1위 일치 | A 10/12 · B 9/12 · C 10/12 · D 10/12 | +| top-3 | 네 조건 모두 12/12 | +| 저장 | small 6,148 B/건 → large 12,292 B/건 (정확히 2배) | +| 토큰 | 조건 간 차이 없음. 임베딩 토큰 수는 입력 텍스트가 정한다 | +| 시딩 | 42.5 ~ 44.2초. 조건이 아니라 GMS 왕복 시간이 대부분을 차지한다 | -**합계로는 어느 조건도 기준선을 넘지 못한다.** 그런데 합계가 같은 A·C·D 는 **서로 다른 -질의에서 실패한다** — 이 표가 이 측정의 결과다. +합계로는 어느 조건도 기준선을 넘지 못한다. 그런데 합계가 같은 A·C·D 는 서로 다른 +질의에서 실패한다. 이 표가 이 측정의 핵심 결과다. | # | 질의 | A | B | C | D | |---|---|---|---|---|---| @@ -27,17 +27,18 @@ top-3 네 조건 모두 12/12 나머지 8건은 네 조건 모두 PASS 다. -**표본이 12건이므로 1건 차이를 유의미하다고 읽으면 안 된다.** 아래 해석은 「어느 질의가 +표본이 12건이므로 1건 차이를 유의미하다고 읽으면 안 된다. 아래 해석은 「어느 질의가 어느 조건에서 바뀌었나」까지이며, 「몇 점이 올랐나」가 아니다. ## 무엇을 왜 재는가 `-174` 의 검색 정확도 10/12 에서 실패 2건이 나왔고 원인이 서로 달랐다. 그중 8번 -(「밥 먹고 산책하면서 쉬어가는 공원」)은 **질의의 「공원」이 본문에 없고 장소명에만 있다**는 -것이었다. 임베딩 입력이 Context 본문 하나이므로 장소명은 벡터에 들어갈 경로가 없다. +(「밥 먹고 산책하면서 쉬어가는 공원」)의 원인은, 질의의 「공원」이 본문에 없고 +장소명에만 있다는 것이었다. 임베딩 입력이 Context 본문 하나이므로 장소명은 벡터에 +들어갈 경로가 없다. -개선 축이 둘이고 어느 쪽이 듣는지 모른다 — 입력에 장소명을 넣는 것과 모델을 키우는 것. -둘을 교차시켜 넷을 잰다. +개선 축이 둘인데 어느 쪽이 효과가 있는지 모른다. 입력에 장소명을 넣는 것과 모델을 +키우는 것이다. 둘을 교차시켜 네 조건을 쟀다. | 조건 | 모델 | dim | 장소명 결합 | |---|---|---|---| @@ -46,10 +47,10 @@ top-3 네 조건 모두 12/12 | **C** | `text-embedding-3-large` | 3072 | 끔 | | **D** | `text-embedding-3-large` | 3072 | 켬 | -**실경로로 잰다.** 장소명 결합은 back `EmbeddingInputComposer` 가 하고 그 결과가 -`POST /internal/v1/context/process` 의 `text` 로 실려 나간다. 시딩 데이터의 `contextBody` 에 -장소명을 미리 박아 재면 화면 본문이 오염되고, **측정이 검증하는 코드 경로가 채택 후 배포할 -경로와 달라진다.** +실제 코드 경로로 쟀다. 장소명 결합은 back 의 `EmbeddingInputComposer` 가 하고, 그 +결과가 `POST /internal/v1/context/process` 의 `text` 로 실려 나간다. 시딩 데이터의 +`contextBody` 에 장소명을 미리 넣어 재는 방법도 있지만, 그렇게 하면 화면 본문이 +오염되고 측정이 검증하는 코드 경로가 채택 후 실제로 배포할 경로와 달라진다. ## 실행 환경 @@ -70,23 +71,20 @@ top-3 네 조건 모두 12/12 | **C** | **10/12** | 12/12 | 43.2s | 39,982 | 12,292 B | | **D** | **10/12** | 12/12 | 43.5s | 40,750 | 12,292 B | -**A 와 C 는 같은 10/12 지만 어느 질의가 실패했는지가 다르다.** 합계만 보면 「효과 없음」으로 -읽히는데 그렇지 않다 — 아래 §조건 C. +A 와 C 는 같은 10/12 지만 어느 질의가 실패했는지가 다르다. 합계만 보면 「효과 +없음」으로 읽히는데 그렇지 않다. 아래 §조건 C 에서 설명한다. ### 조건 A — 기준선이 재현된다 -`-174` 의 10/12 를 그대로 재현했다. **실패 2건이 같은 질의(7번 피맥 · 8번 공원)이고 -유사도가 소수 넷째 자리까지 일치한다.** 임베딩이 결정적이므로 예상된 결과이며, 이 재현이 -나머지 세 조건의 수치가 비교 대상을 갖는 근거다. +`-174` 의 10/12 를 그대로 재현했다. 실패 2건이 같은 질의(7번 피맥 · 8번 공원)이고 +유사도가 소수 넷째 자리까지 일치한다. 임베딩이 결정적이므로 예상된 결과이며, 이 +재현이 나머지 세 조건의 수치가 비교 대상을 갖는 근거다. -토큰도 맞아떨어진다. - -``` -임베딩 4,002 = -174 의 시딩·검색분 1,845 + 프리셋 1배치 2,157 -판정 34,710 (-174: 31,067) — 판정은 비결정적이라 흔들린다. Keyword 76행 vs 74행 -``` +토큰도 계산과 일치한다. 임베딩 4,002 토큰은 `-174` 의 시딩·검색분 1,845 에 프리셋 +1배치 2,157 을 더한 값이다. 판정은 34,710 토큰인데(`-174` 는 31,067), 판정은 +비결정적이라 실행마다 달라진다. Keyword 행 수도 76행으로 `-174` 의 74행과 다르다. -### 조건 B — 노린 것을 맞히지 못했고 다른 것을 깨뜨렸다 +### 조건 B — 개선 대상이던 8번은 그대로였고 6번이 깨졌다 | # | 질의 | A | B | |---|---|---|---| @@ -94,19 +92,20 @@ top-3 네 조건 모두 12/12 | 7 | 친구들이랑 피자에 맥주 마신 곳 | FAIL (3위) | FAIL (**2위**로 상승) | | 8 | 밥 먹고 산책하면서 쉬어가는 공원 | FAIL (2위) | FAIL (2위 그대로) | -**8번이 이 조건의 존재 이유였는데 바뀌지 않았다.** 장소명 「동교어린이공원」이 입력에 -들어갔는데도 순위가 그대로다. 경쟁 상대인 「치킨버거 이스트사이드」의 본문에 이미 「공원」이 -있고(「이거 사들고 그네 공원 갔음」) 그쪽에도 장소명이 붙으므로, 결합은 **양쪽에 똑같이** -작용해 상대적 우위를 만들지 못했다. +8번이 이 조건의 존재 이유였는데 결과가 바뀌지 않았다. 장소명 「동교어린이공원」이 +입력에 들어갔는데도 순위가 그대로다. 경쟁 상대인 「치킨버거 이스트사이드」의 본문에 +이미 「공원」이 있고(「이거 사들고 그네 공원 갔음」) 그쪽에도 장소명이 붙으므로, +결합은 양쪽에 똑같이 작용해 상대적 우위를 만들지 못했다. -6번은 `-174` 가 「0.0087 차이라 다음 실행에서 뒤집힐 수 있다」고 적어 둔 바로 그 건이다. -장소명 결합이 그 아슬아슬한 균형을 반대쪽으로 밀었다. +6번은 `-174` 가 「0.0087 차이라 다음 실행에서 뒤집힐 수 있다」고 적어 둔 바로 그 +건이다. 장소명 결합이 그 근소한 차이를 반대쪽으로 넘겼다. -유사도가 전반적으로 내려갔다(1번 0.5021 → 0.3344). **저장 입력에만 장소명이 붙고 질의에는 -붙지 않는 비대칭**이 그대로 나타난 것으로 보인다 — 사용자는 어느 장소를 찾는지 모르므로 -질의에 장소명을 쓸 수 없고, 그 비대칭까지 포함해 재는 것이 이 조건의 정의였다. +유사도가 전반적으로 내려갔다(1번 0.5021 → 0.3344). 저장 입력에만 장소명이 붙고 +질의에는 붙지 않는 비대칭이 그대로 나타난 것으로 보인다. 사용자는 어느 장소를 +찾는지 모르는 상태로 검색하므로 질의에 장소명을 쓸 수 없다. 그 비대칭까지 포함해 +재는 것이 이 조건의 정의였다. -### 조건 C — 노렸던 8번을 모델 확대가 고쳤다 +### 조건 C — 개선 대상이던 8번을 모델 확대가 고쳤다 | # | 질의 | A | B | C | |---|---|---|---|---| @@ -114,87 +113,95 @@ top-3 네 조건 모두 12/12 | 7 | 친구들이랑 피자에 맥주 마신 곳 | FAIL (3위) | FAIL (2위) | FAIL (2위) | | 8 | 밥 먹고 산책하면서 쉬어가는 공원 | FAIL (2위) | FAIL (2위) | **PASS** 0.5722 | -**8번이 통과했다.** 장소명을 결합하지 않고도 통과했다는 것이 중요하다 — 이 질의의 실패 -원인을 `-174` 는 「「공원」이 장소명에만 있다」로 읽었는데, 그 진단이 맞았다면 입력에 장소명을 -넣은 B 가 고쳤어야 했다. B 는 못 고쳤고 C 가 고쳤다. **원인은 어휘가 입력에 없다는 것이 -아니라 `small` 이 「그네팟」·「산책하면서 머물다」를 「공원」과 잇지 못했다는 것**으로 보인다. +8번이 통과했다. 장소명을 결합하지 않고도 통과했다는 것이 중요하다. 이 질의의 실패 +원인을 `-174` 는 「「공원」이 장소명에만 있다」로 읽었는데, 그 진단이 맞았다면 입력에 +장소명을 넣은 B 가 고쳤어야 했다. B 는 고치지 못했고 C 가 고쳤다. 따라서 원인은 +어휘가 입력에 없다는 것이 아니라, `small` 모델이 「그네팟」·「산책하면서 머물다」를 +「공원」과 의미상 연결하지 못했다는 것으로 보인다. 6번은 A 에서 0.0087 차이로 통과한 건이라 B·C 에서 뒤집힌 것을 퇴행으로 세기 어렵다. -`-174` 가 「사루카메와 쿠로코식당 둘 다 라멘집이라 분리가 어려운 것이 정상이며 이 정도 -차이는 다음 실행에서 뒤집힐 수 있다」고 미리 적어 둔 그 건이다. +`-174` 가 「사루카메와 쿠로코식당 둘 다 라멘집이라 분리가 어려운 것이 정상이며 이 +정도 차이는 다음 실행에서 뒤집힐 수 있다」고 미리 적어 둔 그 건이다. -7번(축약어 「피맥」)은 어느 조건도 고치지 못했다. 다만 A 의 3위에서 B·C 는 2위로 올라왔다. +7번(축약어 「피맥」)은 어느 조건도 고치지 못했다. 다만 A 의 3위에서 B·C 는 2위로 +올라왔다. -토큰은 A 와 **같다**(임베딩 4,002). 토큰 수는 입력 텍스트가 정하는 것이라 모델 크기와 -무관하다. 저장은 정확히 2배다(6,148 → 12,292 B). +토큰은 A 와 같다(임베딩 4,002). 토큰 수는 입력 텍스트가 정하는 것이라 모델 크기와 +무관하다. 저장 용량은 정확히 2배다(6,148 → 12,292 B). ### 조건 D — 7번을 처음 고쳤고 1번을 깨뜨렸다 -**7번(축약어 「피맥」)이 네 조건 중 처음으로 통과했다.** `large` 만으로도(C), 장소명만으로도(B) -안 되던 것이 둘을 겹쳤을 때 됐다. 기대 Record 는 「뉴오더클럽 연남」이고 그 장소명이 입력에 -붙으면서 「친구들이랑」 축이 지배하던 경쟁을 뒤집은 것으로 보인다. +7번(축약어 「피맥」)이 네 조건 중 처음으로 통과했다. `large` 만으로도(C), +장소명만으로도(B) 안 되던 것이 둘을 겹쳤을 때 됐다. 기대 Record 는 「뉴오더클럽 +연남」이고, 그 장소명이 입력에 붙으면서 「친구들이랑」 축이 지배하던 경쟁을 뒤집은 +것으로 보인다. -**대신 1번(「비 오는 날 가려고 저장한 곳」)이 깨졌다.** 세 조건에서 통과하던 것이 3위로 밀렸고 -「언덕 위 야경 식당」이 1위가 됐다. B 에서 관측된 유사도 하락(장소명이 저장 입력에만 붙는 -비대칭)이 large 에서도 같은 방향으로 작용한 것으로 보인다. +대신 1번(「비 오는 날 가려고 저장한 곳」)이 깨졌다. 세 조건에서 통과하던 것이 3위로 +밀렸고 「언덕 위 야경 식당」이 1위가 됐다. B 에서 관측된 유사도 하락(장소명이 저장 +입력에만 붙는 비대칭)이 large 에서도 같은 방향으로 작용한 것으로 보인다. -계측 한 건이 빠졌다 — 판정 토큰 로그가 36행인데 `context_ai_state` 는 37건 모두 COMPLETED 다. -`_usage.record` 는 기록 실패를 삼키므로(계측이 본 작업을 죽이지 않는다) 이런 누락이 가능하다. -**판정 자체는 37건 전부 성공했다.** +계측 한 건이 빠졌다. 판정 토큰 로그가 36행인데 `context_ai_state` 는 37건 모두 +COMPLETED 다. `_usage.record` 는 기록 실패를 삼키도록 되어 있으므로(계측 실패가 본 +작업을 중단시키지 않게 하기 위해서다) 이런 누락이 가능하다. 판정 자체는 37건 전부 +성공했다. ## 무엇을 말할 수 있고 무엇을 말할 수 없는가 **말할 수 있는 것** -- `-174` 가 8번의 원인으로 지목한 「장소명이 임베딩에 없다」는 **진단이 틀렸다.** 그 진단이 - 맞았다면 B 가 고쳤어야 하는데 B 는 못 고쳤고, 장소명을 넣지 않은 C 가 고쳤다 -- 장소명 결합은 **양날이다.** 7번을 고치지만(D) 1번을 깨뜨리고(D) 6번의 균형을 밀어낸다(B). - 저장 입력에만 붙고 질의에는 붙지 않는 비대칭이 유사도 전반을 끌어내린다 -- 비용 축은 저장 하나뿐이다. 토큰은 조건 간 차이가 없고 시딩 소요도 GMS 왕복이 지배한다. - `large` 채택의 비용은 **벡터 2배 + 기존 임베딩 전량 재생성**이다 -- `vector(3072)` 은 지금 스키마에서 동작한다. pgvector 의 2000차원 색인 상한에 걸리지 않는 - 것은 **벡터 인덱스가 없기 때문**이며(37행 순차 스캔), 데이터가 늘어 색인이 필요해지면 - 그때 이 제약이 되살아난다 +- `-174` 가 8번의 원인으로 지목한 「장소명이 임베딩에 없다」는 진단이 틀렸다. 그 + 진단이 맞았다면 B 가 고쳤어야 하는데 B 는 고치지 못했고, 장소명을 넣지 않은 C 가 + 고쳤다. +- 장소명 결합은 이득과 손실이 함께 있다. 7번을 고치지만(D) 1번을 깨뜨리고(D) 6번의 + 근소한 균형을 반대쪽으로 넘긴다(B). 저장 입력에만 붙고 질의에는 붙지 않는 + 비대칭이 유사도 전반을 끌어내린다. +- 비용 축은 저장 하나뿐이다. 토큰은 조건 간 차이가 없고 시딩 소요도 GMS 왕복이 + 대부분이다. `large` 채택의 비용은 벡터 저장 2배와 기존 임베딩 전량 재생성이다. +- `vector(3072)` 는 지금 스키마에서 동작한다. pgvector 의 2000차원 색인 상한에 + 걸리지 않는 것은 벡터 인덱스가 없기 때문이며(37행 순차 스캔), 데이터가 늘어 + 색인이 필요해지면 그때 이 제약이 다시 문제가 된다. **말할 수 없는 것** -- **어느 조건이 낫다는 결론.** 12건 표본에서 1건 차이이고, 세 조건이 10/12 로 동률이다 -- **일반화.** 질의 12건과 기대값을 우리가 작성했다(`-174` §3.3). 재현되는 것은 「의도한 - 매칭」이지 사용자 만족도가 아니다 -- **판정(Keyword) 품질의 조건 간 비교.** 판정은 비결정적이라 같은 조건을 두 번 돌려도 - Keyword 행 수가 달라진다(76 · 81 관측). 이 측정은 검색만 본다 -- **반복 분산.** 조건마다 1회씩 쟀다. 임베딩이 결정적이라 검색 순위는 재현되지만, 6번처럼 - 차이가 0.01 미만인 건은 모델이 바뀌면 어느 쪽으로든 넘어간다 +- **어느 조건이 낫다는 결론.** 12건 표본에서 1건 차이이고, 세 조건이 10/12 로 + 동률이다. +- **일반화.** 질의 12건과 기대값을 우리가 작성했다(`-174` §3.3). 재현되는 것은 + 「의도한 매칭」이지 사용자 만족도가 아니다. +- **판정(Keyword) 품질의 조건 간 비교.** 판정은 비결정적이라 같은 조건을 두 번 + 돌려도 Keyword 행 수가 달라진다(76 · 81 관측). 이 측정은 검색만 본다. +- **반복 분산.** 조건마다 1회씩 쟀다. 임베딩이 결정적이라 검색 순위는 재현되지만, + 6번처럼 차이가 0.01 미만인 건은 모델이 바뀌면 어느 쪽으로든 넘어간다. ## 측정 중 드러난 것 (본 측정과 별개) ### 데모 시딩이 현행 back 스키마와 어긋나 있었다 -`tools/demo_seed/seed.py` 가 `core.social_account.email` 을 채우지 않는데, back -`V6__social_account_email_not_null.sql` 이 그 컬럼을 `NOT NULL` 로 만들었다. **그 전에 -시딩한 DB 에서는 back 이 기동조차 못 한다** — Flyway 가 V6 에서 죽는다. +`tools/demo_seed/seed.py` 가 `core.social_account.email` 을 채우지 않는데, back 의 +`V6__social_account_email_not_null.sql` 이 그 컬럼을 `NOT NULL` 로 만들었다. 그 전에 +시딩한 DB 에서는 back 이 기동조차 하지 못한다. Flyway 가 V6 마이그레이션에서 +실패하기 때문이다. -V6 는 일부러 백필을 넣지 않았고(「채울 값이 없다 — 이메일은 공급자가 주는 값이다」) 운영 -기준으로는 그 판단이 옳다. 시딩이 만든 계정은 실제 공급자 계정이 아니므로 이쪽에서 값을 -만들어 넣었다 — `{key}@demo-seed.invalid`(RFC 2606 예약 TLD, 실재하는 주소로 보이면 -시연 화면에서 진짜 이메일과 구별되지 않는다). +V6 는 일부러 백필을 넣지 않았고(「채울 값이 없다 — 이메일은 공급자가 주는 값이다」) +운영 기준으로는 그 판단이 옳다. 시딩이 만든 계정은 실제 공급자 계정이 아니므로 +시딩 쪽에서 값을 만들어 넣었다. 값은 `{key}@demo-seed.invalid` 다(RFC 2606 예약 +TLD. 실재하는 주소로 보이면 시연 화면에서 진짜 이메일과 구별되지 않는다). ### worktree 에서 실행하면 데모 JWT 키가 새로 생긴다 -`tools/demo_seed/_client.py` 의 `ROOT` 가 파일 위치 기준이라 worktree 에서는 그쪽 -`.demo/demo-jwt-key.pem` 을 새로 만든다. back 에 메인 워킹트리의 키를 주면 서명이 갈라져 -**모든 요청이 401** 이고, back 로그에는 아무것도 남지 않는다(필터가 조용히 통과시키고 -인가 단계가 401 을 만든다). 양쪽에 같은 키를 줘야 한다. +`tools/demo_seed/_client.py` 의 `ROOT` 가 파일 위치 기준이라 worktree 에서 실행하면 +그쪽 `.demo/demo-jwt-key.pem` 을 새로 만든다. back 에 메인 워킹트리의 키를 주면 +서명이 갈라져 모든 요청이 401 이 되고, back 로그에는 아무것도 남지 않는다(필터가 +조용히 통과시키고 인가 단계가 401 을 만든다). 양쪽에 같은 키를 줘야 한다. -`JWT_PRIVATE_KEY` 는 개행을 지운 한 줄로 넘겨도 된다 — `JwtKeyProvider.fromPem` 이 -`replaceAll("\\s", "")` 로 공백을 전부 지운다. +`JWT_PRIVATE_KEY` 는 개행을 지운 한 줄로 넘겨도 된다. `JwtKeyProvider.fromPem` 이 +`replaceAll("\\s", "")` 로 공백을 전부 지우기 때문이다. ### 로컬 DB 복구 — 완료했다 -측정은 `ai.context_embedding` · `ai.keyword_preset` 두 컬럼을 `vector(3072)` 로 바꾸고 조건별 -`grid-*` profile 로 데이터를 채운다. **Flyway 마이그레이션을 만들지 않았다** — 이 티켓은 측정이고 -차원 채택은 별건이다. 여기서 `V6__…sql` 을 만들면 아직 하지 않은 결정이 스키마 이력에 먼저 -박힌다. +측정은 `ai.context_embedding` · `ai.keyword_preset` 두 컬럼을 `vector(3072)` 로 +바꾸고 조건별 `grid-*` profile 로 데이터를 채운다. Flyway 마이그레이션은 만들지 +않았다. 이 티켓은 측정이고 차원 채택은 별건이기 때문이다. 여기서 `V6__…sql` 을 +만들면 아직 하지 않은 결정이 스키마 이력에 먼저 기록된다. 복구 절차와 결과는 아래와 같고, 실행해 확인했다. @@ -211,15 +218,18 @@ context_embedding 37행 · 같은 profile context_ai_state 37건 COMPLETED · Keyword 72행 ``` -`grid-*` profile 행은 하나도 남지 않았다. `alter_dim.py` 는 차원을 바꿀 때 기존 벡터를 전부 -버리므로(차원이 다른 값은 캐스팅되지 않고 컬럼이 `NOT NULL` 이다) 재시딩이 복구의 일부다. +`grid-*` profile 행은 하나도 남지 않았다. `alter_dim.py` 는 차원을 바꿀 때 기존 +벡터를 전부 버리므로(차원이 다른 값은 캐스팅되지 않고 컬럼이 `NOT NULL` 이다) +재시딩이 복구의 일부다. -**서버 두 개는 내려 둔 상태다.** 측정 전 이 환경에는 `-174` 세션이 07-30 에 띄운 uvicorn·jar 가 -남아 있었는데(각각 :8000 · :8080) 조건별 env 로 다시 띄우기 위해 종료했다. 다시 필요하면 -`-174` §7 재현 절차로 띄우면 되고, 데이터는 위 상태 그대로다. +서버 두 개는 내려 둔 상태다. 측정 전 이 환경에는 `-174` 세션이 07-30 에 띄운 +uvicorn·jar 가 남아 있었는데(각각 :8000 · :8080) 조건별 env 로 다시 띄우기 위해 +종료했다. 다시 필요하면 `-174` §7 재현 절차로 띄우면 되고, 데이터는 위 상태 +그대로다. ### 고아 임베딩 행 -`--reset` 은 `demo-seed` provider 로 식별된 member 의 데이터만 지우므로, 다른 member 가 -남긴 `ai.context_embedding` 행은 계속 남는다. 참조하는 `core.context` 가 없는 행이 8건 -있었고 저장 비용 평균에 섞여 들었다. 지웠고, 하네스는 이제 **조건 profile 로 한정해** 센다. +`--reset` 은 `demo-seed` provider 로 식별된 member 의 데이터만 지우므로, 다른 +member 가 남긴 `ai.context_embedding` 행은 계속 남는다. 참조하는 `core.context` 가 +없는 행이 8건 있었고 저장 비용 평균에 섞여 들었다. 지웠고, 하네스는 이제 조건 +profile 로 한정해 센다. diff --git a/docs/implements/2026-07-31-gms-call-observability.md b/docs/implements/2026-07-31-gms-call-observability.md index 34c88f0..a8b6a02 100644 --- a/docs/implements/2026-07-31-gms-call-observability.md +++ b/docs/implements/2026-07-31-gms-call-observability.md @@ -1,7 +1,9 @@ -# GMS 호출·재선점 로그 계측 (S15P11A705-197) +# GMS 호출·재선점 로그 계측 — 실패율·지연·재선점을 로그로 볼 수 있게 했다 -`dev`의 세 gate가 전부 열렸는데 **무엇이 실패하는지 볼 수단이 없던 것**을 메웠다. -로그까지이며 `/metrics` 엔드포인트는 만들지 않았다. +- **티켓**: S15P11A705-197 + +`dev`의 세 gate가 전부 열렸는데 무엇이 실패하는지 볼 수단이 없었다. 그 공백을 +메웠다. 이번 범위는 로그까지이며 `/metrics` 엔드포인트는 만들지 않았다. ## 1. 무엇이 없었나 @@ -50,11 +52,11 @@ UPDATE로 처리하고 둘 다 rowcount `1`을 돌려준다. 로그만으로 가 - **Embedding 단계는 거짓 양성이 섞인다.** `load_resume`과 UPDATE 사이에 경합 창이 있어, 읽은 값이 `PROCESSING`이어도 실제로 재선점했다는 보장이 없다. -재선점은 드물지만 중요한 신호다 — 앞선 처리가 만료(600초) 안에 끝나지 못했다는 뜻이고 -원인은 프로세스 종료 아니면 GMS 지연 둘뿐이다. **거짓 양성이 섞이면 그 신호 자체를 못 -믿게 되므로** 정확도를 포기할 수 없었다. +재선점은 드물지만 중요한 신호다. 재선점이 났다는 것은 앞선 처리가 만료(600초) 안에 +끝나지 못했다는 뜻이고, 원인은 프로세스 종료 아니면 GMS 지연 둘뿐이다. 거짓 양성이 +섞이면 그 신호 자체를 믿을 수 없게 되므로 정확도를 포기할 수 없었다. -그래서 UPDATE 직전 상태를 **같은 문장 안에서** 함께 읽는다. +그래서 UPDATE 직전 상태를 같은 SQL 문장 안에서 함께 읽는다. ```sql WITH prev AS ( @@ -84,10 +86,10 @@ SELECT (SELECT count(*) FROM started) AS affected, (SELECT status FROM prev) AS **벤더·모델 이름은 싣는다.** 공개 설정이고 정본이 코드에 있으며([P45](../proposals/P45-public-config-in-code.md)), `VendorCall.label`이 이미 *"모델명은 공개 설정이므로 값 노출 제약이 없다"*고 적어 둔 -값이다. 그리고 이것을 빼면 로그에 남는 값이 사라진다 — 폴백 체인에서 **어느 경로가 -막혔는가가 곧 원인**이기 때문이다(§3.4의 "쿼터는 경로별로 걸린다"). +값이다. 그리고 이것을 빼면 로그에 남는 값이 사라진다. 폴백 체인에서는 어느 경로가 +막혔는가가 곧 원인이기 때문이다(§3.4의 "쿼터는 경로별로 걸린다"). -전송 실패에서 예외 **메시지**를 쓰지 않고 타입 이름만 쓰는 것도 같은 이유다. httpx는 +전송 실패에서 예외 메시지를 쓰지 않고 타입 이름만 쓰는 것도 같은 이유다. httpx는 예외 메시지에 요청 URL을 넣는다. ### 2.4 정상 호출은 `DEBUG`, 분모는 창 집계 @@ -96,10 +98,10 @@ SELECT (SELECT count(*) FROM started) AS affected, (SELECT status FROM prev) AS 성공을 아예 안 세면 실패율의 분모가 없다. 그래서 개별 행은 결과가 레벨을 가르고 (`DEBUG`/`WARNING`/`ERROR`), 60초 창 집계 한 줄만 `INFO`로 낸다. -**집계를 밀어내는 것은 타이머가 아니라 다음 호출이다.** 호출이 없으면 요약도 나오지 -않으므로 유휴 상태의 로그가 조용하다. 대가는 마지막 창이 남지 않는 것이고, lifespan -`finally`의 `flush()`가 그것을 처리한다 — 시연이 끝나고 파드를 내리는 순간의 실패율이 -정확히 거기서 사라진다. +집계를 내보내는 계기는 타이머가 아니라 다음 호출이다. 호출이 없으면 요약도 나오지 +않으므로 유휴 상태의 로그가 조용하다. 대가는 마지막 창이 남지 않는 것이다. lifespan +`finally`의 `flush()`가 그것을 처리한다. 시연이 끝나고 파드를 내리는 순간의 +실패율이 정확히 그 마지막 창에 해당하기 때문이다. 전체 레벨 표는 [failure-recovery.md](../spec/failure-recovery.md) §2.4가 정본이다. @@ -128,7 +130,7 @@ WARNING app.service.stage ctx=1 stage=keyword reclaimed stale PROCESSING (expiry | `status=ConnectTimeout` | 응답 자체가 없었다 | | `outcome=unclassified` | **오류 분류가 새고 있다.** 그 단계는 PROCESSING에 머문다 | -## 4. 계측을 붙이다 발견한 것 — httpx가 URL을 흘리고 있었다 +## 4. 계측을 붙이다 발견한 것 — httpx가 요청 URL을 로그에 남기고 있었다 테스트는 전부 초록인데 실행 출력을 눈으로 보니 우리 행 바로 위에 이것이 있었다. @@ -137,16 +139,16 @@ INFO httpx HTTP Request: POST https:///gmsapi/generativelanguage.googl v1beta/models/gemini-2.5-flash:generateContent "HTTP/1.1 429 Too Many Requests" ``` -**httpx가 요청마다 INFO로 전체 URL을 남긴다.** 두 가지가 동시에 깨져 있었다 — §2.3의 -endpoint 미노출 기준을 정면으로 어기고, §2.4의 "성공은 조용하게"를 무의미하게 만든다 -(성공한 호출도 이 줄은 INFO로 나온다). +httpx가 요청마다 INFO 레벨로 전체 URL을 남기고 있었다. 두 가지가 동시에 깨져 +있었다. §2.3의 endpoint 미노출 기준을 정면으로 어기고, §2.4의 "성공은 조용하게"를 +무의미하게 만든다(성공한 호출도 이 줄은 INFO로 나온다). -`caplog`를 로거 이름으로 걸러 읽는 테스트가 이것을 못 잡았다. 자기 로거만 보므로 옆에서 -무엇이 새고 있어도 초록이다. `configure_logging()`이 `httpx` 로거를 `WARNING`으로 올리게 -하고, **로거를 가리지 않고 전부 읽는** 테스트를 따로 두어 고정했다. +`caplog`를 로거 이름으로 걸러 읽는 테스트가 이것을 잡지 못했다. 자기 로거만 보므로 +다른 로거에서 무엇이 새고 있어도 통과한다. `configure_logging()`이 `httpx` 로거를 +`WARNING`으로 올리게 하고, 로거를 가리지 않고 전부 읽는 테스트를 따로 두어 고정했다. -정보는 잃지 않는다. 같은 호출에 대해 `app.client.gms`가 더 나은 행을 낸다 — 결과 분류와 -소요 시간이 있고 URL은 없다. +정보는 잃지 않는다. 같은 호출에 대해 `app.client.gms`가 더 나은 행을 낸다. 그 행에는 +결과 분류와 소요 시간이 있고 URL은 없다. ## 5. 검증 @@ -158,12 +160,12 @@ endpoint 미노출 기준을 정면으로 어기고, §2.4의 "성공은 조용 | `tests/test_repo.py` (5건 추가) | `prev_status`·`reclaimed` 조합, keyword 가드가 CTE 재작성 후에도 유지되는지 | | 전체 | 254 → **283 passed** · line 99.81% · branch 98.72% · `check_coverage_gate.py` ok | -미달로 남은 2 line · 2 branch는 이번 변경과 무관하다 — `FOR UPDATE`로 잠근 직후의 -재검사라 같은 트랜잭션에서 도달 불가이며, `-110`에서 **`pragma`를 붙이지 않고 미달로 -두기로** 한 그 두 줄이다(붙이면 나중에 도달 가능해져도 아무도 모른다). +미달로 남은 2 line · 2 branch는 이번 변경과 무관하다. `FOR UPDATE`로 잠근 직후의 +재검사라 같은 트랜잭션에서 도달할 수 없는 줄이고, `-110`에서 `pragma`를 붙이지 않고 +미달로 두기로 한 그 두 줄이다(붙이면 나중에 도달 가능해져도 아무도 모른다). -**"로그 코드를 넣었다"를 검증으로 치지 않았다.** 실패를 실제로 만들어 행이 나오는 것을 -보았고, §4는 그렇게 하지 않았으면 놓쳤을 결함이다. +"로그 코드를 넣었다"를 검증으로 치지 않았다. 실패를 실제로 만들어 행이 나오는 것을 +확인했고, §4는 그렇게 하지 않았으면 놓쳤을 결함이다. ## 6. 하지 않은 것 diff --git a/docs/implements/2026-07-31-gms-error-body-redaction.md b/docs/implements/2026-07-31-gms-error-body-redaction.md index 8142eae..ce0c691 100644 --- a/docs/implements/2026-07-31-gms-error-body-redaction.md +++ b/docs/implements/2026-07-31-gms-error-body-redaction.md @@ -10,29 +10,31 @@ ## 이 문서가 다루는 것 -`-220`이 응답 **본문**을 막았고, 이 티켓이 **로그**를 막는다. +`-220`이 응답 본문의 누출을 막았고, 이 티켓은 로그의 누출을 막는다. ``` resp.text[:200] → 예외 메시지 → 다섯 곳의 로그 + 트레이스백 ``` -`-220`은 예외가 HTTP 응답이 되는 지점에 핸들러를 넣어 본문을 고정 문구로 바꿨다. 같은 -예외 메시지가 **로그로도 나간다**는 것은 그 티켓의 범위가 아니었고, 그대로 남아 있었다. +`-220`은 예외가 HTTP 응답이 되는 지점에 핸들러를 넣어 본문을 고정 문구로 바꿨다. +그러나 같은 예외 메시지가 로그로도 나간다는 것은 그 티켓의 범위가 아니었고, 그 +경로는 그대로 남아 있었다. --- ## 1. 실측 — 세 벤더가 오류 본문에 무엇을 담는가 -**티켓이 세운 가설과 다르다.** 티켓은 *"게이트웨이가 요청 URL을 에코한다 → endpoint -누출"*로 위험을 좁혔다. 실제로 재 보니 축이 둘 더 있었고, **가장 걱정하던 자격 증명은 -한 건도 나오지 않았다.** +실측 결과는 티켓이 세운 가설과 다르다. 티켓은 *"게이트웨이가 요청 URL을 에코한다 → +endpoint 누출"*로 위험을 좁혔다. 실제로 재 보니 위험 축이 둘 더 있었고, 가장 +걱정하던 자격 증명은 한 건도 나오지 않았다. ### 1.1 어떻게 쟀나 실제 GMS 키로 네 경로(OpenAI·Gemini·Anthropic·임베딩)에 오류를 **의도적으로** 만들어 응답 본문을 받았다. 19건이다. 자격 증명 에코 여부를 보는 401 케이스에는 진짜 키가 아니라 가짜 값을 넣었고, 스크립트는 세션 임시 디렉터리에만 두고 커밋하지 않았다. 응답에 진짜 키가 -섞여 나오면 값을 지우고 **에코됐다는 사실만** 남기도록 짰다 — 한 건도 걸리지 않았다. +섞여 나오면 값을 지우고 에코됐다는 사실만 남기도록 짰다. 실제로는 한 건도 걸리지 +않았다. | 축 | 케이스 | |---|---| @@ -67,40 +69,41 @@ resp.text[:200] → 예외 메시지 → 다섯 곳의 로그 + 트레이스백 | Anthropic | `{"message":"[Anthropic 에러] Request failed with status code 400","statusCode":400,"error":{"type":"error","error":{"type":"invalid_request_error","message":"max_tokens: must be greater than or equal to…` | | 임베딩(OpenAI) | `{"message":"[OpenAI 에러] Request failed with status code 400","statusCode":400,"error":{"error":{"message":"Invalid 'input': input cannot be an empty array.","type":"invalid_request_error","param":null…` | -**요청 값이 잘려서 되돌아온다 — 이것이 실측의 핵심이다.** 요청 값에 +요청 값이 잘려서 되돌아온다. 이것이 실측의 핵심이다. 요청 값에 `PINLOG-CTX-MARKER-205-abcdef`를 심고 400을 유발했더니 OpenAI가 이렇게 답했다. ``` Invalid value: 'PIN...def'. Supported values are: 'system', 'assistant', 'user', … ``` -**앞 3자와 뒤 3자만 남기고 잘라서 에코한다.** 완전 일치로 마커를 찾는 검사는 이것을 -「에코 없음」으로 판정한다(T61). +앞 3자와 뒤 3자만 남기고 잘라서 에코한다. 완전 일치로 마커를 찾는 검사는 이것을 +「에코 없음」으로 잘못 판정한다(T61). -사용자 Context 원문이 실리는 자리(`messages[].content`·`parts[].text`·`input`)는 값이 아니라 -**타입·경로만** 되돌아왔다 — `messages.0.content: Input should be…`, `Invalid value at -'contents[0].parts[0]' (text)`. PII가 통째로 새는 경로는 관측되지 않았다. +사용자 Context 원문이 실리는 자리(`messages[].content`·`parts[].text`·`input`)는 +값이 아니라 타입과 경로만 되돌아왔다(`messages.0.content: Input should be…`, +`Invalid value at 'contents[0].parts[0]' (text)`). PII가 통째로 새는 경로는 관측되지 +않았다. ### 1.3 그래서 무엇이 결론인가 -**"관측된 것을 지운다"로 규칙을 세우면 안 된다.** 오늘 자격 증명이 안 나온다는 사실은 -게이트웨이 구현에 달려 있고 우리가 통제하지 않는다. 그리고 요청 값이 **잘려서라도** -round-trip 한다는 것이 실측으로 확인된 이상, 그 자리에 언젠가 자격 증명이 실릴 수 있다고 -보는 편이 맞다. `-220`이 스텁으로 재현한 누출이 정확히 그 모양이었다. +"관측된 것을 지운다"로 규칙을 세우면 안 된다. 오늘 자격 증명이 안 나온다는 사실은 +게이트웨이 구현에 달려 있고 우리가 통제하지 않는다. 그리고 요청 값이 잘려서라도 +round-trip 한다는 것이 실측으로 확인된 이상, 그 자리에 언젠가 자격 증명이 실릴 수 +있다고 보는 편이 맞다. `-220`이 스텁으로 재현한 누출이 정확히 그 형태였다. -규칙의 근거는 **"되돌아올 수 있는 자리를 막는다"**다. +그래서 규칙의 근거를 "되돌아올 수 있는 자리를 막는다"로 잡았다. -> **아무것도 안 새는 벤더**: 자격 증명 기준으로는 **네 경로 모두**다. 근거는 §1.2의 -> 401 고정 문구 — 벤더가 답하기 전에 게이트웨이가 끊으므로 벤더별 차이가 생길 자리가 -> 없다. endpoint 기준으로는 **네 경로 모두 샌다**(맨 호스트). 벤더가 갈리는 축은 -> 자격 증명이 아니라 **본문 길이와 중첩 깊이**였다. +> 아무것도 안 새는 벤더: 자격 증명 기준으로는 네 경로 모두다. 근거는 §1.2의 401 고정 +> 문구다. 벤더가 답하기 전에 게이트웨이가 끊으므로 벤더별 차이가 생길 자리가 없다. +> endpoint 기준으로는 네 경로 모두 샌다(맨 호스트). 벤더 간에 차이가 나는 축은 +> 자격 증명이 아니라 본문 길이와 중첩 깊이였다. --- ## 2. 로그 경로 전수 -응답 본문만 보면 절반을 놓친다. `resp.text`가 예외 메시지에 들어간 뒤 **그 메시지가 -어디서 로그가 되는가**를 셌다. +응답 본문만 보면 절반을 놓친다. `resp.text`가 예외 메시지에 들어간 뒤 그 메시지가 +어디서 로그가 되는가를 셌다. | # | 위치 | 레벨 | 어떻게 | |---|---|---|---| @@ -111,13 +114,13 @@ round-trip 한다는 것이 실측으로 확인된 이상, 그 자리에 언젠 | 5 | `app/service/keyword_service.py:111` | `WARNING` | 〃 | | 6 | uvicorn 트레이스백 | `ERROR` | 분류 밖 예외의 메시지가 그대로 | -**티켓이 적은 「분류 밖 예외 → 트레이스백」은 여섯 중 하나다.** 나머지 다섯은 **분류가 -정상 동작하는 경로**이며, 그쪽이 훨씬 자주 실행된다 — 1번은 `502` 한 번에 두 줄이 난다. -즉 **막힌 것은 트레이스백이 아니라 평상시 경로**였다. +티켓이 적은 「분류 밖 예외 → 트레이스백」은 여섯 경로 중 하나다. 나머지 다섯은 +분류가 정상 동작하는 경로이며, 그쪽이 훨씬 자주 실행된다. 1번은 `502` 한 번에 두 +줄이 난다. 즉 누출의 주 경로는 트레이스백이 아니라 평상시 경로였다. 계약이 *"분류 밖 예외는 여전히 500으로 나가며 트레이스백을 남긴다"*를 남은 구멍으로 -지목한 것은 맞지만 **좁았다.** `-221`의 「애매한 것은 분류하지 않는다」와 무관하게, 분류에 -성공해도 같은 문자열이 샜다. +지목한 것은 맞지만, 그 지목은 범위가 좁았다. `-221`의 「애매한 것은 분류하지 +않는다」와 무관하게, 분류에 성공해도 같은 문자열이 샜다. ### 2.1 명세와 코드가 어긋나 있었다 @@ -126,8 +129,9 @@ round-trip 한다는 것이 실측으로 확인된 이상, 그 자리에 언젠 > credential·endpoint·**요청/응답 본문**은 어느 레벨에서도 남기지 않습니다. `-197`이 이 문장을 쓸 때 `app.client.gms`가 내는 행만 보고 썼다. 그 로거는 실제로 본문을 -싣지 않는다. 그러나 같은 표에 있는 `app.service.*`와 `app.client.retry` 행은 예외 객체를 -`%s`로 받는다. **문장은 참이 아니었고, 아무도 그것을 몰랐다.** §2.6으로 갈라 고쳤다. +싣지 않는다. 그러나 같은 표에 있는 `app.service.*`와 `app.client.retry` 행은 예외 +객체를 `%s`로 받는다. 즉 명세의 문장은 참이 아니었고, 아무도 그것을 몰랐다. +§2.6으로 갈라 고쳤다. ### 2.2 응답 없는 실패도 원천이다 @@ -135,7 +139,7 @@ round-trip 한다는 것이 실측으로 확인된 이상, 그 자리에 언젠 > 메시지 본문에는 URL 이 섞여 들어올 수 있어 그쪽이 아니라 타입 이름을 쓴다. -맞는 판단이지만 **그 바로 다음 줄**이 같은 예외를 `{exc}`로 메시지에 넣는다. 계측 +맞는 판단이지만 그 바로 다음 줄이 같은 예외를 `{exc}`로 메시지에 넣는다. 계측 필드(`rec.status`)만 지키고 예외 메시지는 열려 있었다. 임베딩 쪽도 같다. 둘 다 마스킹을 걸었다. @@ -145,27 +149,29 @@ round-trip 한다는 것이 실측으로 확인된 이상, 그 자리에 언젠 ### 3.1 막는 지점을 로그 호출부가 아니라 client로 잡았다 -호출부는 여섯이고 그중 하나(트레이스백)는 **애초에 호출부가 없다.** 호출부마다 가리면 -§2의 표를 사람이 관리해야 하고, 다음에 늘어나는 일곱 번째를 놓친다. 예외 메시지 자체가 -깨끗하면 어디서 찍히든 안전하다. +호출부는 여섯이고 그중 하나(트레이스백)는 애초에 호출부가 없다. 호출부마다 가리면 +§2의 표를 사람이 관리해야 하고, 다음에 늘어나는 일곱 번째를 놓친다. 예외 메시지 +자체가 깨끗하면 어디서 찍히든 안전하다. -대안으로 **root 로거에 `logging.Filter`**를 거는 방법이 있었다. 트레이스백까지 한 번에 -덮지만 채택하지 않았다 — 모든 로그 행에 정규식을 물리는 비용이 상시로 들고, 우리와 무관한 -라이브러리 로그까지 문자열을 건드린다. 원천이 하나뿐인데 하류 전체를 검사할 이유가 없다. +대안으로 root 로거에 `logging.Filter`를 거는 방법이 있었다. 트레이스백까지 한 번에 +덮지만 채택하지 않았다. 모든 로그 행에 정규식을 물리는 비용이 상시로 들고, 우리와 +무관한 라이브러리 로그까지 문자열을 건드리기 때문이다. 원천이 하나뿐인데 하류 전체를 +검사할 이유가 없다. ### 3.2 자격 증명이 endpoint보다 먼저다 -계약이 지정한 우선순위다. 순서가 실제로 결과를 가른다 — `https://host/?key=sk-…`에서 -URL 규칙이 먼저 걸리면 키가 통째로 ``에 삼켜져 **"무엇이 지워졌는지"가 사라진다.** -지워진 것이 키인지 경로인지는 대응이 갈리는 정보다(키면 재발급, 경로면 설정 수정). +계약이 지정한 우선순위다. 순서가 실제로 결과를 바꾼다. `https://host/?key=sk-…`에서 +URL 규칙이 먼저 걸리면 키가 통째로 `` 마스킹에 함께 지워져, "무엇이 +지워졌는지"라는 정보가 사라진다. 지워진 것이 키인지 경로인지에 따라 대응이 +달라진다(키면 재발급, 경로면 설정 수정). -값만 지우고 **이름은 남긴다**. `"apiKey": "***"`가 `***`보다 낫다. +값만 지우고 이름은 남긴다. `"apiKey": "***"`가 `***`보다 낫다. ### 3.3 마스킹이 절단보다 먼저다 -`resp.text[:200]`을 그대로 두고 그 뒤에 마스킹하면, 200자 경계에 걸친 키의 **앞부분이 -잘린 채 남는다.** 잘린 자격 증명도 자격 증명이다 — §1.2에서 OpenAI가 값을 잘라 에코하는 -것을 봤으므로 이건 가정이 아니다. +`resp.text[:200]`을 그대로 두고 그 뒤에 마스킹하면, 200자 경계에 걸친 키의 앞부분이 +잘린 채 남는다. 잘린 자격 증명도 자격 증명이다. §1.2에서 OpenAI가 값을 잘라 +에코하는 것을 확인했으므로 이것은 가정이 아니다. 그래서 `redact_body()`는 `redact(text[:4096])[:200]` 순서다. 출력은 마스킹을 마친 문자열의 부분 문자열이므로, 마스킹을 우회해 출력에 들어가는 경로가 없다. `4096`은 병적으로 긴 @@ -174,8 +180,8 @@ URL 규칙이 먼저 걸리면 키가 통째로 ``에 삼켜져 **"무엇 ### 3.4 맨 호스트까지 지운다 — 진단을 잃지 않는다 `Model not found in request for domain api.openai.com`에서 호스트를 지우면 문장이 -`… for domain `가 된다. 잃는 것이 없다 — **어느 벤더였는지는 같은 시각의 -`app.client.gms` 행이 `vendor=openai`로 이미 말한다**(`-197`). +`… for domain `가 된다. 이렇게 해도 잃는 정보가 없다. 어느 벤더였는지는 같은 +시각의 `app.client.gms` 행이 `vendor=openai`로 이미 말해 주기 때문이다(`-197`). TLD를 화이트리스트로 한정했다. 점이 든 평범한 식별자를 건드리지 않기 위해서다. @@ -186,9 +192,9 @@ TLD를 화이트리스트로 한정했다. 점이 든 평범한 식별자를 건 ### 3.5 본문 200자를 지우지 않는다 -티켓의 확정 판단이고 실측이 이를 뒷받침한다. §1.2의 벤더 400 본문에서 마스킹 대상은 -**한 글자도 없다** — 전부 진단 문구다. 무조건 지웠으면 `Invalid 'max_completion_tokens'`, -`max_tokens: must be greater than or equal to 1` 같은 **원인 그 자체**를 버렸을 것이다. +티켓의 확정 판단이고 실측이 이를 뒷받침한다. §1.2의 벤더 400 본문에는 마스킹 대상이 +한 글자도 없다. 전부 진단 문구다. 무조건 지웠으면 `Invalid 'max_completion_tokens'`, +`max_tokens: must be greater than or equal to 1` 같은 원인 그 자체를 버렸을 것이다. 이 네 본문이 그대로 통과하는지를 테스트로 고정했다(§4). @@ -196,13 +202,13 @@ TLD를 화이트리스트로 한정했다. 점이 든 평범한 식별자를 건 ## 4. 검증 -### 4.1 RED → GREEN +### 4.1 구현 전 실패 확인 → 구현 후 통과 -`tests/test_log_redaction.py` 18건. 마스킹 모듈을 넣기 전 **동작 단언 6건이 RED**였고, -실패 형태가 곧 §2의 표였다 — 예외 메시지·재시도 로그·전체 로그 행·트레이스백·전송 실패 -메시지·구조 방어. +`tests/test_log_redaction.py` 18건. 마스킹 모듈을 넣기 전에는 동작 단언 6건이 +실패했고, 실패 지점이 곧 §2의 표와 일치했다(예외 메시지·재시도 로그·전체 로그 +행·트레이스백·전송 실패 메시지·구조 방어). -`app/core/redact.py`와 두 client를 고친 뒤 18건 GREEN. +`app/core/redact.py`와 두 client를 고친 뒤 18건 전부 통과했다. | 무엇을 | 단언 | |---|---| @@ -221,8 +227,9 @@ TLD를 화이트리스트로 한정했다. 점이 든 평범한 식별자를 건 `test_no_unredacted_response_body_in_app`은 `app/` 전체를 파싱해 `resp.text` 접근 노드를 모으고, `redact_body(...)` 인자 안에 든 것을 뺀 나머지를 위반으로 낸다. -처음에 텍스트로 훑었더니 `gms_roundtrip.py`의 **docstring**이 위반으로 잡혔다 — 그 파일은 -`resp.text[:200]`을 *설명*하고 있었다. 산문을 코드로 오인하는 검사는 오래 못 간다(T63). +처음에 텍스트 검색으로 훑었더니 `gms_roundtrip.py`의 docstring이 위반으로 잡혔다. +그 파일은 `resp.text[:200]`을 설명하고 있었을 뿐이다. 산문을 코드로 오인하는 검사는 +유지하기 어렵다(T63). ### 4.3 전체 @@ -233,39 +240,40 @@ pytest 404 passed coverage line 99.83% · branch 98.99% (게이트 80/80) ``` -이 수치는 **`-223`(판정 n회 다수결)을 병합한 뒤**의 것이다. 두 티켓이 `keyword_service.py`를 +이 수치는 `-223`(판정 n회 다수결)을 병합한 뒤의 것이다. 두 티켓이 `keyword_service.py`를 함께 건드려서, 병합 전(375건)과 후를 모두 돌려 회귀가 없음을 확인했다. ### 4.4 하지 않은 것 -- **실서버 대조를 하지 않았다.** `-220`처럼 uvicorn 두 대를 띄워 로그를 눈으로 비교하는 - 절차는 밟지 않았다. 이 변경은 응답이 아니라 **로그 문자열**을 바꾸므로 계약 테스트가 - 보는 것과 실서버가 내는 것이 같다 — `caplog`이 실제 로거 레코드를 받는다. 대신 §1의 - 실측을 **실제 GMS로** 했고, 그 본문이 테스트 픽스처의 정본이다. -- **429 본문을 재지 못했다.** 공용 게이트웨이를 의도적으로 밀어 429를 만드는 것은 같은 키를 - 쓰는 다른 세션·시연에 영향이 간다. `-197`이 기록한 것은 상태 코드와 헤더 줄뿐이라 - 본문 형태는 미상이다. 429가 `[GMS 에러]` 계열이면 §1.2와 같고, 벤더 원문을 중첩하면 - §1.2의 벤더 계열과 같다 — 어느 쪽이든 마스킹 규칙은 그대로 적용된다. +- **실서버 대조를 하지 않았다.** `-220`처럼 uvicorn 두 대를 띄워 로그를 눈으로 + 비교하는 절차는 밟지 않았다. 이 변경은 응답이 아니라 로그 문자열을 바꾸므로 계약 + 테스트가 보는 것과 실서버가 내는 것이 같다(`caplog`이 실제 로거 레코드를 받는다). + 대신 §1의 실측을 실제 GMS로 했고, 그 본문이 테스트 픽스처의 정본이다. +- **429 본문을 재지 못했다.** 공용 게이트웨이를 의도적으로 밀어 429를 만드는 것은 + 같은 키를 쓰는 다른 세션·시연에 영향이 간다. `-197`이 기록한 것은 상태 코드와 헤더 + 줄뿐이라 본문 형태는 미상이다. 429가 `[GMS 에러]` 계열이면 §1.2와 같고, 벤더 + 원문을 중첩하면 §1.2의 벤더 계열과 같다. 어느 쪽이든 마스킹 규칙은 그대로 + 적용된다. --- -## 5. 남긴 것 +## 5. 남은 문제 - **`tools/` 는 범위 밖이다.** `tools/keyword_eval/probe_vendors.py`·`tools/demo_seed/_client.py` 등이 `r.text`를 그대로 출력한다. 측정 도구는 사람이 손으로 돌리고 출력이 컨테이너 로그로 가지 않으므로 위험이 다르다. 구조 방어 테스트도 `app/`만 본다. 필요해지면 별도 티켓. -- **`_usage.py`의 토큰 로그는 건드리지 않았다.** `PINLOG_TOKEN_LOG`가 있을 때만 동작하고 - **200 응답만** 기록한다 — 오류 본문이 닿지 않는다. +- **`_usage.py`의 토큰 로그는 건드리지 않았다.** `PINLOG_TOKEN_LOG`가 있을 때만 + 동작하고 200 응답만 기록하므로 오류 본문이 들어갈 일이 없다. - **`vendors.py`의 `_unwrap`은 200 응답의 봉투를 파싱하다 난 예외를 싣는다.** 키 이름 같은 구조 정보라 위험이 낮다고 보고 그대로 뒀다. 여기에 모델 출력 값이 실릴 여지는 남는다. - **운영 로그 조치는 이 티켓 밖이다.** 이미 남은 로그의 Loki 검색과 키 재발급 판단은 `ai#69`로 인프라 파트에 있다. -- **`/metrics`는 건드리지 않았다** — prod 승격 전 승인 항목(§2.4). -- **거대 요청 본문에서 GMS가 오해를 부르는 400을 낸다**(T62). 이 티켓의 범위는 아니지만 - 분류에 영향이 있다 — 자세한 것은 트러블슈팅. +- **`/metrics`는 건드리지 않았다.** prod 승격 전 승인 항목이다(§2.4). +- **거대 요청 본문에서 GMS가 오해를 부르는 400을 낸다**(T62). 이 티켓의 범위는 + 아니지만 오류 분류에 영향이 있다. 자세한 것은 트러블슈팅 문서에 있다. -## 관련 함정 +## 관련 장애 기록 -측정·구현 중 걸린 셋을 +측정·구현 중 발견한 문제 세 건을 [2026-07-31-log-redaction-pitfalls.md](../troubleshooting/2026-07-31-log-redaction-pitfalls.md) (T61~T63)에 남겼다. diff --git a/docs/implements/2026-07-31-judge-prompt-rule.md b/docs/implements/2026-07-31-judge-prompt-rule.md index 04a5fea..36bcc04 100644 --- a/docs/implements/2026-07-31-judge-prompt-rule.md +++ b/docs/implements/2026-07-31-judge-prompt-rule.md @@ -3,28 +3,28 @@ - **티켓**: S15P11A705-219 - **날짜**: 2026-07-31 - **선행**: [후보 임계값 τ](2026-07-31-candidate-threshold.md) (`S15P11A705-210`) — 라벨과 데이터를 그대로 물려받았다 -- **함정**: [T43~T49](../troubleshooting/2026-07-31-judge-prompt-ab.md) +- **측정 중 발견한 문제**: [T43~T49](../troubleshooting/2026-07-31-judge-prompt-ab.md) - **하네스**: `tools/prompt_ab/` ## 요약 **판정 프롬프트를 바꾸지 않는다.** 성격이 다른 개정안 둘을 실데이터 42건에 각각 걸어 -반복 측정한 결과, 어느 쪽도 오분류 감소가 **같은 프롬프트를 다시 돌렸을 때의 변동**을 +반복 측정한 결과, 어느 쪽도 오분류 감소가 같은 프롬프트를 다시 돌렸을 때의 변동을 넘지 못했다. -``` -현행 프롬프트 유지 llm_client.SYSTEM 변경 없음 · 코드 변경 없음 -B(규칙 정밀화) 오분류 Δ -0.60행 — 효과 없음 -C(미선택 기본값) 오분류 Δ -1.60행 · 범위 겹침 · 순열검정 p=0.081 — 기준 미달 -fit 0건 Context A·B·C 모두 8.00 ± 0.00 — **사용자가 보는 손실은 전혀 안 움직인다** -``` +| 항목 | 결과 | +|---|---| +| 결정 | 현행 프롬프트 유지. `llm_client.SYSTEM` 변경 없음 · 코드 변경 없음 | +| B(규칙 정밀화) | 오분류 Δ -0.60행. 효과 없음 | +| C(미선택 기본값) | 오분류 Δ -1.60행 · 회차 범위 겹침 · 순열검정 p=0.081. 판정 기준 미달 | +| fit 0건 Context | A·B·C 모두 8.00 ± 0.00. 사용자가 보는 손실은 전혀 움직이지 않는다 | `-210` 이 「후보 선정 층으로는 못 푼다」로 끝나며 판정 프롬프트를 후속으로 지목했다. -**그 층에서도 안 된다**는 것이 이 티켓의 답이고, 이유는 τ 때 와 다르다. +그 층에서도 안 된다는 것이 이 티켓의 답이고, 이유는 τ 때와 다르다. -τ 는 적합·부적합의 **유사도 분포가 겹쳐서** 못 갈랐다. 프롬프트는 겨눌 표적이 서 있지 -않아서 안 된다 — **오분류의 70%가 회차마다 나타났다 사라지고, 규칙이 이름으로 지목한 -유형은 오히려 굳어졌다.** +τ 는 적합·부적합의 유사도 분포가 겹쳐서 둘을 가르지 못했다. 프롬프트는 규칙이 지목할 +고정된 대상이 없어서 효과를 내지 못한다. 오분류의 70%가 회차마다 나타났다 사라지고, +규칙이 이름으로 지목한 유형은 오히려 매 회차 반복해서 나타났다. ## 1. 무엇을 어떻게 쟀나 @@ -33,9 +33,9 @@ fit 0건 Context A·B·C 모두 8.00 ± 0.00 — **사용자가 보는 `tau_grid/matrix.py` 가 뜬 유사도 행렬을 그대로 쓴다. 후보 집합·본문·프리셋이 조건 사이에 완전히 같아야 하므로 재임베딩도 재시딩도 하지 않는다. τ=0.30 · k=10 고정. -`-210` 의 τ 스윕과 결정적으로 다른 점이 하나 있다. **τ 는 유사도 행렬 위에서 GMS 없이 -재구성할 수 있었지만 프롬프트는 LLM 을 실제로 불러야 한다.** 그래서 이 티켓의 설계는 -전부 호출 비용과 그 비결정성 위에 서 있다. +`-210` 의 τ 스윕과 결정적으로 다른 점이 하나 있다. τ 는 유사도 행렬 위에서 GMS 호출 +없이 재구성할 수 있었지만, 프롬프트는 LLM 을 실제로 불러야 한다. 그래서 이 티켓의 +설계는 전부 호출 비용과 판정 비결정성을 전제로 세웠다. | | | |---|---| @@ -45,24 +45,22 @@ fit 0건 Context A·B·C 모두 8.00 ± 0.00 — **사용자가 보는 전문과 각 조항을 왜 더했는지는 `tools/prompt_ab/variants.py` 에 있다. -**B 의 실패 모양이 C 를 지정했다.** B 를 5회 재고 나서 만들었으므로 C 는 사후 개입이고, -그 사실을 §3 에 적는다. +B 의 실패 형태를 보고 C 를 설계했다. B 를 5회 재고 나서 만들었으므로 C 는 사후 +개입이고, 그 사실을 §3 에 적는다. ### 1.2 몇 회를 왜 돌렸나 -`-210` 이 실측한 것 하나가 이 측정 전체를 규정한다 — **같은 프롬프트로 재판정만 해도 -Context 11/42(26%)에서 결과가 달라진다**(T39). 그러므로 「B 가 A 보다 오분류 2건 적다」는 -문장은 그 자체로 아무것도 말하지 않는다. +`-210` 이 실측한 사실 하나가 이 측정 전체를 규정한다. 같은 프롬프트로 재판정만 해도 +Context 11/42(26%)에서 결과가 달라진다(T39). 그러므로 「B 가 A 보다 오분류 2건 +적다」는 문장은 그 자체로는 아무것도 말하지 않는다. -``` -5회 두 조건의 관측 범위가 겹치지 않는가를 볼 수 있다 - 효과가 없는데 그렇게 갈릴 확률 1/C(10,5) ≈ 0.4% - 3회면 그 확률이 5% 로 올라 흔들림과 구분되지 않는다 -10회 C 가 5회에서 사전 기준을 못 넘어 확장했다 (§3.2) -``` +- **5회** — 두 조건의 관측 범위가 겹치지 않는지를 볼 수 있다. 효과가 없는데 범위가 + 갈릴 확률은 1/C(10,5) ≈ 0.4% 다. 3회면 그 확률이 5% 로 올라 판정 변동과 구분되지 + 않는다. +- **10회** — C 가 5회에서 사전 기준을 넘지 못해 확장했다 (§3.2). -**조건을 바꾸지 않고 같은 하네스로 회차만 더했다.** 자를 바꾸는 것과 표본을 늘리는 것을 -갈라 두지 않으면 기준이 결과를 따라간다(T46). +조건을 바꾸지 않고 같은 하네스로 회차만 더했다. 판정 기준을 바꾸는 것과 표본을 +늘리는 것을 갈라 두지 않으면 기준이 결과를 따라가게 된다(T46). | 조건 | 회차 | LLM 호출 | |---|---|---| @@ -76,25 +74,27 @@ Context 11/42(26%)에서 결과가 달라진다**(T39). 그러므로 「B 가 A ### 1.3 「오분류」를 무엇으로 셌나 -`-210` 의 `labels.yaml` 83행을 **한 줄도 고치지 않고** 그대로 쓴다. 기준이 바뀌면 `-210` -과 비교가 불가능해진다. +`-210` 의 `labels.yaml` 83행을 한 줄도 고치지 않고 그대로 쓴다. 기준이 바뀌면 +`-210` 과 비교가 불가능해진다. -다만 그것만으로는 부족하다. **τ 스윕과 달리 재판정은 없던 행을 만든다**(T44). A·B·C 를 -5회씩 돌린 결과 라벨 밖 행이 24종 나왔고, 빼고 세면 개정안이 새로 고른 것이 전부 없는 -셈이 되어 비교가 기운다. `tools/prompt_ab/labels_extra.yaml` 로 **커버리지만 넓혔다** — -기준은 원본 머리말 그대로다. +다만 그것만으로는 부족하다. τ 스윕과 달리 재판정은 기존 라벨에 없던 행을 +만든다(T44). A·B·C 를 5회씩 돌린 결과 라벨 밖 행이 24종 나왔고, 이 행들을 빼고 세면 +개정안이 새로 고른 것이 전부 없는 셈이 되어 비교가 기운다. 그래서 +`tools/prompt_ab/labels_extra.yaml` 로 라벨 커버리지만 넓혔다. 판정 기준은 원본 +머리말 그대로다. -라벨을 붙일 때 **어느 조건이 고른 행인지는 보지 않았다**(T45). 그래도 남는 한계가 있고 -숨기지 않는다 — 라벨을 붙인 사람이 개정 프롬프트를 설계한 사람이기도 하다. +라벨을 붙일 때 어느 조건이 고른 행인지는 보지 않았다(T45). 그래도 남는 한계가 있고 +숨기지 않는다. 라벨을 붙인 사람이 개정 프롬프트를 설계한 사람이기도 하다. ### 1.4 벤더를 하나로 묶었다 -`-175` 에서 같은 프롬프트로도 벤더별 일치도가 `0.53~0.93` 으로 갈렸다. 폴백 체인이 살아 -있으면 429 한 번에 회차 하나가 다른 모델로 판정되고, **그 혼입은 프롬프트 효과와 구분되지 -않는다.** 체인을 `openai:gpt-4o-mini` 하나로 강제했다. +`-175` 에서 같은 프롬프트로도 벤더별 일치도가 `0.53~0.93` 으로 갈렸다. 폴백 체인이 +살아 있으면 429 한 번에 회차 하나가 다른 모델로 판정되고, 그 혼입은 프롬프트 효과와 +구분되지 않는다. 그래서 체인을 `openai:gpt-4o-mini` 하나로 강제했다. 그 값은 기본 체인의 1순위이므로 프로덕션 대표성도 유지된다. 실제로 답한 모델을 회차 -파일에 남기고 집계가 확인한다 — **설정을 읽어서 적으면 틀린다**(T43). +파일에 남기고 집계가 확인한다. 설정값을 읽어서 적으면 실제 답한 모델과 다를 수 +있다(T43). ## 2. 비결정성이라는 바닥 @@ -105,15 +105,15 @@ C 회차끼리 45 min 5 · 중앙 9 · max 13 A 대 C 100 min 5 · 중앙 10 · max 17 ``` -**같은 조건 중앙 10 · 다른 조건 중앙 10.** 프롬프트를 바꾸는 것이 같은 프롬프트를 다시 -돌리는 것과 구분되지 않는다. +같은 조건끼리 비교해도 중앙 10, 다른 조건끼리 비교해도 중앙 10 이다. 프롬프트를 +바꾸는 것이 같은 프롬프트를 다시 돌리는 것과 구분되지 않는다. -A 회차끼리의 중앙 11/42(26%)는 `-210` 이 대조군으로 잰 11/42 와 정확히 같다. **다른 -하네스·다른 회차로 재현됐다** — 그 값은 우연이 아니라 이 판정 경로의 성질이다. +A 회차끼리의 중앙 11/42(26%)는 `-210` 이 대조군으로 잰 11/42 와 정확히 같다. 다른 +하네스·다른 회차로 재현된 것이므로, 그 값은 우연이 아니라 이 판정 경로의 성질이다. ## 3. 결과 -### 3.1 B — 규칙을 정밀하게 써도 안 듣는다 +### 3.1 B — 규칙을 정밀하게 써도 효과가 없다 A·B 각 5회. @@ -124,22 +124,23 @@ A·B 각 5회. | 경계 unclear 행 | 9.00 ± 1.22 | 10.40 ± 0.89 | +1.40 | 겹침 | | **fit 0건 Context** | **8.00 ± 0.00** | **8.00 ± 0.00** | **0.00** | — | -**B 가 이름으로 지목한 유형이 하나도 안 움직였다.** 5회 중 5회 붙는 오분류 넷 -(`265 WALK` · `274 DRINK` · `289 DRINK` · `284 WITH_FAMILY`)은 전부 B 가 조항으로 겨눈 -것들이다 — 활동 미언급 · 메뉴 연상 · 동행 미언급. 그대로 5/5 였다. +B 가 이름으로 지목한 유형이 하나도 움직이지 않았다. 5회 중 5회 붙는 오분류 넷 +(`265 WALK` · `274 DRINK` · `289 DRINK` · `284 WITH_FAMILY`)은 전부 B 가 조항으로 +지목한 유형들이다(활동 미언급 · 메뉴 연상 · 동행 미언급). 그런데 그대로 5/5 였다. 「무엇이 근거가 아닌가」를 더 정밀하게 쓰는 방향은 여기서 답이 나왔다. 추가 측정을 -하지 않았다 — 평균 차이 0.6행은 표본을 늘려 갈릴 값이 아니고, **그 판단을 더 재기 전에** -내렸다. +하지 않았다. 평균 차이 0.6행은 표본을 늘려서 판가름날 값이 아니라고 보았고, 그 +판단을 더 재기 전에 내렸다. ### 3.2 C — 개선 신호는 있으나 기준을 넘지 못한다 -축을 바꿨다. 규칙을 더 쓰는 대신 **고르는 것이 기본인가 고르지 않는 것이 기본인가**를 -건드렸다. A·B 모두 모델에게 후보 목록이 무엇인지 말한 적이 없고, 모델 입장에서 그것은 -누군가 골라 준 목록으로 읽힌다. 실제로는 임베딩이 긁어 온 평균 7.4개다. +축을 바꿨다. 규칙을 더 쓰는 대신, 고르는 것이 기본인가 고르지 않는 것이 기본인가를 +건드렸다. A·B 모두 모델에게 후보 목록이 어디서 왔는지 말한 적이 없고, 모델 입장에서 +그것은 누군가 골라 준 목록으로 읽힌다. 실제로는 임베딩이 유사도로 뽑아 온 평균 +7.4개다. -5회에서 Δ **-2.60**행이 나왔다. 사전 기준(범위 비중첩)은 못 넘었다(A 9~14 · C 6~12). -**표본을 10회로 늘렸다.** +5회에서 Δ -2.60행이 나왔다. 사전 기준(범위 비중첩)은 넘지 못했다(A 9~14 · C 6~12). +그래서 표본을 10회로 늘렸다. | 지표 | A (10회) | C (10회) | Δ | 범위 | p | |---|---|---|---|---|---| @@ -150,64 +151,66 @@ A·B 각 5회. | 선택 0건 Context | 3.10 ± 0.74 | 3.50 ± 0.97 | +0.40 | 겹침 | 0.446 | | **fit 0건 Context** | **8.00 ± 0.00** | **8.00 ± 0.00** | **0.00** | — | 1.000 | -**표본을 늘리자 효과가 -2.60 에서 -1.60 으로 줄었다.** 5회에서 멈추고 기준을 낮췄다면 +표본을 늘리자 효과가 -2.60 에서 -1.60 으로 줄었다. 5회에서 멈추고 기준을 낮췄다면 부풀린 값을 채택했을 것이다. -``` -교환비 줄인 오분류 +1.60 · 잃은 정상 +0.30 → 0.19 - `-210` 이 τ 에서 기각한 1.22 보다 훨씬 좋다 -순이득 낙관(경계=오분류) +0.30 · 비관(경계=정상) +2.30 — 부호는 안정적이다 -``` +교환비(줄인 오분류 +1.60 대 잃은 정상 +0.30)는 0.19 로, `-210` 이 τ 에서 기각한 +1.22 보다 훨씬 좋다. 순이득은 경계 라벨을 오분류로 계산하면 +0.30, 정상으로 계산하면 ++2.30 으로 부호가 안정적이다. -**교환비만 보면 채택할 만하다.** 그런데 그 앞에 서야 할 질문을 못 넘었다 — 이 차이가 -회차 운인가 아닌가. 사전 기준(범위 분리)도 보강 기준(순열검정 p<0.05)도 통과하지 -못했다. 그리고 **사용자가 보는 손실(fit 0건 Context)이 세 조건 모두 정확히 8.00 ± -0.00** 이다. 표준편차가 0 이라는 것은 회차 운으로도 안 움직인다는 뜻이다. +교환비만 보면 채택할 만하다. 그런데 그 앞에 서야 할 질문을 넘지 못했다. 이 차이가 +회차 운인가 아닌가라는 질문이다. 사전 기준(범위 분리)도 보강 기준(순열검정 +p<0.05)도 통과하지 못했다. 그리고 사용자가 보는 손실(fit 0건 Context)이 세 조건 모두 +정확히 8.00 ± 0.00 이다. 표준편차가 0 이라는 것은 회차 운으로도 움직이지 않는다는 +뜻이다. -### 3.3 C 가 줄인 것은 C 가 겨눈 것이 아니다 +### 3.3 C 가 줄인 것은 C 가 지목한 유형이 아니다 오분류 30종의 회차별 선택 빈도(A·C 각 10회 중). -| 행 | A | C | C 가 겨눴나 | +| 행 | A | C | C 가 지목했나 | |---|---|---|---| -| `274 DRINK` (가지튀김 → 술자리) | 10 | **10** | 겨눔(메뉴 연상) | -| `289 DRINK` (같은 본문) | 10 | **10** | 겨눔 | -| `265 WALK` (가게 안 기록 → 산책) | 9 | **10** | 겨눔(활동 미언급) | -| `266 TRENDY` (브런치카페 → 감성적) | 8 | **10** | 겨눔(업종 연상) | -| `284 WITH_FAMILY` (동행 없음) | 8 | **9** | 겨눔(동행 규칙) | -| `275 VIEW_GOOD` (언덕 → 전망) | 7 | 8 | 겨눔(정의 어긋남) | +| `274 DRINK` (가지튀김 → 술자리) | 10 | **10** | 지목(메뉴 연상) | +| `289 DRINK` (같은 본문) | 10 | **10** | 지목 | +| `265 WALK` (가게 안 기록 → 산책) | 9 | **10** | 지목(활동 미언급) | +| `266 TRENDY` (브런치카페 → 감성적) | 8 | **10** | 지목(업종 연상) | +| `284 WITH_FAMILY` (동행 없음) | 8 | **9** | 지목(동행 규칙) | +| `275 VIEW_GOOD` (언덕 → 전망) | 7 | 8 | 지목(정의 어긋남) | | `278 GATHERING` | 4 | 6 | — | -| `273 QUICK_STOP` (정찬 → 간단하게) | 7 | **5** | 겨눔(정의 어긋남) | +| `273 QUICK_STOP` (정찬 → 간단하게) | 7 | **5** | 지목(정의 어긋남) | | `286 ALONE` (맛 불평뿐인 글) | 7 | **5** | — | -| `279 DESSERT` (라멘집 → 디저트) | 4 | **1** | 겨눔(업종 연상) | +| `279 DESSERT` (라멘집 → 디저트) | 4 | **1** | 지목(업종 연상) | | `269 VIEW_GOOD` · `259 GATHERING` · `268 CELEBRATION` | 2 | **0** | 일부 | -**방향이 일관되지 않다.** C 가 조항으로 지목한 유형 중 다섯이 오히려 늘었고(`265`·`266`· -`284`·`275`·`278`), 셋이 줄었다(`273`·`279`·`269`계열). 순효과 -1.60행은 그 상쇄의 잔액이다. +방향이 일관되지 않다. C 가 조항으로 지목한 유형 중 다섯이 오히려 +늘었고(`265`·`266`·`284`·`275`·`278`), 셋이 줄었다(`273`·`279`·`269`계열). 순효과 +-1.60행은 그 상쇄의 잔액이다. -**프롬프트에 쓴 규칙과 실제로 움직인 것 사이에 대응이 없다.** 규칙이 겨냥대로 작동한 +프롬프트에 쓴 규칙과 실제로 움직인 오분류 사이에 대응이 없다. 규칙이 의도대로 작동한 것이 아니라 판정의 전반적 성향이 조금 이동했고, 그 이동이 어떤 것은 고치고 어떤 것은 악화시켰다. -### 3.4 오분류는 표적이 아니라 분포다 +### 3.4 오분류는 고정된 대상이 아니라 회차마다 바뀌는 분포다 -``` -현행 판정 1회가 낸 오분류 10종 (labels.yaml) -회차 25개에서 나타난 오분류 30종 -A 에서 10/10 붙는 것 2종 (274·289 DRINK — 같은 본문 한 쌍) -A 에서 흔들리는 것 25종 -대조: 흔들리는 fit 6종 (63종 중) -``` +| 항목 | 값 | +|---|---| +| 현행 판정 1회가 낸 오분류 (labels.yaml) | 10종 | +| 회차 25개에서 나타난 오분류 | 30종 | +| A 에서 10/10 붙는 것 | 2종 (274·289 DRINK — 같은 본문 한 쌍) | +| A 에서 회차마다 나타났다 사라지는 것 | 25종 | +| 대조: 회차마다 달라지는 fit | 6종 (63종 중) | -**정상 판정은 안정적이고 오분류만 흔들린다.** 규칙이 겨눌 고정된 표적이 사실상 두 -개뿐이고 — 그마저 같은 본문의 중복 쌍이라 실질 하나 — 나머지는 회차마다 얼굴이 바뀐다. +정상 판정은 안정적이고 오분류만 회차마다 달라진다. 규칙이 지목할 수 있는 고정된 +오분류가 사실상 두 종뿐이고, 그마저 같은 본문의 중복 쌍이라 실질적으로 하나다. +나머지는 회차마다 다른 행에서 나타난다. -`-210` §4.1 이 중복 본문 5쌍에서 같은 것을 봤다(한쪽에만 나타난 `CELEBRATION`·`VIEW_GOOD` -이 전부 unfit). 그때는 표본 5쌍이었고 여기서는 회차 25개로 직접 셌다. **오분류는 「항상 -붙는 것」이 아니라 「흔들릴 때 붙는 것」이다.** +`-210` §4.1 이 중복 본문 5쌍에서 같은 현상을 봤다(한쪽에만 나타난 +`CELEBRATION`·`VIEW_GOOD` 이 전부 unfit). 그때는 표본 5쌍이었고 여기서는 회차 25개로 +직접 셌다. 오분류는 「항상 붙는 것」이 아니라 「판정이 흔들릴 때 붙는 것」이다. -프롬프트 규칙은 「항상 붙는 것」을 겨누는 도구다. 그것이 하나뿐인 문제에 그 도구를 대면 -잔액이 노이즈와 같은 크기로 나온다 — 그것이 §3.2 의 숫자다. +프롬프트 규칙은 「항상 붙는 것」을 지목해 고치는 도구다. 고정된 대상이 하나뿐인 +문제에 그 도구를 쓰면 순효과가 판정 변동과 같은 크기로 나온다. 그것이 §3.2 의 +숫자다. ## 4. 논의 포인트에 대한 판단 @@ -215,70 +218,71 @@ A 에서 흔들리는 것 25종 ### 4.1 「본문에 근거 없으면 미선택」을 어떻게 표현할 것인가 -**규칙 문장으로 표현하는 방향은 답이 나왔다 — 그 축으로는 안 된다.** +규칙 문장으로 표현하는 방향은 답이 나왔다. 그 축으로는 안 된다. -현행 A 에도 「글에서 근거를 찾을 수 있는 것만 고릅니다」가 **이미 있다.** 없어서 안 되는 -것이 아니라 「근거」가 모델에게 느슨한 것이 문제였고, B 는 그것을 유형으로 좁혔는데 -(업종·메뉴 연상 배제 · 동행 실등장 요구 · 정의 어긋남 배제) 효과가 -0.60행이었다. +현행 A 에도 「글에서 근거를 찾을 수 있는 것만 고릅니다」가 이미 있다. 규칙이 없어서 +안 되는 것이 아니라 「근거」가 모델에게 느슨하게 읽히는 것이 문제였고, B 는 그것을 +유형으로 좁혔는데(업종·메뉴 연상 배제 · 동행 실등장 요구 · 정의 어긋남 배제) 효과가 +-0.60행이었다. `description` 대조를 절차로 요구하는 것(티켓이 준 힌트)은 B·C 둘 다에 넣었다. 그것만 -따로 재지는 않았다 — B 전체가 0 이므로 그 안의 한 조항을 갈라 재도 0 아래에서 갈릴 값이 -없다. +따로 재지는 않았다. B 전체가 0 이므로 그 안의 한 조항을 갈라 재도 0 아래에서 갈릴 +값이 없다. -예시는 **일부러 넣지 않았다.** 42건으로 측정하면서 그 42건의 오답을 프롬프트에 적으면 -측정이 자기충족이 된다. 그 대가로 효과가 약해졌을 수 있고 그것이 이 결론의 한계다 — -실데이터 예시를 넣으면 그 예시에 해당하는 건은 잡히겠지만, **그렇게 얻은 수치는 다음 -데이터에서 재현되지 않는다.** +예시는 일부러 넣지 않았다. 42건으로 측정하면서 그 42건의 오답을 프롬프트에 적으면 +측정이 자기충족이 된다. 그 대가로 효과가 약해졌을 수 있고 그것이 이 결론의 한계다. +실데이터 예시를 넣으면 그 예시에 해당하는 건은 잡히겠지만, 그렇게 얻은 수치는 다음 +데이터에서 재현되지 않는다. ### 4.2 정상 판정 손실을 어디까지 감수할 것인가 -**`-210` 의 교환비 기준을 그대로 적용했고, 그 기준을 대는 자리까지 못 갔다.** +`-210` 의 교환비 기준을 그대로 적용했고, 그 기준을 적용할 자리까지 가지 못했다. -C 의 교환비는 0.19 로 τ 의 최선(1.22)보다 훨씬 좋다 — 오분류를 줄이면서 정상 판정을 -거의 잃지 않는다. 그런데 **교환비는 「효과가 있다」가 먼저 서야 의미가 있는 값**이고, +C 의 교환비는 0.19 로 τ 의 최선(1.22)보다 훨씬 좋다. 오분류를 줄이면서 정상 판정을 +거의 잃지 않는다. 그런데 교환비는 「효과가 있다」가 먼저 성립해야 의미가 있는 값이고, 그 앞 질문을 통과하지 못했다. -τ 와 프롬프트는 손실의 성격이 다르다는 것도 함께 봤다. τ 는 후보를 없애 카드를 통째로 -비게 하고(부당하게 빔), 프롬프트는 개별 판단만 바꾼다. 그래서 Context 단위 지표를 함께 -냈고 — **fit 0건 Context 가 세 조건 모두 8.00 ± 0.00 이었다.** 사용자가 보는 것은 -프롬프트로 움직이지 않는다. +τ 와 프롬프트는 손실의 성격이 다르다는 것도 함께 봤다. τ 는 후보를 없애 카드를 +통째로 비게 하고(부당하게 빈 Context), 프롬프트는 개별 판단만 바꾼다. 그래서 Context +단위 지표를 함께 냈는데, fit 0건 Context 가 세 조건 모두 8.00 ± 0.00 이었다. +사용자가 보는 결과는 프롬프트로 움직이지 않는다. ### 4.3 벤더별로 다르게 작용하는 것을 어떻게 다룰 것인가 -**폴백을 끄고 단일 벤더로 고정했다.** §1.4. 이 티켓에서 벤더를 조건으로 두지 않았다 — -프롬프트 효과가 벤더 하나에서 이미 노이즈 수준이라, 벤더를 조건에 더하면 조건이 6개가 -되고 호출이 배가 되는데 **더 큰 노이즈 위에서 더 작은 신호를 찾는 일**이 된다. +폴백을 끄고 단일 벤더로 고정했다(§1.4). 이 티켓에서 벤더를 조건으로 두지 않았다. +프롬프트 효과가 벤더 하나에서 이미 판정 변동 수준이라, 벤더를 조건에 더하면 조건이 +6개가 되고 호출이 배가 되는데 더 큰 변동 위에서 더 작은 신호를 찾는 일이 된다. -다만 이 결론의 적용 범위가 그만큼 좁다. `gpt-4o-mini` 에서 안 되는 것이 `gemini-2.5-flash` -나 `claude-haiku` 에서도 안 된다는 근거는 이 측정에 없다. §7 에 한계로 적었다. +다만 이 결론의 적용 범위가 그만큼 좁다. `gpt-4o-mini` 에서 안 되는 것이 +`gemini-2.5-flash` 나 `claude-haiku` 에서도 안 된다는 근거는 이 측정에 없다. §7 에 +한계로 적었다. ### 4.4 다루지 않는 게 나은 것 -**셋 다 다뤘다.** 대신 티켓에 없던 것 하나를 범위 밖으로 밀었다 — §6 의 confidence -필터다. 그것은 프롬프트가 아니라 코드 층이고, 예비 관측이 재현되지 않아 지금 제안하면 -틀린 방향을 지목하게 된다. +티켓이 물은 셋은 다 다뤘다. 대신 티켓에 없던 것 하나를 범위 밖으로 뺐다. §6 의 +confidence 필터다. 그것은 프롬프트가 아니라 코드 층이고, 예비 관측이 재현되지 않아 +지금 제안하면 틀린 방향을 지목하게 된다. ## 5. 그래서 왜 안 되는가 τ 가 안 됐던 이유와 프롬프트가 안 되는 이유가 다르다. -``` -τ 적합·부적합의 유사도 분포가 겹친다 — 가를 선이 없다 -프롬프트 오분류가 고정된 표적이 아니다 — 겨눌 것이 없다 -``` +- **τ** — 적합·부적합의 유사도 분포가 겹친다. 임계값을 어디에 두어도 둘을 가를 수 + 없다. +- **프롬프트** — 오분류가 고정된 대상이 아니다. 규칙이 지목할 것이 없다. `-210` §6 은 「유사도는 이 프리셋이 이 본문과 얼마나 가까운가를 재지, 붙여도 되는가를 -재지 않는다」고 썼고, 붙여도 되는지를 재는 층이 판정 프롬프트라고 지목했다. **그 진단은 -맞지만 처방이 따라오지 않는다.** 판정 층이 그 판단을 하는 곳인 것은 맞는데, 같은 입력에 -같은 후보를 주고 같은 프롬프트로 물어도 Context 26% 에서 답이 달라진다. 판단 기준을 -아무리 정밀하게 써도 **기준을 적용하는 쪽이 매번 같은 답을 내지 않는다.** +재지 않는다」고 썼고, 붙여도 되는지를 재는 층이 판정 프롬프트라고 지목했다. 그 진단은 +맞지만 처방이 따라오지 않는다. 판정 층이 그 판단을 하는 곳인 것은 맞는데, 같은 +입력에 같은 후보를 주고 같은 프롬프트로 물어도 Context 26% 에서 답이 달라진다. 판단 +기준을 아무리 정밀하게 써도, 기준을 적용하는 쪽이 매번 같은 답을 내지 않는다. -남는 방향은 프롬프트 문구가 아니라 **판정 자체의 분산을 줄이는 것**이다(§8). +남는 방향은 프롬프트 문구가 아니라 판정 자체의 분산을 줄이는 것이다(§8). ## 6. 검토하고 범위 밖으로 둔 것 — confidence 필터 -측정 중에 유망해 보이는 신호가 하나 나왔다. DB 에 저장된 현행 판정 83행의 `confidence` -분포다. +측정 중에 유망해 보이는 신호가 하나 나왔다. DB 에 저장된 현행 판정 83행의 +`confidence` 분포다. ``` fit n=63 min 0.70 p50 0.90 max 1.00 @@ -288,10 +292,11 @@ unclear n=10 min 0.60 p50 0.80 max 0.90 하한 0.70 을 두면 unfit 4건 제거 · fit 유실 0 · 교환비 0.00 ``` -**유사도가 못 한 것을 confidence 는 한다** — τ 는 어느 값에서도 교환비 1.22 가 최선이었다. +유사도가 하지 못한 분리를 confidence 는 해내는 것처럼 보였다. τ 는 어느 값에서도 +교환비 1.22 가 최선이었다. -**그런데 재현되지 않는다.** 같은 조건으로 판정을 한 번 더 돌려(`--outdir .prompt_ab/probe`, -42회) confidence 를 다시 받았다. +그런데 재현되지 않는다. 같은 조건으로 판정을 한 번 더 +돌려(`--outdir .prompt_ab/probe`, 42회) confidence 를 다시 받았다. ``` fit n=62 min 0.60 p50 0.90 @@ -300,13 +305,13 @@ unfit n=8 min 0.60 p50 0.80 하한 0.70 을 두면 unfit 1건 제거 · fit 유실 2 — 분리가 사라진다 ``` -DB 저장분에서 본 깨끗한 분리는 **그 1회의 우연이었다.** 판정이 내는 것은 선택뿐 아니라 +DB 저장분에서 본 깨끗한 분리는 그 1회의 우연이었다. 판정이 내는 것은 선택뿐 아니라 confidence 도 비결정적이고, 1회 관측으로 문턱을 정하면 다음 판정에서 무너진다. -이 티켓의 결론을 한 번 더 확인해 주는 결과다 — **판정 층의 어떤 신호든 1회 관측으로 -판단하면 안 된다.** 그래서 후속으로 제안하되(§8) 「유망하다」가 아니라 「회차를 반복해 -재야 알 수 있다」로 적는다. 하네스에는 confidence 기록을 넣어 두었으므로 후속이 재측정 -없이 바로 이어 갈 수 있다. +이 티켓의 결론을 한 번 더 확인해 주는 결과다. 판정 층의 어떤 신호든 1회 관측으로 +판단하면 안 된다. 그래서 후속으로 제안하되(§8) 「유망하다」가 아니라 「회차를 반복해 +재야 알 수 있다」로 적는다. 하네스에는 confidence 기록을 넣어 두었으므로 후속이 +재측정 없이 바로 이어 갈 수 있다. ## 7. 검증 @@ -314,7 +319,7 @@ confidence 도 비결정적이고, 1회 관측으로 문턱을 정하면 다음 |---|---| | 데이터 | `pinlog-demo`(:15432) · Context 42건(고유 본문 37 + 중복 5) · 프리셋 27 · 현행 판정 83행 | | profile | `openai-text-embedding-3-small-1536-cosine-v1` | -| 판정 모델 | **`gpt-4o-mini`** — 회차 파일의 `JudgeResult.model` 실측. 설정이 아니라 답한 값이다(T43) | +| 판정 모델 | **`gpt-4o-mini`** — 회차 파일의 `JudgeResult.model` 실측. 설정값이 아니라 실제로 답한 값이다(T43) | | GMS 호출 | 판정 1,092회 (A 420 · B 210 · C 420 · 확인용 42). 실패 0 · 임베딩 0 | | 코드 변경 | **없음.** `llm_client.SYSTEM` 을 건드리지 않았다 | @@ -328,32 +333,34 @@ export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" .venv/Scripts/python.exe tools/prompt_ab/score_ab.py --base A --cond C ``` -`run.py` 는 이미 있는 회차를 건너뛴다 — 중단되면 이어 달린다. +`run.py` 는 이미 있는 회차를 건너뛴다. 중단되면 이어서 실행할 수 있다. ### 검증하지 않은 것 -- **다른 벤더** — `gpt-4o-mini` 하나로만 쟀다(§4.3). 폴백 체인의 2·3순위에서 같은 결론이 - 나온다는 근거가 이 측정에 없다 -- **라벨의 교차 검증** — `-210` 과 같은 한계다. 여기에 더해 `labels_extra.yaml` 24행은 - **개정 프롬프트를 설계한 사람이 붙였다.** 조건은 가렸지만 개정안이 노린 유형은 알고 - 있었다(T45) -- **B 의 10회 확장** — 5회에서 Δ -0.60 이라 늘려 갈릴 값이 아니라고 보고 접었다. - 그 판단을 더 재기 전에 내렸다 -- **C 의 조항별 몫** — C 는 B 에 셋을 더한 것인데 셋을 갈라 재지 않았다. B 단독이 0 - 이므로 C 의 효과는 전부 추가분의 몫이나, 그 셋 중 어느 것인지는 모른다 -- **실제 서버 E2E** — 프롬프트를 바꾸지 않기로 했으므로 돌리지 않았다 -- **중복 5쌍의 영향** — `-210` 과 같다. 42건 기준으로 냈고 37건 기준으로 다시 내지 않았다 +- **다른 벤더** — `gpt-4o-mini` 하나로만 쟀다(§4.3). 폴백 체인의 2·3순위에서 같은 + 결론이 나온다는 근거가 이 측정에 없다. +- **라벨의 교차 검증** — `-210` 과 같은 한계다. 여기에 더해 `labels_extra.yaml` + 24행은 개정 프롬프트를 설계한 사람이 붙였다. 어느 조건이 고른 행인지는 가렸지만, + 개정안이 지목한 유형은 알고 있었다(T45). +- **B 의 10회 확장** — 5회에서 Δ -0.60 이라 표본을 늘려도 판가름날 값이 아니라고 + 보고 중단했다. 그 판단을 더 재기 전에 내렸다. +- **C 의 조항별 몫** — C 는 B 에 조항 셋을 더한 것인데 셋을 갈라 재지 않았다. B + 단독이 0 이므로 C 의 효과는 전부 추가분의 몫이나, 그 셋 중 어느 것인지는 모른다. +- **실제 서버 E2E** — 프롬프트를 바꾸지 않기로 했으므로 돌리지 않았다. +- **중복 5쌍의 영향** — `-210` 과 같다. 42건 기준으로 냈고 37건 기준으로 다시 내지 + 않았다. ## 8. 후속 제안 | 제안 | 근거 | 비고 | |---|---|---| -| **판정 분산 자체를 줄인다** — 같은 입력을 n회 판정해 다수결(self-consistency) | §2·§3.4. 오분류의 25/27종이 흔들림의 산물이고, 문구로는 닿지 않는다 | 티켓 미발행. **이 티켓의 실질적 후속.** 호출이 n배라 비용·지연과 함께 봐야 한다 | +| **판정 분산 자체를 줄인다** — 같은 입력을 n회 판정해 다수결(self-consistency) | §2·§3.4. A 에서 관측된 오분류 27종 중 25종이 판정 변동의 산물이고, 프롬프트 문구로는 해결되지 않는다 | 티켓 미발행. **이 티켓의 실질적 후속.** 호출이 n배라 비용·지연과 함께 봐야 한다 | | confidence 문턱을 **회차 반복으로** 재본다 | §6. 1회분은 깨끗이 갈렸으나 다음 1회에서 무너졌다. 어느 쪽이 참인지 모른다 | 하네스에 기록을 넣어 두었다. `run.py` 를 돌리면 바로 모인다 | | 벤더를 조건으로 둔 재측정 | §4.3. 이 결론의 적용 범위가 `gpt-4o-mini` 하나다 | 분산이 큰 위에서 재는 것이라 회차를 더 늘려야 한다 | -| 라벨 교차 검증 | 결론이 라벨 한 명에 걸려 있고, 그 한 명이 개정안 설계자다 | 이의는 해당 행만 고치고 `score_ab.py` 한 번. GMS 를 안 부른다 | -| 저빈도·오작동 프리셋 점검 | `-210` 이 남긴 것. `274/289 DRINK` 가 10/10 으로 굳어 있는 것도 프리셋 쪽 문제일 수 있다 | 프롬프트·τ 와 무관 | +| 라벨 교차 검증 | 결론이 판정자 한 명의 라벨에 걸려 있고, 그 한 명이 개정안 설계자다 | 이의는 해당 행만 고치고 `score_ab.py` 한 번. GMS 를 부르지 않는다 | +| 저빈도·오작동 프리셋 점검 | `-210` 이 남긴 것. `274/289 DRINK` 가 10/10 으로 매 회차 나타나는 것도 프리셋 쪽 문제일 수 있다 | 프롬프트·τ 와 무관 | -**「항상 붙는 오분류」가 실질 하나(가지튀김 → `DRINK`)뿐이라는 것이 마지막 줄의 근거다.** -그 하나는 프롬프트 규칙으로 못 고쳤지만 프리셋 `description`·`examples` 를 고치면 닿을 -수 있다 — 그것은 판정 층이 아니라 **후보의 정의**를 바꾸는 일이다. +「항상 붙는 오분류」가 실질적으로 하나(가지튀김 → `DRINK`)뿐이라는 것이 마지막 줄의 +근거다. 그 하나는 프롬프트 규칙으로 고치지 못했지만, 프리셋 +`description`·`examples` 를 고치면 해결할 수 있을 것으로 본다. 그것은 판정 층이 +아니라 후보의 정의를 바꾸는 일이다. diff --git a/docs/implements/2026-07-31-judge-vote.md b/docs/implements/2026-07-31-judge-vote.md index d497852..faaa387 100644 --- a/docs/implements/2026-07-31-judge-vote.md +++ b/docs/implements/2026-07-31-judge-vote.md @@ -1,9 +1,9 @@ -# 판정 n회 다수결 — 흔들림은 지워지지만 오분류는 거의 안 준다. n=1 을 유지한다 +# 판정 n회 다수결 — 판정 변동은 지워지지만 오분류 행 수는 거의 줄지 않는다. n=1 을 유지한다 - **티켓**: S15P11A705-223 - **날짜**: 2026-07-31 - **선행**: [τ](2026-07-31-candidate-threshold.md)(`-210`) · [프롬프트](2026-07-31-judge-prompt-rule.md)(`-219`) — 라벨과 데이터를 그대로 물려받았다 -- **함정**: [T57~T60](../troubleshooting/2026-07-31-judge-vote.md) +- **측정 중 발견한 문제**: [T57~T60](../troubleshooting/2026-07-31-judge-vote.md) - **하네스**: `tools/judge_vote/` (+ `-219` 의 `tools/prompt_ab/`) ## 요약 @@ -11,48 +11,50 @@ **`PINLOG_JUDGE_VOTE_N` 을 1 로 둔다.** 다수결 경로는 넣었고 되돌릴 수 있게 했지만 켜지 않는다. -``` -다수결은 설계대로 작동한다 비결정성 24% → 12% (n=5) · 오분류 레퍼토리 25종 → 12종 -그런데 오분류 행은 안 준다 10.13 → 9.17행 (n=5, -0.96) · 범위 겹침 · p=0.21 -fit 0건 Context 8.03 → 8.00 → 8.00 — 세 번째 티켓 연속으로 안 움직인다 -비용 호출 5배 · Context당 지연 1.57s → 2.60s -``` +| 항목 | 결과 | +|---|---| +| 다수결은 설계대로 작동한다 | 비결정성 24% → 12% (n=5) · 오분류 레퍼토리 25종 → 12종 | +| 그런데 오분류 행 수는 줄지 않는다 | 10.13 → 9.17행 (n=5, -0.96) · 회차 범위 겹침 · p=0.21 | +| fit 0건 Context | 8.03 → 8.00 → 8.00. 세 번째 티켓 연속으로 움직이지 않는다 | +| 비용 | 호출 5배 · Context당 지연 1.57s → 2.60s | -**이 티켓은 앞의 둘과 다른 이유로 끝난다.** `-210` 은 가를 선이 없어서, `-219` 는 겨눌 -표적이 없어서 실패했다. 다수결은 **겨눈 것을 정확히 맞혔다** — 흔들리는 오분류 13종을 -완전히 지웠다. 그런데 행 수가 안 줄었다. 지운 것들이 원래 드물었고, 동시에 **자주 -나타나던 오분류 7종을 100% 로 굳혔기** 때문이다(§4). +이 티켓은 앞의 둘과 다른 이유로 끝난다. `-210` 은 적합·부적합을 가를 임계값이 없어서 +실패했고, `-219` 는 규칙이 지목할 고정된 오분류가 없어서 실패했다. 다수결은 의도한 +대상을 정확히 제거했다. 회차마다 나타났다 사라지던 오분류 13종을 완전히 지웠다. +그런데도 행 수가 줄지 않았다. 지운 것들이 원래 드물게 나타났고, 동시에 자주 나타나던 +오분류 7종을 100% 출현으로 고정시켰기 때문이다(§4). -남은 오분류는 흔들림이 아니다. 그래서 판정 층에서 더 할 것이 없다 — 그쪽은 프리셋 -`description` 축이고 `back#136` 이 이미 들고 있다. +남은 오분류는 판정 변동의 산물이 아니다. 그래서 판정 층에서 더 할 것이 없다. 남은 +문제는 프리셋 `description` 축이고 `back#136` 이 이미 다루고 있다. ## 1. 무엇을 어떻게 쟀나 -### 1.1 새로 부르지 않고 접는다 +### 1.1 새로 부르지 않고 기존 회차를 조합한다 -n회 다수결 1회분은 「같은 조건에서 독립으로 뽑은 판정 n개를 다수결한 것」이다. `-219` -하네스의 회차 하나가 정확히 그 「독립으로 뽑은 판정」이다. 그러므로 회차 3개를 묶어 -다수결한 것과 n=3 을 실제로 한 번 돌린 것은 **같은 확률변수**다. +n회 다수결 1회분은 「같은 조건에서 독립으로 뽑은 판정 n개를 다수결한 것」이다. +`-219` 하네스의 회차 하나가 정확히 그 「독립으로 뽑은 판정」이다. 그러므로 회차 3개를 +묶어 다수결한 것과 n=3 을 실제로 한 번 돌린 것은 같은 확률변수다. ``` 따로 돌리면 n=1 ×10 + n=3 ×10 + n=5 ×6 = 420 + 1,260 + 1,260 = 2,940 호출 -접으면 회차 30개 = 1,260 호출로 셋을 전부 얻는다 +조합하면 회차 30개 = 1,260 호출로 셋을 전부 얻는다 ``` -`-210` 이 유사도 행렬 하나로 임의의 τ 를 재구성한 것과 같은 수법이다. 저쪽은 τ 가 -임베딩을 안 바꿔서 됐고, 이쪽은 회차가 서로 독립이라 된다. +`-210` 이 유사도 행렬 하나로 임의의 τ 를 재구성한 것과 같은 방법이다. 그쪽은 τ 가 +임베딩을 바꾸지 않아서 가능했고, 이쪽은 회차가 서로 독립이라 가능하다. -**다수결 규칙은 서비스 코드를 그대로 부른다**(`app.service.judge_vote.combine`). 여기 -같은 식을 다시 적으면 「우리가 잰 규칙」과 「서버가 쓰는 규칙」이 갈라진다. +다수결 규칙은 서비스 코드를 그대로 부른다(`app.service.judge_vote.combine`). 하네스에 +같은 식을 다시 적으면 「우리가 잰 규칙」과 「서버가 쓰는 규칙」이 갈라질 수 있기 +때문이다. -접은 것이 맞는지는 접어서 확인할 수 없으므로 **실제 경로를 따로 돌렸다**(§1.4). +조합이 맞는지는 조합만으로 확인할 수 없으므로 실제 경로를 따로 돌려 대조했다(§1.4). ### 1.2 회차를 30개로 잡은 이유 -`-219` 가 남긴 경고가 이 설계를 규정한다 — *"5회에서 멈추고 기준을 낮췄다면 부풀린 -값을 채택했을 것"*(Δ -2.60 이 10회에서 -1.60 으로 줄었다). +`-219` 가 남긴 경고가 이 설계를 규정한다. *"5회에서 멈추고 기준을 낮췄다면 부풀린 +값을 채택했을 것"* (Δ -2.60 이 10회에서 -1.60 으로 줄었다). -회차 30개면 접어서 조건별 관측이 이렇게 나온다. +회차 30개면 조합해서 조건별 관측이 이렇게 나온다. | 조건 | 관측 | 관측당 회차 | |---|---|---| @@ -60,45 +62,45 @@ n회 다수결 1회분은 「같은 조건에서 독립으로 뽑은 판정 n개 | n=3 | 10 | 3 | | n=5 | 6 | 5 | -n=3 의 관측 10개는 `-219` 가 C 에서 쓴 표본 크기와 같다. n=5 는 6개뿐이라 그만큼 약하고, -그 한계를 §8 에 적는다. +n=3 의 관측 10개는 `-219` 가 C 에서 쓴 표본 크기와 같다. n=5 는 6개뿐이라 그만큼 +약하고, 그 한계를 §8 에 적는다. ### 1.3 「오분류」를 무엇으로 셌나 -`-210` 의 `labels.yaml` 83행과 `-219` 의 `labels_extra.yaml` 24행을 **한 줄도 고치지 -않고** 그대로 썼다. 새 기준을 만들지 않는 것이 이 티켓의 계약이다. +`-210` 의 `labels.yaml` 83행과 `-219` 의 `labels_extra.yaml` 24행을 한 줄도 고치지 +않고 그대로 썼다. 새 기준을 만들지 않는 것이 이 티켓의 계약이다. -**라벨을 새로 붙이지 않았다.** 회차 30개에서 라벨 밖 행이 나왔지만(n=1 에서 관측당 +라벨을 새로 붙이지 않았다. 회차 30개에서 라벨 밖 행이 나왔지만(n=1 에서 관측당 0.80행) 붙이지 않고 `unlabeled` 로 따로 셌다. `-219` 가 *"라벨을 붙인 사람이 개정 프롬프트를 설계한 사람이기도 하다"* 를 한계로 남겼는데, 여기서 또 붙이면 그 한계가 -누적된다. 대신 `score_ab.py` 의 낙관·비관 양극단으로 결론이 뒤집히는지 본다 — 뒤집히지 -않았다(§3). +누적된다. 대신 `score_ab.py` 의 낙관·비관 양극단 계산으로 결론이 뒤집히는지 확인했다. +뒤집히지 않았다(§3). -### 1.4 접은 것과 실제로 부른 것을 대조했다 +### 1.4 조합한 것과 실제로 부른 것을 대조했다 -`tools/judge_vote/run_live.py` 가 `KeywordService._judge_n` 을 그대로 불러 실제로 n회 -호출한다. 동시 호출·정족수·다수결이 전부 그 메서드 안에 있으므로 **규칙을 다시 적지 -않는다**. +`tools/judge_vote/run_live.py` 가 `KeywordService._judge_n` 을 그대로 불러 실제로 +n회 호출한다. 동시 호출·정족수·다수결이 전부 그 메서드 안에 있으므로 규칙을 다시 +적지 않는다. -| 지표 | 접은 n=3 (10관측) | 실제 n=3 (3관측) | 범위 | p | +| 지표 | 조합 n=3 (10관측) | 실제 n=3 (3관측) | 범위 | p | |---|---|---|---|---| | 오분류 unfit 행 | 9.40 ± 1.78 | 8.33 ± 1.53 | 겹침 | 0.465 | | 정상 fit 행 | 62.20 ± 0.92 | 63.00 ± 0.00 | 겹침 | 0.262 | | fit 0건 Context | 8.00 ± 0.00 | 8.00 ± 0.00 | — | 1.000 | -**어느 지표도 갈리지 않는다.** 호출 수도 Context 1건당 정확히 126/42 = 3.0 회였다 -(재시도가 섞이면 어긋난다). 실경로 관측이 3개뿐이라 강한 검정은 아니고, **접은 값이 -틀렸다는 증거가 없다**까지가 이 대조가 말하는 것이다. +어느 지표도 갈리지 않는다. 호출 수도 Context 1건당 정확히 126/42 = 3.0 회였다 +(재시도가 섞이면 이 값이 어긋난다). 실경로 관측이 3개뿐이라 강한 검정은 아니고, 이 +대조가 말하는 것은 「조합한 값이 틀렸다는 증거가 없다」까지다. ### 1.5 벤더를 하나로 묶었다 -`-219` 와 같은 이유로 `openai:gpt-4o-mini` 단일 고정. 폴백이 살아 있으면 회차마다 다른 -모델이 섞이고 그 혼입은 n 의 효과와 구분되지 않는다. 전 회차·전 실경로 호출이 +`-219` 와 같은 이유로 `openai:gpt-4o-mini` 단일 고정. 폴백이 살아 있으면 회차마다 +다른 모델이 섞이고 그 혼입은 n 의 효과와 구분되지 않는다. 전 회차·전 실경로 호출이 `gpt-4o-mini` 로 답했고 실패 0건이었다. ## 2. 다수결은 비결정성을 실제로 줄인다 -이것이 이 티켓의 전제였고, **전제는 맞았다.** +이것이 이 티켓의 전제였고, 전제는 맞았다. ``` 쌍 어긋난 Context (/42) 비율 @@ -107,16 +109,16 @@ n=3 관측끼리 45 min 4 · 중앙 7 · max 11 17% n=5 관측끼리 15 min 3 · 중앙 5 · max 9 12% ``` -n=1 의 중앙 10/42(24%)는 `-210`(11/42) · `-219`(11/42) 가 각각 잰 값과 같다. **세 번째 -하네스로 재현됐다.** +n=1 의 중앙 10/42(24%)는 `-210`(11/42) · `-219`(11/42) 가 각각 잰 값과 같다. 세 번째 +하네스에서도 재현된 것이다. -그리고 n 을 올리면 그 값이 절반으로 준다. **판정의 흔들림 자체는 다수결로 다룰 수 -있다** — 이것은 `-210` 도 `-219` 도 못 한 것이다. +그리고 n 을 올리면 그 값이 절반으로 준다. 판정의 변동 자체는 다수결로 다룰 수 있다. +이것은 `-210` 도 `-219` 도 하지 못한 일이다. -## 3. 그런데 오분류는 거의 안 준다 +## 3. 그런데 오분류 행 수는 거의 줄지 않는다 -`-210`·`-219` 와 같은 자를 그대로 댄다. 사전 기준은 **범위 비중첩**, 보강 기준은 -순열검정 `p<0.05` 다. +`-210`·`-219` 와 같은 판정 기준을 그대로 적용한다. 사전 기준은 범위 비중첩, 보강 +기준은 순열검정 `p<0.05` 다. | 지표 | n=1 (30관측) | n=3 (10관측) | n=5 (6관측) | |---|---|---|---| @@ -127,7 +129,8 @@ n=1 의 중앙 10/42(24%)는 `-210`(11/42) · `-219`(11/42) 가 각각 잰 값 | 선택 0건 Context | 3.10 ± 0.74 | 3.40 ± 0.70 | 3.50 ± 0.84 | | **fit 0건 Context** | **8.03 ± 0.18** | **8.00 ± 0.00** | **8.00 ± 0.00** | -관측 수를 맞춘 판정(n=1 은 앞 10관측, 순열검정은 T59 때문에 수를 맞춰야 한다). +아래는 관측 수를 맞춘 판정이다(n=1 은 앞 10관측만 쓴다. 순열검정은 T59 때문에 관측 +수를 맞춰야 한다). | | n=3 | n=5 | |---|---|---| @@ -137,87 +140,87 @@ n=1 의 중앙 10/42(24%)는 `-210`(11/42) · `-219`(11/42) 가 각각 잰 값 | 잃은 정상 판정 | **-0.40 (오히려 늘었다)** | **-0.53 (오히려 늘었다)** | | 순이득 낙관/비관 | +1.00 / +1.40 | +1.63 / +1.50 | -**교환비는 이 프로젝트에서 처음으로 음수다.** `-210` 의 τ 는 오분류 1건을 지우는 데 -정상 1.22건을 버렸고 그래서 기각됐다. 다수결은 **정상 판정을 버리지 않는다** — 오히려 -조금 늘린다(흔들려서 빠지던 fit 이 다수결로 살아난다). 순이득의 부호도 경계 라벨을 -어느 극단으로 돌리든 양수다. +교환비는 이 프로젝트에서 처음으로 음수다. `-210` 의 τ 는 오분류 1건을 지우는 데 정상 +판정 1.22건을 버렸고 그래서 기각됐다. 다수결은 정상 판정을 버리지 않는다. 오히려 +조금 늘린다(판정이 흔들려서 빠지던 fit 이 다수결로 살아난다). 순이득의 부호도 경계 +라벨을 어느 극단으로 계산하든 양수다. -그런데 **사전 기준도 보강 기준도 못 넘는다.** 그리고 `fit 0건 Context` 가 안 움직인다. +그런데 사전 기준도 보강 기준도 넘지 못한다. 그리고 `fit 0건 Context` 가 움직이지 +않는다. -`-219` 에서 이 값은 세 조건 모두 `8.00 ± 0.00` 이었다. 여기서도 n=3·n=5 가 `8.00 ± 0.00` -이고, n=1 만 `8.03 ± 0.18` 이다 — 30관측 중 한 번 9가 나왔다. **다수결이 이 값에 한 일은 -「줄인 것」이 아니라 「흔들리지 않게 만든 것」이다.** 사용자가 보는 손실은 그대로 8건이다. +`-219` 에서 이 값은 세 조건 모두 `8.00 ± 0.00` 이었다. 여기서도 n=3·n=5 가 +`8.00 ± 0.00` 이고, n=1 만 `8.03 ± 0.18` 이다(30관측 중 한 번 9가 나왔다). 다수결이 +이 값에 한 일은 「줄인 것」이 아니라 「변동하지 않게 만든 것」이다. 사용자가 보는 +손실은 그대로 8건이다. ### 3.1 라벨 없는 행이 사라진 것은 실질 개선이다 -`unlabeled` 가 0.80 → 0.10 → 0.00 으로 완전히 사라졌다. 이 행들은 **회차 30개 중 1~2회만 -나타난 일회성 선택**이고(§4 표에서 전부 <34% 구간), 라벨이 없는 이유도 `-210`·`-219` -어느 판정에서도 안 나왔기 때문이다. +`unlabeled` 가 0.80 → 0.10 → 0.00 으로 완전히 사라졌다. 이 행들은 회차 30개 중 +1~2회만 나타난 일회성 선택이고(§4 표에서 전부 <34% 구간), 라벨이 없는 이유도 +`-210`·`-219` 어느 판정에서도 나타난 적이 없기 때문이다. 성격상 거의 확실히 오분류다. 낙관 계산(경계=오분류)이 이것을 오분류로 세고, 그 경우 -Δ 가 n=5 에서 **-1.76행**이 된다. 그래도 범위는 겹친다. +Δ 가 n=5 에서 -1.76행이 된다. 그래도 회차 범위는 겹친다. -## 4. 왜 행 수는 안 주는가 — 지우는 것과 굳히는 것 +## 4. 왜 행 수는 줄지 않는가 — 지우는 효과와 고정하는 효과가 상쇄한다 -**이 절이 이 티켓의 답이다.** 오분류 행별로 n=1 과 n=5 의 출현율을 나란히 놓았다. +이 절이 이 티켓의 답이다. 오분류 행별로 n=1 과 n=5 의 출현율을 나란히 놓았다. | 행 | n=1 (30회) | n=5 (6관측) | | |---|---|---|---| -| `274 DRINK` (가지튀김 → 술자리) | 100% | **100%** | 살아남음 | -| `289 DRINK` (같은 본문) | 100% | **100%** | 살아남음 | -| `265 WALK` (가게 안 기록 → 산책) | 97% | **100%** | 굳음 | -| `284 WITH_FAMILY` (동행 없음) | 93% | **100%** | 굳음 | -| `275 VIEW_GOOD` (언덕 → 전망) | 83% | **100%** | 굳음 | -| `290 VIEW_GOOD` | 80% | **100%** | 굳음 | -| `266 TRENDY` (브런치카페 → 감성적) | 70% | **100%** | 굳음 | +| `274 DRINK` (가지튀김 → 술자리) | 100% | **100%** | 그대로 남음 | +| `289 DRINK` (같은 본문) | 100% | **100%** | 그대로 남음 | +| `265 WALK` (가게 안 기록 → 산책) | 97% | **100%** | 100% 로 고정 | +| `284 WITH_FAMILY` (동행 없음) | 93% | **100%** | 100% 로 고정 | +| `275 VIEW_GOOD` (언덕 → 전망) | 83% | **100%** | 100% 로 고정 | +| `290 VIEW_GOOD` | 80% | **100%** | 100% 로 고정 | +| `266 TRENDY` (브런치카페 → 감성적) | 70% | **100%** | 100% 로 고정 | | `273 QUICK_STOP` | 50% | 50% | — | -| `286 ALONE` | 50% | 67% | 굳음 | +| `286 ALONE` | 50% | 67% | 출현율 상승 | | `283 WALK` · `283 STUDY_WORK` | 47% | 50% · 33% | — | -| `279 DESSERT` | 30% | 17% | 줄음 | +| `279 DESSERT` | 30% | 17% | 출현율 하락 | | `286 QUIET` · `278 GATHERING` · `293 CELEBRATION` 등 **13종** | 3~37% | **0%** | **지워짐** | -``` -n=5 에서 완전히 사라진 오분류 13종 -n=5 에서 100% 로 살아남은 오분류 7종 -``` +n=5 에서 완전히 사라진 오분류가 13종이고, n=5 에서 100% 로 남은 오분류가 7종이다. -**다수결은 겨눈 것을 정확히 맞혔다.** 흔들리던 13종이 완전히 사라졌다 — 오분류의 -레퍼토리가 25종에서 12종으로 준다. +다수결은 의도한 대상을 정확히 제거했다. 회차마다 나타났다 사라지던 13종이 완전히 +사라졌고, 오분류의 레퍼토리가 25종에서 12종으로 줄었다. -그런데 행 수는 왜 안 주나. 두 힘이 상쇄한다. +그런데 행 수는 왜 줄지 않나. 두 힘이 상쇄한다. ``` -지운다 13종 × 각 0.03~0.37행 = 약 -1.7행 -굳힌다 70~97% 이던 5종을 100% 로 = 약 +0.8행 - (265 +0.03 · 284 +0.07 · 275 +0.17 · 290 +0.20 · 266 +0.30 · 286 ALONE +0.17) - 순 약 -0.9행 ← 실측 -0.96 +지운다 13종 × 각 0.03~0.37행 = 약 -1.7행 +고정한다 70~97% 로 나타나던 5종이 100% 로 = 약 +0.8행 + (265 +0.03 · 284 +0.07 · 275 +0.17 · 290 +0.20 · 266 +0.30 · 286 ALONE +0.17) + 순 약 -0.9행 ← 실측 -0.96 ``` -**다수결은 소수의견을 지우는 장치이므로, 다수의견은 반대로 굳힌다.** 70% 로 나타나던 -오분류는 「가끔 틀리는 것」이었는데 다수결을 거치면 **매번 틀리는 것**이 된다. 지운 것이 -드물었고 굳힌 것이 흔했으므로 잔액이 노이즈 크기로 남는다. +다수결은 소수의견을 지우는 장치이므로, 다수의견은 반대로 매번 나타나게 만든다. 70% +로 나타나던 오분류는 「가끔 틀리는 것」이었는데 다수결을 거치면 「매번 틀리는 것」이 +된다. 지운 것이 드물게 나타나던 것들이었고 고정된 것이 자주 나타나던 것들이었으므로, +잔액이 판정 변동과 같은 크기로 남는다. ### 4.1 남은 7종은 판정 층의 문제가 아니다 -100% 로 살아남은 7종은 `-219` §3.3 이 프롬프트로도 못 움직인 것들과 **정확히 같은 -목록**이다(`274`·`289 DRINK` · `265 WALK` · `284 WITH_FAMILY` · `275 VIEW_GOOD` · +100% 로 남은 7종은 `-219` §3.3 이 프롬프트로도 움직이지 못한 것들과 정확히 같은 +목록이다(`274`·`289 DRINK` · `265 WALK` · `284 WITH_FAMILY` · `275 VIEW_GOOD` · `266 TRENDY`). `-219` 는 그것을 프리셋 `description` 축으로 지목했다. 세 티켓이 서로 다른 층에서 같은 곳을 가리킨다. -``` --210 τ 를 어디에 두든 정상 판정이 함께 잘린다 후보 선정 층으로는 못 푼다 --219 프롬프트 규칙이 겨눌 고정된 표적이 없다 판정 문구 층으로는 못 푼다 --223 흔들리는 것은 다 지웠는데 7종이 남는다 남은 것은 흔들림이 아니다 -``` +| 티켓 | 확인한 것 | 결론 | +|---|---|---| +| `-210` | τ 를 어디에 두든 정상 판정이 함께 잘린다 | 후보 선정 층으로는 못 푼다 | +| `-219` | 프롬프트 규칙이 지목할 고정된 오분류가 없다 | 판정 문구 층으로는 못 푼다 | +| `-223` | 변동하는 것은 다 지웠는데 7종이 남는다 | 남은 것은 판정 변동이 아니다 | -**`-219` 는 「표적이 없다」로 끝났는데, 다수결이 흔들림을 걷어내자 표적이 드러났다.** -그 표적은 판정 층에 없다 — 후보의 정의(`description`·`examples`)에 있고 `back#136` 이 -기획 의견을 기다리고 있다. +`-219` 는 「지목할 대상이 없다」로 끝났는데, 다수결이 변동을 걷어내자 그 대상이 +드러났다. 그 대상은 판정 층에 있지 않다. 후보의 정의(`description`·`examples`)에 +있고, `back#136` 이 기획 의견을 기다리고 있다. ## 5. 비용 -실측이다. 지연은 실경로(`run_live.py`)에서 **동시 호출**로 쟀다. +실측이다. 지연은 실경로(`run_live.py`)에서 동시 호출로 쟀다. | | n=1 | n=3 | n=5 | |---|---|---|---| @@ -227,53 +230,56 @@ n=5 에서 100% 로 살아남은 오분류 7종 | 최악 Context 지연 | 3.94s | 3.70s | **5.34s** | | Context 42건 회차 소요 | 65.9s | 84.6s | 109.3s | -동시 호출이라 평균 지연은 n 배가 아니라 **1.3~1.7배**다. 대신 **Context 1건이 만드는 -동시 호출이 n배**가 된다 — 부하는 정직하게 n배다. +동시 호출이라 평균 지연은 n 배가 아니라 1.3~1.7배다. 대신 Context 1건이 만드는 동시 +호출이 n배가 된다. 즉 GMS 부하는 정확히 n배다. -**최악 지연은 n 에 비례하지 않는다.** n=1 의 최악 Context(3.94s)가 n=3(3.70s)보다 크다. -동시 호출의 최악값은 「가장 느린 1회」가 정하는데 그것은 GMS 쪽 꼬리이지 n 이 아니기 -때문이다. n=5 에서 5.34s 로 오르는 것은 **표본을 5개 뽑으면 꼬리를 밟을 확률이 올라가는 -것**이지 5배 기다리는 것이 아니다. +최악 지연은 n 에 비례하지 않는다. n=1 의 최악 Context(3.94s)가 n=3(3.70s)보다 크다. +동시 호출의 최악값은 「가장 느린 1회」가 정하는데 그것은 GMS 쪽 지연 꼬리이지 n 이 +아니기 때문이다. n=5 에서 5.34s 로 오르는 것은 표본을 5개 뽑으면 느린 호출을 만날 +확률이 올라가는 것이지, 5배를 기다리는 것이 아니다. `PROCESSING` 만료 600s 상한(`llm_client` 머리말 §3.2)에는 여유가 크다. ### 개선 폭당 호출 증가 n=1 의 30관측 평균(10.13행)을 기준으로 잡았다. §3 의 판정 표는 순열검정 때문에 관측 -수를 맞춘 10관측 기준(10.20행)이라 Δ 가 조금 다르다 — 둘 다 적는다. +수를 맞춘 10관측 기준(10.20행)이라 Δ 가 조금 다르다. 둘 다 적는다. ``` n=3 Context 42건에 호출 +84 오분류 -0.73행 → 호출 115회당 오분류 1행 n=5 Context 42건에 호출 +168 오분류 -0.96행 → 호출 175회당 오분류 1행 ``` -**n=5 는 n=3 보다 호출을 2배 더 쓰고 오분류를 0.23행 더 지운다.** 한계 효율이 급격히 -떨어진다 — 지울 수 있는 흔들림을 n=3 이 이미 대부분 걷었기 때문이다. +n=5 는 n=3 보다 호출을 2배 더 쓰고 오분류를 0.23행 더 지운다. 한계 효율이 급격히 +떨어진다. 지울 수 있는 판정 변동을 n=3 이 이미 대부분 걷어냈기 때문이다. ## 6. 채택 판단 -**n=1 을 유지한다.** 근거 넷. +**n=1 을 유지한다.** 근거는 넷이다. -1. **사전 기준을 못 넘는다.** 범위가 겹치고 순열검정도 못 넘는다(n=5 에서 p=0.210). - `-219` 가 *"나중에 더한 자가 통과했다고 앞의 자를 무르면 기준을 결과에 맞춘 것"* - 이라고 적었다. 여기서는 **두 자 모두 미달**이라 무를 것도 없다 -2. **사용자가 보는 것이 안 움직인다.** `fit 0건 Context` 8.00 — 세 티켓 연속이다 -3. **비용이 개선에 맞지 않는다.** 호출 3~5배를 써서 오분류 10.13행 중 0.7~1.0행이다 -4. **남은 오분류가 다른 층에 있다.** §4.1. 판정 층에 돈을 더 쓸 이유가 없다 +1. **사전 기준을 넘지 못한다.** 범위가 겹치고 순열검정도 통과하지 못한다(n=5 에서 + p=0.210). `-219` 가 *"나중에 더한 자가 통과했다고 앞의 자를 무르면 기준을 결과에 + 맞춘 것"* 이라고 적었다. 여기서는 두 기준 모두 미달이라 무를 것도 없다. +2. **사용자가 보는 결과가 움직이지 않는다.** `fit 0건 Context` 8.00 — 세 티켓 + 연속이다. +3. **비용이 개선에 맞지 않는다.** 호출 3~5배를 써서 오분류 10.13행 중 0.7~1.0행을 + 줄인다. +4. **남은 오분류가 다른 층에 있다.** §4.1. 판정 층에 비용을 더 쓸 이유가 없다. -**개선이 없다는 결론이 아니다.** 다수결은 실제로 작동했고(§2·§4) 정상 판정을 버리지 -않는 유일한 수단이다(교환비 음수). 다만 **지금 이 데이터에서 그것이 사는 곳이 없다** — -흔들리는 오분류가 원래 드물었기 때문이다. +개선이 없다는 결론이 아니다. 다수결은 실제로 작동했고(§2·§4) 정상 판정을 버리지 않는 +유일한 수단이다(교환비 음수). 다만 지금 이 데이터에는 다수결이 지울 수 있는 대상(회차 +변동성 오분류)이 원래 드물어서, 효과가 행 수로 나타나지 않는다. ### 켤 만한 조건 이 판단은 「지금」에 걸려 있다. 아래가 바뀌면 다시 재야 한다. - **`back#136` 이 프리셋 `description` 을 고쳐 7종이 사라지면** — 그때 남는 오분류는 - 전부 흔들림이고, 다수결의 몫이 지금보다 훨씬 커진다. **이 순서가 중요하다** -- **더 흔들리는 모델로 갈아타면** — `-219` §4.3 이 벤더별 일치도를 0.53~0.93 으로 쟀다. - 체인 2·3순위가 `gpt-4o-mini` 보다 흔들리면 다수결의 값이 달라진다 -- **비용 구조가 바뀌면** — 지금은 호출당 과금이 개선을 못 산다 + 전부 회차 변동성이고, 다수결이 지울 수 있는 몫이 지금보다 훨씬 커진다. 이 순서가 + 중요하다. +- **더 변동이 큰 모델로 갈아타면** — `-219` §4.3 이 벤더별 일치도를 0.53~0.93 으로 + 쟀다. 체인 2·3순위가 `gpt-4o-mini` 보다 변동이 크면 다수결의 효과가 달라진다. +- **비용 구조가 바뀌면** — 지금은 호출당 과금 대비 개선이 맞지 않는다. 되돌릴 수 있게 두는 것이 그래서 중요하다. 코드는 들어가 있고 설정 한 줄이다. @@ -281,46 +287,47 @@ n=5 Context 42건에 호출 +168 오분류 -0.96행 → 호출 175회당 ### 규칙 -``` -선택 votes * 2 > n 엄격 다수결. 분모는 성공 수가 아니라 n -confidence 찬성한 회차들의 중앙값 평균은 이상치 하나에 끌린다 -unmatched 같은 다수결 규칙 합집합은 다수결이 아니라 누적이다 -model 가장 많이 답한 모델 체인이 하나면 전 회차가 같다 -n=1 votes >= 1 현행과 **정확히** 같다 -``` +| 항목 | 규칙 | 이유 | +|---|---|---| +| 선택 | `votes * 2 > n` | 엄격 다수결. 분모는 성공 수가 아니라 n | +| confidence | 찬성한 회차들의 중앙값 | 평균은 이상치 하나에 끌린다 | +| unmatched | 같은 다수결 규칙 | 합집합은 다수결이 아니라 누적이다 | +| model | 가장 많이 답한 모델 | 체인이 하나면 전 회차가 같다 | +| n=1 | `votes >= 1` | 현행과 정확히 같다 | ### 동점 — 성립하지 않게 두고, 짝수를 기동에서 막는다 `votes * 2 > n` 은 전순서라 동점 자체가 없다. 짝수 n 도 동점을 만들지 않는다(n=4 는 -3표를 요구한다). 그런데도 **짝수를 `SettingsError` 로 막는다** — 이유는 동점이 아니라 -**지배당하기 때문**이다. n=4 는 n=3 과 같은 2표가 아니라 3표를 요구하면서 호출은 33% -더 쓴다. 「비용을 더 내고 규칙만 더 조인 상태」가 설정 오타 하나로 만들어지는 것을 -막는다. 그런 조합을 원했다면 n 이 아니라 문턱으로 표현해야 한다. +3표를 요구한다). 그런데도 짝수를 `SettingsError` 로 막는다. 이유는 동점이 아니라 +비용 대비 열등하기 때문이다. n=4 는 n=3 과 같은 2표가 아니라 3표를 요구하면서 호출은 +33% 더 쓴다. 「비용을 더 내면서 규칙만 더 엄격해진 상태」가 설정 오타 하나로 +만들어지는 것을 막는 것이다. 그런 조합을 원했다면 n 이 아니라 문턱값으로 표현해야 +한다. ### 부분 실패 — 분모를 낮추지 않는다 -계약이 미리 지목한 지점이다(`-219` 하네스는 실패한 회차를 평균에 그대로 넣었고, 그때는 -실패 0이라 미발현이었다). +계약이 미리 지목한 지점이다(`-219` 하네스는 실패한 회차를 평균에 그대로 넣었고, +그때는 실패 0이라 문제가 드러나지 않았다). ``` 성공 2/3 진행. 분모는 3 이라 2표를 받아야 남는다 — 보수적으로 기운다 성공 1/3 정족수 미달. 저장하지 않고 PROCESSING 으로 두어 재스캔이 회수한다 ``` -분모를 성공 수로 낮추면 n=3 에서 1회만 성공했을 때 그 1회가 곧 다수결이 되어 -**다수결을 켠 채로 n=1 을 실행하는 상태**가 된다. 정족수 미달을 「선택 0건 정상 완료」로 -저장하면 판정 실패가 성공으로 기록된다 — `test_quorum_missed_keeps_processing_for_rescan` -이 그것을 고정한다. +분모를 성공 수로 낮추면 n=3 에서 1회만 성공했을 때 그 1회가 곧 다수결이 되어, +다수결을 켠 채로 사실상 n=1 을 실행하는 상태가 된다. 정족수 미달을 「선택 0건 정상 +완료」로 저장하면 판정 실패가 성공으로 기록된다. +`test_quorum_missed_keeps_processing_for_rescan` 이 그것을 고정한다. -섞인 실패에서는 **transient 를 우선해 던진다.** permanent 로 올리면 그 Context 가 -FAILED 로 굳지만 transient 면 재스캔이 회수한다 — 둘 중 하나가 틀렸을 때 되돌릴 수 -있는 쪽을 고른다. +transient 와 permanent 실패가 섞이면 transient 를 우선해 던진다. permanent 로 올리면 +그 Context 가 FAILED 로 확정되지만, transient 면 재스캔이 회수한다. 둘 중 하나가 +틀렸을 때 되돌릴 수 있는 쪽을 고른 것이다. ### 되돌리기 -`PINLOG_JUDGE_VOTE_N=1`(기본값)이면 호출 1회, 예외는 그대로 전파, `combine` 은 입력을 -그대로 돌려준다. **실데이터로도 확인했다** — 회차 30개를 n=1 로 접은 결과가 원본 -회차와 **30/30 완전 일치**했다(`selections` 전체 비교). +`PINLOG_JUDGE_VOTE_N=1`(기본값)이면 호출 1회, 예외는 그대로 전파, `combine` 은 +입력을 그대로 돌려준다. 실데이터로도 확인했다. 회차 30개를 n=1 로 조합한 결과가 원본 +회차와 30/30 완전 일치했다(`selections` 전체 비교). ## 8. 검증 @@ -328,7 +335,7 @@ FAILED 로 굳지만 transient 면 재스캔이 회수한다 — 둘 중 하나 |---|---| | 데이터 | `pinlog-demo`(:15432) · Context 42건 · 프리셋 27 · 현행 판정 83행 | | profile | `openai-text-embedding-3-small-1536-cosine-v1` | -| 판정 모델 | **`gpt-4o-mini`** — 전 회차 실측(`JudgeResult.model`). 설정이 아니라 답한 값이다(T43) | +| 판정 모델 | **`gpt-4o-mini`** — 전 회차 실측(`JudgeResult.model`). 설정값이 아니라 실제로 답한 값이다(T43) | | GMS 호출 | **2,142회** (회차 풀 1,260 · 실경로 n=1 84 · n=3 378 · n=5 420). 실패 **0** · 임베딩 0 | | 테스트 | `386 passed` · line 99.83% · branch 98.98% (`origin/dev` 병합 후. 이 브랜치 단독은 338) · `ruff check .` 통과 | @@ -345,29 +352,30 @@ export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" ### 검증하지 않은 것 -- **실제 서버 E2E** — `n=1` 유지로 결론냈으므로 다수결을 켠 배포 경로를 돌리지 않았다. - `_judge_n` 은 `run_live.py` 가 실호출로 덮었지만 **DB 저장까지 관통한 n>1 경로는 - 통합 테스트(Fake)까지**다 -- **n=5 의 관측이 6개뿐** — n=3 의 10개보다 약하다. 회차를 50개로 늘리면 10개가 되지만 - GMS 840회를 더 쓰면서 판정 기준은 그대로다. `-219` 가 B 를 5회에서 접은 것과 같은 판단 -- **접은 값과 실경로의 대조가 3관측** — 갈리지 않는다는 것까지만 말한다(§1.4) -- **조건 간 표본 독립** — 접은 조건들이 같은 호출 풀을 공유한다. 평균·범위 비교는 - 유효하지만 순열검정은 참고값이다(T59). `--shuffle` 3종으로 분할 의존성은 배제했다 - (n=5 오분류 8.67~9.33 — 결론이 안 바뀐다) -- **다른 벤더** — `gpt-4o-mini` 하나다. `-219` 와 같은 한계 -- **라벨 교차 검증** — `-210`·`-219` 와 같다. 이 티켓은 라벨을 **더하지 않았다** -- **중복 5쌍의 영향** — 42건 기준. 37건 기준으로 다시 내지 않았다 +- **실제 서버 E2E** — `n=1` 유지로 결론냈으므로 다수결을 켠 배포 경로를 돌리지 + 않았다. `_judge_n` 은 `run_live.py` 가 실호출로 덮었지만, DB 저장까지 관통한 n>1 + 경로는 통합 테스트(Fake)까지다. +- **n=5 의 관측이 6개뿐** — n=3 의 10개보다 약하다. 회차를 50개로 늘리면 10개가 + 되지만, GMS 840회를 더 쓰면서 판정 기준은 그대로다. `-219` 가 B 를 5회에서 중단한 + 것과 같은 판단이다. +- **조합한 값과 실경로의 대조가 3관측** — 갈리지 않는다는 것까지만 말한다(§1.4). +- **조건 간 표본 독립** — 조합한 조건들이 같은 호출 풀을 공유한다. 평균·범위 비교는 + 유효하지만 순열검정은 참고값이다(T59). `--shuffle` 3종으로 분할 방식에 대한 + 의존성은 배제했다(n=5 오분류 8.67~9.33 — 결론이 바뀌지 않는다). +- **다른 벤더** — `gpt-4o-mini` 하나다. `-219` 와 같은 한계다. +- **라벨 교차 검증** — `-210`·`-219` 와 같다. 이 티켓은 라벨을 더하지 않았다. +- **중복 5쌍의 영향** — 42건 기준. 37건 기준으로 다시 내지 않았다. ## 9. 후속 제안 | 제안 | 근거 | 비고 | |---|---|---| | **프리셋 `description`·`examples` 개정** | §4.1. 세 티켓이 각각 다른 층에서 같은 7종을 가리켰다 | `back#136` 기획 의견 대기 중. **이 줄이 남은 유일한 실질 방향이다** | -| `back#136` 이후 다수결 재측정 | §6. 7종이 사라지면 남는 오분류가 전부 흔들림이 되어 다수결의 몫이 커진다 | 하네스 그대로. 회차 30개 = 1,260호출 | -| confidence 문턱을 회차 반복으로 | `-219` §6 이 남긴 것. **이번 회차 30개에 confidence 가 기록돼 있다** | `.prompt_ab/runs/*.json` 의 `confidences` — 다만 gitignore 라 수명이 짧다(T57) | -| 벤더를 조건으로 둔 재측정 | §1.5. 결론의 적용 범위가 `gpt-4o-mini` 하나다 | 다수결은 흔들리는 모델에서 값이 더 크다 | -| 저빈도 프리셋 점검 | `274/289 DRINK` 가 n 을 올려도 100% 다 | `-210` 이 남긴 것과 같은 줄 | - -**`-210`·`-219`·`-223` 이 판정 경로의 세 층을 전부 재고 닫았다.** 후보 선정(τ)·판정 -문구(프롬프트)·판정 분산(다수결) 어느 것도 오분류를 못 줄인다. 남은 것은 **후보의 -정의**이고 그것은 AI 파트 소관이 아니다. +| `back#136` 이후 다수결 재측정 | §6. 7종이 사라지면 남는 오분류가 전부 회차 변동성이 되어 다수결이 지울 수 있는 몫이 커진다 | 하네스 그대로. 회차 30개 = 1,260호출 | +| confidence 문턱을 회차 반복으로 | `-219` §6 이 남긴 것. 이번 회차 30개에 confidence 가 기록돼 있다 | `.prompt_ab/runs/*.json` 의 `confidences` — 다만 gitignore 라 수명이 짧다(T57) | +| 벤더를 조건으로 둔 재측정 | §1.5. 결론의 적용 범위가 `gpt-4o-mini` 하나다 | 다수결은 변동이 큰 모델에서 효과가 더 크다 | +| 저빈도 프리셋 점검 | `274/289 DRINK` 가 n 을 올려도 100% 다 | `-210` 이 남긴 것과 같은 항목 | + +`-210`·`-219`·`-223` 이 판정 경로의 세 층을 전부 재고 닫았다. 후보 선정(τ) · 판정 +문구(프롬프트) · 판정 분산(다수결) 어느 것도 오분류를 줄이지 못한다. 남은 것은 +후보의 정의이고, 그것은 AI 파트 단독 소관이 아니다. diff --git a/docs/implements/2026-07-31-search-cut.md b/docs/implements/2026-07-31-search-cut.md index 4b25d1e..9c7b56c 100644 --- a/docs/implements/2026-07-31-search-cut.md +++ b/docs/implements/2026-07-31-search-cut.md @@ -1,4 +1,4 @@ -# 개인 검색 결과 컷 측정 — `τ_abs` × `r` +# 개인 검색 결과 컷 측정 — `τ_abs`=0.30 과 `r`=0.60 을 함께 채택한다 - **티켓**: S15P11A705-213 - **날짜**: 2026-07-31 @@ -9,27 +9,25 @@ ## 요약 -``` -채택 τ_abs = 0.30 · r = 0.60 정답 누락 0/12 · 빈 결과 0/12 - 꼬리 제거 76.3%(비관) · 무관 질의 11/15 침묵 -안전 상한 τ_abs = 0.36 · r = 0.80 이 이상에서 정답이 사라진다 -둘 다 필요 r 은 무관 질의를 15건 중 0건도 침묵시키지 못한다(1위를 언제나 남긴다) - τ_abs 는 같은 안전 마진에서 꼬리 제거가 r 보다 약하다 -함께 고침 limit 기본값 10 → 20 (공용 계약 08 §6.1) -``` +| 항목 | 내용 | +|---|---| +| 채택 | `τ_abs = 0.30` · `r = 0.60`. 정답 누락 0/12 · 빈 결과 0/12 · 꼬리 제거 76.3%(비관) · 무관 질의 15건 중 11건에서 결과 0건 | +| 안전 상한 | `τ_abs = 0.36` · `r = 0.80`. 이 이상에서 정답이 사라진다 | +| 둘 다 필요한 이유 | `r` 은 무관 질의 15건 중 한 건도 0건으로 만들지 못한다(1위를 언제나 남긴다). `τ_abs` 는 같은 안전 마진에서 꼬리 제거가 `r` 보다 약하다 | +| 함께 고침 | `limit` 기본값 10 → 20 (공용 계약 08 §6.1) | -**이 문서는 `personal-search.md` 의 이전 판단(「컷오프를 적용하지 않는다」)을 뒤집는다.** -그 판단의 근거는 무관 질의 **1건**의 실측이었고, 15건으로 늘리자 그 근거가 무너졌다. +이 문서는 `personal-search.md` 의 이전 판단(「컷오프를 적용하지 않는다」)을 뒤집는다. +그 판단의 근거는 무관 질의 1건의 실측이었고, 15건으로 늘리자 그 근거가 무너졌다. ## 무엇을 왜 재는가 검색이 반환하는 것은 유사도 상위 `size` 개뿐이고 하한이 없다. 데이터가 42건인 현재 -`size=20` 은 **보유 Record 전량**을 뜻한다(소유자별 최대 17건). 즉 지금 검색은 「자기 +`size=20` 은 보유 Record 전량을 뜻한다(소유자별 최대 17건). 즉 지금 검색은 「자기 기록을 유사도 순으로 정렬」하는 것에 가깝고, 관련 기록이 없는 질의에도 전량이 나온다. -축은 둘이고 어느 쪽이 듣는지 모른다. +개선 축은 둘이고 어느 쪽이 효과가 있는지 모른다. -| 축 | 규칙 | 노리는 것 | +| 축 | 규칙 | 의도한 효과 | |---|---|---| | `τ_abs` | `sim ≥ τ_abs` | 관련 기록이 **없는** 질의를 0건으로 | | `r` | `sim ≥ r × top1.sim` | 관련 기록은 있고 **꼬리가 긴** 질의를 짧게 | @@ -41,8 +39,8 @@ `demo_data.yaml` 의 검증 질의 12건에는 기대 정답이 붙어 있다(`expect`). 그것이 「정답 누락」의 기준이다. -**그것만으로는 부족하다.** 12건은 전부 정답을 가진 질의라 `τ_abs` 의 고유 가치 — 「관련 -기록이 없다」를 표현하는 것 — 가 드러나지 않는다. 그래서 **정답이 없는 무관 질의**를 +그것만으로는 부족하다. 12건은 전부 정답을 가진 질의라, `τ_abs` 의 고유 가치인 「관련 +기록이 없다」를 표현하는 능력이 드러나지 않는다. 그래서 정답이 없는 무관 질의를 설계에 넣었다. ``` @@ -50,37 +48,40 @@ 무관 질의 15건 질의 5종 × 소유자 3명 · 정답 없음 · 반환 170행 ``` -무관 질의 5종은 PinLog 가 담는 것(음식점·카페·공원 방문 기록)과 범주가 다른 생활 검색이다. -첫 항목은 `personal-search.md §6` 이 이미 실측한 「자동차 엔진오일 교환 정비소」다. +무관 질의 5종은 PinLog 가 담는 것(음식점·카페·공원 방문 기록)과 범주가 다른 생활 +검색이다. 첫 항목은 `personal-search.md §6` 이 이미 실측한 「자동차 엔진오일 교환 +정비소」다. ### 라벨 -「꼬리를 얼마나 잘랐나」를 「정답 아닌 것을 얼마나 잘랐나」로 세면 **이득이 부풀고 컷을 -과하게 잡게 된다.** 정답 아닌 141행에 라벨을 붙였다(`labels.yaml`). +「꼬리를 얼마나 잘랐나」를 「정답 아닌 것을 얼마나 잘랐나」로 세면 이득이 부풀고 컷을 +과하게 잡게 된다. 그래서 정답 아닌 141행에 라벨을 붙였다(`labels.yaml`). -``` -hit demo_data.yaml 의 expect (12행) — matrix.json 이 자동으로 안다 -plausible 질의가 요구한 성질 중 하나 이상을 본문이 실제로 말한다 (16행) -irrelevant 본문이 질의의 어느 성질도 말하지 않는다 (114행) -``` +| 라벨 | 기준 | 행 수 | +|---|---|---| +| `hit` | `demo_data.yaml` 의 expect. `matrix.json` 이 자동으로 안다 | 12행 | +| `plausible` | 질의가 요구한 성질 중 하나 이상을 본문이 실제로 말한다 | 16행 | +| `irrelevant` | 본문이 질의의 어느 성질도 말하지 않는다 | 114행 | -`plausible` 을 **넉넉히** 잡았다. 「돈카츠 먹으러 자주 갔던 곳」에 대해 「거의 일주일마다 -가던」 비건 샌드위치집은 종류가 정반대지만 「자주 갔던」 축이 부합하므로 `plausible` 이다. -컷의 이득을 과대평가하지 않는 방향이며, 이 라벨로도 이득이면 더 엄격한 라벨에서도 이득이다. +`plausible` 을 넉넉히 잡았다. 「돈카츠 먹으러 자주 갔던 곳」에 대해 「거의 일주일마다 +가던」 비건 샌드위치집은 종류가 정반대지만 「자주 갔던」 축이 부합하므로 `plausible` +이다. 컷의 이득을 과대평가하지 않는 방향이며, 이 라벨로도 이득이면 더 엄격한 +라벨에서도 이득이다. -꼬리 제거율은 **비관**(irrelevant 만 이득)과 **낙관**(plausible 도 잡음) 양쪽으로 낸다. +꼬리 제거율은 비관(irrelevant 만 이득으로 계산)과 낙관(plausible 도 잡음으로 계산) +양쪽으로 낸다. ### 하네스 -`-210` 의 구조를 따랐다 — 벡터를 한 번 떠서 굳히고 격자를 오프라인으로 훑는다. 컷은 +`-210` 의 구조를 따랐다. 벡터를 한 번 떠서 고정하고 격자를 오프라인으로 훑는다. 컷은 유사도에 걸리고 임베딩에는 걸리지 않으므로 격자마다 GMS 를 부를 이유가 없다. -**총 GMS 호출은 임베딩 배치 1회다**(질의 17건이 `_BATCH=128` 안에 들어간다). -`ai.context_keyword` 를 건드리지 않으므로 같은 시각 진행 중이던 `-219`(판정 프롬프트)와 -충돌하지 않았다. +총 GMS 호출은 임베딩 배치 1회다(질의 17건이 `_BATCH=128` 안에 들어간다). +`ai.context_keyword` 를 건드리지 않으므로 같은 시각 진행 중이던 `-219`(판정 +프롬프트)와 충돌하지 않았다. -`-210` 의 재구성은 근사였다(후보가 줄면 LLM 판정이 뒤집힌다). **이쪽은 정확하다** — -검색 경로에 LLM 이 없다. §검증에서 실서버와 27/27 일치를 확인했다. +`-210` 의 재구성은 근사였다(후보가 줄면 LLM 판정이 뒤집힌다). 이쪽은 정확하다. 검색 +경로에 LLM 이 없기 때문이다. §검증에서 실서버와 27/27 일치를 확인했다. ## 분포 — 격자는 여기서 나왔다 @@ -98,16 +99,16 @@ irrelevant n=114 min=0.0924 p25=0.2199 p50=0.2693 p75=0.3244 max=0.4488 ### 두 겹침 ``` -기대 정답 최솟값 0.3642 vs irrelevant 최댓값 0.4488 겹침 구간에 irrelevant 12/114건 +기대 정답 최솟값 0.3642 vs irrelevant 최댓값 0.4488 겹침 구간에 irrelevant 12/114건 기대 정답 최솟값 0.3642 vs 무관 질의 top-1 최댓값 0.3819 간격 -0.0176 ``` -**어떤 `τ_abs` 도 잡음을 다 자르면서 정답을 다 살릴 수 없다.** `-210` 이 τ 에서 만난 것과 +어떤 `τ_abs` 도 잡음을 다 자르면서 정답을 다 살릴 수 없다. `-210` 이 τ 에서 만난 것과 같은 구조다. 두 번째 줄이 이 측정의 핵심 관측이다. `personal-search.md §6` 은 무관 질의 1건으로 -「관련 top-1 최소 0.5263 / 무관 top-1 최대 0.3143, 간격 +0.2120」을 적고 그것을 컷 미적용의 -근거로 삼았다. **표본을 15건으로 늘리자 그 간격이 음수가 됐다.** +「관련 top-1 최소 0.5263 / 무관 top-1 최대 0.3143, 간격 +0.2120」을 적고 그것을 컷 +미적용의 근거로 삼았다. 표본을 15건으로 늘리자 그 간격이 음수가 됐다. 간격을 없앤 것은 「치과 임플란트 상담 받을 곳」 × jeongheon → 연남칼국수 `0.3819` 다. 질의의 「상담」과 본문의 「기도형이랑 팀플 관련 상담도 했음」이 맞물린 것으로 보인다. @@ -135,12 +136,12 @@ irrelevant n=114 min=0.0924 p25=0.2199 p50=0.2693 p75=0.3244 max=0.4488 | 0.80 | 19 | 0/12 | 0/12 | 97.4% | **0/15** | | **0.85** | 15 | **1/12** | 0/12 | 99.1% | **0/15** | -**`r` 의 무관 질의 열이 어느 값에서도 `0/15` 다.** `r` 은 정의상 1위를 언제나 남기므로 +`r` 의 무관 질의 열이 어느 값에서도 `0/15` 다. `r` 은 정의상 1위를 언제나 남기므로 「이 사용자에게 관련 기록이 없다」를 표현할 수 없다. 이것이 `r` 하나로 충분하지 않은 근거이며, 검증 질의만 재서는 보이지 않는다. -반대로 `τ_abs` 는 같은 안전 마진에서 꼬리 제거가 약하다 — 상한의 83%(0.30)에서 62.3%, -`r` 은 상한의 75%(0.60)에서 73.7% 다. **둘은 서로를 대체하지 않는다.** +반대로 `τ_abs` 는 같은 안전 마진에서 꼬리 제거가 약하다. 상한의 83%(0.30)에서 +62.3% 인데, `r` 은 상한의 75%(0.60)에서 73.7% 다. 둘은 서로를 대체하지 않는다. ### 안전 상한을 정하는 것은 데이터점 하나다 @@ -149,11 +150,11 @@ irrelevant n=114 min=0.0924 p25=0.2199 p50=0.2693 p75=0.3244 max=0.4488 「친구들이랑 피자에 맥주 마신 곳」 → 뉴오더클럽 연남 sim=0.3642 (3위 · r=0.807) ``` -축약어 「피맥」이 임베딩에서 풀리지 않는 그 질의다 — `-174` 부터 `-191` 4조건 전부에서 +축약어 「피맥」이 임베딩에서 풀리지 않는 그 질의다. `-174` 부터 `-191` 4조건 전부에서 1위를 놓쳤고, 조건 D(large + 장소명 결합)에서만 통과했다. -**두 축의 상한을 같은 한 점이 정한다.** 그 점이 흔들리면 두 축이 동시에 무너진다. -그래서 상한에 붙이지 않고 마진을 뒀다. +두 축의 상한을 같은 한 점이 정한다. 그 점이 흔들리면 두 축이 동시에 무너진다. 그래서 +상한에 붙이지 않고 마진을 뒀다. ``` τ_abs = 0.30 상한 0.36 대비 17% 아래 @@ -181,16 +182,17 @@ r = 0.60 상한 0.80 대비 25% 아래 | 10 | 104 | 0/12 | 31.6% | | 20 | 142 | 0/12 | 0% | -정답이 전부 3위 안에 있으므로(`-191`: top-3 네 조건 모두 12/12) `limit=3` 도 정답을 잃지 -않는다. **그런데 이 티켓은 `limit` 기본값을 10 에서 20 으로 올렸다** — 공용 계약 08 §6.1 -정합이 목적이고, 그 방향은 꼬리를 **늘린다**. +정답이 전부 3위 안에 있으므로(`-191`: top-3 네 조건 모두 12/12) `limit=3` 도 정답을 +잃지 않는다. 그런데 이 티켓은 `limit` 기본값을 10 에서 20 으로 올렸다. 공용 계약 08 +§6.1 정합이 목적이고, 그 방향은 꼬리를 늘린다. 상충은 아니다. `limit` 은 「몇 건까지 받겠다」는 계약이고 컷은 「받을 가치가 있는가」의 -판정이라 층이 다르다. 다만 `limit` 을 계약값으로 올리는 변경과 컷 도입이 같은 PR 에 있는 -것은 우연이 아니다 — **올리기만 하고 컷이 없으면 잡음이 그만큼 늘어난다.** +판정이라 층이 다르다. 다만 `limit` 을 계약값으로 올리는 변경과 컷 도입이 같은 PR 에 +있는 것은 우연이 아니다. `limit` 을 올리기만 하고 컷이 없으면 잡음이 그만큼 늘어난다. -`limit=3` 같은 값으로 문제를 덮지 않은 이유: 데이터가 42건이라 성립하는 값이고, 사용자 -기록이 수백 건이 되면 3위 안에 정답이 있다는 전제가 먼저 깨진다. +`limit=3` 같은 값으로 문제를 덮지 않은 이유는 다음과 같다. 그 값은 데이터가 42건이라 +성립하는 값이고, 사용자 기록이 수백 건이 되면 3위 안에 정답이 있다는 전제가 먼저 +깨진다. ## 검증 @@ -202,13 +204,14 @@ r = 0.60 상한 0.80 대비 25% 아래 ``` `-210` 의 `verify_reconstruction.py` 는 대조군이 필요했다(같은 τ 로 다시 판정만 해도 -Context 26% 가 흔들린다, T39). **이쪽은 대조군 없이 정확 일치를 요구한다** — 검색 경로에 -LLM 이 없으므로 어긋나면 그것은 분산이 아니라 구현 결함이다. +Context 26% 가 달라진다, T39). 이쪽은 대조군 없이 정확 일치를 요구한다. 검색 경로에 +LLM 이 없으므로, 어긋나면 그것은 분산이 아니라 구현 결함이다. -컷 규칙을 구현에서 `import` 하지 않고 검증 스크립트에 다시 적었다. `import` 하면 구현이 -명세와 달라도 둘이 함께 틀려 검증이 통과한다. +컷 규칙을 구현에서 `import` 하지 않고 검증 스크립트에 다시 적었다. `import` 하면 +구현이 명세와 달라도 둘이 함께 틀려 검증이 통과하기 때문이다. -단위 테스트 8건을 함께 넣었다(`tests/test_unit.py`). 그중 둘이 이 티켓의 결론을 고정한다. +단위 테스트 8건을 함께 넣었다(`tests/test_unit.py`). 그중 둘이 이 티켓의 결론을 +고정한다. ``` test_cut_ratio_alone_never_empties r 은 1위를 언제나 남긴다 @@ -221,32 +224,34 @@ test_cut_can_return_zero_rows τ_abs 는 0건을 만들 수 있다 **말할 수 있는 것** -- `personal-search.md §6` 의 컷 미적용 근거가 **무너졌다.** 무관 질의 1건에서 보인 간격 - +0.2120 은 15건에서 -0.0176 이 된다 -- `τ_abs` 와 `r` 은 **서로를 대체하지 않는다.** `r` 은 무관 질의를 0건으로 만들 수 없고 - (구조적 성질이지 값의 문제가 아니다), `τ_abs` 는 질의마다 다른 유사도 대역을 못 따라간다 -- 채택값에서 **정답 누락 0 · 빈 결과 0** 이다. 꼬리는 비관 기준 76.3% 줄고 무관 질의 - 15건 중 11건이 0건이 된다 -- 오프라인 재구성이 실서버와 **정확히** 일치한다. 앞으로 값을 다시 정할 때 GMS 를 부를 - 필요가 없다(`matrix.json` 이 커밋돼 있다) +- `personal-search.md §6` 의 컷 미적용 근거가 무너졌다. 무관 질의 1건에서 보인 간격 + +0.2120 은 15건에서 -0.0176 이 된다. +- `τ_abs` 와 `r` 은 서로를 대체하지 않는다. `r` 은 무관 질의를 0건으로 만들 수 + 없고(구조적 성질이지 값의 문제가 아니다), `τ_abs` 는 질의마다 다른 유사도 대역을 + 따라가지 못한다. +- 채택값에서 정답 누락 0 · 빈 결과 0 이다. 꼬리는 비관 기준 76.3% 줄고 무관 질의 + 15건 중 11건이 0건이 된다. +- 오프라인 재구성이 실서버와 정확히 일치한다. 앞으로 값을 다시 정할 때 GMS 를 부를 + 필요가 없다(`matrix.json` 이 커밋돼 있다). **말할 수 없는 것** -- **채택값이 최적이라는 것.** 상한의 83%·75% 라는 마진은 판단이지 측정이 아니다 -- **일반화.** 질의 12건과 기대값을 우리가 작성했고(`-174` §3.3) 무관 질의 5종도 이 티켓의 - 작업자가 골랐다. 재현되는 것은 「의도한 매칭」이지 사용자 만족도가 아니다 -- **데이터가 늘었을 때의 동작.** 42건에서 `limit=20` 은 전량이다. 사용자 기록이 수백 건이 - 되면 top-1 이 올라가 `r` 이 더 세게 자르고, 그때 이 값을 다시 재야 한다 +- **채택값이 최적이라는 것.** 상한의 83%·75% 라는 마진은 판단이지 측정이 아니다. +- **일반화.** 질의 12건과 기대값을 우리가 작성했고(`-174` §3.3) 무관 질의 5종도 이 + 티켓의 작업자가 골랐다. 재현되는 것은 「의도한 매칭」이지 사용자 만족도가 아니다. +- **데이터가 늘었을 때의 동작.** 42건에서 `limit=20` 은 전량이다. 사용자 기록이 수백 + 건이 되면 top-1 유사도가 올라가 `r` 이 더 세게 자르고, 그때 이 값을 다시 재야 한다. - **라벨의 객관성.** 판정자는 한 명이고 교차 검증이 없다. `plausible` 16행 중 「도피 — - 브런치 카페」는 판정자가 스스로 경계라고 적은 건이다 -- **컷이 사용자 만족을 올린다는 것.** 잰 것은 「의도한 정답을 잃지 않으면서 무관한 것을 - 줄였다」이지 사용자 반응이 아니다 + 브런치 카페」는 판정자가 스스로 경계라고 적은 건이다. +- **컷이 사용자 만족을 올린다는 것.** 잰 것은 「의도한 정답을 잃지 않으면서 무관한 + 것을 줄였다」이지 사용자 반응이 아니다. ## 이 측정과 별개로 드러난 것 -### 임베딩은 「결정적」이지만 배치 구성에 따라 소수 넷째 자리가 흔들린다 +### 임베딩은 「결정적」이지만 배치 구성에 따라 소수 넷째 자리가 달라진다 -무관 질의를 추가하며 배치가 12건에서 17건이 되자 **기존 12건의 유사도가 미세하게 달라졌다.** +무관 질의를 추가하며 배치가 12건에서 17건이 되자 기존 12건의 유사도가 미세하게 +달라졌다. ``` 치킨버거 이스트사이드 (「밥 먹고 산책하면서 쉬어가는 공원」) 0.5264 → 0.5258 @@ -254,8 +259,8 @@ irrelevant 최댓값 0.4489 → 0.4488 ``` 순위와 결론은 바뀌지 않았고 채택값 근처(0.3642)도 그대로다. 다만 `-191` 이 「임베딩이 -결정적이라 소수 넷째 자리까지 일치한다」고 적은 것은 **같은 배치 구성일 때**의 이야기다. -`10⁻⁴` 규모 차이가 결론을 가르는 값은 채택하지 말아야 한다는 뜻이기도 하다 — 상한에 +결정적이라 소수 넷째 자리까지 일치한다」고 적은 것은 같은 배치 구성일 때의 이야기다. +`10⁻⁴` 규모 차이가 결론을 가르는 값은 채택하지 말아야 한다는 뜻이기도 하다. 상한에 마진을 둔 또 하나의 이유다. ### 무관 질의도 소유자에 따라 결과가 갈린다 @@ -263,6 +268,7 @@ irrelevant 최댓값 0.4489 → 0.4488 같은 「치과 임플란트 상담 받을 곳」이 host 에게는 top-1 `0.2686`, jeongheon 에게는 `0.3819` 다. 보유 Record 가 많을수록 우연히 겹치는 것이 나올 확률이 오른다. -**단일 `τ_abs` 는 이 차이를 모른다.** 기록이 많은 사용자일수록 무관 질의에 결과가 남는다. -현재 데이터에서는 17건 대 6건의 차이지만, 사용자 규모가 커지면 이 방향의 편차가 커진다. -`r` 은 이 문제에도 답하지 못한다(1위를 남기므로). 후속에서 다룰 값이다. +단일 `τ_abs` 는 이 차이를 반영하지 못한다. 기록이 많은 사용자일수록 무관 질의에 +결과가 남는다. 현재 데이터에서는 17건 대 6건의 차이지만, 사용자 규모가 커지면 이 +방향의 편차가 커진다. `r` 은 이 문제에도 답하지 못한다(1위를 남기므로). 후속에서 +다룰 값이다. diff --git a/docs/implements/2026-07-31-search-error-contract.md b/docs/implements/2026-07-31-search-error-contract.md index 5e807b4..2aac803 100644 --- a/docs/implements/2026-07-31-search-error-contract.md +++ b/docs/implements/2026-07-31-search-error-contract.md @@ -16,16 +16,17 @@ 임베딩 502 → 3회 재시도 → TransientError → API 응답 500 ``` -**분류도 재시도도 이미 맞았다.** `-121` 이 `classify_http_status` 로 `502 → TransientError` -를 확정했고 `retry.py` 가 지수 백오프로 세 번 시도한다. `tests/test_client_retry.py` 가 -그 매핑을 상태 코드별로 단언한다. +분류도 재시도도 이미 맞았다. `-121` 이 `classify_http_status` 로 +`502 → TransientError` 를 확정했고 `retry.py` 가 지수 백오프로 세 번 시도한다. +`tests/test_client_retry.py` 가 그 매핑을 상태 코드별로 단언한다. -비어 있던 것은 **그 예외가 응답이 되는 지점**이다. `app/main.py` 의 예외 핸들러는 -`ProfileMismatchError`(422) 하나뿐이었고, 분류된 나머지는 uvicorn 까지 올라가 500 이 됐다. +비어 있던 것은 그 예외가 응답이 되는 지점이다. `app/main.py` 의 예외 핸들러는 +`ProfileMismatchError`(422) 하나뿐이었고, 분류된 나머지는 uvicorn 까지 올라가 500 이 +됐다. -> 이 결함이 안 보인 이유가 완료 조건에 있다. 클라이언트 테스트는 예외가 **던져지는 것**까지 -> 보고, API 테스트는 Fake 를 꽂아 **성공 형식**만 봤다. 두 파일 사이에 계층 하나가 통째로 -> 비어 있었고, 각자는 자기 범위에서 통과하고 있었다. +> 이 결함이 안 보인 이유가 완료 조건에 있다. 클라이언트 테스트는 예외가 던져지는 +> 것까지 보고, API 테스트는 Fake 를 꽂아 성공 형식만 봤다. 두 파일 사이에 계층 하나가 +> 통째로 비어 있었고, 각자는 자기 범위에서 통과하고 있었다. --- @@ -34,12 +35,13 @@ ### 1.1 명세가 API 층 변환을 말하지 않았다 `failure-recovery.md` §2.1·§2.2 는 분류별 **동작**을 정하지만 그것은 Context 처리 경로의 -이야기다 — "상태를 PROCESSING 으로 둔다", "해당 단계만 FAILED 로 전이한다". 검색은 사용자 -요청 경로라 바꿀 상태가 없고 **분류가 곧 응답**인데, 그 변환 규칙이 어느 문서에도 없었다. +이야기다("상태를 PROCESSING 으로 둔다", "해당 단계만 FAILED 로 전이한다"). 검색은 +사용자 요청 경로라 바꿀 상태가 없고 분류가 곧 응답인데, 그 변환 규칙이 어느 문서에도 +없었다. `personal-search.md` 도 §6 반환 형식에서 성공 응답만 정했다. -**코드만 고치면 같은 공백이 남는다.** 그래서 `failure-recovery.md` §2.5 와 +코드만 고치면 같은 공백이 남는다. 그래서 `failure-recovery.md` §2.5 와 `personal-search.md` §6.2 를 함께 넣었다. ### 1.2 `back` 은 500 과 503 을 구분하지 않는다 — 사용자 화면은 이미 같다 @@ -52,22 +54,23 @@ // 그 밖의 모든 상태 코드 → AiSearchException.unavailable() ``` -`SEARCH_UNAVAILABLE` 은 `HttpStatus.SERVICE_UNAVAILABLE` 이다. 즉 AI 가 `500` 을 주든 `503` -을 주든 사용자는 「검색을 일시적으로 사용할 수 없습니다」를 본다. **이 티켓은 사용자 화면을 -바꾸지 않는다.** +`SEARCH_UNAVAILABLE` 은 `HttpStatus.SERVICE_UNAVAILABLE` 이다. 즉 AI 가 `500` 을 +주든 `503` 을 주든 사용자는 「검색을 일시적으로 사용할 수 없습니다」를 본다. 따라서 +이 티켓은 사용자 화면을 바꾸지 않는다. -`ai#69` 의 두 번째 답변이 *"AI 가 503 을 주면 back 이 「일시적으로 사용할 수 없음」으로 -전달한다"* 고 적은 것은 맞지만, **`500` 도 이미 그렇게 전달되고 있다**는 사실이 빠져 있다. -`back` 은 응답 본문도 읽지 않는다 — `serverProfile` 을 보는 422 말고는 버린다. +`ai#69` 의 두 번째 답변이 *"AI 가 503 을 주면 back 이 「일시적으로 사용할 수 +없음」으로 전달한다"* 고 적은 것은 맞지만, `500` 도 이미 그렇게 전달되고 있다는 +사실이 빠져 있다. `back` 은 응답 본문도 읽지 않는다. `serverProfile` 을 보는 422 +말고는 버린다. -그러면 이 변경의 값어치는 어디에 있는가. **관측이다.** +그러면 이 변경의 실질적 가치는 어디에 있는가. 운영자가 보는 관측 정보다. -``` -지금 전부 500 — "AI 가 깨졌나 GMS 가 깨졌나" 를 로그 없이 못 가른다 -이후 503 게이트웨이 장애·타임아웃. 기다리면 낫는다 -이후 502 키·모델명·base URL·차원. 배포 설정을 고쳐야 낫는다 -이후 500 우리 코드의 결함. 비로소 알림 대상이 된다 -``` +| 상태 | 의미 | +|---|---| +| 지금 | 전부 500. "AI 가 깨졌나 GMS 가 깨졌나"를 로그 없이 구분할 수 없다 | +| 이후 503 | 게이트웨이 장애·타임아웃. 기다리면 낫는다 | +| 이후 502 | 키·모델명·base URL·차원. 배포 설정을 고쳐야 낫는다 | +| 이후 500 | 우리 코드의 결함. 비로소 알림 대상이 된다 | 인프라가 `ai#69` 를 연 계기가 *"원인 불명의 500"* 이었다. 그 상태 코드가 의미를 되찾는 것이 이 티켓의 실질이다. @@ -90,8 +93,8 @@ api key sk-live-DEADBEEF rejected by gms.ssafy.io ``` 실제 GMS 가 무엇을 담는지는 우리가 통제하지 않는다. `probe.py` 가 세운 -*"credential·endpoint·profile 값을 어떤 분기에서도 싣지 않는다"* 와 `-197` 이 로그로 확장한 -같은 기준(§2.4 원칙 4)에 어긋난다. **핸들러가 이 누출도 막는다.** +*"credential·endpoint·profile 값을 어떤 분기에서도 싣지 않는다"* 와 `-197` 이 로그로 +확장한 같은 기준(§2.4 원칙 4)에 어긋난다. 핸들러가 이 누출도 막는다. --- @@ -99,15 +102,17 @@ api key sk-live-DEADBEEF rejected by gms.ssafy.io ### 2.1 `TransientError` → 503, `PermanentError` → 502 -`503` 은 이론의 여지가 없다. `502` 를 고른 근거는 **500 을 비워 두는 것**이다. +`503` 은 이론의 여지가 없다. `502` 를 고른 근거는 500 을 「우리 코드의 결함」 전용으로 +비워 두는 것이다. -티켓은 `502 또는 500` 으로 열어 뒀지만, `PermanentError` 를 500 으로 내면 분류된 실패와 -분류되지 않은 예외가 같은 코드를 쓰게 되어 §1.2 에서 얻으려던 구분이 절반만 남는다. -「업스트림이 우리 요청을 거절했다」는 `502 Bad Gateway` 의 사전적 의미와도 맞는다. +티켓은 `502 또는 500` 으로 열어 뒀지만, `PermanentError` 를 500 으로 내면 분류된 +실패와 분류되지 않은 예외가 같은 코드를 쓰게 되어 §1.2 에서 얻으려던 구분이 절반만 +남는다. 「업스트림이 우리 요청을 거절했다」는 `502 Bad Gateway` 의 사전적 의미와도 +맞는다. -`back` 이 재시도를 억제하도록 신호를 준다는 논거는 **쓰지 않았다.** `AiSearchClient` 는 -애초에 재시도하지 않는다(사용자 요청 경로라 기다릴수록 손해). 이 구분이 가르는 것은 -호출자의 동작이 아니라 **운영자가 무엇을 고쳐야 하는가**다. +`back` 이 재시도를 억제하도록 신호를 준다는 논거는 쓰지 않았다. `AiSearchClient` 는 +애초에 재시도하지 않는다(사용자 요청 경로라 기다릴수록 손해다). 이 구분이 바꾸는 +것은 호출자의 동작이 아니라, 운영자가 무엇을 고쳐야 하는가에 대한 정보다. ### 2.2 응답 본문은 고정 문구 한 줄 @@ -140,9 +145,10 @@ api key sk-live-DEADBEEF rejected by gms.ssafy.io `httpx.MockTransport` → **실제** `EmbeddingClient` → `SearchService` → router → 예외 핸들러 → HTTP 응답까지 한 요청으로 관통한다. 18건. -**Fake client 를 쓰지 않는 것이 이 파일의 요점이다.** `FakeEmbeddingClient(raise_exc=...)` -로 예외를 주입하면 핸들러 매핑은 보이지만 분류 경로를 건너뛴다 — `classify_http_status` 가 -바뀌어도 통과한다. `ai#69` 를 놓친 구멍이 정확히 그 모양이었다. +Fake client 를 쓰지 않는 것이 이 파일의 요점이다. +`FakeEmbeddingClient(raise_exc=...)` 로 예외를 주입하면 핸들러 매핑은 보이지만 분류 +경로를 건너뛴다. 그러면 `classify_http_status` 가 바뀌어도 테스트가 통과한다. +`ai#69` 를 놓친 구멍이 정확히 그 형태였다. `tests/README.md` 의 「인터페이스 레벨 Fake」 규칙은 *파이프라인이 client 를 무엇으로 대체하는가*의 규칙이고(`integration-tests.md` §4.2), 여기서 고정하는 것은 client 가 아니라 @@ -157,22 +163,23 @@ api key sk-live-DEADBEEF rejected by gms.ssafy.io | `400`·`401`·`403`·`404` | `502`, 호출 **1회**(재시도 없음) | | 차원 불일치(200 인데 못 씀) | `502` | | 분류 밖 예외 | **`500` 유지** | -| 502 두 번 뒤 성공 | `200` — 전 경로가 산다 | +| 502 두 번 뒤 성공 | `200` — 전체 경로가 정상 동작한다 | | Profile 불일치 | `422` 유지, 임베딩 미호출 | | 시크릿 누락 | `401`, 임베딩 미호출 | | 오류 응답 본문 | credential·endpoint·profile·검색어 **없음** | -### 3.2 RED → GREEN +### 3.2 구현 전 실패 확인 → 구현 후 통과 -핸들러를 넣기 **전에** 14건 RED 를 확인했다. 실패 형태가 「500 이 나왔다」가 아니라 -**예외가 응답 계층으로 그대로 샜다**는 것이었고, 그것이 운영에서 uvicorn 이 500 을 만드는 -바로 그 지점이다. +핸들러를 넣기 전에 14건이 실패하는 것을 확인했다. 실패 형태가 「500 이 나왔다」가 +아니라 예외가 응답 계층으로 그대로 새는 것이었고, 그것이 운영에서 uvicorn 이 500 을 +만드는 바로 그 지점이다. -핸들러 추가 후 18건 GREEN, 전체 309건 통과. coverage line 99.8% / branch 98.7%. +핸들러 추가 후 18건 전부 통과, 전체 309건 통과. coverage line 99.8% / branch 98.7%. ### 3.3 실서버 대조 — 502 를 만들어 503 을 봤다 -테스트만으로는 「핸들러를 넣었다」에 가깝다. 로컬 GMS 스텁(`/gmsapi/api.openai.com/v1`)을 +테스트만으로는 「핸들러 코드를 넣었다」까지만 확인된다. 실제 동작을 보기 위해 로컬 +GMS 스텁(`/gmsapi/api.openai.com/v1`)을 띄우고 시연 DB(`:15432`, preset 27건)에 붙인 uvicorn 두 대를 같은 스텁에 물려 대조했다. | 업스트림 | `origin/dev`(a5e1142) | 이 브랜치 | @@ -193,17 +200,17 @@ WARNING app.main upstream transient failure: TransientError INFO: "POST /internal/v1/search HTTP/1.1" 503 Service Unavailable ``` -`502` 응답에 1.06초가 걸렸다 — 재시도 두 번의 백오프가 그 안에 있다. `401` 은 0.33초, -재시도가 없다. +`502` 응답에 1.06초가 걸렸다. 재시도 두 번의 백오프가 그 안에 있다. `401` 은 +0.33초로, 재시도가 없다. --- -## 4. 남긴 것 +## 4. 남은 문제 -- **DB 실패는 여전히 500 이다.** `failure-recovery.md` §2.1 은 "DB 연결 실패, 잠금 타임아웃, - 직렬화 실패"를 일시 오류로 두지만 `SearchService` 는 asyncpg 예외를 분류하지 않는다. - 검색 중 DB 가 죽으면 `503` 이 아니라 `500` 이 나간다. 이 티켓의 범위 밖이며, 고치려면 - repository 층에 분류를 넣어야 한다 — 별도 티켓 후보다. +- **DB 실패는 여전히 500 이다.** `failure-recovery.md` §2.1 은 "DB 연결 실패, 잠금 + 타임아웃, 직렬화 실패"를 일시 오류로 두지만 `SearchService` 는 asyncpg 예외를 + 분류하지 않는다. 검색 중 DB 접속이 실패하면 `503` 이 아니라 `500` 이 나간다. 이 + 티켓의 범위 밖이며, 고치려면 repository 층에 분류를 넣어야 한다. 별도 티켓 후보다. - **Circuit breaker 를 넣지 않았다.** `failure-recovery.md` §2.2 가 언급하지만 미구현이고 이 티켓이 다루는 문제가 아니다. - **`Retry-After` 헤더를 붙이지 않았다.** `back` 이 재시도하지 않으므로 소비자가 없다. @@ -211,8 +218,8 @@ INFO: "POST /internal/v1/search HTTP/1.1" 503 Service Unavailable 구조라 예외가 응답이 되지 않으며, 그 경로의 분류별 동작은 §2.1·§2.2 가 이미 정하고 `ContextProcessingService` 가 자체적으로 잡는다. -## 관련 함정 +## 관련 장애 기록 -로컬 재현 중 걸린 셋을 +로컬 재현 중 발견한 문제 세 건을 [2026-07-31-error-contract-pitfalls.md](../troubleshooting/2026-07-31-error-contract-pitfalls.md) (T43~T45)에 남겼다. diff --git a/docs/implements/2026-07-31-seed-guard.md b/docs/implements/2026-07-31-seed-guard.md index 1077b4a..180bd86 100644 --- a/docs/implements/2026-07-31-seed-guard.md +++ b/docs/implements/2026-07-31-seed-guard.md @@ -1,4 +1,4 @@ -# 시연 도구 결함 3건 — 조용히 지나간 실패를 시끄럽게 만든다 +# 시연 도구 결함 3건 — 오류 없이 지나가던 실패를 실행 전에 차단·보고하게 했다 - **티켓**: S15P11A705-198 - **상태**: 완료 @@ -7,15 +7,17 @@ ## 이 문서가 다루는 것 -세 결함은 성격이 같다. **이미 한 번씩 우리를 물었고, 셋 다 아무 오류 없이 지나갔다.** +세 결함은 성격이 같다. 이미 한 번씩 실제 사고를 냈고, 셋 다 발생 시점에는 아무 오류 +없이 지나갔다. -``` -1 seed.py 가 email 을 안 채웠다 → back 이 Flyway V6 에서 죽는다. 시딩 시점엔 멀쩡했다 -2 --reset 이 고아 행을 남긴다 → 저장 비용 평균이 8행만큼 틀렸다. 아무도 안 알려줬다 -3 worktree 에서 JWT 키가 갈라진다 → 전 요청 401 인데 back 로그에 아무것도 안 남는다 -``` +| # | 결함 | 결과 | +|---|---|---| +| 1 | `seed.py` 가 `email` 을 안 채웠다 | back 기동이 Flyway V6 에서 실패한다. 시딩 시점에는 문제가 없었다 | +| 2 | `--reset` 이 고아 행을 남긴다 | 저장 비용 평균이 8행만큼 틀렸다. 아무도 알려주지 않았다 | +| 3 | worktree 에서 JWT 키가 갈라진다 | 전 요청이 401 인데 back 로그에 아무것도 남지 않는다 | -값 자체는 `-191` 이 이미 고쳤다(`email` 채움, 고아 8행 삭제). 여기서 고칠 것은 **재발 경로**다. +값 자체는 `-191` 이 이미 고쳤다(`email` 채움, 고아 8행 삭제). 여기서 고칠 것은 재발 +경로다. --- @@ -30,14 +32,16 @@ | `pinlog-demo-postgres-1` | **15432** | 시연·E2E 정본 | 8테이블 · Flyway V1~V6·V100~V102 | 27 | 37 | | `pinlog-pgv-e2e` | **5433** | 07-27 E2E 하네스 잔재 | **스키마만 있고 테이블 0개** | 27 | 8 (`ctx 1001~1008`) | -**`ai/.env` 의 `DATABASE_URL` 은 `:5433` 이다.** 시연 절차([`-174` §7](2026-07-30-real-data-e2e.md))는 -매 명령에 `DATABASE_URL=...:15432` 를 명시적으로 덮어써서 돌린다. 즉 **기본값이 정본을 가리키지 -않으며, 환경변수를 빠뜨리면 도구가 조용히 다른 DB 에 붙는다.** +`ai/.env` 의 `DATABASE_URL` 은 `:5433` 이다. 시연 +절차([`-174` §7](2026-07-30-real-data-e2e.md))는 매 명령에 `DATABASE_URL=...:15432` +를 명시적으로 덮어써서 돌린다. 즉 기본값이 정본을 가리키지 않으며, 환경변수를 +빠뜨리면 도구가 아무 표시 없이 다른 DB 에 붙는다. `-198` 계약이 "프리셋 27행·임베딩 37행" 이라고 적은 것은 `:15432` 기준이고 그것은 맞다. `:5433` 의 8행은 07-27 하네스가 만든 합성 `context_id`(1001~1008)다. -> 이것은 결함 3(키가 갈라진다)과 같은 계열이다 — **환경이 갈라졌는데 아무도 말해 주지 않는다.** +> 이것은 결함 3(키가 갈라진다)과 같은 계열이다. 환경이 갈라졌는데 아무도 말해 주지 +> 않는다. ### 1.2 결함 2 의 실물은 임베딩이 아니라 `context_keyword_analysis` 다 @@ -51,35 +55,37 @@ ai.context_embedding 37행 고아 0 ai.context_keyword 72행 고아 0 ``` -`reset()` 의 삭제 목록에 `ai.context_keyword_analysis` 가 **아예 없다**. `ai_state`·`embedding`· -`keyword` 셋만 지운다. 그래서 시딩을 반복할 때마다 이 테이블만 단조 증가했다. +`reset()` 의 삭제 목록에 `ai.context_keyword_analysis` 가 아예 없다. +`ai_state`·`embedding`·`keyword` 셋만 지운다. 그래서 시딩을 반복할 때마다 이 +테이블만 단조 증가했다. -이건 논쟁거리가 아니다 — 지우는 대상이 `demo-seed` member 소유 context 로 한정되므로 -"남의 데이터" 문제가 아니고, 순수한 **누락**이다. +이건 논쟁거리가 아니다. 지우는 대상이 `demo-seed` member 소유 context 로 한정되므로 +"남의 데이터" 문제가 아니고, 순수한 누락이다. 논쟁거리는 남은 222행 쪽이다(§3.2). ### 1.3 결함 1 의 근본 원인은 "우리가 안 채우는 컬럼이 있다는 것을 몰랐다" 는 것 -`V4__social_account.sql` 은 `email` 을 nullable 로 만들었고 주석까지 달아 두었다 — -*"공급자 미제공·미동의 시 null 이다."* 우리는 그 컬럼의 존재를 인지하지 않은 채 -`(member_id, provider, provider_user_id)` 만 넣었고, **NULL 이 정상값인 컬럼이라 아무 오류가 -없었다.** 사고는 그로부터 며칠 뒤 back 이 `V6` 로 `SET NOT NULL` 을 걸 때 터졌다. +`V4__social_account.sql` 은 `email` 을 nullable 로 만들었고 주석까지 달아 +두었다(*"공급자 미제공·미동의 시 null 이다."*). 우리는 그 컬럼의 존재를 인지하지 +않은 채 `(member_id, provider, provider_user_id)` 만 넣었고, NULL 이 정상값인 +컬럼이라 아무 오류가 없었다. 사고는 그로부터 며칠 뒤 back 이 `V6` 로 `SET NOT NULL` +을 걸 때 났다. -그러므로 방어의 대상은 "NOT NULL 제약" 이 아니라 **"우리가 값을 주지 않는 컬럼"** 이다. +그러므로 방어의 대상은 "NOT NULL 제약" 이 아니라 "우리가 값을 주지 않는 컬럼" 이다. 그 컬럼이 언젠가 NOT NULL 이 될 후보다. ### 1.4 결함 3 — worktree 에는 `.env` 도 `.demo/` 도 없다 `_client.py` 의 `KEY_PATH` 는 `ROOT/.demo/demo-jwt-key.pem` 이고 `ROOT` 는 `_client.py` 위치 기준이다. worktree 에서는 그 경로가 worktree 안을 가리키고, `.demo/` 는 gitignore 라 -**존재하지 않는다 → `ensure_key()` 가 새 키를 만든다.** back 에 주입된 것은 메인 워킹트리의 -키이므로 전 요청이 401 이 된다. +존재하지 않는다. 그래서 `ensure_key()` 가 새 키를 만든다. back 에 주입된 것은 메인 +워킹트리의 키이므로 전 요청이 401 이 된다. -401 이 어디서 드러나는가도 확인했다. `BackClient._csrf_token()` 이 CSRF 쿠키를 못 받아 -`RuntimeError` 를 내고, 메시지에 원인 추정까지 적혀 있다. **문제는 그 시점이다** — -`main()` 은 `--reset` 을 먼저 돌리므로 **기존 데이터를 전부 지운 뒤에** 인증이 실패한다. -`T28`(UnicodeEncodeError 로 reset 직후 죽음)과 구조가 같다. +401 이 어디서 드러나는가도 확인했다. `BackClient._csrf_token()` 이 CSRF 쿠키를 못 +받아 `RuntimeError` 를 내고, 메시지에 원인 추정까지 적혀 있다. 문제는 그 시점이다. +`main()` 은 `--reset` 을 먼저 돌리므로 기존 데이터를 전부 지운 뒤에 인증이 실패한다. +`T28`(UnicodeEncodeError 로 reset 직후 실행 중단)과 구조가 같다. --- @@ -98,38 +104,38 @@ ai.context_keyword 72행 고아 0 | 파일 | 무엇을 · 왜 | |---|---| -| `tools/demo_seed/preflight.py` (신규) | 위 다섯. 판정 3종(`diff_write_contract`·`pending_migrations`·`format_orphans`)은 순수 함수로 분리했다 — **일부러 어긋내 RED를 보는 것**이 이 코드의 유일한 검증 수단이라 그 테스트가 DB·HTTP·`.env` 없이 돌아야 한다 | -| `tools/demo_seed/_client.py` | `shared_root()` 신설 — `--git-common-dir`로 worktree 안에서도 메인 워킹트리를 가리킨다. `KEY_PATH`가 이것을 쓴다. `ensure_key()`는 **키를 새로 만들 때 stderr로 말한다**(조용히 만드는 것이 결함 3의 절반이었다) | +| `tools/demo_seed/preflight.py` (신규) | 위 다섯. 판정 3종(`diff_write_contract`·`pending_migrations`·`format_orphans`)은 순수 함수로 분리했다. 일부러 어긋내 실패를 확인하는 것이 이 코드의 유일한 검증 수단이라, 그 테스트가 DB·HTTP·`.env` 없이 돌아야 하기 때문이다 | +| `tools/demo_seed/_client.py` | `shared_root()` 신설 — `--git-common-dir`로 worktree 안에서도 메인 워킹트리를 가리킨다. `KEY_PATH`가 이것을 쓴다. `ensure_key()`는 키를 새로 만들 때 stderr로 알린다(아무 표시 없이 만드는 것이 결함 3의 절반이었다) | | `tools/demo_seed/seed.py` | preflight를 `--reset` 앞에 배치. `reset()`의 `ai.*` 삭제를 `ORPHAN_TABLES` 순회로 바꿔 목록을 단일화. `--prune-orphans` 신설. 시딩 종료 후에도 고아를 센다 | | `tests/test_demo_seed_preflight.py` (신규) | 판정 3종 15케이스. 대부분이 "걸려야 하는 입력" | | `tools/demo_seed/README.md` | preflight 절·`--prune-orphans`·키 경로 고정 | -### 결함 1 — 겨눈 것은 제약이 아니라 "우리가 값을 주지 않는 컬럼"이다 +### 결함 1 — 방어 대상은 제약이 아니라 "우리가 값을 주지 않는 컬럼"이다 -`WRITE_CONTRACT`가 시딩이 직접 INSERT하는 두 테이블의 **모든 컬럼**을 셋 중 하나로 +`WRITE_CONTRACT`가 시딩이 직접 INSERT하는 두 테이블의 모든 컬럼을 셋 중 하나로 선언한다(`SEED` 우리가 채운다 / `DB` 기본값·IDENTITY에 맡긴다 / `NULL` 의도적으로 비운다). 선언에 없는 컬럼이 실제 스키마에 나타나면 거기서 멈춘다. ``` core.social_account: 계약에 없는 컬럼 `nickname` (nullable=True, default=False) — back이 컬럼을 추가했다. 시딩이 값을 넣을지 정하고 WRITE_CONTRACT에 선언하라. - NULL로 두면 back이 나중에 NOT NULL을 걸 때 기동이 죽는다(V6 전례) + NULL로 두면 back이 나중에 NOT NULL을 걸 때 기동이 실패한다(V6 전례) ``` -**nullable인 새 컬럼에서 걸리는 것이 핵심이다.** 제약이 걸리는 시점에는 이미 늦고, +nullable인 새 컬럼에서 걸리는 것이 핵심이다. 제약이 걸리는 시점에는 이미 늦고, 컬럼이 생기는 시점에는 아직 아무 오류도 나지 않는다. 그 사이가 유일한 기회다. ### 결함 2 — 목록을 두 벌 두지 않는다 -`reset()`이 지울 테이블과 고아를 셀 테이블이 **같은 상수**(`ORPHAN_TABLES`)다. -목록에서 빠진 테이블은 reset이 안 지우지만 **집계도 세지 않으므로 조용히 쌓인다** — -그것이 `context_keyword_analysis`가 222행이 된 경로다. 하나로 묶으면 새 테이블을 +`reset()`이 지울 테이블과 고아를 셀 테이블이 같은 상수(`ORPHAN_TABLES`)다. +목록에서 빠진 테이블은 reset이 안 지우면서 집계도 세지 않으므로 아무 표시 없이 +쌓인다. 그것이 `context_keyword_analysis`가 222행이 된 경로다. 하나로 묶으면 새 테이블을 빠뜨려도 집계가 먼저 그것을 고아로 보고한다. ### 결함 3 — 묶는 것과 드러내는 것을 둘 다 한다 -`shared_root()`가 키 경로를 하나로 묶고, 인증 프로브가 **back이 그 키를 실제로 -받아들이는지** 확인한다. 둘은 다른 일을 한다 — 경로를 묶어도 back에 주입된 +`shared_root()`가 키 경로를 하나로 묶고, 인증 프로브가 back이 그 키를 실제로 +받아들이는지 확인한다. 둘은 다른 일을 한다. 경로를 묶어도 back에 주입된 `JWT_PRIVATE_KEY`가 다른 키면 여전히 401이며, 그것은 파일 배치로 막을 수 없다. 프로브는 `provider_user_id='__preflight__'` member 한 쌍을 만들어 인증된 GET을 @@ -139,60 +145,61 @@ core.social_account: 계약에 없는 컬럼 `nickname` (nullable=True, default= ### 3.1 결함 1의 방어를 어디에 두는가 — 후보 셋 중 둘, 형태를 바꿔서 -**「시딩 후 back 기동 스모크」는 넣지 않았다.** back 기동은 우리 도구의 책임 밖이고 -느리며, 무엇보다 **늦다** — 그 시점엔 DB가 이미 오염돼 있어 관측은 되지만 예방은 -안 된다. 실제로 이 세션에서 back을 띄워 보니 낡은 jar 때문에 Flyway가 죽었는데 -(§4.3), 그 진단은 우리가 만든 어떤 장치도 아닌 back의 스택트레이스가 했다. +「시딩 후 back 기동 스모크」는 넣지 않았다. back 기동은 우리 도구의 책임 밖이고 +느리며, 무엇보다 시점이 늦다. 그 시점엔 DB가 이미 오염돼 있어 관측은 되지만 예방은 +안 된다. 실제로 이 세션에서 back을 띄워 보니 낡은 jar 때문에 Flyway가 기동을 +거부했는데(§4.3), 그 진단은 우리가 만든 어떤 장치도 아닌 back의 스택트레이스가 했다. -**「컬럼 집합 대조」는 넣되 방향을 바꿨다.** "제약을 감시한다"가 아니라 "우리가 -값을 주지 않는 컬럼을 선언하게 만든다"이다. 전자는 back이 언제 무엇을 걸지 알아야 -하지만, 후자는 **우리 쪽 인지 상태만 관리하면 된다.** back이 우리 CI가 아니라는 +「컬럼 집합 대조」는 넣되 방향을 바꿨다. "제약을 감시한다"가 아니라 "우리가 값을 +주지 않는 컬럼을 선언하게 만든다"이다. 전자는 back이 언제 무엇을 걸지 알아야 +하지만, 후자는 우리 쪽 인지 상태만 관리하면 된다. back이 우리 CI가 아니라는 현실에서 실제로 작동하는 것은 이쪽이다. -**「트랜잭션 경계」는 다루지 않았다.** 결함 1은 트랜잭션 문제가 아니다 — INSERT는 -정상 커밋됐고 며칠 뒤 다른 프로세스가 죽었다. 경계를 조여도 이 사고는 그대로 난다. +「트랜잭션 경계」는 다루지 않았다. 결함 1은 트랜잭션 문제가 아니다. INSERT는 정상 +커밋됐고 며칠 뒤 다른 프로세스가 실패했다. 경계를 조여도 이 사고는 그대로 난다. -대신 **「미적용 마이그레이션 감지」**를 추가했다. back 레포가 로컬에 있으면 +대신 「미적용 마이그레이션 감지」를 추가했다. back 레포가 로컬에 있으면 `flyway_schema_history`와 대조해 우리 테이블을 건드리는 미적용분을 차단한다. -back 레포가 없으면(CI·배포) 조용히 건너뛴다 — 계약이 아니라 로컬 편의다. +back 레포가 없으면(CI·배포) 검사 없이 건너뛴다. 계약이 아니라 로컬 편의이기 +때문이다. ### 3.2 `--reset`이 무엇까지 지워야 하는가 — 경고만 하고 남긴다 -**지우지 않는다.** 근거가 취향이 아니라 사실에 있다. +지우지 않는다. 근거가 취향이 아니라 사실에 있다. -`tools/e2e/`의 검증 데이터는 **의도적으로** `core`에 대응 행이 없는 `ai` 단독 +`tools/e2e/`의 검증 데이터는 의도적으로 `core`에 대응 행이 없는 `ai` 단독 데이터다(`README.md` "주의"가 명시한다). 그것이 고아 정의에 전부 걸린다. 자동 -삭제는 남의 하네스를 깨뜨린다. 동시에 그 행들이 집계를 틀리게 만드는 것도 사실이다 -(`-191`의 저장 비용 8행). **둘 다 참이므로 도구가 고를 문제가 아니다** — 세어서 +삭제는 다른 하네스를 깨뜨린다. 동시에 그 행들이 집계를 틀리게 만드는 것도 +사실이다(`-191`의 저장 비용 8행). 둘 다 참이므로 도구가 고를 문제가 아니다. 세어서 보여주고 `--prune-orphans`로 사람이 정한다. 삭제는 되돌릴 수 없고 보고는 되돌릴 수 있다. 단, `demo-seed` member 소유 context의 `ai.context_keyword_analysis` 행을 reset이 -지우지 않던 것은 **논쟁거리가 아니라 누락**이라 그냥 고쳤다(§2 결함 2). +지우지 않던 것은 논쟁거리가 아니라 누락이라 그냥 고쳤다(§2 결함 2). ### 3.3 결함 3을 고칠 것인가 드러낼 것인가 — 둘 다, 그러나 드러내기가 본질 -키 경로를 묶는 것은 **한 가지 갈라짐**(worktree)만 막는다. back에 주입된 키가 -다른 경우·`PINLOG_DEMO_JWT_KEY`를 잘못 준 경우는 그대로 남는다. 인증 프로브는 -원인과 무관하게 **결과**를 잡으므로 이쪽이 방어의 본체다. 경로 고정은 가장 흔한 -원인 하나를 없애는 보조다. +키 경로를 묶는 것은 한 가지 갈라짐(worktree)만 막는다. back에 주입된 키가 다른 +경우·`PINLOG_DEMO_JWT_KEY`를 잘못 준 경우는 그대로 남는다. 인증 프로브는 원인과 +무관하게 결과를 잡으므로 이쪽이 방어의 본체다. 경로 고정은 가장 흔한 원인 하나를 +없애는 보조다. ### 3.4 셋 중 빼는 것이 나은 것 — 없다. 다만 하나가 늘었다 셋 다 같은 도구의 같은 진입점에 모이므로 나누는 것이 오히려 비싸다. 대신 §1.1의 -**접속 DB 갈라짐**을 발견해 첫 줄 출력으로 넣었다. `.env` 기본값을 고치는 것은 +접속 DB 갈라짐을 발견해 첫 줄 출력으로 넣었다. `.env` 기본값을 고치는 것은 `ai` 소유이지만 `-174` 절차 문서·다른 세션의 습관과 얽혀 있어 이 티켓에서 바꾸지 않았다(§5). -## 4. 검증 — 방어가 실제로 발화하는가 +## 4. 검증 — 방어가 실제로 걸리는가 -통과만 확인하면 아무것도 검사하지 않는 장치를 놓친다. **셋 다 일부러 어긋내 -RED를 본 뒤 되돌렸다**(`S15P11A705-156`이 완료 조건에 넣은 것과 같은 이유). +통과만 확인하면 아무것도 검사하지 않는 장치를 놓친다. 그래서 셋 다 일부러 어긋내 +실패를 확인한 뒤 되돌렸다(`S15P11A705-156`이 완료 조건에 넣은 것과 같은 이유다). ### 4.1 판정 로직 — 뮤턴트로 확인 `tests/test_demo_seed_preflight.py` 15케이스 통과. 그다음 `diff_write_contract` -첫 줄에 `return []`를 넣어 **판정을 무력화**했더니 5건이 실패했다. +첫 줄에 `return []`를 넣어 판정을 무력화했더니 5건이 실패했다. ``` FAILED test_back이_컬럼을_추가하면_걸린다 @@ -208,7 +215,7 @@ FAILED test_우리가_안_채우는_컬럼에_NOT_NULL이_걸리면_잡힌다[nu `:15432` 서버에 `guardprobe` DB를 새로 만들고 back 마이그레이션 9개를 전부 적용한 뒤, `check_write_contract`를 실제 `information_schema`에 대해 돌렸다. -**남의 데이터를 건드리지 않기 위해 별도 DB를 썼고 끝난 뒤 DROP했다.** +기존 데이터를 건드리지 않기 위해 별도 DB를 썼고 끝난 뒤 DROP했다. | 단계 | 스키마 상태 | 결과 | |---|---|---| @@ -217,10 +224,10 @@ FAILED test_우리가_안_채우는_컬럼에_NOT_NULL이_걸리면_잡힌다[nu | 3 | 그 컬럼에 `SET NOT NULL` | **RED** | | 4 | `DROP COLUMN nickname` | 문제 없음 | -2단계가 이 티켓의 요점이다 — **컬럼이 nullable로 추가된 시점**, 즉 `email`이 -V4에서 태어났고 우리가 놓쳤던 그 순간에 걸린다. +2단계가 이 티켓의 요점이다. 컬럼이 nullable로 추가된 시점, 즉 `email`이 V4에서 +추가됐고 우리가 놓쳤던 그 순간에 걸린다. -> **이 실행이 오탐 하나를 잡았다.** 처음에는 `id`가 "NOT NULL인데 기본값이 없다"로 +> 이 실행이 오탐 하나를 잡았다. 처음에는 `id`가 "NOT NULL인데 기본값이 없다"로 > 걸렸다. `GENERATED ALWAYS AS IDENTITY`는 `information_schema.columns`에서 > `column_default`가 NULL이고 `is_identity`가 별도 컬럼이기 때문이다. 단위 테스트는 > 이 쿼리를 타지 않아 잡히지 않았다. `is_identity`·`is_generated`를 포함하도록 고쳤다. @@ -236,10 +243,10 @@ V4에서 태어났고 우리가 놓쳤던 그 순간에 걸린다. | E | `:15432` · back 기동 · 올바른 키 | 계약 `[ok]` · 인증 `[ok]` · `[WARN]` 고아 222 | **0** | | F | `:15432` · back 기동 · **다른 키** | `[BLOCK]` `HTTP 401 UNAUTHORIZED`, 쓰고 있는 키 경로까지 출력 | **2** | -**F가 결함 3의 재현이다.** 셋 다 `--reset` 이전에 멈췄고 로그가 "아무것도 지우지 -않았다"로 끝난다. C가 §1.1(다른 DB에 조용히 붙는 것)도 같이 잡는다. +F가 결함 3의 재현이다. 셋 다 `--reset` 이전에 멈췄고 로그가 "아무것도 지우지 +않았다"로 끝난다. C가 §1.1(다른 DB에 표시 없이 붙는 것)도 같이 잡는다. -back은 §7 절차로 실제 기동했다. **첫 기동은 실패했다** — DB에는 V6가 적용돼 있는데 +back은 §7 절차로 실제 기동했다. 첫 기동은 실패했다. DB에는 V6가 적용돼 있는데 로컬 `build/libs/` jar이 07-30 빌드라 V6를 담고 있지 않아 Flyway가 `Detected applied migration not resolved locally: 6`으로 거부했다. `bootJar` 리빌드 후 정상 기동(`Current version of schema "public": 102`). @@ -284,14 +291,14 @@ ruff check . All checks passed `tools/`는 ruff `extend-exclude` 대상이라 게이트에 들지 않는다. 그래도 확인했고, 남은 `E501`·`I001`은 `dev` 시점과 동수다(내가 늘리지 않았다). -## 5. 남긴 것 +## 5. 남은 문제 - **`.env`의 `DATABASE_URL`이 `:5433`을 가리킨다**(§1.1). 시연 정본은 `:15432`이고 `-174` §7 절차가 매 명령에 덮어쓴다. 기본값을 바꾸는 것은 `ai` 소유 파일이지만 절차 문서·다른 세션의 습관과 얽혀 있어 이 티켓에서 손대지 않았다. preflight가 - 접속 대상을 첫 줄에 찍으므로 **틀린 DB에 붙으면 즉시 보인다.** + 접속 대상을 첫 줄에 찍으므로 틀린 DB에 붙으면 즉시 보인다. - **`back`의 `build/libs/` jar이 낡아 있었다**(§4.3). 검증을 위해 `bootJar`로 - 리빌드했다 — 소스는 건드리지 않았고 산출물만 갱신됐다. + 리빌드했다. 소스는 건드리지 않았고 산출물만 갱신됐다. - **`verify.py`에는 preflight를 붙이지 않았다.** 그쪽은 읽기만 하므로 실패해도 잃을 것이 없고, 인증이 깨지면 검증이 FAIL로 뜬다. - **고아 222행은 그대로 남겼다**(§3.2). 지울지는 사람이 정한다. diff --git a/docs/implements/2026-07-31-ticket-audit-96-77.md b/docs/implements/2026-07-31-ticket-audit-96-77.md index 65a3d50..501eb43 100644 --- a/docs/implements/2026-07-31-ticket-audit-96-77.md +++ b/docs/implements/2026-07-31-ticket-audit-96-77.md @@ -1,4 +1,4 @@ -# 티켓 대조 감사 — `S15P11A705-96` · `S15P11A705-77` +# 티켓 대조 감사 — `S15P11A705-96` · `S15P11A705-77` 모두 완료 조건이 충족되어 닫아도 된다 - **상태**: 완료 - **날짜**: 2026-07-31 @@ -13,9 +13,9 @@ ## 1. `S15P11A705-96` — dev 배포용 GMS·모델·bootstrap·관측 계약 확정 -### 1.0 이 티켓의 지형이 바뀌었다 +### 1.0 이 티켓의 전제가 바뀌었다 -티켓 발행 시점의 전제는 "**AI dev 배포 전에** 계약을 확정한다"였다. 현재 **dev 배포는 이미 활성화됐다.** +티켓 발행 시점의 전제는 "AI dev 배포 전에 계약을 확정한다"였다. 현재 dev 배포는 이미 활성화됐다. ``` infra apps/dev/ai/values.yaml (origin/main c0b6a6a) @@ -77,7 +77,7 @@ ai#32 docs(S15P11A705-96): define dev deployment approval contract base main · 파일 docs/S15P11A705-96-dev-deployment-contract.md (origin 브랜치에만 존재) ``` -AI 파트는 이 PR에서 요구된 §4 응답표를 리뷰 코멘트로 제출했고 인프라 요청 ①②③④에 모두 구현·회신했다(#33·#34·#35). **계약 내용은 인프라 레포 문서로 실효화됐고 배포도 활성화됐으므로 이 PR의 병합 여부가 배포를 막고 있지는 않다.** 다만 병합되지 않는 한 `ai` 레포에는 `-96` 계약 정본이 없다. **타 파트 소유 PR이므로 처분은 그쪽 판단이다 — 이 감사에서는 사실만 기록한다.** +AI 파트는 이 PR에서 요구된 §4 응답표를 리뷰 코멘트로 제출했고 인프라 요청 ①②③④에 모두 구현·회신했다(#33·#34·#35). 계약 내용은 인프라 레포 문서로 실효화됐고 배포도 활성화됐으므로, 이 PR의 병합 여부가 배포를 막고 있지는 않다. 다만 병합되지 않는 한 `ai` 레포에는 `-96` 계약 정본이 없다. 타 파트 소유 PR이므로 처분은 그쪽 판단이다. 이 감사에서는 사실만 기록한다. 미회신으로 남은 것 하나: `PINLOG_AI_INFRA_PR_TOKEN`의 용도 4문항(대상 레포·권한 범위·만료·커밋 경로). `ai-serving.md` "명시적 미완료 gate" 3번에 그대로 남아 있으며 **image automation 활성화 범위**의 항목이지 dev 배포 gate가 아니다(배포는 이미 열렸다). @@ -85,7 +85,7 @@ AI 파트는 이 PR에서 요구된 §4 응답표를 리뷰 코멘트로 제출 **`docs/spec/model-profile.md` §3.2** — 2-c의 자기모순. `state-machine.md` §2가 M3 개정으로 운영 재처리 `COMPLETED → PENDING`을 Spring 권한으로 명시했는데, `model-profile.md`는 개정 전 서술("되돌리는 전이도 존재하지 않으므로")을 유지해 같은 레포 두 문서가 정반대를 말하고 있었다. 인프라·백엔드가 `model-profile.md`만 읽으면 "Profile 전환 후 재처리 경로가 없다"고 오독한다. -우리 소유 문서이고 정본(`state-machine.md`)이 이미 확정돼 있어 **문구 정합만 맞추는 작고 명확한 수정**이라 판단해 반영했다. 계약 내용을 바꾸지 않는다. +우리 소유 문서이고 정본(`state-machine.md`)이 이미 확정돼 있어, 문구 정합만 맞추는 작고 명확한 수정이라 판단해 반영했다. 계약 내용을 바꾸지 않는다. --- @@ -179,7 +179,7 @@ AI 파트는 이 PR에서 요구된 §4 응답표를 리뷰 코멘트로 제출 | 항목 | 이유 | |---|---| | 두 티켓 본문의 **현재** 내용 | Atlassian 커넥터 미인증. 2026-07-30 JQL 응답 캐시에서 복원했다. 그 이후 본문이 개정됐다면 이 대조의 기준이 낡았다 | -| dev 클러스터에서 Pod이 실제로 Ready인지, bootstrap Job이 성공했는지 | 클러스터 접근 없음. `infra` 매니페스트가 `enabled: true`라는 것까지만 확인했다. **매니페스트가 열렸다는 것과 워크로드가 살아 있다는 것은 다르다** | +| dev 클러스터에서 Pod이 실제로 Ready인지, bootstrap Job이 성공했는지 | 클러스터 접근 없음. `infra` 매니페스트가 `enabled: true`라는 것까지만 확인했다. 매니페스트가 열렸다는 것과 워크로드가 실제로 정상 동작한다는 것은 다르다 | | `infra` 문서 두 편의 서술과 실제 값의 불일치 | `docs/ai-serving.md`·`ai-dev-prerequisites.md`가 "현재 세 gate 모두 false"라고 적는데 `apps/dev/ai/values.yaml`은 셋 다 `true`다. 문서가 activation 이후 갱신되지 않은 것으로 보이나, **타 파트 소유라 확인·정정하지 않았다** | | 키 집합 충돌 (A)/(B) 중 인프라가 어느 쪽을 택했다고 **명시 회신**했는지 | 실물로는 (A)다 — SealedSecret과 8키 validator에 `PINLOG_EMBEDDING_*` 넷이 그대로 있다. 그 결정이 어디서 합의됐는지는 `ai#32` 코멘트에서 찾지 못했다 | | `-77`의 각 정정이 **어느 PR·커밋에서** 이뤄졌는지 | 현재 상태만 대조했다. 판정에는 충분하나, 개별 반영 시점이 필요하면 `docs` 레포 이력 추적이 별도로 필요하다 | diff --git a/docs/implements/2026-08-03-dead-config-keys.md b/docs/implements/2026-08-03-dead-config-keys.md index 185a54a..4e36c71 100644 --- a/docs/implements/2026-08-03-dead-config-keys.md +++ b/docs/implements/2026-08-03-dead-config-keys.md @@ -1,33 +1,37 @@ -# 죽은 설정 키 전수조사 — S15P11A705-224 +# 읽히지 않는 설정 키 전수조사 — 죽은 키 2개를 확인하고 로컬 `.env` 잔재를 정리했다 -**상태: 완료** +- **티켓**: S15P11A705-224 +- **상태**: 완료 + +이 문서에서 「죽은 키」는 설정 파일에 이름이 남아 있지만 코드가 더 이상 읽지 않는 +키를 뜻한다. ## 배경 -`-219`가 이미 T43(`docs/troubleshooting/2026-07-31-judge-prompt-ab.md`)으로 남겼다 — `.env`의 -`PINLOG_JUDGE_MODEL`은 `-175`가 `PINLOG_JUDGE_CHAIN`으로 대체해 **읽히지 않는데** 값이 -체인 2순위(`gemini-2.5-flash`)와 같아 그럴듯해 보였고, 그 값을 읽은 것으로 보이는 -`-210` 리포트 §7의 조건 표가 실제 응답 모델(체인 1순위 `gpt-4o-mini`, `-219` 실측 -1,092회 전 회차)을 잘못 적었다. +발단은 `-219`가 T43(`docs/troubleshooting/2026-07-31-judge-prompt-ab.md`)으로 남긴 +사실이다. `.env`의 `PINLOG_JUDGE_MODEL`은 `-175`가 `PINLOG_JUDGE_CHAIN`으로 대체해 +더 이상 읽히지 않는다. 그런데 그 값이 체인 2순위(`gemini-2.5-flash`)와 같아 그럴듯해 +보였다. 그 값을 읽은 것으로 보이는 `-210` 리포트 §7의 조건 표는 실제 응답 모델(체인 +1순위 `gpt-4o-mini`, `-219` 실측 1,092회 전 회차)을 잘못 적었다. -이 티켓은 `PINLOG_JUDGE_MODEL` 하나가 아니라 **읽히지 않는 설정 키 전체**를 찾는다 — -`.env`·`.env.example`·`app/core/config.py`·배포 Secret 키 목록을 대조하고, 근거는 grep이 -아니라 런타임 검증으로 남긴다. +이 티켓은 `PINLOG_JUDGE_MODEL` 하나가 아니라 읽히지 않는 설정 키 전체를 찾는다. +`.env`·`.env.example`·`app/core/config.py`·배포 Secret 키 목록을 대조하고, 근거는 +grep이 아니라 런타임 검증으로 남긴다. ## 방법 — grep으로 끝내지 않는다 pydantic Settings는 `populate_by_name=True`이고 필드마다 `alias=`를 명시로 지정하므로, alias 문자열이 코드에 리터럴로 있는지는 grep으로 확인할 수 있다. 하지만 그 리터럴이 -**실제로 `Settings()` 인스턴스에 반영되는지**는 별개다 — 필드가 조용히 제거됐는데 옛 -`.env.example` 주석·문서에만 이름이 남아 grep이 걸리는 경우(`PINLOG_JUDGE_MODEL`이 바로 -이 모양)와, 반대로 값이 반영되는데 코드에서 문자열이 다른 곳에 있어 놓치는 경우 둘 다 -가능하다. `-210`이 "임계값이 없다"고 적었다가 실제로는 `config.py:114`에 있었던 사고가 -같은 종류다. +실제로 `Settings()` 인스턴스에 반영되는지는 별개다. 필드가 제거됐는데 옛 +`.env.example` 주석·문서에만 이름이 남아 grep이 걸리는 경우가 있고 +(`PINLOG_JUDGE_MODEL`이 바로 이 경우다), 반대로 값이 반영되는데 코드에서 문자열이 +다른 곳에 있어 grep이 놓치는 경우도 있다. `-210`이 "임계값이 없다"고 적었다가 +실제로는 `config.py:114`에 있었던 사고가 같은 종류다. -그래서 각 후보 키에 대해 **가짜 sentinel 값을 주입해 `Settings()`를 실제로 생성하고, -필드 값에 그 sentinel이 반영되는지** 확인했다(스크립트는 세션 스크래치패드, 휘발 — -아래 "산출물" 참고). 값은 전부 `fake-*`·`sentinel-*` 형태의 무의미한 문자열이며 실제 -Secret을 다루지 않았다. +그래서 각 후보 키에 대해 가짜 sentinel 값을 주입해 `Settings()`를 실제로 생성하고, +필드 값에 그 sentinel이 반영되는지 확인했다(스크립트는 세션 스크래치패드에 있으며 +휘발성이다. 아래 "산출물" 참고). 값은 전부 `fake-*`·`sentinel-*` 형태의 무의미한 +문자열이며 실제 Secret을 다루지 않았다. ## 전수조사 대상과 대조 @@ -40,7 +44,7 @@ Secret을 다루지 않았다. | K8s SealedSecret `ai-db-credentials`(dev) | 1 | `infra/secrets/dev/ai-db-credentials.sealedsecret.yaml` | | `infra/policy/sealedsecrets/ai-dev.yaml` `ownerSecretKeys` | 7 | 봉인 정책 — 위 7개와 일치 확인 | -`Settings`의 15개 필드는 다음과 같다(런타임 sentinel 주입으로 **전부** 읽힘을 확인): +`Settings`의 15개 필드는 다음과 같다(런타임 sentinel 주입으로 전부 읽힘을 확인했다): ``` database_url · gms_api_key · gms_base_url · internal_shared_secret @@ -58,12 +62,12 @@ processing_expiry_sec | `PINLOG_JUDGE_MODEL` | **읽히지 않음** | `Settings.model_fields`에 이 alias를 가진 필드가 없음(런타임 확인). `-175`가 `PINLOG_JUDGE_CHAIN`으로 대체했고 `config.py:108`·`.env.example:53`에 이미 "옛 키는 이제 무시된다" 주석이 있음. `-219`(T43)가 실측으로 확인(-210 §7 오기 원인) | `ai/.env`(로컬)에서만 남아 있던 잔재. 이번에 주석 처리(아래 "조치" 참고). 저장소 추적 파일에는 애초에 없었음 | | `PRESET_CACHE_TTL_SEC` | **읽히지 않음** | `Settings.model_fields`에 해당 필드 없음(런타임 확인). `2026-07-27-e2e-verification.md` F6이 이미 발견해 `ai#24`(2026-07-27, 커밋 `7d82618`)에서 `config.py`·`.env.example`에서 제거하고 `architecture.md §5`를 "재시작으로만"으로 정정함. `app/bootstrap/load_presets.py`도 `get_settings()`만 거치고 별도 `os.environ` 조회가 없어 다른 경로로도 안 읽음 | `ai/.env`(로컬)에서만 남아 있던 잔재 — **2026-07-27 정리 이후에도 로컬 파일에는 반영 안 된 채 6일 넘게 남아 있었다.** 이번에 주석 처리 | -두 키 모두 **저장소 코드·`.env.example`에서는 이미 정리가 끝나 있었다** — 남은 문제는 +두 키 모두 저장소 코드와 `.env.example`에서는 이미 정리가 끝나 있었다. 남은 문제는 개발자 로컬 `.env`(gitignored, 저장소 추적 밖)뿐이었다. -배포 Secret(`ai-owner-secrets`·`ai-db-credentials`, 합 8키)에는 두 키 다 없다 — dev -배포는 애초에 이 죽은 값을 실어 나른 적이 없다. **배포 환경은 인프라 소관이라 값은 -고치지 않았고, 확인 결과만 남긴다**(계약). +배포 Secret(`ai-owner-secrets`·`ai-db-credentials`, 합 8키)에는 두 키 다 없다. 즉 +dev 배포는 애초에 이 죽은 값을 실어 나른 적이 없다. 배포 환경은 인프라 소관이라 값은 +고치지 않았고, 확인 결과만 남긴다(계약). ## 전수조사 결과 — 이 둘 외에는 없다 @@ -72,24 +76,24 @@ processing_expiry_sec `processing_expiry_sec`·`embedding_model`·`embedding_dimension`·`embedding_distance`· `embedding_profile`)는 전부 런타임 sentinel 주입으로 반영을 확인했다. `Settings`에는 있지만 `.env`·`.env.example` 어디에도 없는 `judge_vote_n`·`search_similarity_floor`· -`search_top_ratio`도 마찬가지로 sentinel 주입으로 반영을 확인했다 — 코드 기본값으로만 -동작 중이며 죽은 키가 아니다(단순히 예시 파일에 없을 뿐). +`search_top_ratio`도 마찬가지로 sentinel 주입으로 반영을 확인했다. 이 셋은 코드 +기본값으로만 동작 중이며 죽은 키가 아니다(단순히 예시 파일에 없을 뿐이다). ## 부수 확인 — `.env` 13키 vs `.env.example` 12키의 실제 관계 -**계약의 전제가 정확하지 않았다.** "13 vs 12이므로 example에 없는 키 하나를 찾아 +계약의 전제가 정확하지 않았다. "13 vs 12이므로 example에 없는 키 하나를 찾아 필요하면 추가한다"는 단일 방향 차집합을 가정했지만, 실제로는 양방향이다. -- `.env`에만 있고 `.env.example`에는 없는 키: `PINLOG_JUDGE_MODEL`, `PRESET_CACHE_TTL_SEC` - (2개, 위에서 확인한 대로 **둘 다 죽은 키**) -- `.env.example`에만 있고 `.env`에는 없는 키: `PINLOG_JUDGE_CHAIN` (1개, **살아 있는 키** — - `.env`가 이 키를 생략하고 코드 기본값 `DEFAULT_JUDGE_CHAIN`에 의존 중이라는 뜻이며 - 문제는 아니다) +- `.env`에만 있고 `.env.example`에는 없는 키: `PINLOG_JUDGE_MODEL`, + `PRESET_CACHE_TTL_SEC` (2개. 위에서 확인한 대로 둘 다 죽은 키다) +- `.env.example`에만 있고 `.env`에는 없는 키: `PINLOG_JUDGE_CHAIN` (1개. 코드가 읽는 + 살아 있는 키다. `.env`가 이 키를 생략하고 코드 기본값 `DEFAULT_JUDGE_CHAIN`에 의존 + 중이라는 뜻이며 문제는 아니다) 11(공통) + 2(`.env`만) = 13, 11(공통) + 1(`.env.example`만) = 12로 두 총계가 각각 -맞아떨어진다. **결론: `.env.example`에 추가할 키는 없다** — `.env`의 나머지 2키는 -살아 있지 않아 "필요한 키"가 아니고, `.env.example`이 `.env`보다 하나 더 가진 방향은 -이미 올바른 상태(살아 있는 키를 예시로 문서화)다. +맞아떨어진다. 결론적으로 `.env.example`에 추가할 키는 없다. `.env`의 나머지 2키는 +코드가 읽지 않아 "필요한 키"가 아니고, `.env.example`이 `.env`보다 하나 더 가진 +방향은 이미 올바른 상태(살아 있는 키를 예시로 문서화)다. ## `-197` 계측 점검 — 실제 응답 모델을 로그에서 확인할 수 있는가 @@ -101,16 +105,17 @@ processing_expiry_sec 포함. 기본 로그 레벨은 `INFO`(`app/core/logging.py`)라 개별 호출의 `model=` 필드(`DEBUG`)는 -기본 배포 로그에는 안 보인다. 다만 창 요약은 **벤더 단위**로는 INFO에 항상 남고 -(`[judge:openai ok=N ...]`), 벤더→모델 매핑은 `PINLOG_JUDGE_CHAIN`(공개 값, 정본이 -코드, P45)이 1:1로 고정하므로 창 요약 + 배포 시점 체인 설정을 대조하면 판정 모델을 -특정할 수 있다. - -**결론: 보강 불필요.** 이미 남긴다 — 다만 정확한 모델명이 필요하면 로그 레벨을 -일시적으로 `DEBUG`로 올리거나(운영 손잡이 없음, `-197` 설계상 의도적), 창 요약의 -벤더와 그 시점 `PINLOG_JUDGE_CHAIN` 값을 대조해야 한다. `-210`류 오기를 막는 유일한 -방법은 **`.env`의 죽은 키 값이 아니라 이 로그(또는 `JudgeResult.model`, 저장 경로)를 -근거로 리포트를 쓰는 것**이다 — T43이 이미 이 규칙을 남겼다. +기본 배포 로그에는 보이지 않는다. 다만 창 요약은 벤더 단위로는 INFO에 항상 +남고(`[judge:openai ok=N ...]`), 벤더→모델 매핑은 `PINLOG_JUDGE_CHAIN`(공개 값, +정본이 코드, P45)이 1:1로 고정하므로, 창 요약과 배포 시점 체인 설정을 대조하면 판정 +모델을 특정할 수 있다. + +결론은 보강 불필요다. 필요한 정보는 이미 로그에 남는다. 다만 정확한 모델명이 +필요하면 로그 레벨을 일시적으로 `DEBUG`로 올리거나(운영 중 바꾸는 설정은 없다. +`-197` 설계상 의도적이다), 창 요약의 벤더와 그 시점 `PINLOG_JUDGE_CHAIN` 값을 +대조해야 한다. `-210`류 오기를 막는 유일한 방법은 `.env`의 죽은 키 값이 아니라 이 +로그(또는 저장 경로의 `JudgeResult.model`)를 근거로 리포트를 쓰는 것이다. T43이 이미 +이 규칙을 남겼다. ## 조치 @@ -122,12 +127,12 @@ processing_expiry_sec # [S15P11A705-224] 읽히지 않음 — config.py 에서 이미 제거됨(ai#24, 2026-07-27). 로컬 .env 잔재. 주석 처리. #PRESET_CACHE_TTL_SEC=... ``` - 이 파일은 이 작업자의 로컬 환경에만 적용된다 — 다른 개발자의 로컬 `.env`에 같은 + 이 파일은 이 작업자의 로컬 환경에만 적용된다. 다른 개발자의 로컬 `.env`에 같은 잔재가 있다면 각자 정리해야 한다(README·`.env.example`은 이미 올바르므로 새로 만드는 `.env`는 이 문제가 없다). -2. 저장소 파일(`config.py`·`.env.example`)은 **변경하지 않았다** — 이미 올바른 - 상태였음을 이번 조사로 확인했다. -3. 배포 Secret(`ai-owner-secrets`·`ai-db-credentials`)도 변경하지 않았다 — 인프라 +2. 저장소 파일(`config.py`·`.env.example`)은 변경하지 않았다. 이미 올바른 상태였음을 + 이번 조사로 확인했다. +3. 배포 Secret(`ai-owner-secrets`·`ai-db-credentials`)도 변경하지 않았다. 인프라 소관이며, 애초에 죽은 키를 담고 있지 않았다. 4. `-197` 계측·`-210`/`-219` 리포트 모두 수정하지 않았다(계약 — 확정 판단). diff --git a/docs/implements/2026-08-03-dev-deploy-gap.md b/docs/implements/2026-08-03-dev-deploy-gap.md index 1bbacc6..96bd807 100644 --- a/docs/implements/2026-08-03-dev-deploy-gap.md +++ b/docs/implements/2026-08-03-dev-deploy-gap.md @@ -1,8 +1,8 @@ -# dev 병합이 배포에 닿지 않는 구간 — `ai-image / publish` SKIPPED 와 프리셋 봉인 값 +# dev 병합이 배포에 반영되지 않는 구간 — `ai-image / publish` SKIPPED 는 설계이고, 프리셋 봉인 값은 수동 갱신 대상이다 - **상태**: 완료 - **날짜**: 2026-08-03 -- **유형**: 감사 — 만든 것이 아니라 **어디서 끊겼는가**를 가른 결과다. 산출물은 새 봉인 값 하나다. +- **유형**: 감사 — 만든 것이 아니라 어디서 끊겼는가를 판정한 결과다. 산출물은 새 봉인 값 하나다. - **기준 리비전**: `ai` `origin/dev` **d87c5f5** · `ai` `origin/main` **4d667c3** · `infra` `origin/main` **bd75090** - **읽는 순서**: §1 이 "고장인가 설계인가"의 답이고, §4 가 산출물이다. §2 는 이 건의 성격(성공 신호 ≠ 동작)을 다룬다. @@ -21,8 +21,9 @@ infra origin/main apps/dev/ai/values.yaml ai origin/dev d87c5f5 main 에 없는 커밋 10개 ``` -두 값이 각각 다른 이유로 낡아 있다. **`image.tag` 는 정상 동작의 결과이고, `bootstrap.version` 은 -아무도 갱신하지 않는 필드다.** 하나로 묶어 보면 원인을 못 찾는다. +두 값이 각각 다른 이유로 낡아 있다. `image.tag` 는 정상 동작의 결과이고, +`bootstrap.version` 은 아무도 갱신하지 않는 필드다. 하나로 묶어 보면 원인을 찾을 수 +없다. --- @@ -41,8 +42,9 @@ image-publish: needs: check ``` -`gh api .../actions/workflows` 로는 안 보인다 — 그 목록은 워크플로 파일만 세고 job 은 세지 않는다. -**"워크플로 목록에 없다"에서 "존재하지 않는다"로 넘어간 것이 이 건의 첫 오판 지점이다.** +`gh api .../actions/workflows` 로는 보이지 않는다. 그 목록은 워크플로 파일만 세고 +job 은 세지 않기 때문이다. "워크플로 목록에 없다"에서 "존재하지 않는다"로 넘어간 +것이 이 건의 첫 오판 지점이다. ### 1.2 왜 건너뛰나 — 조건 두 개가 모두 필요하다 @@ -52,16 +54,17 @@ image-publish: | `push` | `refs/heads/dev` | SKIPPED — `ref` 불일치 | | `push` | `refs/heads/main` | **실행** | -PR 에서 항상 SKIPPED 였던 것도, `dev` push(run `30785007536`, 08-03 04:39Z)에서 SKIPPED 인 것도 -같은 조건의 두 얼굴이다. 그 run 의 job 셋은 `check` success · `embedding profile parity` success · -`ai-image / publish` **skipped** 였다. +PR 에서 항상 SKIPPED 였던 것도, `dev` push(run `30785007536`, 08-03 04:39Z)에서 +SKIPPED 인 것도 같은 조건이 만든 두 사례다. 그 run 의 job 셋은 `check` success · +`embedding profile parity` success · `ai-image / publish` **skipped** 였다. -PR 에서 이미지가 아예 안 만들어지는 것은 아니다 — `check` job 이 `Validate AI container image` -스텝에서 `push: false` 로 빌드만 한다(`ai-ci.yml:113`). **빌드는 검증하고 게시는 안 한다**가 설계다. +PR 에서 이미지가 아예 안 만들어지는 것은 아니다. `check` job 이 +`Validate AI container image` 스텝에서 `push: false` 로 빌드만 한다(`ai-ci.yml:113`). +빌드는 검증하고 게시는 하지 않는 것이 설계다. ### 1.3 설계라는 근거 넷 -한 곳이 아니라 네 곳이 같은 말을 한다. **고장이면 네 곳이 같이 틀렸어야 한다.** +한 곳이 아니라 네 곳이 같은 말을 한다. 고장이면 네 곳이 같이 틀렸어야 한다. | # | 근거 | 내용 | |---|---|---| @@ -70,8 +73,9 @@ PR 에서 이미지가 아예 안 만들어지는 것은 아니다 — `check` j | ③ | `infra .github/workflows/ai-image-update.yaml:44` | `test "$SOURCE_BRANCH" = main` — `vars.AI_SOURCE_BRANCH` 를 리터럴 `main` 과 대조한다. 이어서 `gh api "repos/Team-PinLog/ai/git/ref/heads/$SOURCE_BRANCH"` 로 **`main` 의 HEAD 만** 조회한다 | | ④ | `CONTRIBUTING.md:48-50` | *"`main` 으로는 `dev` 를 릴리스 시점에 병합하며, 컨테이너 이미지 publish 는 `main` push 에서만 일어난다."* | -②가 특히 결정적이다. **이 조건은 주석이 아니라 테스트로 잠겨 있다.** `dev` 로 넓히려면 계약 -테스트를 먼저 고쳐야 하고, 그 테스트는 "왜 main 전용인가"를 이름에 적어 두었다. +②가 특히 결정적이다. 이 조건은 주석이 아니라 테스트로 고정되어 있다. `dev` 로 +넓히려면 계약 테스트를 먼저 고쳐야 하고, 그 테스트는 "왜 main 전용인가"를 이름에 +적어 두었다. ### 1.4 그렇다면 dev 배포는 무엇을 쓰기로 돼 있었나 — **`ai` 의 `main`** @@ -82,19 +86,21 @@ infra apps/dev/ai/ dev 클러스터(배포 환경 이름) ai origin/dev 통합 브랜치(레포 안의 브랜치 이름) ``` -`apps/dev/ai/values.yaml` 이 pin 하는 것은 **`ai` 레포 `main` 의 HEAD 커밋 이미지**다. -`ai` 의 `dev` 브랜치는 배포 경로에 등장하지 않는다. `CONTRIBUTING.md` 가 이것을 "dev 2단 구조" -(통합 `dev` / 배포 `main`)라고 부른다. +`apps/dev/ai/values.yaml` 이 pin 하는 것은 `ai` 레포 `main` 의 HEAD 커밋 이미지다. +`ai` 의 `dev` 브랜치는 배포 경로에 등장하지 않는다. `CONTRIBUTING.md` 가 이것을 +"dev 2단 구조"(통합 `dev` / 배포 `main`)라고 부른다. -**결론: 고장이 아니다. `ai` 레포 안에 고칠 것이 없다.** `dev` 에 병합한 것이 배포에 닿지 -않은 이유는 파이프라인이 끊겨서가 아니라 **릴리스 PR(`dev` → `main`)이 아직 열리지 않았기 -때문**이다. 고칠 것은 코드가 아니라 절차이고, 그 절차는 `CONTRIBUTING.md` 에 이미 있다(§3). +결론은 다음과 같다. 고장이 아니고, `ai` 레포 안에 고칠 것이 없다. `dev` 에 병합한 +것이 배포에 반영되지 않은 이유는 파이프라인이 끊겨서가 아니라 릴리스 +PR(`dev` → `main`)이 아직 열리지 않았기 때문이다. 고칠 것은 코드가 아니라 절차이고, +그 절차는 `CONTRIBUTING.md` 에 이미 있다(§3). --- ## 2. `ai-image-update` 가 success 인데 아무것도 안 바뀐 이유 -**자동화는 정확히 설계대로 동작했다.** 그리고 **오늘 새벽에 고쳐졌다.** +자동화는 정확히 설계대로 동작했다. 그리고 동작을 막던 설정 문제는 오늘 새벽에 +고쳐졌다. ### 2.1 오늘 실제로 한 번 동작했다 @@ -108,12 +114,13 @@ ai origin/dev 통합 브랜치(레포 안의 브랜치 이름) `infra#172` *"[verified] chore: update AI dev immutable image"* 가 **00:39:26Z 에 병합**됐다. 이때 `image.tag` 가 `d317f563`(07-29, `ai#42`)에서 `4d667c3`(07-31 릴리스)로 갱신됐다. -08-03 00:07 에 `back#122` 로 전달한 "자동화가 한 번도 동작한 적이 없다 — Actions 변수·Secret 이 -등록돼 있지 않다"는 **그 사이에 해소됐다.** 지금 자동화는 살아 있다. +08-03 00:07 에 `back#122` 로 전달한 "자동화가 한 번도 동작한 적이 없다 — Actions +변수·Secret 이 등록돼 있지 않다"는 그 사이에 해소됐다. 지금 자동화는 정상 동작 +중이다. ### 2.2 04:53 의 success 가 뜻하는 것 -살아 있는 자동화가 왜 아무것도 안 했는지는 job 체인을 따라가면 나온다. +정상 동작 중인 자동화가 왜 아무것도 바꾸지 않았는지는 job 체인을 따라가면 나온다. ``` 1 detect-source ai main HEAD 조회 → 4d667c3 @@ -124,14 +131,15 @@ ai origin/dev 통합 브랜치(레포 안의 브랜치 이름) → changed=false → PR 을 만들지 않고 종료 ``` -`create-infra-pr` 의 RED 스텝은 **"values 가 아직 안 맞다"를 실패로 확인**하는 자리다. +`create-infra-pr` 의 RED 스텝은 "values 가 아직 안 맞다"를 실패로 확인하는 자리다. 통과해 버리면 갱신할 것이 없다는 뜻이고, 워크플로는 성공으로 끝난다. -**즉 success 는 "이미지를 갱신했다"가 아니라 "확인했고 갱신할 것이 없었다"였다.** +즉 success 는 "이미지를 갱신했다"가 아니라 "확인했고 갱신할 것이 없었다"였다. `main` 이 07-31 이후 움직이지 않았으니 30분마다 같은 결론이 반복된다. -> **이 건의 성격**: 성공 신호가 동작을 뜻하지 않는다. 같은 함정이 §5 에 하나 더 있다 — -> `bootstrap.version` 은 아무 워크플로도 검사하지 않으므로 **틀려도 아무 신호가 나지 않는다.** +> 이 건의 성격을 요약하면, 성공 신호가 동작을 뜻하지 않는다는 것이다. 같은 함정이 +> §5 에 하나 더 있다. `bootstrap.version` 은 아무 워크플로도 검사하지 않으므로 틀려도 +> 아무 신호가 나지 않는다. --- @@ -151,25 +159,26 @@ ai origin/dev 통합 브랜치(레포 안의 브랜치 이름) | `2a36dcb` · `0c23498` | `-226` 문서 색인 정합 CI 이관 | CI 도구 | | `af72033` | **`-205` GMS 오류 본문 로그 유출 차단** | **앱 코드** `app/client/` 전반 | -**`-205`(`ai#78`)가 이 건의 지시 목록에서 빠져 있었다.** `back#122` 07-31 12:18 코멘트에서 -*"이 변경은 다음 릴리스 대상입니다. 방금 게시한 `4d667c3…` 에는 들어 있지 않습니다"* 라고 -직접 예고한 항목이고, **게이트웨이 오류 본문이 다섯 곳의 로그·트레이스백으로 나가던 경로**를 -막는다. 자격 증명은 에코되지 않는다는 것이 실측으로 확인됐으므로(같은 코멘트) 급한 사안은 -아니지만, **미반영 목록에서 빠뜨릴 항목은 아니다.** +`-205`(`ai#78`)가 이 건의 지시 목록에서 빠져 있었다. `back#122` 07-31 12:18 +코멘트에서 *"이 변경은 다음 릴리스 대상입니다. 방금 게시한 `4d667c3…` 에는 들어 있지 +않습니다"* 라고 직접 예고한 항목이고, 게이트웨이 오류 본문이 다섯 곳의 +로그·트레이스백으로 나가던 경로를 막는다. 자격 증명은 에코되지 않는다는 것이 +실측으로 확인됐으므로(같은 코멘트) 급한 사안은 아니지만, 미반영 목록에서 빠뜨릴 +항목은 아니다. ### 3.1 릴리스 트리거는 이미 충족돼 있다 `CONTRIBUTING.md:59-65` 는 셋 중 하나면 릴리스 PR 을 열라고 한다. -``` -배포 경로에 영향을 주는 변경이 dev 에 들어갔을 때 앱 코드·설정·의존성 ← 해당 (-266·-229·-205) -그 자체로 시연·검증에 필요한 기능이 병합됐을 때 ← 해당 (-266 이 ai#87 을 닫는다) -main 이 dev 보다 10커밋 이상 뒤처졌을 때 ← 10커밋, 경계값 -``` +- 배포 경로에 영향을 주는 변경(앱 코드·설정·의존성)이 dev 에 들어갔을 때 — **해당** + (`-266`·`-229`·`-205`) +- 그 자체로 시연·검증에 필요한 기능이 병합됐을 때 — **해당** (`-266` 이 `ai#87` 을 + 닫는다) +- main 이 dev 보다 10커밋 이상 뒤처졌을 때 — 10커밋, 경계값 -**첫 조건은 이미 며칠 전부터 충족돼 있었다.** 07-31 에 `main` 이 16커밋 뒤처진 사고를 겪고 -규약을 넣었는데, 규약이 "언제 여는가"만 정하고 **"누가 알아채는가"를 정하지 않았다.** -10커밋 조건은 사람이 세야 하고, 세는 시점을 강제하는 장치가 없다. +첫 조건은 이미 며칠 전부터 충족돼 있었다. 07-31 에 `main` 이 16커밋 뒤처진 사고를 +겪고 규약을 넣었는데, 규약이 "언제 여는가"만 정하고 "누가 알아채는가"를 정하지 +않았다. 10커밋 조건은 사람이 세야 하고, 세는 시점을 강제하는 장치가 없다. --- @@ -177,8 +186,8 @@ main 이 dev 보다 10커밋 이상 뒤처졌을 때 ### 4.1 산출 방식을 실측으로 확정했다 -문서에 적힌 표현(*"27 presets 의 full preset SHA-256"*)이 **파일 하나의 해시인지, preset 27개를 -정규화해 계산한 것인지** 애매했다. 실측으로 갈랐다. +문서에 적힌 표현(*"27 presets 의 full preset SHA-256"*)이 파일 하나의 해시인지, +preset 27개를 정규화해 계산한 것인지 애매했다. 실측으로 판별했다. `infra docs/ai-dev-prerequisites.md:137-139` 가 전체 64자를 적어 두었다. @@ -194,15 +203,13 @@ main 이 dev 보다 10커밋 이상 뒤처졌을 때 | `de6e995` (= `origin/main` `4d667c3` 시점 내용) | `204824bd37e6e1f056f1636ec1bb86d2585994a8cdbfd99bb188096cfca04034` | | `2ee6291` (`-228`, = `origin/dev` `d87c5f5` 시점 내용) | `ab321360b0df0a338c5329cdc02294122eeab670d8eaad852e451822c021095b` | -**64자 전부가 일치한다.** 앞 12자만 맞은 것이 아니므로 우연이 아니다. +64자 전부가 일치한다. 앞 12자만 맞은 것이 아니므로 우연이 아니다. -``` -방식 확정 data/keyword_preset.yaml 파일 전체 바이트의 SHA-256 - 그 hex 앞 12자를 preset- 뒤에 붙인다 - YAML 파싱·정규화·항목 단위 계산은 하지 않는다 -``` +산출 방식은 다음과 같이 확정된다. `data/keyword_preset.yaml` 파일 전체 바이트의 +SHA-256 을 구하고, 그 hex 앞 12자를 `preset-` 뒤에 붙인다. YAML 파싱·정규화·항목 +단위 계산은 하지 않는다. -*"27 presets 의"* 는 **27개를 담은 그 파일의** 라는 뜻이었다. preset 개수는 개정 전후 모두 +*"27 presets 의"* 는 27개를 담은 그 파일의 라는 뜻이었다. preset 개수는 개정 전후 모두 27개로 같아(`grep -c '^ - id:'`) 개수 자체는 이 판정에 쓰이지 않는다. ### 4.2 새 값 @@ -227,7 +234,7 @@ git show origin/dev:data/keyword_preset.yaml | sha256sum sha256sum data/keyword_preset.yaml ``` -**OS 에 무관하다.** `.gitattributes` 가 `*.yaml text eol=lf` 로 고정하고 있어 인덱스와 워킹트리가 +이 계산은 OS 에 무관하다. `.gitattributes` 가 `*.yaml text eol=lf` 로 고정하고 있어 인덱스와 워킹트리가 둘 다 LF 다(`git ls-files --eol data/keyword_preset.yaml` → `i/lf w/lf`). 두 방법의 결과가 실제로 같음을 확인했다. @@ -243,21 +250,22 @@ sha256sum data/keyword_preset.yaml ### 4.5 이 값이 왜 갱신돼야 하는가 — 그리고 왜 아무도 안 하는가 -`bootstrap.version` 은 Job 이름에 들어간다(`:17`). 값이 그대로면 **이미지가 갱신돼도 bootstrap -Job 의 정체성이 바뀌지 않는다.** +`bootstrap.version` 은 Job 이름에 들어간다(`:17`). 값이 그대로면 이미지가 갱신돼도 +bootstrap Job 의 정체성이 바뀌지 않는다. -그런데 이 필드는 **어떤 자동화도 건드리지 않는다.** `infra tools/update_ai_image.py` 는 -`image.tag` · `image.digest` · `provenance.pinlog.io/image-source-sha` **셋만** 고쳐 쓰고 -(`FIELD_RE` 가 `repository|tag|digest` 로 한정, `:20-22`), -PR 의 `add-paths` 도 `apps/dev/ai/values.yaml` 한 파일이다. `bootstrap.enabled` 가 `true` 인지 +그런데 이 필드는 어떤 자동화도 건드리지 않는다. `infra tools/update_ai_image.py` 는 +`image.tag` · `image.digest` · `provenance.pinlog.io/image-source-sha` 셋만 고쳐 +쓰고(`FIELD_RE` 가 `repository|tag|digest` 로 한정, `:20-22`), PR 의 `add-paths` 도 +`apps/dev/ai/values.yaml` 한 파일이다. `bootstrap.enabled` 가 `true` 인지 확인만 하고(`:50-51`) `version` 은 읽지도 않는다. -**즉 `image.tag` 는 자동으로 따라오지만 `bootstrap.version` 은 사람이 갱신해야 하고, 틀려도 -어떤 검사도 실패하지 않는다.** §2 의 함정이 여기서 한 번 더 나타난다 — 이번에는 잘못된 성공 -신호가 아니라 **아무 신호도 없는 것**이다. +즉 `image.tag` 는 자동으로 따라오지만 `bootstrap.version` 은 사람이 갱신해야 하고, +틀려도 어떤 검사도 실패하지 않는다. §2 의 함정이 여기서 한 번 더 나타난다. 이번에는 +잘못된 성공 신호가 아니라 아무 신호도 없는 것이다. -> **`-269`(preset_version 개정 경로)와 다른 층이다.** 그쪽은 판정 결과 레코드에 박히는 버전 -> 기록이고, 이쪽은 **실제로 DB 에 심기는 데이터의 배포 아티팩트 식별자**다. 섞지 않는다. +> `-269`(preset_version 개정 경로)와 다른 층이다. 그쪽은 판정 결과 레코드에 기록되는 +> 버전 값이고, 이쪽은 실제로 DB 에 적재되는 데이터의 배포 아티팩트 식별자다. 섞지 +> 않는다. --- @@ -272,16 +280,17 @@ PR 의 `add-paths` 도 `apps/dev/ai/values.yaml` 한 파일이다. `bootstrap.en | `bootstrap.version` 갱신 | **인프라**(수동) | 새 값 §4.2. **자동화 대상이 아니다** | | `apps/prod/ai` 등록 · `PINLOG_AI_BASE_URL` | **인프라** | `back#122` 의 원래 항목. 이 건과 별개로 열려 있음 | -`CONTRIBUTING.md:75-76` 이 이 경계를 이미 적어 두었다 — *"병합 후 이미지가 게시되면 `infra` 에 -`values.yaml` 의 `image.tag` 갱신을 요청한다. 게시와 반영은 다른 일이고, 후자는 AI 파트 권한이 -아니다."* 이 문서는 그 문장에 **`bootstrap.version` 도 같은 쪽**이라는 사실을 더한다. +`CONTRIBUTING.md:75-76` 이 이 경계를 이미 적어 두었다. *"병합 후 이미지가 게시되면 +`infra` 에 `values.yaml` 의 `image.tag` 갱신을 요청한다. 게시와 반영은 다른 일이고, +후자는 AI 파트 권한이 아니다."* 이 문서는 그 문장에 `bootstrap.version` 도 같은 +쪽이라는 사실을 더한다. --- -## 6. 남는 것 +## 6. 남은 문제 | | | |---|---| -| **릴리스 시점을 세는 사람이 없다** | `CONTRIBUTING.md` 규약은 조건만 정하고 관측을 정하지 않는다. 07-31 사고 뒤 규약을 넣었는데 08-03 에 같은 구간이 다시 벌어졌다. **규약이 아니라 장치가 필요한 자리로 보이나, 이 문서는 판정만 남기고 제안하지 않는다** | +| **릴리스 시점을 세는 사람이 없다** | `CONTRIBUTING.md` 규약은 조건만 정하고 관측을 정하지 않는다. 07-31 사고 뒤 규약을 넣었는데 08-03 에 같은 구간이 다시 벌어졌다. 규약이 아니라 장치가 필요한 자리로 보이나, 이 문서는 판정만 남기고 제안하지 않는다 | | **`bootstrap.version` 에 검사가 없다** | 프리셋 파일이 바뀌어도 봉인 값이 낡았다는 신호가 어디에서도 나지 않는다. 검사를 어느 레포에 두는가는 `infra` 소관이 걸려 있어 단독으로 정할 수 없다 | | **DB 행 수준 provenance** | `2026-07-31-ticket-audit-96-77.md` 3-c 가 남긴 별건 그대로 — 배포 아티팩트는 식별되지만 "DB 의 이 행이 어느 커밋 YAML 에서 왔는가"는 여전히 없다 | diff --git a/docs/implements/2026-08-03-docs-index-oneway.md b/docs/implements/2026-08-03-docs-index-oneway.md index a36c4ae..a7b617b 100644 --- a/docs/implements/2026-08-03-docs-index-oneway.md +++ b/docs/implements/2026-08-03-docs-index-oneway.md @@ -8,24 +8,23 @@ ## 요약 -`-226` 이 「누락」 검사(④)를 켤 때 **한 방향만** 켰다 — 전수 표가 가리키는 문서가 파일 -표에 없으면 잡지만, 반대 방향(파일 표에 번호가 있는데 전수 표에 없다)은 **검사할 수 -없다**고 명시적으로 남겼다. 이유는 매핑의 출처가 전수 표 링크뿐이었기 때문이다 — 파일 +`-226` 이 「누락」 검사(④)를 켤 때 한 방향만 켰다. 전수 표가 가리키는 문서가 파일 +표에 없으면 잡지만, 반대 방향(파일 표에 번호가 있는데 전수 표에 없다)은 검사할 수 +없다고 명시적으로 남겼다. 이유는 매핑의 출처가 전수 표 링크뿐이었기 때문이다. 파일 표 자신은 "내가 몇 번인지" 말하지 않았다. -``` -닫혔다(④) 전수 표 → 파일 표 전수 표 링크가 매핑의 유일한 출처였다 -안 닫혔다 파일 표 → 전수 표(반대) 파일 표가 번호를 말하지 않아 검사할 수 없었다 -``` +- 닫힌 방향(④) — 전수 표 → 파일 표. 전수 표 링크가 매핑의 유일한 출처였다. +- 닫히지 않은 방향 — 파일 표 → 전수 표. 파일 표가 번호를 말하지 않아 검사할 수 + 없었다. -이 티켓은 `docs/implements` 의 파일 표에 **번호 컬럼을 추가**해 매핑의 출처를 파일 표 -자신으로 옮기고, 그 컬럼을 근거로 반대 방향 검사(⑤)를 켰다. `docs/troubleshooting` 은 -손대지 않았다 — 그쪽 전수 표는 설계상 문서를 가리키지 않아(문서 링크 0종, `-226` 실측) -컬럼을 넣어도 대조할 출처가 되지 못한다. +이 티켓은 `docs/implements` 의 파일 표에 번호 컬럼을 추가해 매핑의 출처를 파일 표 +자신으로 옮기고, 그 컬럼을 근거로 반대 방향 검사(⑤)를 켰다. `docs/troubleshooting` +은 손대지 않았다. 그쪽 전수 표는 설계상 문서를 가리키지 않아(문서 링크 0종, `-226` +실측) 컬럼을 넣어도 대조할 출처가 되지 못한다. ## 1. 번호 없던 7건 — 다시 세어 확정했다 -계약이 "7건이 실제로 7건인지 다시 세라"고 요구했다 — 그 수는 `-226` 시점 실측이고 그 +계약이 "7건이 실제로 7건인지 다시 세라"고 요구했다. 그 수는 `-226` 시점 실측이고 그 뒤 병합이 있었기 때문이다. 착수 시점에 스크립트로 직접 재계산했다. ```python @@ -46,24 +45,25 @@ entries - ledger = 7건 (재확인, -226 시점과 동일) | `2026-07-31-judge-prompt-rule.md` | `-219` | 검증 | **I40** — 계약이 지목한 문서. 문서 자신의 표현을 그대로 옮겼다(아래 §3) | | `2026-07-31-ticket-audit-96-77.md` | `-96`·`-77` | 감사 | **없음** — 문서 자신이 "만든 것이 아니라 확인한 결과"라고 명시(아래 §4) | -5건은 새 번호, 1건은 기존 번호에 누락된 링크를 채웠고, 1건은 명시적으로 번호가 없다고 -표기했다. **설명을 지어내지 않고 각 문서·파일 표의 기존 표현을 그대로 옮겼다** — 새로 -쓴 것은 코드 산출물 경로(`app/...`, `tools/...`)를 산출 칸에 덧붙인 것뿐이다. +5건은 새 번호, 1건은 기존 번호에 누락된 링크를 채웠고, 1건은 명시적으로 번호가 +없다고 표기했다. 설명을 지어내지 않고 각 문서·파일 표의 기존 표현을 그대로 옮겼다. +새로 쓴 것은 코드 산출물 경로(`app/...`, `tools/...`)를 산출 칸에 덧붙인 것뿐이다. ## 2. `candidate-threshold.md` — 새 번호가 아니라 빠진 링크였다 -`I30`은 이미 "후보 임계값 τ 측정 하네스 `tools/tau_grid/`"를 산출로 적고 있었다. 그런데 -반영처 칸 자체가 **비어 있었다** — 표에 열이 두 칸(`I`·`산출`)만 있고 세 번째 칸(`반영처`)이 -없는 상태였다. `candidate-threshold.md`가 정확히 그 하네스의 리포트이므로, 새 I 번호를 -만드는 대신 I30 의 반영처에 `[τ 재검증 리포트](2026-07-31-candidate-threshold.md)`를 -채웠다. **번호가 없던 것이 아니라 링크가 없던 것**이었다. +`I30`은 이미 "후보 임계값 τ 측정 하네스 `tools/tau_grid/`"를 산출로 적고 있었다. +그런데 반영처 칸 자체가 비어 있었다. 표에 열이 두 칸(`I`·`산출`)만 있고 세 번째 +칸(`반영처`)이 없는 상태였다. `candidate-threshold.md`가 정확히 그 하네스의 +리포트이므로, 새 I 번호를 만드는 대신 I30 의 반영처에 +`[τ 재검증 리포트](2026-07-31-candidate-threshold.md)`를 채웠다. 번호가 없던 것이 +아니라 링크가 없던 것이었다. ## 3. `judge-prompt-rule.md` — 계약이 지목한 문서 `-219`의 산출물이다. 파일 표의 기존 「내용」 칸 문구를 그대로 전수 표 산출 칸으로 옮겼다 — "판정 프롬프트 「본문에 근거 없으면 미선택」 개정안 둘 — 둘 다 채택하지 않는다. 오분류 감소가 같은 프롬프트를 다시 돌렸을 때의 변동을 넘지 못했고, 사용자가 -보는 손실(`fit 0건 Context`)은 세 조건이 같다." 다음 빈 번호(**I40**)를 붙였다 — +보는 손실(`fit 0건 Context`)은 세 조건이 같다." 다음 빈 번호(**I40**)를 붙였다. 이 번호는 병합 후에도 충돌 없이 그대로 남았다(충돌한 것은 §6의 `retry-and-error -classification` 쪽이다). @@ -74,60 +74,56 @@ entries - ledger = 7건 (재확인, -226 시점과 동일) > **유형**: 구현 기록이 아니라 **대조·판정 기록**이다. 만든 것이 아니라 "티켓 항목이 > 이미 해소됐는가"를 확인한 결과다. -전수 표(「구현·산출 — 전수」)는 **산출을 세는 곳**이다. 감사 문서는 산출물이 아니라 +전수 표(「구현·산출 — 전수」)는 산출을 세는 곳이다. 감사 문서는 산출물이 아니라 기존 상태에 대한 판정이므로, 새 번호를 지어내는 대신 파일 표 번호 칸을 `없음`으로 -명시했다. 이것은 편집 재량이 아니라 이 검사가 요구하는 **명시적 상태**다 — 빈 칸이었으면 +명시했다. 이것은 편집 재량이 아니라 이 검사가 요구하는 명시적 상태다. 빈 칸이었으면 ⑤ 가 형식 위반으로 잡았을 것이다. ## 5. `tools/check_docs_index.py` — ⑤ 번호 미등록 -파일 표 맨 앞 칸(번호)을 파싱해 전수 표에 그 번호가 있는지만 본다. 여전히 **누락만 -본다** — 두 표의 설명 문구가 같은지는 검사하지 않는다(`-226`의 §2-b를 어기지 않는다). +파일 표 맨 앞 칸(번호)을 파싱해 전수 표에 그 번호가 있는지만 본다. 여전히 누락만 +본다. 두 표의 설명 문구가 같은지는 검사하지 않는다(`-226`의 §2-b를 어기지 않는다). -``` -파일 표에 번호가 있으면 → 전수 표에 그 번호 행이 있어야 한다 -번호가 「없음」으로 명시되면 → 통과 (명시적 상태다) -번호 칸이 형식에 안 맞으면 → 실패 (판정 불가는 통과가 아니다) -``` +- 파일 표에 번호가 있으면 → 전수 표에 그 번호 행이 있어야 한다 +- 번호가 「없음」으로 명시되면 → 통과 (명시적 상태다) +- 번호 칸이 형식에 안 맞으면 → 실패 (판정 불가는 통과가 아니다) -`IndexSpec`에 `number_column: bool` 필드를 추가해 `docs/implements`만 `True`로 켰다 — +`IndexSpec`에 `number_column: bool` 필드를 추가해 `docs/implements`만 `True`로 켰다. `docs/troubleshooting`은 전수 표가 설계상 문서를 가리키지 않아 컬럼을 넣어도 대조할 출처가 되지 못하므로 대상에서 뺐다(`-226` §6.1 실측 그대로 유지). -### RED 확인 +### 구현 전 실패 확인 -번호 컬럼을 추가하기 전에 먼저 검사·테스트를 작성했다 — 실제 `docs/implements/README.md`에 -아직 번호 컬럼이 없는 상태로 `test_현재_레포의_색인이_정합이다`를 돌려 26건의 "번호 -컬럼 형식" 위반이 나는 것을 확인했다. 그 뒤 README에 번호 컬럼을 채워 GREEN으로 -전환했다. +번호 컬럼을 추가하기 전에 먼저 검사·테스트를 작성했다. 실제 +`docs/implements/README.md`에 아직 번호 컬럼이 없는 상태로 +`test_현재_레포의_색인이_정합이다`를 돌려 26건의 "번호 컬럼 형식" 위반이 나는 것을 +확인했다. 그 뒤 README에 번호 컬럼을 채워 통과로 전환했다. ## 6. 병합 중 실제로 난 충돌 — T56이 예고한 그대로 계약이 "이 작업이 정확히 T56의 함정 위에 있다"고 경고했고, 실제로 `origin/dev`를 -병합하자 그대로 났다. +병합하자 그대로 났다. 두 가지가 함께 났다. -``` -merge=union 이 낡은 무번호 파일 표 전체를 되살렸다 - — 내가 번호 컬럼을 넣어 갱신한 26행 아래에, 번호 컬럼 없는 낡은 26행이 다시 붙었다 -병렬 PR #81(S15P11A705-227, GMS vision-probe)이 같은 I36 을 잡아 충돌했다 - — 그쪽은 병합 시점 dev 의 마지막 번호(I35) 다음을 골랐고, 내 브랜치도 독립적으로 - I36 을 골라 같은 번호가 됐다 -``` +- `merge=union` 이 낡은 무번호 파일 표 전체를 되살렸다. 내가 번호 컬럼을 넣어 갱신한 + 26행 아래에, 번호 컬럼 없는 낡은 26행이 다시 붙었다. +- 병렬 PR #81(S15P11A705-227, GMS vision-probe)이 같은 I36 을 잡아 충돌했다. 그쪽은 + 병합 시점 dev 의 마지막 번호(I35) 다음을 골랐고, 내 브랜치도 독립적으로 I36 을 + 골라 같은 번호가 됐다. 병합 뒤 `tools/check_docs_index.py`를 다시 돌려 확인했고(계약이 요구한 절차), 낡은 무번호 블록은 삭제, `#81`의 신규 문서(`2026-08-03-gms-vision-probe.md`)는 번호 컬럼 -형식에 맞춰 편입했다. 번호 충돌은 **이번에 병합해 들어가는 쪽**(내 `retry-and-error --classification` 행)을 다음 빈 번호로 옮겨 해소했다 — `#81`의 I36 은 이미 `dev`에 +형식에 맞춰 편입했다. 번호 충돌은 이번에 병합해 들어가는 쪽(내 `retry-and-error +-classification` 행)을 다음 빈 번호로 옮겨 해소했다. `#81`의 I36 은 이미 `dev`에 병합된 상태였으므로 그대로 두었다. ``` I36 (충돌) → 내 쪽만 I41 로 재번호. #81 의 I36(vision-probe) 은 유지 ``` -**이 관측이 값진 이유** — `-226`의 리포트가 §4.1에서 "검사가 있어도 충돌 자체는 안 +이 관측이 값진 이유가 있다. `-226`의 리포트가 §4.1에서 "검사가 있어도 충돌 자체는 안 없어진다. 없어지는 것은 '충돌한 채로 병합되는 것'이다"라고 적었는데, 이번에도 정확히 -같은 모양으로 재현됐다. 재번호 작업 자체는 여전히 사람이 한다 — 검사가 하는 일은 -그것을 **틀린 채로 병합되게 두지 않는 것**뿐이다. +같은 형태로 재현됐다. 재번호 작업 자체는 여전히 사람이 한다. 검사가 하는 일은 그것을 +틀린 채로 병합되게 두지 않는 것뿐이다. ## 7. 검증 @@ -162,23 +158,24 @@ python tools/check_docs_index.py 통과 (I1 ## 후속 — 검사 ⑤ 가 못 잡는 것 하나 (중앙 병합 중 발견) -이 PR 을 `origin/dev` 와 병합할 때 **파일 표에 같은 번호가 두 번** 들어간 상태가 만들어졌다. -`merge=union` 이 dev 쪽 행(`I37 dead-config-keys`·`I38 error-wording-split`)과 -이 브랜치가 잡은 행(`I37 real-data-e2e`·`I38 judge-vendor-fallback`)을 **둘 다 남겼기** 때문이다. +이 PR 을 `origin/dev` 와 병합할 때 파일 표에 같은 번호가 두 번 들어간 상태가 +만들어졌다. `merge=union` 이 dev 쪽 행(`I37 dead-config-keys`·`I38 +error-wording-split`)과 이 브랜치가 잡은 행(`I37 real-data-e2e`·`I38 +judge-vendor-fallback`)을 둘 다 남겼기 때문이다. -**검사 ⑤ 는 이것을 통과시킨다.** 「파일 표 번호가 전수 표에 있는가」만 보고 -**파일 표 안에서 같은 번호가 반복되는지는 보지 않는다.** +검사 ⑤ 는 이것을 통과시킨다. 「파일 표 번호가 전수 표에 있는가」만 보고, 파일 표 +안에서 같은 번호가 반복되는지는 보지 않는다. -``` -검사 ① 전수 표의 번호 중복 잡는다 -검사 ⑤ 파일 표 → 전수 표 대조 잡는다 -없음 파일 표 안의 번호 중복 ← 이번에 뚫린 곳 -``` +| 검사 | 대상 | 이번 사례 | +|---|---|---| +| ① | 전수 표의 번호 중복 | 잡는다 | +| ⑤ | 파일 표 → 전수 표 대조 | 잡는다 | +| (없음) | 파일 표 안의 번호 중복 | 이번에 검사가 잡지 못한 곳 | -전수 표 중복(①)은 잡히므로 **번호를 새로 잡을 때는 안전하고**, 이번처럼 **양쪽이 서로 다른 -문서에 같은 번호를 준 뒤 union 이 병합하는 경로**에서만 뚫린다. +전수 표 중복(①)은 잡히므로 번호를 새로 잡을 때는 안전하고, 이번처럼 양쪽 브랜치가 +서로 다른 문서에 같은 번호를 준 뒤 union 이 병합하는 경로에서만 검사를 통과해 버린다. -해소는 dev 를 정본으로 두고 이 브랜치 쪽을 `I43`·`I44` 로 밀었다. +해소는 dev 를 정본으로 두고 이 브랜치 쪽을 `I43`·`I44` 로 옮겼다. -**후속 검사 후보** — 파일 표 번호 컬럼의 중복 검출. 규칙이 명확하고(같은 번호가 두 행에 -나오면 실패) 기존 ① 과 같은 모양이라 붙이기 쉽다. +후속 검사 후보는 파일 표 번호 컬럼의 중복 검출이다. 규칙이 명확하고(같은 번호가 두 +행에 나오면 실패) 기존 ① 과 같은 형태라 붙이기 쉽다. diff --git a/docs/implements/2026-08-03-error-wording-split.md b/docs/implements/2026-08-03-error-wording-split.md index d19dc9b..8f5d7cf 100644 --- a/docs/implements/2026-08-03-error-wording-split.md +++ b/docs/implements/2026-08-03-error-wording-split.md @@ -10,28 +10,30 @@ ## 이 문서가 다루는 것 -`-221`이 남긴 한계 하나다. `-221`은 검색 중 DB 실패를 `503`/`502`로 정확히 분류했지만 -(`app/core/db_errors.py`), 응답 **본문**은 `-220`이 정한 고정 문구 `embedding upstream -...` 그대로 두었다 — 상태 코드·로그는 DB를 가리키는데 본문만 게이트웨이를 가리키는 -상태였다. +`-221`이 남긴 한계 하나다. `-221`은 검색 중 DB 실패를 `503`/`502`로 정확히 +분류했지만(`app/core/db_errors.py`), 응답 본문은 `-220`이 정한 고정 문구 +`embedding upstream ...` 그대로 두었다. 상태 코드와 로그는 DB를 가리키는데 본문만 +게이트웨이를 가리키는 상태였다. -``` -DB 연결 실패 → 503 "embedding upstream unavailable" ← 층이 틀렸다 -DB 인증 실패 → 502 "embedding upstream rejected the request" ← 층이 틀렸다 -``` +| 실패 | 상태 코드 | 본문 | 문제 | +|---|---|---|---| +| DB 연결 실패 | 503 | `"embedding upstream unavailable"` | 본문이 가리키는 하위 시스템이 틀렸다 | +| DB 인증 실패 | 502 | `"embedding upstream rejected the request"` | 본문이 가리키는 하위 시스템이 틀렸다 | -`-221`이 이 한계를 남긴 이유는 명확했다 — 티켓이 *"핸들러를 바꾸지 않는다"*로 확정했고, -본문을 바꾸는 것은 `-220`이 정한 계약의 개정이라 범위 밖으로 미뤘다. 이 티켓이 그 -개정이다. +`-221`이 이 한계를 남긴 이유는 명확했다. 티켓이 *"핸들러를 바꾸지 않는다"*로 +확정했고, 본문을 바꾸는 것은 `-220`이 정한 계약의 개정이라 범위 밖으로 미뤘다. 이 +티켓이 그 개정이다. --- ## 1. 확인 — 무엇이 정말 문제였나 -`-221`이 이미 보고한 사실을 재확인했다. 상태 코드는 정확했다(`test_db_unreachable_returns_503`, -`test_db_misconfigured_target_returns_502` — `-221`에서 이미 초록). 로그도 갈렸다 -(`app.core.db`가 `DatabaseTransientError`/`DatabasePermanentError` 타입 이름을 남긴다). -**본문 딱 하나만 사실과 달랐다.** +`-221`이 이미 보고한 사실을 재확인했다. 상태 코드는 +정확했다(`test_db_unreachable_returns_503`, +`test_db_misconfigured_target_returns_502` — `-221`에서 이미 통과). 로그도 DB와 +게이트웨이가 구분됐다(`app.core.db`가 +`DatabaseTransientError`/`DatabasePermanentError` 타입 이름을 남긴다). 본문 딱 하나만 +사실과 달랐다. ## 2. 결정 — 원인을 담지 않고 층 이름만 바꾼다 @@ -41,14 +43,14 @@ DB 인증 실패 → 502 "embedding upstream rejected the request" ← 층 client._embed_batch`), `-221`이 DB 축에서 같은 원칙을 적용해 `db_errors.py`의 예외 메시지에 타입 이름·SQLSTATE만 남기도록 했다. `-205`가 그 경로의 실제 누출(GMS 응답 200자가 다섯 곳의 로그·트레이스백으로 새던 것)을 실측으로 확정했다. 이 세 근거가 -그대로 유효하므로 이 티켓에서도 본문에 예외 메시지·SQLSTATE·host·DSN 어느 것도 -신지 않는다 — **값은 로그가 담당하고, 본문은 층 이름만 말한다.** +그대로 유효하므로 이 티켓에서도 본문에 예외 메시지·SQLSTATE·host·DSN 어느 것도 싣지 +않는다. 값은 로그가 담당하고, 본문은 실패한 하위 시스템의 이름만 말한다. ### 2.2 핸들러가 타입을 본다 `-221`이 `DatabaseTransientError(TransientError)`·`DatabasePermanentError (PermanentError)` 하위 타입을 이미 두었으므로, `app/main.py`의 두 핸들러 안에서 -`isinstance` 하나로 층을 가른다. **새 핸들러를 추가하지 않았다** — `-220`이 정한 +`isinstance` 하나로 하위 시스템을 가른다. 새 핸들러를 추가하지 않았다. `-220`이 정한 "변환은 예외 핸들러 한 곳에서만 한다"는 원칙(라우터가 개별적으로 잡으면 `-121`과 같은 형태로 갈라진다)을 그대로 지킨다. @@ -60,11 +62,11 @@ detail = ( ) ``` -`PermanentError` 핸들러도 대칭이다 — `"database rejected the request"` / +`PermanentError` 핸들러도 대칭이다. `"database rejected the request"` / `"embedding upstream rejected the request"`. -**상태 코드 분기, 재시도 정책, `db_errors.py`의 분류 규칙(§2.5 「애매한 것은 분류하지 -않는다」)은 건드리지 않았다.** 이 티켓이 바꾸는 것은 문구 딱 하나다. +상태 코드 분기, 재시도 정책, `db_errors.py`의 분류 규칙(§2.5 「애매한 것은 분류하지 +않는다」)은 건드리지 않았다. 이 티켓이 바꾸는 것은 문구 딱 하나다. ## 3. `static/05` 반영 여부 — 반영하지 않는다 @@ -74,22 +76,22 @@ detail = ( **근거 둘.** 1. **`static/05`는 이 문구를 소유하지 않는다.** `static/05_AI_설계.md` §17이 `failure- - recovery.md`를 "AI 파트 세부 문서"로 **참조만** 한다(직접 확인, 836줄 문서에 + recovery.md`를 "AI 파트 세부 문서"로 참조만 한다(직접 확인, 836줄 문서에 `embedding upstream`·응답 본문 문구 자체는 등장하지 않는다). 고정 문구는 처음부터 `-220`이 `failure-recovery.md`에 정의한 AI 파트 소유 계약이었고, 공용 문서는 그 문서의 존재만 가리킨다. 존재를 가리키는 포인터는 가리키는 대상이 바뀌어도 갱신할 내용이 없다. 2. **`back`이 이 필드의 값을 읽지 않는다.** `back` 레포 `AiSearchClient.translate` - (`domain/ai/client/AiSearchClient.java:136-158`)를 직접 읽었다 — `422` + (`domain/ai/client/AiSearchClient.java:136-158`)를 직접 읽었다. `422` 응답에서만 `serverProfile` 필드 유무를 보고, `401`/`403`은 상태 코드만 보며, 그 - 외 모든 상태 코드(이 티켓이 다루는 `502`/`503` 포함)는 **상태 코드만 로그에 남기고 - 본문은 버린다**("응답 본문은 남기지 않는다 — 내부 API라도 로그로 새어 나갈 이유가 + 외 모든 상태 코드(이 티켓이 다루는 `502`/`503` 포함)는 상태 코드만 로그에 남기고 + 본문은 버린다("응답 본문은 남기지 않는다 — 내부 API라도 로그로 새어 나갈 이유가 없다", 155행 주석). 사용자에게는 `AiSearchException.unavailable()` 하나로 묶여 `503 SEARCH_UNAVAILABLE`이 나가고, 이 개정 전후로 그 화면은 같다. 공용 계약이 - 규정하는 것은 파트 간 **동작**이지 AI 파트가 내부적으로 관측용으로 쓰는 문구가 + 규정하는 것은 파트 간 동작이지 AI 파트가 내부적으로 관측용으로 쓰는 문구가 아니다. -두 근거 모두 "back이 안 읽으면 반영할 이유가 약하다"는 티켓의 가설과 일치했다 — 가설 +두 근거 모두 "back이 안 읽으면 반영할 이유가 약하다"는 티켓의 가설과 일치했다. 가설 검증으로 그쳤고 새 판단을 만들지 않았다. --- @@ -98,11 +100,11 @@ detail = ( ### 4.1 계약 테스트 — `tests/test_api_error_contract.py` 신규 4건 + 회귀 2건 -`-220`·`-221`이 세운 원칙을 그대로 지켰다. **Fake를 쓰지 않는다** — `MockTransport` +`-220`·`-221`이 세운 원칙을 그대로 지켰다. Fake를 쓰지 않는다. `MockTransport` → 실제 `EmbeddingClient`, 실제 `asyncpg` 풀 → 실제 `SearchService` → router → 예외 핸들러 → HTTP 응답까지 한 요청으로 관통한다. 예외를 직접 주입하면 분류 경로를 -건너뛰어 `db_errors.py`나 `classify_http_status`가 바뀌어도 이 파일이 통과한다 — -`ai#69`를 놓친 구멍과 같은 모양이라 여기서도 피한다. +건너뛰어 `db_errors.py`나 `classify_http_status`가 바뀌어도 이 파일이 통과하게 된다. +`ai#69`를 놓친 구멍과 같은 구조라 여기서도 피한다. | 테스트 | 실패시키는 방법 | 단언 | |---|---|---| @@ -113,14 +115,15 @@ detail = ( 민감 값 비노출은 기존 계약 테스트(`test_error_response_exposes_no_configured_values`, `test_db_error_response_exposes_no_connection_details`)가 이미 지키고 있고, 이번에 -추가한 네 문구는 전부 값이 없는 상수라 별도 테스트를 더하지 않았다 — 기존 두 테스트가 +추가한 네 문구는 전부 값이 없는 상수라 별도 테스트를 더하지 않았다. 기존 두 테스트가 그대로 통과하는 것으로 충분하다. -### 4.2 RED → GREEN +### 4.2 구현 전 실패 확인 → 구현 후 통과 -`app/main.py`를 고치기 전 신규 DB 축 테스트 2건을 먼저 돌려 **RED 확인** — 두 테스트 -모두 `embedding upstream ...`가 나와 실패했다(GMS 회귀 테스트 2건은 이미 GREEN이었다, -현재 동작이 맞았으므로). 핸들러에 `isinstance` 분기를 넣은 뒤 4건 모두 GREEN. +`app/main.py`를 고치기 전 신규 DB 축 테스트 2건을 먼저 돌려 실패를 확인했다. 두 +테스트 모두 `embedding upstream ...`가 나와 실패했다(GMS 회귀 테스트 2건은 현재 +동작이 맞았으므로 이미 통과 상태였다). 핸들러에 `isinstance` 분기를 넣은 뒤 4건 모두 +통과했다. ``` FAILED test_db_transient_failure_uses_database_wording (embedding upstream unavailable != database unavailable) @@ -140,12 +143,12 @@ branch coverage 98.99% (196/198) 둘 다 게이트(80%) 통과 `app/main.py`는 100% 라인·브랜치 커버리지를 유지한다. 미달 두 줄(`embedding_service.py` 83행, `keyword_service.py` 248행)은 이 티켓 이전부터 있던 미달이며 이번 변경과 무관하다. -실서버 대조는 별도로 하지 않았다 — `-221`이 그 방식(전용 pgvector 컨테이너를 -`docker stop`)을 이미 검증했고, 이번 변경은 그 경로의 **문구**만 바꿔 계약 테스트가 +실서버 대조는 별도로 하지 않았다. `-221`이 그 방식(전용 pgvector 컨테이너를 +`docker stop`)을 이미 검증했고, 이번 변경은 그 경로의 문구만 바꾸며 계약 테스트가 이미 실제 `asyncpg` 실패를 통해 관통하고 있다. 상태 코드·재시도·분류 로직에 손대지 않았으므로 별도 실서버 대조가 새로 확인해 줄 것이 없다고 판단했다. -## 5. 남긴 것 +## 5. 남은 문제 - **`static/05` 반영하지 않음** — §3의 근거. 후속 필요 없음. - **Context 처리 경로(`/context/process`)는 영향 없음** — `-221`이 확인한 대로 202를 diff --git a/docs/implements/2026-08-03-gms-image-probe.md b/docs/implements/2026-08-03-gms-image-probe.md index 32e3d6b..1086eaa 100644 --- a/docs/implements/2026-08-03-gms-image-probe.md +++ b/docs/implements/2026-08-03-gms-image-probe.md @@ -18,21 +18,18 @@ ## 요약 -``` -축 A 지원한다 — 두 경로. gpt-image-1 · gemini-2.5-flash-image 모두 1024x1024 PNG 생성 - 게이트웨이에 모델 allowlist 가 있다 — dall-e-3 · imagen-3 은 차단된다 - 정책 거절이 벤더마다 다른 층에서 온다 — OpenAI 400, Gemini **200** - -축 B 가설 C — 토큰은 **치수**에 붙고 **바이트에는 안 붙는다** - -227 의 8,524 는 게이트웨이 가산이 아니라 gpt-4o-mini 의 타일 요금이다 - (텍스트 23 + 2,833 + 5,667 x 타일수, 7개 조건 전부 잔차 0) - -⚠ **더 급한 것이 나왔다.** 게이트웨이 요청 본문 상한이 약 100 KB 대에 있고, - 넘으면 「Model not found in request」로 온다. 실사용 대화 캡처는 - **토큰을 쓰기 전에 이 벽에 먼저 막힌다.** ai#98 의 전제가 바뀐다 -``` - -**축 A 는 `-253` 본문의 범위 밖이다**(본문은 축 B 만 다룬다). 같은 하네스라 함께 쟀다 — +- **축 A(생성)** — 지원한다. `gpt-image-1` · `gemini-2.5-flash-image` 두 경로 모두 + 1024×1024 PNG 를 생성한다. 게이트웨이에 모델 allowlist 가 있어 + `dall-e-3` · `imagen-3` 은 차단된다. 정책 거절이 벤더마다 다른 층에서 온다. OpenAI + 는 400 으로, Gemini 는 **200** 으로 온다. +- **축 B(분석)** — 가설 C 를 채택한다. 토큰은 이미지 치수에 붙고 바이트 수에는 붙지 + 않는다. `-227` 의 8,524 는 게이트웨이 가산이 아니라 `gpt-4o-mini` 의 타일 + 요금이다(텍스트 23 + 2,833 + 5,667 × 타일수, 7개 조건 전부 잔차 0). +- **⚠ 더 급한 것이 나왔다.** 게이트웨이 요청 본문 상한이 약 100 KB 대에 있고, 넘으면 + 「Model not found in request」 오류로 온다. 실사용 대화 캡처는 토큰을 쓰기 전에 이 + 상한에서 먼저 거부된다. `ai#98` 의 전제가 바뀐다. + +축 A 는 `-253` 본문의 범위 밖이다(본문은 축 B 만 다룬다). 같은 하네스라 함께 쟀고, 티켓 분리 여부는 중앙이 정한다. [§6](#6-중앙이-정할-것)에 적었다. --- @@ -58,7 +55,7 @@ Anthropic 은 시도하지 않았다 — 공개 API 에 이미지 **생성** 엔 ### 1.2 404 는 한 번도 나오지 않았다 — 게이트웨이는 **경로가 아니라 `model` 로 라우팅한다** -칩은 `404`·`401`·`403` 을 구분하라고 했다. **셋 다 관측되지 않았다.** GMS 는 모든 거부를 +칩은 `404`·`401`·`403` 을 구분하라고 했다. 셋 다 관측되지 않았다. GMS 는 모든 거부를 `400` + 자체 봉투로 낸다. ``` @@ -66,15 +63,17 @@ Anthropic 은 시도하지 않았다 — 공개 API 에 이미지 **생성** 엔 [GMS 에러] Model not found in request for domain {host} 요청에서 model 을 못 찾음 ``` -두 번째 문구가 구조를 드러낸다. **대조군으로 넣은 `GET .../models`(모델 목록)가 세 벤더 -모두 이 오류를 냈다** — GET 에는 본문이 없어 `model` 필드가 없기 때문이다. 즉 게이트웨이는 -URL 경로만 보고 프록시하는 것이 아니라 **본문에서 `model` 을 읽어 라우팅한다.** +두 번째 문구가 구조를 드러낸다. 대조군으로 넣은 `GET .../models`(모델 목록)가 세 벤더 +모두 이 오류를 냈다. GET 에는 본문이 없어 `model` 필드가 없기 때문이다. 즉 +게이트웨이는 URL 경로만 보고 프록시하는 것이 아니라 본문에서 `model` 을 읽어 +라우팅한다. -그래서 대조군은 의도한 역할("경로 구성이 맞는지")을 못 했지만, 더 나은 답을 줬다 — -**모델 목록 조회는 GMS 를 통해 구조적으로 불가능하다.** 어떤 모델이 열려 있는지는 -시도해 보는 것 말고 알 방법이 없다. +그래서 대조군은 의도한 역할("경로 구성이 맞는지")을 하지 못했지만, 더 나은 답을 줬다. +모델 목록 조회는 GMS 를 통해 구조적으로 불가능하다. 어떤 모델이 열려 있는지는 시도해 +보는 것 말고 알 방법이 없다. -이 사실은 §4 의 상한 문제와 같은 뿌리다. +이 사실은 §4 의 상한 문제와 같은 원인(게이트웨이가 본문을 파싱해 라우팅한다)에서 +나온다. ### 1.3 여덟 항목 @@ -83,7 +82,7 @@ URL 경로만 보고 프록시하는 것이 아니라 **본문에서 `model` 을 | **지원 여부** | 예 | 예 | | **API 형식** | OpenAI Images (`/v1/images/generations`) | Gemini `generateContent` + `responseModalities:["IMAGE"]` | | **정사각형** | **직접 지원** — `size:"1024x1024"` | **직접 지원** — 기본 출력이 1024×1024 | -| **비정사각형** | `size:"1024x1536"` 가 실제로 먹음(응답 2.23 MB) | `imageConfig.aspectRatio:"3:4"` → **864×1184**. 비율은 먹지만 정확한 픽셀은 모델이 정한다 | +| **비정사각형** | `size:"1024x1536"` 가 실제로 적용됨(응답 2.23 MB) | `imageConfig.aspectRatio:"3:4"` → **864×1184**. 비율은 적용되지만 정확한 픽셀은 모델이 정한다 | | **응답 형식** | `data[0].b64_json` — **base64만, URL 없음** | `candidates[0].content.parts[0].inlineData.data` — 같음 | | **비용(1024×1024)** | 입력 22 + 출력 **1,056**(전량 image token) = 1,078 | 입력 17 + 출력 **1,290** = 1,307 | | **비용(세로 1024×1536)** | 출력 1,584 → 합 1,606 | — | @@ -91,14 +90,14 @@ URL 경로만 보고 프록시하는 것이 아니라 **본문에서 `model` 을 | **한도** | 429 **0건** — 아래 참조 | 429 **0건** | | **오류 구분** | 정책 거절 → **HTTP 400** | 정책 거절 → **HTTP 200** | -**한도는 하한만 잰다.** 20회 상한 안에서 429 를 한 번도 못 봤다 — `gpt-image-1` 5회를 -약 59초에, `gemini` 5회를 약 28초에 연속으로 통과시켰다. **분당 한도는 최소 그 이상** -이라는 것이 이 측정이 말할 수 있는 전부다. 일일 한도는 재지 않았다. 상한을 정확히 -알려면 429 가 날 때까지 밀어야 하는데, 그것은 공용 게이트웨이를 태우는 일이고 칩이 -금지했다. +한도는 하한만 잰다. 20회 상한 안에서 429 를 한 번도 보지 못했다. `gpt-image-1` 5회를 +약 59초에, `gemini` 5회를 약 28초에 연속으로 통과시켰다. 분당 한도는 최소 그 +이상이라는 것이 이 측정이 말할 수 있는 전부다. 일일 한도는 재지 않았다. 상한을 +정확히 알려면 429 가 날 때까지 호출을 계속 보내야 하는데, 그것은 공용 게이트웨이에 +부하를 주는 일이고 칩이 금지했다. -**p95 는 내지 않았다.** n=5 로는 추정할 수 없다. 위 표는 최소·중앙·최대이고, 실사용 -타임아웃을 잡는다면 **관측 최대(gpt-image-1 17.3s)를 하한으로** 두는 편이 안전하다. +p95 는 내지 않았다. n=5 로는 추정할 수 없다. 위 표는 최소·중앙·최대이고, 실사용 +타임아웃을 잡는다면 관측 최대(gpt-image-1 17.3s)를 하한으로 두는 편이 안전하다. ### 1.4 오류 구분 — **벤더마다 층이 다르다** @@ -118,17 +117,18 @@ Gemini HTTP 200 ← ‼ "usageMetadata": {"promptTokenCount": 16, "totalTokenCount": 16}} ``` -**상태 코드만 보는 호출부는 Gemini 의 거절을 성공으로 읽고 `parts[0]` 에서 죽는다.** -`-205` 가 정리한 접두 규칙(`[GMS 에러]` 게이트웨이 / `[ 에러]` 벤더)은 여기서도 -성립하지만, **Gemini 의 거절은 애초에 오류 봉투로 오지 않으므로 그 규칙이 닿지 않는다.** +상태 코드만 보는 호출부는 Gemini 의 거절을 성공으로 읽고, `parts[0]` 접근에서 예외가 +발생한다. `-205` 가 정리한 접두 규칙(`[GMS 에러]` 게이트웨이 / `[ 에러]` +벤더)은 여기서도 성립하지만, Gemini 의 거절은 애초에 오류 봉투로 오지 않으므로 그 +규칙을 적용할 자리가 없다. 정리하면 `ai#97` 의 호출부가 갈라야 할 것은 세 가지다. -``` -[GMS 에러] 접두 게이트웨이가 막았다 — 모델 allowlist·본문 상한. GMS 운영 문제 -[ 에러] 접두 벤더가 막았다 — moderation_blocked 등. 우리 프롬프트 문제 -200 인데 finishReason≠STOP 벤더가 조용히 거절했다 — 상태 코드로는 안 보인다 -``` +| 응답 형태 | 의미 | 성격 | +|---|---|---| +| `[GMS 에러]` 접두 | 게이트웨이가 막았다 — 모델 allowlist·본문 상한 | GMS 운영 문제 | +| `[ 에러]` 접두 | 벤더가 막았다 — moderation_blocked 등 | 우리 프롬프트 문제 | +| 200 인데 `finishReason≠STOP` | 벤더가 오류 없이 거절했다 | 상태 코드로는 보이지 않는다 | --- @@ -136,14 +136,15 @@ Gemini HTTP 200 ← ‼ ### 2.1 재현 — `-227` 의 8,524 는 8,523 이다 -같은 프롬프트·같은 모델(`gpt-4o-mini`)로 1×1 PNG 를 다시 보냈다. `prompt_tokens = 8,523` -— `-227` 의 8,524 와 1 차이다. **출발점은 재현됐다.** +같은 프롬프트·같은 모델(`gpt-4o-mini`)로 1×1 PNG 를 다시 보냈다. +`prompt_tokens = 8,523` 으로, `-227` 의 8,524 와 1 차이다. 출발점은 재현됐다. -**그 1 은 이미지 쪽이 아니다.** 두 이미지(`-227` 의 투명 PNG 68 B · 이번 단색 69 B)는 둘 다 -1×1 이고, §2.3 이 **바이트가 토큰에 안 붙는다**를 보였으므로 이미지 기여분은 양쪽 다 8,500 으로 -같다. 차이는 텍스트 쪽(23 vs 24)이다. **어디서 왔는지는 이 측정이 답하지 못한다** — 두 회차의 -요청 본문을 나란히 두고 비교한 것이 아니라 프롬프트 문자열의 미세한 차이인지 토크나이저 -갱신인지 가를 근거가 없다. 1 토큰이라 비용 판단을 바꾸지 않으므로 호출을 더 쓰지 않았다. +그 1 은 이미지 쪽이 아니다. 두 이미지(`-227` 의 투명 PNG 68 B · 이번 단색 69 B)는 둘 +다 1×1 이고, §2.3 이 바이트 수가 토큰에 붙지 않는다는 것을 보였으므로 이미지 +기여분은 양쪽 다 8,500 으로 같다. 차이는 텍스트 쪽(23 vs 24)이다. 그 차이가 어디서 +왔는지는 이 측정이 답하지 못한다. 두 회차의 요청 본문을 나란히 두고 비교한 것이 +아니라, 프롬프트 문자열의 미세한 차이인지 토크나이저 갱신인지 가를 근거가 없다. +1 토큰이라 비용 판단을 바꾸지 않으므로 호출을 더 쓰지 않았다. ### 2.2 토큰 — 치수만 움직인다 @@ -171,8 +172,8 @@ Gemini HTTP 200 ← ‼ ### 2.3 가설 판정 — **C** -두 검사를 기계적으로 돌린다(`report.py`). 벤더 공개 공식은 **쓰지 않는다** — 판정에 -넣으면 "공식대로 나왔으니 정상"이라는 순환이 된다. +두 검사를 기계적으로 돌린다(`report.py`). 벤더 공개 공식은 판정에 쓰지 않는다. +판정에 넣으면 "공식대로 나왔으니 정상"이라는 순환이 되기 때문이다. | 검사 | 무엇을 보는가 | 결과 | |---|---|---| @@ -185,12 +186,13 @@ Gemini HTTP 200 ← ‼ 바이트 불변 ✓ · 치수 반응 ✓ → C ← 채택 ``` -**가설 A(게이트웨이 가산) 기각의 근거는 경로를 가로지른 대조다.** Gemini 만 보면 토큰이 -평탄해 A 처럼 보인다. 그러나 **같은 게이트웨이를 지나는** OpenAI 와 Anthropic 이 치수에 -반응한다 — 게이트웨이가 상수를 얹고 있다면 어느 경로도 반응할 수 없다. Gemini 의 평탄함은 -그 벤더가 이 크기 대역을 한 칸으로 세기 때문이다. +가설 A(게이트웨이 가산) 기각의 근거는 경로를 가로지른 대조다. Gemini 만 보면 토큰이 +평탄해 A 처럼 보인다. 그러나 같은 게이트웨이를 지나는 OpenAI 와 Anthropic 이 치수에 +반응한다. 게이트웨이가 상수를 얹고 있다면 어느 경로도 반응할 수 없다. Gemini 의 +평탄함은 그 벤더가 이 크기 대역을 한 칸으로 세기 때문이다. -**보강(판정에 쓰지 않음).** OpenAI 공개 타일 공식과 맞춰 보면 7개 조건 **전부 잔차 0** 이다. +보강 근거도 있다(판정에는 쓰지 않았다). OpenAI 공개 타일 공식과 맞춰 보면 7개 조건 +전부 잔차 0 이다. ``` prompt_tokens = 텍스트(23) + 2,833 + 5,667 × 타일수 @@ -201,10 +203,10 @@ prompt_tokens = 텍스트(23) + 2,833 + 5,667 × 타일수 | `px1-solid` · `px64-noise` · `px512-*` | ~512×512 이하 | 1 | 8,523 | 8,523 | **+0** | | `px1024-solid` · `px2048-solid` | 1024·2048 | 4 | 25,524 | 25,524 | **+0** | -1024 와 2048 이 **같은 값**인 것이 공식의 2단계(짧은 변을 768 로 축소)를 그대로 보인다. +1024 와 2048 이 같은 값인 것이 공식의 2단계(짧은 변을 768 로 축소)를 그대로 보인다. -> **`-253` 본문의 세 가설 중 C 다.** 8,524 는 게이트웨이가 얹은 것도, base64 를 텍스트로 -> 센 것도 아니라 **`gpt-4o-mini` 의 이미지 요금 그 자체**다. 비용 예측은 벤더 공식으로 +> `-253` 본문의 세 가설 중 C 다. 8,524 는 게이트웨이가 얹은 것도, base64 를 텍스트로 +> 센 것도 아니라 `gpt-4o-mini` 의 이미지 요금 그 자체다. 비용 예측은 벤더 공식으로 > 세우면 되고, 게이트웨이를 변수로 넣을 필요가 없다. ### 2.4 비용 손잡이 — 벤더 선택이 94배 @@ -217,8 +219,9 @@ claude-haiku-4-5 1,407 18배 싸다 gemini-2.5-flash-lite 272 94배 싸다 ``` -`ai#98` 이 대화 캡처를 분석한다면 **모델 선택이 유일하게 큰 손잡이**다. `detail:"low"` 도 -재려 했으나 해당 조건이 전부 본문 상한(§4)에 걸려 200 을 못 받았다 — **미측정**이다. +`ai#98` 이 대화 캡처를 분석한다면 모델 선택이 비용을 조절할 수 있는 유일하게 큰 +수단이다. `detail:"low"` 도 재려 했으나 해당 조건이 전부 본문 상한(§4)에 걸려 200 을 +받지 못했다. 미측정이다. --- @@ -232,15 +235,15 @@ usage 만 보면 게이트웨이가 이미지를 버리고 텍스트만 넘겼 노이즈 조건 "노이즈" · "잡음" ``` -세 벤더 모두 **이미지 내용에 대해** 답했다. `-227` 의 결론(게이트웨이가 이미지 파트를 +세 벤더 모두 이미지 내용에 대해 답했다. `-227` 의 결론(게이트웨이가 이미지 파트를 통과시킨다)이 크기를 바꿔도 유지된다. --- ## 4. 게이트웨이 요청 본문 상한 (`-225` 와 겹친다) -**이것이 이 측정에서 가장 급한 결과다.** 축 B 1회차에서 실사용 크기(787 KB) 조건이 -**세 경로 전부 400** 이었다. +이것이 이 측정에서 가장 급한 결과다. 축 B 1회차에서 실사용 크기(787 KB) 조건이 +세 경로 전부 400 이었다. ``` openai [GMS 에러] Model not found in request for domain api.openai.com @@ -250,16 +253,16 @@ gemini [Gemini 에러] ... "GenerateContentRequest.contents: contents is not ### 4.1 무엇이 일어나는가 -§1.2 에서 본 것과 같은 문구다. 게이트웨이는 **본문을 파싱해 `model` 을 읽는다.** 본문이 +§1.2 에서 본 것과 같은 문구다. 게이트웨이는 본문을 파싱해 `model` 을 읽는다. 본문이 상한을 넘으면 그 파싱이 실패하고, 게이트웨이는 그것을 「모델을 못 찾겠다」로 보고한다. -Gemini 경로가 더 나쁜 얼굴을 보여 준다 — 거기서는 게이트웨이가 **잘린 본문을 그대로 벤더에 -전달했고**, 벤더가 "contents 가 없다"고 답했다. 게이트웨이가 자기 실패를 벤더 오류로 -포장한 셈이다. +Gemini 경로에서는 더 나쁜 동작이 관측됐다. 거기서는 게이트웨이가 잘린 본문을 그대로 +벤더에 전달했고, 벤더가 "contents 가 없다"고 답했다. 게이트웨이의 실패가 벤더 오류의 +형태로 나타난 것이다. -**크기 거부가 「모델 오류」로 온다.** `-205` T62 가 텍스트 본문에서 본 것과 같은 얼굴이고, -`S15P11A705-225`(긴 Context 가 모델 오류로 오진)와 **같은 원인**이다. 이미지에서도 -재현된다는 것을 이 측정이 확인한다. +즉 크기 거부가 「모델 오류」로 온다. `-205` T62 가 텍스트 본문에서 본 것과 같은 +형태이고, `S15P11A705-225`(긴 Context 가 모델 오류로 오진)와 같은 원인이다. +이미지에서도 재현된다는 것을 이 측정이 확인한다. ### 4.2 어디에 있는가 @@ -270,27 +273,27 @@ Gemini 경로가 더 나쁜 얼굴을 보여 준다 — 거기서는 게이트 | 통과 최대 | **96,024 B** | `px512-n45`(이미지 71,776 B) | 200 | | 거부 최소 | **129,564 B** | `px512-n61`(이미지 96,931 B) | 400 | -**상한은 96,024 B 와 129,564 B 사이다.** 구간에 드는 흔한 값이 `100,000`(100 KB) · -`102,400`(100 KiB) · `128,000` 셋이라 실측만으로는 못 가른다 — 더 좁히려면 호출이 더 -필요한데 축 B 30회를 소진했다. +상한은 96,024 B 와 129,564 B 사이다. 구간에 드는 흔한 값이 `100,000`(100 KB) · +`102,400`(100 KiB) · `128,000` 셋이라 실측만으로는 가를 수 없다. 더 좁히려면 호출이 +더 필요한데 축 B 30회를 소진했다. -### 4.3 실무 환산 — **이미지 약 70 KB** +### 4.3 실무 환산 — 이미지 약 70 KB -base64 가 4/3 로 부풀므로 **이미지 크기의 상한은 본문 상한보다 훨씬 낮다.** +base64 가 4/3 로 부풀므로 이미지 크기의 상한은 본문 상한보다 훨씬 낮다. ``` 확인된 통과 이미지 원본 71,776 B (~70 KB) 확인된 거부 이미지 원본 96,931 B (~95 KB) ``` -> **`ai#98` 의 전제가 바뀐다.** 대화 캡처 스크린샷은 보통 200 KB ~ 2 MB 다. **현재 GMS 를 -> 통해서는 보낼 수 없다** — 토큰 비용을 걱정하기 전에 요청이 400 으로 되돌아온다. +> `ai#98` 의 전제가 바뀐다. 대화 캡처 스크린샷은 보통 200 KB ~ 2 MB 다. 현재 GMS 를 +> 통해서는 보낼 수 없다. 토큰 비용을 걱정하기 전에 요청이 400 으로 되돌아온다. > `back#138` 이 전제한 「최대 10 MiB」와는 두 자릿수 차이다. > -> 클라이언트나 FastAPI 가 **보내기 전에 축소**하는 것이 유일한 우회로 보인다. 그리고 -> §2.3 이 다행한 쪽이다 — **토큰은 치수에만 붙으므로**, 같은 치수를 유지한 채 JPEG 품질을 -> 낮춰 바이트만 줄이면 **인식 품질과 토큰 비용을 그대로 두고** 상한을 통과할 수 있다. -> 이 우회의 실효성은 재지 않았다(§5). +> 클라이언트나 FastAPI 가 보내기 전에 축소하는 것이 유일한 우회로 보인다. 그리고 +> §2.3 의 결과가 이 우회에 유리하다. 토큰은 치수에만 붙으므로, 같은 치수를 유지한 채 +> JPEG 품질을 낮춰 바이트만 줄이면 인식 품질과 토큰 비용을 그대로 두고 상한을 통과할 +> 수 있다. 이 우회의 실효성은 재지 않았다(§5). --- @@ -298,28 +301,30 @@ base64 가 4/3 로 부풀므로 **이미지 크기의 상한은 본문 상한보 - **`detail:"low"` 를 못 쟀다.** 계획한 두 조건이 전부 §4 의 본문 상한에 걸려 200 을 못 받았다. 상한 아래(≤70 KB)에서 다시 재야 한다 — 호출 2회면 된다. -- **JPEG 을 안 만들었다.** 형식이 아니라 바이트 수가 변수이고, 「같은 치수 · 다른 바이트」는 - PNG 대조쌍이 이미 36배로 만든다. 다만 §4.3 의 우회(품질을 낮춘 JPEG)를 검증하려면 - JPEG 인코더가 필요하다 — 그때는 Pillow 를 개발 의존성으로 들이는 편이 낫다. +- **JPEG 을 안 만들었다.** 형식이 아니라 바이트 수가 변수이고, 「같은 치수 · 다른 + 바이트」는 PNG 대조쌍이 이미 36배 차이로 만든다. 다만 §4.3 의 우회(품질을 낮춘 + JPEG)를 검증하려면 JPEG 인코더가 필요하다. 그때는 Pillow 를 개발 의존성으로 들이는 + 편이 낫다. - **상한의 정확한 값을 못 정했다.** 96,024 ~ 129,564 B 구간까지다(§4.2). - **분당·일일 한도를 못 쟀다.** 429 가 한 건도 안 났다(§1.3). 하한만 남는다. - **p95 를 못 냈다.** 축 A n=5 · 축 B 조건당 n=1~2 다. 최소·중앙·최대만 적었다. -- **Gemini·Anthropic 의 바이트 대조쌍이 없다.** 두 경로는 상한 아래 바이트 사다리를 - 못 밟았다(호출 배분을 OpenAI 에 몰았다 — 8,523 이 거기서 나왔으므로). 가설 판정은 - 경로를 가로지른 대조로 성립하지만(§2.3), 벤더별 단독 확인은 OpenAI 뿐이다. -- **`px2048-noise`(12.6 MB)를 안 보냈다.** 787 KB 가 이미 거부됐으므로 답이 정해져 있고, - 같은 호출을 상한 좁히기에 쓰는 편이 나았다. +- **Gemini·Anthropic 의 바이트 대조쌍이 없다.** 두 경로에서는 상한 아래의 바이트 + 단계별 측정을 하지 못했다(호출 배분을 OpenAI 에 몰았다. 8,523 이 거기서 + 나왔으므로). 가설 판정은 경로를 가로지른 대조로 성립하지만(§2.3), 벤더별 단독 + 확인은 OpenAI 뿐이다. +- **`px2048-noise`(12.6 MB)를 안 보냈다.** 787 KB 가 이미 거부됐으므로 답이 정해져 + 있고, 같은 호출을 상한 좁히기에 쓰는 편이 나았다. - **`ai#97`·`ai#98` 에 코멘트하지 않았고 Jira 도 전이하지 않았다.** 중앙 몫이다. --- ## 6. 중앙이 정할 것 -1. **축 A 를 `-253` 에 담을 것인가.** `-253` 본문은 축 B(토큰 소비 규명)만 다룬다. 축 A 는 - 범위 밖이고 결과도 독립적이라(생성 지원 여부 · 모델 allowlist · 정책 거절 층) **별도 - 티켓이 자연스럽다.** 이 문서는 하네스가 같아 한 곳에 뒀다. +1. **축 A 를 `-253` 에 담을 것인가.** `-253` 본문은 축 B(토큰 소비 규명)만 다룬다. + 축 A 는 범위 밖이고 결과도 독립적이라(생성 지원 여부 · 모델 allowlist · 정책 거절 + 층) 별도 티켓이 자연스럽다. 이 문서는 하네스가 같아 한 곳에 뒀다. 2. **§4 를 별도 티켓으로 올릴 것인가.** 본문 상한은 `-253` 이 물은 것이 아니지만 - `ai#98` 을 직접 막고 `-225` 와 같은 원인이다. **`-225` 에 합칠지 새로 낼지**가 판단 + `ai#98` 을 직접 막고 `-225` 와 같은 원인이다. `-225` 에 합칠지 새로 낼지가 판단 대상이다. 3. **상한을 더 좁힐 것인가.** 호출 2~4회로 100,000 · 102,400 · 128,000 을 가를 수 있다. 4. **`detail:"low"` 재측정.** 호출 2회. diff --git a/docs/implements/2026-08-03-gms-vision-probe.md b/docs/implements/2026-08-03-gms-vision-probe.md index db050b5..f94069c 100644 --- a/docs/implements/2026-08-03-gms-vision-probe.md +++ b/docs/implements/2026-08-03-gms-vision-probe.md @@ -8,22 +8,20 @@ ## 요약 -``` -결론 지원한다 — 세 경로(OpenAI · Gemini · Anthropic) 모두 200, 이미지를 실제로 읽었다 -근거 1x1 PNG 를 각 벤더 스펙 그대로 실어 보냈고, 세 응답 모두 이미지 내용에 대한 - 답(색상 등 한 단어)을 반환했다 — 빈 응답이나 이미지 무시가 아니다 -호출 수 3건(경로당 1회). 공용 게이트웨이 부담 최소화 원칙을 지켰다 -영향 back#138 이 제안한 `Front → FastAPI → 이미지 분석` 흐름은 GMS 비전 거부로 - 막히지 않는다. 이 재고가 유일한 선행 조건이었다(같은 이슈 코멘트 기준) -``` +| 항목 | 내용 | +|---|---| +| 결론 | 지원한다. 세 경로(OpenAI · Gemini · Anthropic) 모두 200 이고, 모델이 이미지를 실제로 읽었다 | +| 근거 | 1×1 PNG 를 각 벤더 스펙 그대로 실어 보냈고, 세 응답 모두 이미지 내용에 대한 답(색상 등 한 단어)을 반환했다. 빈 응답이나 이미지 무시가 아니다 | +| 호출 수 | 3건(경로당 1회). 공용 게이트웨이 부담 최소화 원칙을 지켰다 | +| 영향 | `back#138` 이 제안한 `Front → FastAPI → 이미지 분석` 흐름은 GMS 비전 거부로 막히지 않는다. 이 재고가 유일한 선행 조건이었다(같은 이슈 코멘트 기준) | ## 왜 재는가 `back#138`은 대화 캡처 이미지를 FastAPI가 직접 분석하는 흐름(Spring 중계 방식으로 -합의됨)을 제안했다. 그 이슈에서 **"GMS 게이트웨이가 비전 요청을 프록시하는지 확인한 -적이 없다"**는 것을 짚었고, 확인이 안 되면 설계 전체가 성립하지 않는다고 남겼다. -같은 이슈에서 "오늘 안에 못 돌렸다"고 재고를 약속만 하고 지키지 못한 이력이 있다 -— `S15P11A705-227`로 그 약속을 이행한다. +합의됨)을 제안했다. 그 이슈에서 "GMS 게이트웨이가 비전 요청을 프록시하는지 확인한 +적이 없다"는 것을 짚었고, 확인이 안 되면 설계 전체가 성립하지 않는다고 남겼다. +같은 이슈에서 "오늘 안에 못 돌렸다"고 재고를 약속만 하고 지키지 못한 이력이 있다. +`S15P11A705-227`로 그 약속을 이행한다. 현재 FastAPI의 GMS 호출은 텍스트 전용이다(임베딩 `text-embedding-3-small`, 판정 `gpt-4o-mini → gemini-2.5-flash → claude-haiku-4-5` 벤더 폴백). 코드에 이미지·멀티모달 @@ -33,7 +31,7 @@ `.env`의 `GMS_API_KEY`·`GMS_BASE_URL`을 읽어 세 벤더 경로에 각 1회씩, 벤더 API 스펙 그대로 이미지 파트를 실은 요청을 보냈다. 이미지는 1×1 투명 PNG(68바이트, base64)로 -크기 요인을 배제했다 — `-205`가 큰 본문에서 게이트웨이가 "모델을 못 찾겠다"는 400을 +크기 요인을 배제했다. `-205`가 큰 본문에서 게이트웨이가 "모델을 못 찾겠다"는 400을 먼저 내는 것을 관측했으므로(T62), 비전 거부와 크기 거부가 섞이지 않게 가장 작은 이미지로 쟀다. @@ -45,14 +43,15 @@ 세 요청 모두 프롬프트는 "이 이미지에 무엇이 보이는가? 한 단어로 답하라"이고 `max_tokens`류를 16으로 제한해 비용을 최소화했다. 스크립트는 세션 스크래치패드에만 -두고 커밋하지 않았다 — 값은 `.env`에서 메모리로만 읽고, 출력 전 모든 응답 본문에 -`GMS_API_KEY` 문자열 치환 + 20자 이상 영숫자 토큰 마스킹을 거쳤다(안전망, 실제로는 -한 건도 걸리지 않음 — `-205`의 결론과 일치, 자격 증명은 애초에 에코되지 않는다). +두고 커밋하지 않았다. 값은 `.env`에서 메모리로만 읽고, 출력 전 모든 응답 본문에 +`GMS_API_KEY` 문자열 치환과 20자 이상 영숫자 토큰 마스킹을 거쳤다(안전망이며, +실제로는 한 건도 걸리지 않았다. 자격 증명은 애초에 에코되지 않는다는 `-205`의 결론과 +일치한다). ## 결과 세 경로 모두 `200`, 세 벤더 모두 이미지 내용에 대한 실제 답을 반환했다(빈 문자열이나 -거부가 아니다) — 게이트웨이가 이미지 파트를 통과시키지 않았다면 벤더가 이미지 없이 +거부가 아니다). 게이트웨이가 이미지 파트를 통과시키지 않았다면 벤더가 이미지 없이 텍스트 프롬프트만으로 무관한 답을 하거나 스키마 오류로 4xx를 냈을 것이다. | 경로 | 상태 | 모델이 실제로 본 이미지에 답했는가 | 비고 | @@ -61,13 +60,13 @@ | Gemini(`gemini-2.5-flash-lite`) | 200 | 예 — 색상 단어 1개 | `usageMetadata` 에 이미지 모달리티 토큰이 별도로 잡힘(`promptTokensDetails`) | | Anthropic(`claude-haiku-4-5-20251001`) | 200 | 예 — 짧은 답 1자 | `usage.input_tokens=40`, 세 경로 중 이미지 토큰 비용이 가장 낮게 관측 | -세 응답 모두 `[GMS 에러]`·`[ 에러]` 고정 문구가 없는 정상 200 봉투였다 — -`-205`가 정리한 형식(게이트웨이 오류 vs 벤더 오류)과 대조할 필요 자체가 없었다, +세 응답 모두 `[GMS 에러]`·`[ 에러]` 고정 문구가 없는 정상 200 봉투였다. +`-205`가 정리한 형식(게이트웨이 오류 vs 벤더 오류)과 대조할 필요 자체가 없었다. 애초에 오류가 아니었기 때문이다. ## 결론 -**GMS 게이트웨이는 멀티모달(이미지 입력) 요청을 통과시킨다 — 세 벤더 경로 모두.** +GMS 게이트웨이는 세 벤더 경로 모두에서 멀티모달(이미지 입력) 요청을 통과시킨다. `back#138`이 제안한 `Front → Spring → FastAPI → 이미지 분석`(카카오 검색 포함) 흐름은 GMS 비전 거부로 막히지 않는다. 같은 이슈에서 이것이 "유일한 선행 조건"으로 남아 있었으므로, 이 결과로 해당 조건은 해소된다. @@ -77,13 +76,13 @@ GMS 비전 거부로 막히지 않는다. 같은 이슈에서 이것이 "유일 - **429·5xx 본문은 재지 않았다.** 공용 게이트웨이를 의도적으로 밀어 오류를 유발하는 것은 이 재고의 목적(지원 여부 확인)과 무관하고 다른 세션·시연에 영향을 준다. `-205`가 이미 네 텍스트 경로의 오류 본문 형태(게이트웨이 고정 문구 vs 벤더 원문 - 중첩)를 실측해 뒀다 — 이미지 경로가 오류를 낼 경우도 같은 형태를 따를 것으로 - 본다(같은 게이트웨이 구현이므로), 별도 실측은 하지 않았다. + 중첩)를 실측해 뒀다. 이미지 경로가 오류를 낼 경우도 같은 형태를 따를 것으로 + 본다(같은 게이트웨이 구현이므로). 별도 실측은 하지 않았다. - **실제 이미지 분석 프롬프트·응답 스키마는 설계하지 않았다.** 이 재고는 "통과하는가" 만 확인한다. 카카오 장소 검색과의 결합, 응답 스키마, 프롬프트 설계는 `back#138`이 이 결과를 반영해 티켓을 세울 때의 범위다. - **이미지 크기·다중 이미지 상한은 재지 않았다.** 1×1 PNG 한 장만 썼다. 실사용 이미지(최대 10 MiB, `back#138` ghkim1632 코멘트 기준)에서의 게이트웨이 크기 제한은 - 별도 확인이 필요하다 — `-205`의 T62(거대 본문 → "모델 없음" 오진)가 이미지에도 + 별도 확인이 필요하다. `-205`의 T62(거대 본문 → "모델 없음" 오진)가 이미지에도 적용될 가능성이 있다. - **호출 토큰 비용(특히 OpenAI 8,524)의 원인은 조사하지 않았다.** 관측만 남긴다. diff --git a/docs/implements/2026-08-03-preset-description.md b/docs/implements/2026-08-03-preset-description.md index b126460..20e2e1d 100644 --- a/docs/implements/2026-08-03-preset-description.md +++ b/docs/implements/2026-08-03-preset-description.md @@ -1,163 +1,190 @@ -# 프리셋 `description`·`examples` 개정 — `examples` 만 채택한다. `description` 은 해롭다 +# 프리셋 `description`·`examples` 개정 — `examples` 개정만 채택한다. `description` 개정은 키워드가 붙지 않는 Context 를 늘린다 - **티켓**: S15P11A705-228 - **날짜**: 2026-08-03 -- **선행**: [τ](2026-07-31-candidate-threshold.md)(`-210`) · [프롬프트](2026-07-31-judge-prompt-rule.md)(`-219`) · [다수결](2026-07-31-judge-vote.md)(`-223`) — 라벨·데이터·집계 코드를 그대로 물려받았다 -- **함정**: [T68·T69](../troubleshooting/2026-08-03-preset-description.md) -- **하네스**: `tools/preset_desc/` (+ `-219` 의 `tools/prompt_ab/score_ab.py` 를 그대로 호출) +- **선행**: [τ](2026-07-31-candidate-threshold.md)(`-210`) · [프롬프트](2026-07-31-judge-prompt-rule.md)(`-219`) · [다수결](2026-07-31-judge-vote.md)(`-223`). 세 선행 티켓의 라벨·데이터·집계 코드를 그대로 물려받았다 +- **측정 중 발견한 문제**: [T68·T69](../troubleshooting/2026-08-03-preset-description.md) +- **하네스**: `tools/preset_desc/` (집계는 `-219` 의 `tools/prompt_ab/score_ab.py` 를 그대로 호출한다) ## 요약 **`examples` 개정만 채택한다(조건 `E`). `description` 개정은 채택하지 않는다.** -``` -E (examples 만) **채택.** 오분류 11.00 → 6.90행 (Δ -4.10 · p=0.0001) - 정상 판정 손실 -0.80행 · 교환비 0.20 - **순이득의 부호가 양극단 모두 양수** (+1.30 / +5.30) -D (description 만) 기각. fit 0건 Context 8.20 → 10.00 — 사용자 손실이 범위 분리로 악화 -DE (둘 다) 기각. 오분류 감소는 가장 크나(Δ -4.70) 순이득의 부호가 갈린다 - (-0.60 / +3.80). D 의 해로움이 E 의 이득 아래 묻힌다 -``` - -**네 티켓 만에 처음으로 무언가를 채택한다.** - -| | 왜 끝났나 | +| 조건 | 개정 대상 | 판정 | +|---|---|---| +| `E` | `examples` 만 | **채택** | +| `D` | `description` 만 | 기각 | +| `DE` | 둘 다 | 기각 | + +조건별 판정의 근거는 다음과 같다. + +- **`E` 를 채택한다.** 라벨 기준 오분류가 회차 평균 11.00행에서 6.90행으로 줄었다 + (Δ -4.10, 순열검정 p=0.0001). 정상 판정 손실은 0.80행에 그쳤고, 교환비(오분류 1건을 + 지우기 위해 버리는 정상 판정 수)는 0.20이다. 라벨이 없는 행을 전부 오분류로 계산해도, + 전부 정상으로 계산해도 순이득이 양수다(+1.30 / +5.30). **순이득의 부호가 양극단 모두 + 양수라는 것**이 채택의 결정적 근거다. +- **`D` 는 기각한다.** 적합(fit) 키워드가 하나도 붙지 않는 Context 수가 8.20개에서 + 10.00개로 늘었다. 이 지표는 사용자가 실제로 보는 손실이고, 악화 폭은 회차 범위가 + 분리될 만큼 뚜렷하다. 개정이 의도한 오분류 감소보다 사용자 손실 악화가 크다. +- **`DE` 는 기각한다.** 오분류 감소는 세 조건 중 가장 크다(Δ -4.70). 그러나 라벨이 없는 + 행이 크게 늘어, 그 행들을 어느 쪽으로 계산하느냐에 따라 순이득의 부호가 갈린다 + (-0.60 / +3.80). `D` 가 만드는 손실이 `E` 가 만드는 이득에 가려져 합산 수치만으로는 + 보이지 않는다. + +**이 계열의 네 번째 티켓에서 처음으로 개정안을 채택한다.** 앞선 세 티켓이 채택에 +이르지 못한 이유와 이번 티켓의 차이는 다음과 같다. + +| 티켓 | 채택하지 못한(또는 채택한) 이유 | |---|---| -| `-210` | 가를 선이 없었다 — 적합·부적합의 유사도 분포가 겹친다 | -| `-219` | 겨눌 표적이 없었다 — 오분류가 회차마다 다르다 | -| `-223` | 겨눈 것을 맞혔는데 행 수가 안 줄었다 | -| **`-228`** | **겨눈 것을 맞혔고 행 수도 줄었다. 다만 움직인 것이 예상한 쪽이 아니었다** | +| `-210` | 적합 판정과 부적합 판정의 유사도 분포가 겹쳐서, 임계값을 어디에 두어도 둘을 가를 수 없었다 | +| `-219` | 오분류가 회차마다 달라져서, 수정이 노릴 고정된 대상이 없었다 | +| `-223` | 노린 오분류는 제거했지만 전체 오분류 행 수가 줄지 않았다 | +| **`-228`** | **노린 오분류를 제거했고 전체 행 수도 줄었다. 다만 효과를 낸 필드가 예상(`description`)이 아니라 `examples` 였다** | -티켓의 추론은 *"오분류가 `description` 의 너무 넓은 의미 범위 때문일 것으로 보이나 -`examples` 쪽이 원인일 수도 있다"* 였다. **`examples` 쪽이었다.** 그리고 `description` -개정은 이득이 아니라 **손해**였다 — `fit 0건 Context` 가 `8.20 → 10.00` 으로 악화하고 -범위가 분리된다. 세 티켓 동안 `8.00 ± 0.00`(σ=0)으로 꿈쩍도 않던 지표가 처음 움직였는데 -나쁜 쪽이다. +티켓의 원래 추론은 *"오분류가 `description` 의 너무 넓은 의미 범위 때문일 것으로 보이나 +`examples` 쪽이 원인일 수도 있다"* 였다. 측정 결과 원인은 `examples` 쪽이었다. +`description` 개정은 이득이 아니라 손해로 나타났다. 적합 키워드가 없는 Context +(`fit 0건 Context`)가 `8.20 → 10.00` 으로 악화했고 회차 범위가 분리됐다. 이 지표는 +앞선 세 티켓 동안 `8.00 ± 0.00`(표준편차 0)으로 한 번도 변하지 않던 값이다. 처음 +움직였는데 나쁜 방향으로 움직였다. -**갈라 재지 않았다면 `DE` 를 봤을 것이고, 그것은 `description` 의 해로움을 `examples` 의 -이득 아래 묻은 채로 배포하는 것이었다.** +조건을 갈라 재지 않고 `DE` 하나만 측정했다면 `description` 의 손실이 `examples` 의 +이득에 가려진 채로 배포됐을 것이다. 조건을 셋으로 가른 설계(§1.1)가 이 오판을 막았다. ### 채택의 근거와 그 한계를 함께 적는다 -`E` 는 `-219` 가 **사전에 정한 기준(범위 비중첩)을 넘지 못한다**(`base` 9~14 · `E` 4~9 — -한 점에서 만난다). 그 자를 무르지 않는다 — *"나중에 더한 자가 통과했다고 앞의 자를 -무르면 기준을 결과에 맞춘 것"*(`-219`)이기 때문이다. **넘지 못했다고 적고, 그 위에서 -판단한다.** - -판단의 근거는 이 티켓이 `-219`·`-223` 에 없던 **독립 증거**를 갖는다는 것이다. - -``` -보강 기준 p=0.0001 — -219 의 C 는 같은 자리에서 p=0.081 이었다 -순이득 부호 양극단 모두 양수. 라벨 밖 3.30행을 전부 오분류로 쳐도 이득이다 -교환비 0.20 -210 이 기각한 1.22 의 1/6 -후보 선정 층 정상 판정을 하나도 안 잃고 오분류 2건을 **결정적으로** 지운다(§5) -라벨 42건 밖 홀드아웃 30건에서 근거 없는 쪽만 내려간다(§4.3) -5회 → 10회 효과가 줄지 않고 커졌다 (-3.20 → -4.10). -219 의 함정이 재현되지 않았다 -``` - -**그런데 사용자가 보는 손실은 거의 안 움직인다.** `fit 0건 Context` 가 `8.20 → 8.00` -이다(범위 겹침, p=0.47). 계약이 *"사용자가 보는 손실이 움직이는가가 진짜 지표"* 라고 -지목한 값이고, **오분류 행 4.10개를 지우고도 카드가 비는 Context 는 그대로 8건이다.** -채택하되 이것이 무엇을 사는지는 정확히 적어야 한다 — **붙어 있는 카드가 조금 정확해지는 -것이지 빈 카드가 채워지는 것이 아니다.** +`E` 는 `-219` 가 사전에 정한 기준(회차 범위 비중첩)을 넘지 못한다. `base` 의 오분류 +범위는 9~14행이고 `E` 는 4~9행이라 한 점(9행)에서 만난다. 이 기준을 사후에 완화하지 +않는다. `-219` 가 *"나중에 더한 자가 통과했다고 앞의 자를 무르면 기준을 결과에 맞춘 +것"* 이라고 못박았기 때문이다. **기준을 넘지 못했다고 기록하고, 그 위에서 판단한다.** -`DE` 는 기각하지만 그 이유는 `E` 와 다르다. 개정이 겨눈 5종의 유사도를 낮추자 **후보 -슬롯이 비고 그 자리를 안 고친 22종이 가져간다**(후보 231 → 241건). 판정이 그 새 후보를 -고르고 그 행들에는 라벨이 없다 — 회차당 `5.60`행이고 이 실험에서 **범위가 분리된 가장 -큰 이동**이다. 그 폭이 순이득보다 크므로 부호가 정해지지 않는다. +그렇게 판단할 수 있는 근거는 이 티켓이 `-219`·`-223` 에 없던 독립 증거를 갖는다는 +것이다. -**그 행에 라벨을 붙이는 것이 이 티켓이 해서는 안 되는 일이다.** 개정안을 설계한 사람이 -그 개정안이 새로 고른 행을 채점하면 `-219` 가 한계로 남긴 편향이 그대로 누적된다. -`E` 를 고를 수 있는 것은 **`E` 의 결론이 그 라벨에 걸려 있지 않기 때문**이다(라벨 밖이 -`3.30`행뿐이고 양극단 모두 양수다). `DE` 의 부호는 모른 채로 둔다 — 후속은 라벨 확장이고 -그것은 **개정안을 모르는 사람이** 해야 한다(§8). +| 독립 증거 | 내용 | +|---|---| +| 순열검정 보강 | p=0.0001. `-219` 의 조건 C 는 같은 자리에서 p=0.081 이었다 | +| 순이득 부호 | 라벨 처리의 양극단 모두 양수. 라벨 밖 3.30행을 전부 오분류로 계산해도 이득이 남는다 | +| 교환비 0.20 | `-210` 이 기각 근거로 삼은 1.22 의 1/6 수준이다 | +| 후보 선정 층 | LLM 판정 이전 단계에서, 정상 판정을 하나도 잃지 않고 오분류 2건을 후보에서 제거한다(§5) | +| 라벨 42건 밖 | 라벨과 무관한 홀드아웃 30건에서, 근거 없는 방향의 유사도만 내려간다(§4.3) | +| 5회 → 10회 확장 | 효과가 줄지 않고 커졌다(-3.20 → -4.10). `-219` 에서 회차를 늘리자 효과가 줄던 문제가 재현되지 않았다 | + +**그러나 사용자가 보는 손실은 거의 움직이지 않는다.** `fit 0건 Context` 는 +`8.20 → 8.00` 이다(회차 범위 겹침, p=0.47). 계약이 *"사용자가 보는 손실이 움직이는가가 +진짜 지표"* 라고 지목한 값이 이것이다. 오분류 행 4.10개를 지우고도, 카드에 키워드가 +하나도 붙지 않는 Context 는 여전히 8건이다. 채택하되 이 채택이 무엇을 개선하는지는 +정확히 적어야 한다. **키워드가 붙어 있는 카드가 조금 더 정확해지는 것이지, 빈 카드가 +채워지는 것이 아니다.** + +`DE` 의 기각 이유는 `E` 와 다르다. 개정이 수정 대상 5종의 유사도를 낮추자 후보 슬롯이 +비고, 그 자리를 수정하지 않은 22종이 차지한다(후보 등장 231 → 241건). LLM 판정이 그 +새 후보를 선택하는데 그 행들에는 라벨이 없다. 이 라벨 없는 행이 회차당 `5.60`행이고, +이 실험에서 회차 범위가 분리된 가장 큰 이동이다. 그 폭이 순이득 자체보다 크므로 +순이득의 부호를 정할 수 없다. + +그 행에 라벨을 붙여 부호를 정하는 것은 이 티켓이 해서는 안 되는 일이다. 개정안을 +설계한 사람이 그 개정안이 새로 고른 행을 채점하면, `-219` 가 한계로 남긴 판정자 편향이 +그대로 누적된다. `E` 를 채택할 수 있는 것은 `E` 의 결론이 그 라벨에 걸려 있지 않기 +때문이다. `E` 의 라벨 밖 행은 `3.30`행뿐이고 그것을 어느 쪽으로 계산해도 순이득이 +양수다. `DE` 의 부호는 확정하지 않은 채로 남긴다. 후속 작업은 라벨 확장이고, 그것은 +개정안을 모르는 사람이 해야 한다(§8). ## 1. 무엇을 어떻게 쟀나 ### 1.1 왜 조건을 셋으로 갈랐나 -`description` 은 **두 곳**에 들어가고 `examples` 는 한 곳에만 들어간다. +`description` 은 시스템의 두 곳에 들어가고 `examples` 는 한 곳에만 들어간다. ``` ① 임베딩 입력 app/client/embedding_client.py:36 f"{display_name}. {description} {examples}" ② 판정 프롬프트 app/service/keyword_service.py:98 cand_dicts 에 description 만 실린다 ``` -합쳐서 재면 **후보가 바뀌어서 좋아진 것인지 판정이 바뀌어서 좋아진 것인지** 모르고, -그러면 다음 사람이 무엇을 더 고쳐야 하는지도 모른다. 그래서 `D`(①+②) · `E`(①만) · -`DE`(둘 다)를 따로 돌렸다. +두 필드를 합쳐서 재면, 결과가 좋아졌을 때 후보 집합이 바뀌어서 좋아진 것인지 LLM +판정이 바뀌어서 좋아진 것인지 구분할 수 없다. 구분하지 못하면 다음 사람이 무엇을 더 +고쳐야 하는지도 알 수 없다. 그래서 `D`(①+②) · `E`(①만) · `DE`(둘 다)를 따로 돌렸다. + +`-219`·`-223` 의 하네스를 그대로 쓸 수 없던 이유가 여기 있다. 그 하네스는 후보 집합을 +조건 사이에 완전히 같게 두는 것이 설계의 핵심이었다. 조건이 프롬프트뿐이어야 하므로 +`.tau/matrix.json` 하나를 모든 조건이 공유한다. 이 티켓의 조건은 ①(임베딩 입력)을 +바꾸므로 유사도 행렬이 조건마다 따로 있어야 한다. 다만 집계는 `prompt_ab/score_ab.py` +를 그대로 호출한다. 집계를 새로 작성하면 `-219`·`-223` 과 같은 기준으로 재고 있다는 +보장이 사라지기 때문이다. -`-219`·`-223` 의 하네스를 그대로 못 쓴 이유가 여기 있다. 저쪽은 후보 집합을 조건 사이에 -**완전히 같게** 두는 것이 설계의 핵심이었다(조건이 프롬프트뿐이어야 하므로 -`.tau/matrix.json` 하나를 공유한다). 이 티켓은 조건이 ①을 바꾸므로 **행렬이 조건마다 -있어야 한다.** 집계는 `prompt_ab/score_ab.py` 를 **그대로 부른다** — 새로 짜면 -`-219`·`-223` 과 같은 자를 대고 있다는 보장이 사라진다. +### 1.2 `base2` — 임베딩 비결정성을 측정의 바닥으로 깐다 -### 1.2 `base2` — 임베딩 비결정성을 바닥으로 깐다 +측정 도중, 임베딩 API 가 같은 배치 구성으로도 결정적이지 않다는 사실이 드러났다(T68). +「배치 구성이 바뀔 때만 결과가 흔들린다」고 `T42` 가 적어 둔 전제가 틀린 것이다. -측정 도중 임베딩 API 가 **같은 배치 구성으로도 결정적이지 않다**는 것이 드러났다(T68). -`T42` 가 「배치 구성이 바뀔 때만 흔들린다」고 적어 둔 전제가 틀렸다. +그래서 `base2` 조건을 추가했다. `base` 와 입력 텍스트가 글자 하나까지 같은 조건이다. +개정 조건이 아니라, 임베딩 API 의 흔들림 크기를 재는 기준선이다. -그래서 `base2` 를 넣었다 — `base` 와 **글자 하나까지 같은** 조건이다. 조건이 아니라 -바닥이다. +측정한 흔들림의 크기는 다음과 같다. ``` |Δsim| 최대 0.004413 · 중앙 0.000013 rank 1,134쌍 중 43쌍에서 다르다 -후보 집합 τ=0.30·k=10 에서 42건 **전부 같다** +후보 집합 τ=0.30·k=10 에서 42건 전부 같다 ``` -**마지막 줄이 이 티켓을 구했다.** 흔들림이 절단선을 넘지 않으므로 `D`·`E`·`DE` 의 후보 -변화는 전부 개정의 몫이다. 넘었다면 이 티켓의 모든 수치가 임베딩 운과 섞였을 것이다. +마지막 줄이 이 측정의 유효성을 지켰다. 흔들림이 후보 절단선을 넘지 않으므로, +`D`·`E`·`DE` 에서 관측되는 후보 변화는 전부 개정의 효과다. 만약 흔들림이 절단선을 +넘었다면 이 티켓의 모든 수치에 임베딩 비결정성이 섞여 개정 효과와 구분할 수 없었을 +것이다. -`base2` 는 판정 회차를 돌리지 않는다 — 후보가 `base` 와 같으므로 그 판정은 `base` -재판정과 구분되지 않고, 그러면 회차만 늘리는 것과 같다. +`base2` 는 판정 회차를 돌리지 않는다. 후보가 `base` 와 같으므로 `base2` 의 판정은 +`base` 재판정과 구분되지 않고, 돌린다면 회차만 늘리는 것과 같기 때문이다. ### 1.3 회차 수 -`-219` 가 남긴 경고가 이 설계를 규정한다 — *"5회에서 멈추고 기준을 낮췄다면 부풀린 -값을 채택했을 것"*(Δ `-2.60` 이 10회에서 `-1.60` 으로 줄었다). +`-219` 가 남긴 경고가 이 설계를 규정한다. `-219` 에서는 효과 크기 Δ `-2.60` 이 +10회에서 `-1.60` 으로 줄었고, 리포트는 *"5회에서 멈추고 기준을 낮췄다면 부풀린 값을 +채택했을 것"* 이라고 적었다. -`base`·`D`·`DE` 를 10회씩 잡고 **`E` 만 5회로 뒀다.** 「후보 층에서 이미 방향이 갈렸으니 -원인 규명용이고 채택 후보가 아니다」로 봤기 때문이다. +처음에는 `base`·`D`·`DE` 를 10회씩 돌리고 `E` 만 5회로 뒀다. 후보 층에서 이미 방향이 +갈렸으므로 `E` 는 원인 규명용이지 채택 후보가 아니라고 봤기 때문이다. -**5회 실측이 나오자 `E` 가 세 조건 중 가장 좋았다** — 교환비 `0.19` · 정상 판정 손실 -`-0.60`행 · 순이득 부호가 양극단에서 다 양수. 채택 판단이 걸린 조건이 됐으므로 **10회로 -늘렸다.** 위의 `-219` 경고가 그 결정의 근거다. **유망해 보인다는 것은 회차를 늘릴 -이유이지 멈출 이유가 아니다.** +그런데 5회 실측 결과 `E` 가 세 조건 중 가장 좋았다. 교환비 `0.19`, 정상 판정 손실 +`-0.60`행, 그리고 순이득 부호가 양극단에서 모두 양수였다. 채택 판단이 걸린 조건이 +됐으므로 10회로 늘렸다. 위의 `-219` 경고가 그 결정의 근거다. **결과가 유망해 보인다는 +것은 회차를 늘릴 이유이지 측정을 멈출 이유가 아니다.** | 조건 | 회차 | 판정 호출 | |---|---|---| | `base` · `D` · `E` · `DE` | 10 | 420 × 4 = **1,680** | -늘린 결과가 `-219` 와 반대로 나왔다. +회차를 늘린 결과는 `-219` 와 반대 방향이었다. ``` --219 의 C 5회 Δ -2.60 → 10회 Δ -1.60 효과가 줄었다 (그래서 그 티켓은 기각했다) -이 티켓의 E 5회 Δ -3.20 → 10회 Δ -4.10 **효과가 커졌다** +-219 의 C 5회 Δ -2.60 → 10회 Δ -1.60 효과가 줄었다 (그 티켓은 이것을 근거로 기각했다) +이 티켓의 E 5회 Δ -3.20 → 10회 Δ -4.10 효과가 커졌다 ``` `base2` 는 판정 회차를 돌리지 않는다(§1.2). ### 1.4 라벨을 한 줄도 더하지 않았다 -`tau_grid/labels.yaml`(83행) + `prompt_ab/labels_extra.yaml`(24행)을 **그대로** 썼다. -기준이 바뀌면 `-210`·`-219`·`-223` 과 비교가 끊긴다. +`tau_grid/labels.yaml`(83행)과 `prompt_ab/labels_extra.yaml`(24행)을 그대로 썼다. +라벨 기준이 바뀌면 `-210`·`-219`·`-223` 과의 비교가 끊어지기 때문이다. -라벨 밖 행은 `score_ab.py` 가 `unlabeled` 로 따로 세고, 낙관·비관 양극단으로 돌려 -결론이 뒤집히는지 본다. **이 티켓에서는 그 양극단이 조건 선택을 실제로 갈랐다** — -`DE` 는 뒤집히고(`-0.60` / `+3.80`) `E` 는 안 뒤집힌다(`+1.30` / `+5.30`). 채택이 `E` 로 -간 이유의 절반이 이 칸이다(§7). +라벨 밖 행은 `score_ab.py` 가 `unlabeled` 로 따로 세고, 그 행들을 전부 오분류로 +계산하는 극단과 전부 정상으로 계산하는 극단을 모두 산출해 결론이 뒤집히는지 확인한다. +이 티켓에서는 그 양극단 계산이 조건 선택을 실제로 갈랐다. `DE` 는 순이득 부호가 +뒤집히고(`-0.60` / `+3.80`) `E` 는 뒤집히지 않는다(`+1.30` / `+5.30`). 채택이 `E` 로 +간 이유의 절반이 이 계산이다(§7). -**그리고 이것이 이 티켓에서 라벨을 더할 수 없는 이유이기도 하다.** 새로 나타난 행은 -내가 만든 후보 집합이 고른 것이고, 그것을 내가 `fit` 으로 판정하면 `DE` 가 채택되고 -`unfit` 으로 판정하면 기각된다. **라벨과 결론 사이에 직접 경로가 있다.** `-219` 가 -`labels_extra.yaml` 을 붙이며 남긴 한계가 여기서는 훨씬 강해진다 — 저쪽은 프롬프트가 -새로 고른 행이었고 이쪽은 **후보 집합 자체가 내 손에서 나왔다.** +같은 사정이 이 티켓에서 라벨을 추가할 수 없는 이유이기도 하다. 새로 나타난 행은 이 +티켓의 작업자가 만든 후보 집합이 고른 것이다. 그 행을 작업자가 `fit` 으로 판정하면 +`DE` 가 채택되고 `unfit` 으로 판정하면 기각된다. **라벨과 결론 사이에 직접 경로가 +생긴다.** `-219` 가 `labels_extra.yaml` 을 추가하며 남긴 한계가 여기서는 더 강해진다. +그쪽은 프롬프트가 새로 고른 행이었지만, 이쪽은 후보 집합 자체가 작업자의 개정안에서 +나왔다. ## 2. 후보 층 — 개정이 후보 집합을 어떻게 움직였나 -`.preset_desc/cands.json`. 판정을 부르기 전, 임베딩만으로 나오는 값이다. +이 절의 수치는 `.preset_desc/cands.json` 에 있다. LLM 판정을 호출하기 전, 임베딩 +유사도만으로 결정되는 값이다. | 조건 | 평균 후보 | base 와 다른 Context | 더해진 후보 | 빠진 후보 | 후보 0건 Context | |---|---|---|---|---|---| @@ -167,7 +194,8 @@ rank 1,134쌍 중 43쌍에서 다르다 | `E` | 7.119 | 19 | 11 | 21 | 0 | | `DE` | 6.929 | 28 | 18 | 36 | 0 | -**개정은 설계대로 좁혔다.** 겨눈 5종의 후보 등장이 크게 준다. +개정은 설계 의도대로 의미 범위를 좁혔다. 수정 대상으로 삼은 5종 프리셋의 후보 등장 +횟수가 크게 줄었다. | 프리셋 | `base` | `D` | `E` | `DE` | |---|---|---|---|---| @@ -177,22 +205,23 @@ rank 1,134쌍 중 43쌍에서 다르다 | `WITH_FAMILY` | 11 | 14 | 2 | **5** | | `DRINK` | 12 | 12 | 16 | 15 | -**그리고 그 빈자리를 안 고친 22종이 가져간다.** +그리고 줄어든 자리를 수정하지 않은 22종이 차지했다. ``` -안 고친 22종의 후보 등장 base 231 → D 234 · E 235 · DE 241 +수정하지 않은 22종의 후보 등장 base 231 → D 234 · E 235 · DE 241 ``` -이것이 §3·§4.2 에서 보는 `unlabeled` 폭증의 기계적 원인이다. 좁히기는 겨눈 프리셋만 -내리는 것이 아니라 **다른 프리셋을 밀어 올린다.** 후보 슬롯이 `k=10` 으로 고정이라 -그렇다. +이것이 §3·§4.2 에서 관측되는 `unlabeled`(라벨 없는 행) 증가의 기계적 원인이다. 한 +프리셋의 의미 범위를 좁히는 것은 그 프리셋의 후보 등장만 줄이는 것이 아니라 다른 +프리셋의 후보 등장을 늘린다. 후보 슬롯 수가 `k=10` 으로 고정되어 있기 때문이다. ## 3. 판정 층 — 조건별 결과 -`base` 대비. 회차 평균 ± 표준편차 [최소~최대]. **범위가 겹치면 회차 운으로 뒤집힌다** — -`-219` 가 사전에 정한 기준이다. +이 절의 수치는 모두 `base` 대비이고, 회차 평균 ± 표준편차 [최소~최대] 형식이다. +두 조건의 회차 범위가 겹치면 그 차이는 회차 운으로 뒤집힐 수 있다는 뜻이다. 범위 +비중첩은 `-219` 가 사전에 정한 판정 기준이다. -### 3.1 `D` — `description` 만. 기각 +### 3.1 `D` — `description` 만 개정. 기각 ``` 오분류 unfit 11.00 ± 1.56 [9~14] → 7.50 ± 1.84 [6~11] Δ -3.50 겹침 p=0.0008 @@ -202,19 +231,21 @@ fit 0건 Context 8.20 ± 0.42 [8~9] → 10.00 ± 0.00 [10~10] Δ +1.80 교환비 1.09 ``` -**두 줄이 이 조건을 끝낸다.** +두 줄이 이 조건의 기각을 결정한다. -`fit 0건 Context` 가 `8.20 → 10.00` 으로 **악화**했고 범위가 분리된다. 이 지표는 -`-219`·`-223` 에서 `8.00 ± 0.00`(σ=0)으로 세 티켓 연속 꿈쩍도 않던 것이다. 처음 -움직였는데 **나쁜 쪽으로** 움직였다 — 42개 Context 중 10개에서 카드에 맞는 키워드가 -하나도 안 붙는다. 사용자가 실제로 보는 손실이 이것이다. +첫째, `fit 0건 Context`(적합 키워드가 하나도 붙지 않는 Context 수)가 `8.20 → 10.00` +으로 악화했고 회차 범위가 분리된다. 이 지표는 `-219`·`-223` 에서 `8.00 ± 0.00` +(표준편차 0)으로 세 티켓 연속 변하지 않던 값이다. 처음 움직였는데 나쁜 방향으로 +움직였다. 42개 Context 중 10개에서 카드에 맞는 키워드가 하나도 붙지 않게 된다는 +뜻이고, 이것이 사용자가 실제로 보는 손실이다. -교환비 `1.09` 는 `-210` 이 최선의 τ 에서도 기각한 `1.22` 에 근접한다. 오분류 1건을 -지우는 데 정상 판정 1.09건을 버린다. +둘째, 교환비 `1.09` 는 `-210` 이 최선의 τ 에서도 기각 근거로 삼은 `1.22` 에 근접한다. +오분류 1건을 지우는 데 정상 판정 1.09건을 버린다는 뜻이다. -`D` 의 `fit` 손실은 안 고친 22종에 집중된다(§4.2) — 겨누지 않은 곳이 대가를 치렀다. +`D` 의 정상 판정 손실은 수정하지 않은 22종에 집중된다(§4.2). 수정하지 않은 프리셋이 +개정의 대가를 치렀다. -### 3.2 `DE` — 둘 다. 기준을 넘었으나 부호가 갈린다 +### 3.2 `DE` — 둘 다 개정. 사전 기준을 넘었으나 순이득 부호가 갈린다 ``` 오분류 unfit 11.00 ± 1.56 [9~14] → 6.30 ± 1.06 [5~8] Δ -4.70 **분리** p=0.0000 @@ -225,25 +256,25 @@ fit 0건 Context 8.20 ± 0.42 [8~9] → 8.00 ± 0.00 [8~8] Δ -0.20 교환비 0.66 ``` -**앞선 세 티켓이 못 넘은 것을 넘었다.** +이 조건은 앞선 세 티켓이 넘지 못한 기준을 넘었다. -``` -범위 분리 -219 가 사전에 정한 기준. -219·-223 은 둘 다 겹쳤다 -교환비 0.66 -210 이 기각한 1.22 보다 낫다 -fit 0건 Context 가 안 나빠졌다 D 와 갈리는 지점이다 -``` +- 오분류 감소의 회차 범위가 분리된다. 범위 분리는 `-219` 가 사전에 정한 기준이고, + `-219`·`-223` 은 둘 다 범위가 겹쳤다. +- 교환비 `0.66` 은 `-210` 이 기각한 `1.22` 보다 낫다. +- `fit 0건 Context` 가 나빠지지 않았다. `D` 와 갈리는 지점이다. -**그런데 순이득의 부호가 안 정해진다.** +**그러나 순이득의 부호가 정해지지 않는다.** ``` 낙관(경계·라벨없음 = 오분류) -0.60 비관(경계·라벨없음 = 정상) +3.80 ``` -`unlabeled` 가 `0.70 → 5.60`행으로 뛴 것이 원인이다(§2 의 후보 이동이 기계적 원인). -이 폭이 순이득 자체보다 크므로 **어느 쪽으로도 결론이라고 부를 수 없다.** +라벨 없는 행이 `0.70 → 5.60`행으로 늘어난 것이 원인이다(§2 의 후보 이동이 그 기계적 +원인이다). 이 증가 폭이 순이득 자체보다 크므로, 라벨 없는 행을 어느 쪽으로 계산하느냐에 +따라 순이득이 손실도 되고 이득도 된다. 어느 쪽도 결론이라고 부를 수 없다. -### 3.3 `E` — `examples` 만. **채택** +### 3.3 `E` — `examples` 만 개정. **채택** ``` 오분류 unfit 11.00 ± 1.56 [9~14] → 6.90 ± 1.45 [4~9] Δ -4.10 겹침 p=0.0001 @@ -255,20 +286,22 @@ fit 0건 Context 8.20 ± 0.42 [8~9] → 8.00 ± 0.00 [8~8] Δ -0.20 순이득 낙관(경계·라벨없음=오분류) **+1.30** · 비관 **+5.30** ``` -**`DE` 와 갈리는 칸이 마지막 줄이다.** `E` 도 라벨 밖 행이 늘지만(`+2.60`) 오분류 감소가 -그보다 크므로 **그 행을 전부 오분류로 쳐도 이득이 남는다.** `DE` 는 라벨 밖이 `+4.90` -이라 그 계산이 음수로 뒤집혔다. +`DE` 와 갈리는 지점이 마지막 줄(순이득)이다. `E` 도 라벨 밖 행이 늘지만(`+2.60`) +오분류 감소가 그보다 크다. 따라서 라벨 밖 행을 전부 오분류로 계산해도 이득이 남는다. +`DE` 는 라벨 밖 증가가 `+4.90` 이라 같은 계산이 음수로 뒤집혔다. -정상 판정 손실이 `-0.80`행이고 범위가 겹치며 `p=0.158` 이다 — **잃는다고 말할 근거가 -약하다.** `D`(`-3.80`, 범위 분리)·`DE`(`-3.10`, 범위 분리)와 정면으로 갈린다. +정상 판정 손실은 `-0.80`행이고 회차 범위가 겹치며 `p=0.158` 이다. 정상 판정을 +잃는다고 말할 통계적 근거가 약하다는 뜻이다. `D`(`-3.80`, 범위 분리)·`DE`(`-3.10`, +범위 분리)와 정면으로 갈린다. -`선택 0건 Context` 가 `+0.80` 늘었다(`p=0.066`). 후보가 좁아져 아무것도 안 고르는 -Context 가 늘어난 것인데, **그런데도 `fit 0건 Context` 는 안 늘었다** — 새로 비는 -Context 는 원래 `fit` 이 없던 것들이라는 뜻이다. +`선택 0건 Context`(LLM 이 아무 키워드도 고르지 않은 Context 수)는 `+0.80` 늘었다 +(`p=0.066`). 후보가 좁아져 아무것도 고르지 않는 Context 가 늘어난 것이다. 그런데도 +`fit 0건 Context` 는 늘지 않았다. 새로 비게 된 Context 는 원래부터 적합 판정이 없던 +것들이라는 뜻이다. -#### `-223` 이 못 지운 7행이 어떻게 됐나 +#### `-223` 이 지우지 못한 오분류 7행이 어떻게 됐나 -회차 10개 중 그 행이 나온 횟수다. +아래 표는 회차 10개 중 해당 오분류가 나타난 횟수다. | 행 | `base` | `D` | **`E`** | `DE` | |---|---|---|---|---| @@ -280,13 +313,14 @@ Context 는 원래 `fit` 이 없던 것들이라는 뜻이다. | `274 DRINK` (가지튀김 → 술자리) | 10 | 10 | **10** | 10 | | `289 DRINK` (같은 본문) | 10 | 10 | **10** | 9 | -**`E` 가 7행 중 3행을 완전히 지웠다.** `-210`(τ)·`-219`(프롬프트)·`-223`(다수결)이 -셋 다 못 움직인 행들이다. `284 WITH_FAMILY` 는 `E` 에서 후보 자격을 잃어(§5) LLM 이 -무엇을 답하든 나올 수 없다. +`E` 는 7행 중 3행을 완전히 지웠다. `-210`(τ)·`-219`(프롬프트)·`-223`(다수결)이 셋 다 +움직이지 못한 행들이다. `284 WITH_FAMILY` 는 `E` 에서 후보 자격 자체를 잃었으므로(§5) +LLM 이 무엇을 답하든 결과에 나올 수 없다. -**그리고 셋은 그대로다.** `265 WALK` 와 `274`·`289 DRINK` 는 10/10 으로 요지부동이고, -`DRINK` 는 §2.1 에서 유사도가 오히려 올랐다(0.3139 → 0.3247). 「가지튀김이 미쳤음」은 -네 번째 티켓에서도 살아남았다 — 이 한 쌍은 프리셋 텍스트로 닿는 문제가 아니다. +그리고 3행은 그대로 남았다. `265 WALK` 와 `274`·`289 DRINK` 는 10회 전부에서 +나타났고, `DRINK` 는 개정 후 유사도가 오히려 올랐다(0.3139 → 0.3247). 본문 +「가지튀김이 미쳤음」에 `DRINK 술자리` 가 붙는 오분류는 네 번째 티켓에서도 사라지지 +않았다. 이 본문·프리셋 쌍은 프리셋 텍스트 수정으로 해결되는 문제가 아니다. #### 새로 생긴 오분류 @@ -298,79 +332,89 @@ Context 는 원래 `fit` 이 없던 것들이라는 뜻이다. 291 VIEW_GOOD 0 → 3 ``` -셋 다 **고치지 않은 프리셋**이다. §2.2 의 슬롯 경쟁이 판정 층에서도 같은 얼굴로 나온다. -그런데도 총계가 `-4.10` 인 것은 지운 쪽이 훨씬 크기 때문이다. +셋 다 수정하지 않은 프리셋이다. §2 에서 관측한 후보 슬롯 경쟁(한 프리셋을 좁히면 다른 +프리셋이 후보로 올라오는 현상)이 판정 층에서도 같은 방식으로 나타난 것이다. 그런데도 +총계가 `-4.10` 인 것은 지워진 오분류가 새로 생긴 것보다 훨씬 많기 때문이다. ## 4. 자기충족을 어떻게 피했나 -계약이 지목한 위험이다 — 라벨 42건을 보고 고치면 **그 42건에서만** 좋아지고 그 수치는 -다음 데이터에서 재현되지 않는다. 방어를 셋 뒀다. +계약이 지목한 위험이다. 라벨 42건을 보고 프리셋을 고치면 그 42건에서만 좋아지고, +그 수치는 다음 데이터에서 재현되지 않는다. 이 위험에 대한 방어를 셋 뒀다. ### 4.1 수정 내용을 라벨과 무관한 원칙으로 정했다 -**대상 선정은 라벨에서 왔지만 수정 내용은 라벨을 보지 않는다.** 이 구분이 방어의 핵심이다. +수정 대상 선정은 라벨에서 왔지만, 수정 내용은 라벨을 보지 않고 정했다. 이 구분이 +방어의 핵심이다. -대상은 `-219` §3.3 과 `-223` §4 가 **같은 목록**으로 남긴 것을 썼다 — 판정을 30회 돌려도 -100%로 붙는 오분류 7행, 프리셋으로 접으면 5종(`DRINK`·`WALK`·`WITH_FAMILY`·`VIEW_GOOD`·`TRENDY`). +대상은 `-219` §3.3 과 `-223` §4 가 같은 목록으로 남긴 것을 썼다. 판정을 30회 돌려도 +100%로 붙는 오분류 7행이고, 프리셋으로 묶으면 5종(`DRINK`·`WALK`·`WITH_FAMILY`·`VIEW_GOOD`·`TRENDY`)이다. -수정은 **프리셋 텍스트 안에서만** 진단했다. 원칙 하나다. +수정은 프리셋 텍스트 안에서만 진단했다. 원칙은 하나다. > `description`·`examples` 에 **다른 프리셋의 의미 영역에 속하는 어휘**가 섞여 있으면 -> 그것이 연상 경로다. 그 프리셋 고유의 축으로 바꾼다. +> 그것이 오분류를 만드는 연상 경로다. 그 어휘를 그 프리셋 고유의 축으로 바꾼다. -`DRINK` 의 `examples` 에 「안주가 좋아서」가 있다 — 안주는 음식이고 음식 평가는 `MEAL`· -`DESSERT` 의 축이다. `WALK` 에 「밥 먹고 근처 한 바퀴」가 있다 — 「밥 먹고」는 `MEAL` 이다. -**오답 본문이 무엇이었는지 몰라도 이 진단은 선다.** 정본과 각 수정의 근거는 -[`tools/preset_desc/variants.py`](../../tools/preset_desc/variants.py) 에 줄 단위 주석으로 있다. +예를 들어 `DRINK` 의 `examples` 에 「안주가 좋아서」가 있다. 안주는 음식이고, 음식 +평가는 `MEAL`·`DESSERT` 의 의미 영역이다. `WALK` 에는 「밥 먹고 근처 한 바퀴」가 있다. +「밥 먹고」는 `MEAL` 의 영역이다. **오분류된 본문이 무엇이었는지 몰라도 이 진단은 +성립한다.** 각 수정의 정본과 근거는 +[`tools/preset_desc/variants.py`](../../tools/preset_desc/variants.py) 에 줄 단위 +주석으로 있다. -시드 머리말이 `description` 을 *"정의가 아니라 의미 범위. 동의어·인접 개념 포함"* 이라고 -규정하므로 이것은 **넓히라고 쓰인 필드를 좁히는 작업**이다. 그래서 좁히는 대상을 갈랐다 — -동의어와 인접 개념 일반은 유지하고(`DRINK` 의 「한잔·반주」는 술의 다른 이름이다) -**다른 프리셋 소관**만 걷었다(「모임」→`GATHERING`, 「회식」→`WITH_COLLEAGUES`). +시드 파일 머리말은 `description` 을 *"정의가 아니라 의미 범위. 동의어·인접 개념 +포함"* 이라고 규정한다. 즉 이 수정은 넓히라고 만들어진 필드를 좁히는 작업이다. 그래서 +좁히는 대상을 갈랐다. 동의어와 인접 개념 일반은 유지했다(`DRINK` 의 「한잔·반주」는 +술의 다른 이름이므로 남긴다). 걷어낸 것은 다른 프리셋 소관의 어휘뿐이다(「모임」은 +`GATHERING` 소관, 「회식」은 `WITH_COLLEAGUES` 소관). -### 4.2 고친 5종 대 안 고친 22종 +### 4.2 수정한 5종 대 수정하지 않은 22종 -`tools/preset_desc/split_score.py`. **42건에 과적합했다면 고친 5종에서만 좋아지고 -22종은 제자리여야 한다.** +분리 집계는 `tools/preset_desc/split_score.py` 가 한다. 만약 라벨 42건에 과적합했다면 +수정한 5종에서만 좋아지고 22종은 변화가 없어야 한다. -| | 고친 5종 | | 안 고친 22종 | | | +| | 수정한 5종 | | 수정하지 않은 22종 | | | |---|---|---|---|---|---| | | 오분류 Δ | 정상 Δ | 오분류 Δ | 정상 Δ | 라벨없음 Δ | | `D` | **-2.20** (겹침) | -1.30 (겹침) | -1.30 (겹침, p=0.10) | **-2.50 (분리)** | +1.90 | | **`E`** | **-3.80** (겹침, p=0.0000) | **-0.40 (겹침, p=0.35)** | -0.30 (겹침, p=0.72) | **-0.40 (겹침, p=0.31)** | +2.40 | | `DE` | **-4.40 (분리)** | -2.30 (분리) | -0.30 (겹침, p=0.74) | -0.80 (겹침, p=0.03) | **+5.20 (분리)** | -**이 표가 세 조건을 가른다.** +이 표가 세 조건의 차이를 드러낸다. -`D` 는 안 고친 22종이 정상 판정 `2.50`행을 잃고 **범위가 분리**된다 — 고친 5종이 잃은 -`1.30`행보다 크다. **겨누지 않은 곳에서 더 크게 잃었다.** 총합만 보면 안 보인다 -(`D` 의 전체 오분류 감소 `-3.50` 은 `E` 의 `-4.10` 과 비슷해 보인다). +`D` 는 수정하지 않은 22종이 정상 판정 `2.50`행을 잃고 회차 범위가 분리된다. 수정한 +5종이 잃은 `1.30`행보다 크다. 수정 대상이 아닌 곳에서 더 크게 잃은 것이다. 총합만 +보면 이것이 보이지 않는다. `D` 의 전체 오분류 감소 `-3.50` 은 `E` 의 `-4.10` 과 +비슷해 보이기 때문이다. -`E` 는 **오분류 감소가 겨눈 5종에 몰리고**(`-3.80` 대 `-0.30`) **어느 무리에서도 정상 -판정을 유의하게 잃지 않는다**(`-0.40`·`-0.40`, 둘 다 `p>0.3`). 과적합이라면 5종에서만 -좋아지고 22종이 나빠져야 하는데, 22종은 **나빠지지 않았다.** +`E` 는 오분류 감소가 수정한 5종에 몰려 있고(`-3.80` 대 `-0.30`), 어느 무리에서도 +정상 판정을 유의하게 잃지 않는다(`-0.40`·`-0.40`, 둘 다 `p>0.3`). 과적합이라면 5종만 +좋아지고 22종이 나빠져야 하는데, 22종은 나빠지지 않았다. -`DE` 는 효과가 겨눈 5종에 집중되지만(오분류 `-4.40`, 범위 분리) 안 고친 22종의 -`unlabeled` 가 `0.40 → 5.60`행으로 뛰고 **범위가 분리된다**(`p=0.0000`). `DE` 가 부호를 -못 정하는 이유가 정확히 이 칸이다. `E` 는 같은 칸이 `+2.40` 이라 순이득을 뒤집지 못한다. +`DE` 는 오분류 감소가 수정한 5종에 집중되지만(`-4.40`, 범위 분리), 수정하지 않은 +22종의 라벨 없는 행이 `0.40 → 5.60`행으로 늘고 범위가 분리된다(`p=0.0000`). `DE` 의 +순이득 부호가 정해지지 않는 이유가 정확히 이 칸이다. `E` 는 같은 칸이 `+2.40` 이라 +순이득을 뒤집지 못한다. -**세 조건 다 `unlabeled` 증가가 안 고친 22종에서 나온다.** §2 의 슬롯 경쟁이 원인이고, -`description` 을 프리셋 단위로 고치는 한 이 상호작용은 계속 생긴다(§8.2 ②). +세 조건 모두에서 라벨 없는 행의 증가는 수정하지 않은 22종에서 나온다. 원인은 §2 의 +후보 슬롯 경쟁이고, `description` 을 프리셋 단위로 수정하는 한 이 상호작용은 계속 +생긴다(§8.2 의 후속 ② 참조). ### 4.3 홀드아웃 — 라벨 42건 **밖**의 본문 -`tools/preset_desc/probe.py`. 42건에 없는 본문 30건을 새로 써서 임베딩만으로 쟀다. -`-210` 의 `probe_gate.py` 와 같은 수법이다. +홀드아웃 검증은 `tools/preset_desc/probe.py` 가 한다. 라벨 42건에 없는 본문 30건을 +새로 작성해 임베딩 유사도만으로 쟀다. `-210` 의 `probe_gate.py` 와 같은 방법이다. -**두 묶음이 반대 방향을 예측한다.** 개정 원칙이 「다른 프리셋 소관 어휘를 걷는다」였으니 +본문을 두 묶음으로 나눴고, 개정 원칙(「다른 프리셋 소관 어휘를 걷는다」)이 두 묶음에 +반대 방향의 변화를 예측한다. ``` cross 걷어낸 어휘 영역에 있으나 그 키워드의 근거는 없는 본문 유사도가 내려가야 한다 -direct 그 키워드의 근거가 본문에 있는 본문 유지되어야 한다 +direct 그 키워드의 근거가 본문에 있는 본문 유사도가 유지되어야 한다 ``` -**한 방향만 재면 안 된다.** `cross` 만 내려가면 「좁혔다」가 아니라 「전부 낮췄다」일 수 -있고, 그것은 붙어야 할 것도 안 붙는다는 뜻이다. +한 방향만 재면 안 된다. `cross` 만 내려가는 것을 확인하면 「의미 범위를 좁혔다」와 +「전반적으로 유사도를 낮췄다」를 구분할 수 없다. 후자라면 붙어야 할 키워드도 안 붙게 +된다. | 조건 | `cross` 평균 Δ | `direct` 평균 Δ | 분리 폭 | |---|---|---|---| @@ -378,34 +422,37 @@ direct 그 키워드의 근거가 본문에 있는 본문 | `E` | -0.0161 | +0.0226 | 0.0387 | | `DE` | **-0.0162** | **+0.0313** | **0.0475** | -**`DE` 는 두 방향이 다 예측대로 갔다.** 분리 폭 `0.0475` 는 T68 이 실측한 임베딩 흔들림 -최댓값 `0.0044` 의 10배가 넘으므로 비결정성으로 설명되지 않는다. +`DE` 는 두 방향이 모두 예측대로 움직였다. 분리 폭 `0.0475` 는 T68 이 실측한 임베딩 +흔들림 최댓값 `0.0044` 의 10배가 넘으므로, 이 결과는 임베딩 비결정성으로 설명되지 +않는다. -`D` 단독의 `cross` `-0.0040` 은 **그 흔들림 안에 있다** — `description` 만으로는 임베딩 -축이 거의 안 움직인다는 뜻이고, `D` 의 후보 이동이 주로 판정 프롬프트(②) 쪽에서 왔음을 -시사한다. +`D` 단독의 `cross` 변화 `-0.0040` 은 그 흔들림 범위 안에 있다. `description` 수정만으로는 +임베딩 축이 거의 움직이지 않는다는 뜻이고, `D` 의 후보 이동이 주로 판정 프롬프트(②) +쪽에서 왔음을 시사한다. -**이 프로브의 한계.** 문장을 이 티켓의 작업자가 썼고 **그 작업자가 개정안을 설계한 -사람이기도 하다.** 개정 원칙이 겨눈 어휘를 알고 있으므로 `cross` 문장이 그쪽으로 기울었을 -수 있다. **42건과 독립이라는 것까지가 이 프로브가 보증하는 것이고, 편향으로부터 -독립이라는 뜻은 아니다.** 문장은 개정안을 확정한 **뒤에** 썼다 — 프로브를 먼저 쓰고 -거기 맞춰 개정하면 그것이 바로 자기충족이다. +이 프로브의 한계를 함께 적는다. 홀드아웃 문장을 이 티켓의 작업자가 썼고, 그 작업자가 +개정안을 설계한 사람이기도 하다. 개정 원칙이 걷어낸 어휘를 알고 있는 사람이 문장을 +썼으므로, `cross` 문장이 그 어휘 쪽으로 기울었을 수 있다. **이 프로브가 보증하는 것은 +라벨 42건과의 독립까지이고, 작성자 편향으로부터의 독립은 보증하지 않는다.** 문장은 +개정안을 확정한 뒤에 썼다. 프로브 문장을 먼저 쓰고 거기 맞춰 개정하면 그것이 바로 +자기충족이기 때문이다. ## 5. T69 — 다시 센 수치 -`tools/tau_grid/score.py` 의 격자 루프가 `rank <= k` 를 먼저 걸어서 **`rank > K` 로 -밀려난 행을 어느 칸에도 세지 않는다**(T69). τ 만 조건일 때는 rank 가 고정이라 무해했고, -**이 티켓이 임베딩을 바꾸자 같은 코드가 조용히 틀린 값을 냈다.** +`tools/tau_grid/score.py` 의 격자 루프는 `rank <= k` 조건을 먼저 적용하기 때문에, +`rank > K` 로 밀려난 행을 어느 칸에도 세지 않는다(T69). τ 만 바꾸는 측정에서는 rank 가 +고정이라 이 결함이 드러나지 않았다. 이 티켓이 임베딩을 바꾸자 같은 코드가 조용히 틀린 +값을 냈다. ``` tau-score-DE.json τ=0.30 행이 「unfit 2 제거 · fit 유실 0」이라고 낸다 실제 unfit 3 · fit 2 다. 교환비가 0.00 으로 보이지만 실제로는 0.67 ``` -`score.py` 는 **고치지 않았다.** 그 파일은 `-210` 의 수치를 재현하는 기준점이고 지금 -고치면 세 티켓의 표가 어느 판으로 계산된 것인지 갈린다. 대신 -[`tools/preset_desc/rank_loss.py`](../../tools/preset_desc/rank_loss.py) 로 따로 세어 -나란히 낸다. +`score.py` 는 고치지 않았다. 그 파일은 `-210` 의 수치를 재현하는 기준점이고, 지금 +고치면 세 티켓의 표가 어느 판으로 계산된 것인지 알 수 없게 된다. 대신 +[`tools/preset_desc/rank_loss.py`](../../tools/preset_desc/rank_loss.py) 로 밀려난 행을 +따로 세어 나란히 낸다. | 조건 | 빠진 행 | fit | unfit | unclear | `tau_cut` | `rank_push` | `both` | |---|---|---|---|---|---|---|---| @@ -414,32 +461,35 @@ tau-score-DE.json τ=0.30 행이 「unfit 2 제거 · fit 유실 0」이라고 | `E` | 2 | 0 | 2 | 0 | 1 | 0 | 1 | | `DE` | 5 | 2 | 3 | 0 | 2 | 1 | 2 | -`base` 가 `0` 인 것이 이 표를 믿을 근거다 — 라벨의 모집단이 현행 후보 집합과 정확히 -맞아 있다. +`base` 행이 `0` 인 것이 이 표를 신뢰할 근거다. 라벨의 모집단이 현행 후보 집합과 +정확히 일치한다는 뜻이다. -**사유를 가른 것이 T69 가 다음 사람에게 남긴 지시다.** 같은 칸에 합치면 「τ 를 올려서 -잃었다」와 「다른 프리셋이 밀어내서 잃었다」가 구분되지 않는다. +빠진 사유를 세 칸(`tau_cut`·`rank_push`·`both`)으로 가른 것은 T69 가 다음 사람에게 +남긴 지시를 따른 것이다. 같은 칸에 합치면 「τ 를 올려서 잃었다」와 「다른 프리셋이 +밀어내서 잃었다」가 구분되지 않는다. ``` -tau_cut τ 를 내리면 돌아온다 -rank_push 다른 프리셋이 자리를 가져갔다 — **τ 로는 못 돌린다** +tau_cut τ 를 내리면 그 행이 돌아온다 +rank_push 다른 프리셋이 후보 자리를 가져갔다. τ 를 조정해도 돌아오지 않는다 ``` -`DE` 가 잃은 5행 중 **3행(`rank_push` 1 + `both` 2)은 τ 로 되돌릴 수 없다.** §2 의 -「안 고친 22종이 빈자리를 가져간다」가 여기서도 같은 얼굴로 나온다. +`DE` 가 잃은 5행 중 3행(`rank_push` 1 + `both` 2)은 τ 로 되돌릴 수 없다. §2 에서 +관측한 「수정하지 않은 22종이 빈자리를 가져간다」는 현상이 여기서도 같은 원인으로 +나타난다. -**이 값들은 §3 의 판정 수치에 영향을 주지 않는다.** §3 은 `score_ab.py` 가 회차 파일의 +이 값들은 §3 의 판정 수치에 영향을 주지 않는다. §3 은 `score_ab.py` 가 회차 파일의 실제 선택을 세는 것이고 τ 격자를 거치지 않는다. T69 가 오염시킨 것은 `tau-score-*.json` 뿐이고, 이 리포트는 그 파일의 τ=0.30 행을 §6 외에는 인용하지 않는다. ## 6. τ 재검증 -`description` 이 바뀌면 프리셋 임베딩이 재생성되므로 `-210` 이 잰 τ=0.30 이 그대로인지 -다시 봐야 한다. +`description` 이 바뀌면 프리셋 임베딩이 재생성된다. 따라서 `-210` 이 정한 τ=0.30 이 +개정 후에도 유효한지 다시 확인해야 한다. ### 6.1 `base` 행렬이 `-210` 을 그대로 재현한다 -먼저 이것이 서야 조건 비교가 성립한다. 프리셋 임베딩을 **새로 떠서** 만든 `base` 행렬이다. +조건 비교가 성립하려면 이것부터 확인되어야 한다. 프리셋 임베딩을 새로 생성해 만든 +`base` 행렬을 `-210` 원본과 대조했다. | | `-210` 원본 | 이 티켓의 `base` | |---|---|---| @@ -449,13 +499,13 @@ rank_push 다른 프리셋이 자리를 가져갔다 — **τ 로는 못 돌 | τ=0.34 교환비 | 1.22 | **1.22** | | τ=0.34 정당하게 빔 / 부당하게 빔 | 5 / 5 | **5 / 5** | -**표가 한 칸도 안 어긋난다.** T68 의 임베딩 흔들림(최대 `0.0044`)이 `-210` 의 결론을 +표가 한 칸도 어긋나지 않는다. T68 의 임베딩 흔들림(최대 `0.0044`)이 `-210` 의 결론을 건드리지 않는다는 뜻이기도 하다. ### 6.2 개정 뒤 -`removed` 는 현행 판정 83행 중 그 τ 에서 후보를 통과하지 못하는 행이다(T69 때문에 -`rank>K` 로 밀린 것은 §5 에서 따로 셌다). +아래 표의 `removed` 는 현행 판정 83행 중 해당 τ 에서 후보를 통과하지 못하는 행이다 +(T69 때문에 `rank>K` 로 밀린 행은 §5 에서 따로 셌다). | τ | `base` fit / unfit | **`E`** fit / unfit | `DE` fit / unfit | 후보0 Context (base·E·DE) | |---|---|---|---|---| @@ -466,15 +516,16 @@ rank_push 다른 프리셋이 자리를 가져갔다 — **τ 로는 못 돌 | 0.34 | 11 / 9 | 12 / 7 | 11 / 8 | 3 · 3 · 3 | | 0.40 | 30 / 9 | 32 / 8 | 30 / 9 | 15 · 15 · 15 | -**τ=0.30 은 개정 뒤에도 유효하다.** 0.31 로 올리는 순간 세 조건 모두 정상 판정 6건을 -잃는 모양이 같고, **개정이 최적 τ 를 옮기지 않았다.** τ 를 올려 얻는 것도 그대로 없다 — -어느 조건에서도 `fit` 유실이 `unfit` 제거보다 빠르게 는다. +τ=0.30 은 개정 뒤에도 유효하다. 0.31 로 올리는 순간 세 조건 모두 정상 판정 6건을 잃는 +모양이 같다. 즉 개정이 최적 τ 를 옮기지 않았다. τ 를 올려서 얻는 것도 여전히 없다. +어느 조건에서도 `fit` 유실이 `unfit` 제거보다 빠르게 늘어난다. -그리고 `E` 는 **τ 를 움직이지 않은 채로** `τ=0.30` 그 자리에서 오분류 1건을 정상 손실 -0 으로 지운다. `-210` 이 *"τ 를 어디에 두든 정상 판정이 함께 잘린다"* 로 끝난 곳에서, -**τ 가 아니라 프리셋 텍스트를 고치면 같은 τ 로 그 일이 된다.** +한편 `E` 는 τ 를 움직이지 않은 채로, `τ=0.30` 그 자리에서 오분류 1건을 정상 손실 0 +으로 지운다. `-210` 은 *"τ 를 어디에 두든 정상 판정이 함께 잘린다"* 는 결론으로 +끝났다. 같은 τ 에서 프리셋 텍스트를 고치면 τ 조정으로 할 수 없던 일이 된다는 것을 이 +결과가 보여준다. -분포 자체도 조금 갈렸다. +유사도 분포 자체도 조건에 따라 조금 갈렸다. | 조건 | fit 최솟값 | unfit 최댓값 | 겹침 구간 안의 fit | |---|---|---|---| @@ -483,14 +534,15 @@ rank_push 다른 프리셋이 자리를 가져갔다 — **τ 로는 못 돌 | **`E`** | 0.3001 | **0.4077** | 35/63 | | `DE` | 0.2554 | **0.3749** | **22/63** | -`DE` 가 겹침을 34 → 22 로 줄인다 — `-210` 이 「가를 선이 없다」고 한 그 겹침이 실제로 -줄었다. **다만 여전히 겹치므로 τ 하나로 가를 수 없다는 결론은 그대로다.** `E` 는 이 -축에서는 거의 안 움직인다(35/63) — `E` 의 이득은 분포를 벌린 데서 오는 것이 아니라 -**개별 오분류를 후보에서 떨어뜨린 데서** 온다(§5). +`DE` 는 적합·부적합 분포의 겹침을 34 → 22 로 줄인다. `-210` 이 「가를 선이 없다」고 +표현한 그 분포 겹침이 실제로 줄어든 것이다. 다만 여전히 겹치므로, τ 하나로 적합과 +부적합을 가를 수 없다는 `-210` 의 결론은 그대로 유효하다. `E` 는 이 축에서는 거의 +움직이지 않는다(35/63). 즉 `E` 의 이득은 분포를 벌린 데서 오는 것이 아니라 개별 +오분류를 후보에서 떨어뜨린 데서 온다(§5). -T68 이 남긴 경고를 여기 함께 적는다 — **`-210` 의 τ 격자가 `0.01` 간격인데 임베딩 -흔들림의 실측 최댓값이 `0.0044` 다.** 인접한 두 τ 값의 구분은 신뢰할 수 없고, 격자를 -`0.01` 보다 촘촘하게 두는 것은 의미가 없다. +T68 이 남긴 경고를 여기 함께 적는다. `-210` 의 τ 격자는 `0.01` 간격인데 임베딩 +흔들림의 실측 최댓값이 `0.0044` 다. 따라서 인접한 두 τ 값 사이의 차이는 신뢰할 수 +없고, 격자를 `0.01` 보다 촘촘하게 두는 것은 의미가 없다. ## 7. 판정 @@ -504,164 +556,178 @@ T68 이 남긴 경고를 여기 함께 적는다 — **`-210` 의 τ 격자가 ` | `fit 0건 Context` 가 안 나빠졌는가 | **아니다 (+1.80, 분리)** | **그렇다 (-0.20, 겹침)** | 그렇다 (-0.20, 겹침) | | 정상 판정을 유의하게 잃는가 | **잃는다 (-3.80, 분리)** | **아니다 (-0.80, p=0.16)** | **잃는다 (-3.10, 분리)** | | 교환비가 `-210` 의 `1.22` 보다 나은가 | 간신히 (1.09) | **그렇다 (0.20)** | 그렇다 (0.66) | -| 안 고친 22종이 대가를 치르는가 | **그렇다 (정상 -2.50, 분리)** | **아니다 (-0.40, p=0.31)** | 아니다 | +| 수정하지 않은 22종이 대가를 치르는가 | **그렇다 (정상 -2.50, 분리)** | **아니다 (-0.40, p=0.31)** | 아니다 | | **순이득의 부호가 안정적인가** | **아니다** | **그렇다 (+1.30 / +5.30)** | **아니다** | -`D` 는 사용자가 보는 손실이 악화하고 겨누지 않은 22종이 대가를 치른다 — **분명한 기각**이다. +`D` 는 사용자가 보는 손실(`fit 0건 Context`)이 악화하고, 수정 대상이 아닌 22종이 +정상 판정을 잃는다. 분명한 기각이다. -`DE` 는 다르다. 사전 기준까지 넘었고 홀드아웃도 예측대로 갔다. 마지막 한 줄에서 멈춘다. -**`DE` 가 부호를 정할 방법은 하나뿐이고 그것은 이 티켓이 해서는 안 되는 일이다** — -`DE` 가 새로 고른 `5.60`행에 라벨을 붙이면 정해지는데, 그 라벨을 **개정안을 설계한 -사람이** 붙이면 `-219` 가 한계로 남긴 편향이 누적되고 그렇게 얻은 「채택」은 측정이 -아니라 자기 확인이다. **모른다고 두고 넘긴다**(§8.2 ①). +`DE` 는 다르다. 사전 기준(범위 분리)까지 넘었고 홀드아웃도 예측대로 갔다. 기각은 +마지막 한 줄(순이득 부호) 때문이다. `DE` 의 부호를 정하는 방법은 하나뿐인데, 그것이 이 +티켓이 해서는 안 되는 일이다. `DE` 가 새로 고른 `5.60`행에 라벨을 붙이면 부호가 +정해진다. 그러나 그 라벨을 개정안을 설계한 사람이 붙이면 `-219` 가 한계로 남긴 판정자 +편향이 누적되고, 그렇게 얻은 「채택」은 측정이 아니라 자기 확인이다. 부호를 모르는 +채로 두고 후속 작업으로 넘긴다(§8.3 의 ①). -`E` 를 고를 수 있는 것은 **결론이 그 라벨에 걸려 있지 않기 때문**이다. 라벨 밖이 -`3.30`행뿐이고, 그것을 전부 오분류로 쳐도 순이득이 `+1.30` 이다. `DE` 와의 차이가 여기다. +`E` 를 채택할 수 있는 것은 결론이 그 라벨에 걸려 있지 않기 때문이다. `E` 의 라벨 밖 +행은 `3.30`행뿐이고, 그것을 전부 오분류로 계산해도 순이득이 `+1.30` 이다. `DE` 와의 +차이가 여기에 있다. ### 사전 기준 미달을 어떻게 다루는가 -`E` 는 범위 비중첩을 못 넘었다(`base` 최솟값 9 = `E` 최댓값 9). **그 자를 무르지 않는다.** -`-219` 가 못박은 대로 *"나중에 더한 자가 통과했다고 앞의 자를 무르면 기준을 결과에 맞춘 -것"* 이다. 넘지 못했다고 적고, 그 위에서 판단한다. +`E` 는 범위 비중첩 기준을 넘지 못했다(`base` 최솟값 9 = `E` 최댓값 9). 이 기준을 +사후에 완화하지 않는다. `-219` 가 *"나중에 더한 자가 통과했다고 앞의 자를 무르면 +기준을 결과에 맞춘 것"* 이라고 못박은 대로다. 기준을 넘지 못했다고 기록하고, 그 +위에서 판단한다. -판단의 근거는 **이 티켓이 `-219`·`-223` 에 없던 독립 증거를 갖는다**는 것이다. 사전 -기준이 겨눈 위험은 「42건 회차 운을 효과로 읽는 것」이었고, 아래 넷은 그 위험과 다른 -경로로 같은 방향을 가리킨다. +판단의 근거는 이 티켓이 `-219`·`-223` 에 없던 독립 증거를 갖는다는 것이다. 사전 +기준이 막으려던 위험은 「42건의 회차 운을 효과로 읽는 것」이었다. 아래 네 증거는 그 +위험과 다른 경로로 같은 방향을 가리킨다. -``` -후보 선정 층 판정 이전에, 정상 판정 손실 0 으로 오분류 2건이 결정적으로 빠진다(§5) -라벨 42건 밖 홀드아웃 30건에서 근거 없는 쪽만 내려간다(§4.3). 회차 운이 아니다 -순이득 부호 라벨 처리의 양극단 모두 양수. 라벨 커버리지에 안 걸린다 -회차 확장 5회 → 10회에서 효과가 커졌다. -219 의 축소 함정이 재현되지 않았다 -``` +- **후보 선정 층**: LLM 판정 이전 단계에서, 정상 판정 손실 0 으로 오분류 2건이 + 후보에서 확정적으로 빠진다(§5). 판정 회차 운과 무관한 결과다. +- **라벨 42건 밖**: 홀드아웃 30건에서 근거 없는 방향의 유사도만 내려간다(§4.3). 회차 + 운이 아니다. +- **순이득 부호**: 라벨 없는 행 처리의 양극단에서 모두 양수다. 라벨 커버리지에 결론이 + 걸려 있지 않다. +- **회차 확장**: 5회 → 10회에서 효과가 커졌다. `-219` 에서 회차를 늘리자 효과가 줄던 + 문제가 재현되지 않았다. -`-219` 의 `C` 는 같은 자리에서 `p=0.081` 이었고 위 넷 중 어느 것도 없었다. **같은 기준을 -같은 방식으로 적용해 다른 결론이 나온다.** +`-219` 의 조건 C 는 같은 자리에서 `p=0.081` 이었고 위 네 증거 중 어느 것도 없었다. +같은 기준을 같은 방식으로 적용했는데 증거가 달라서 다른 결론이 나온 것이다. ### 이 채택이 사는 것과 사지 못하는 것 -``` -산다 붙어 있는 카드가 조금 정확해진다 — 오분류 11.00 → 6.90행 -못 산다 빈 카드는 그대로다 — fit 0건 Context 8.20 → 8.00 (범위 겹침) -``` +- **개선되는 것**: 키워드가 붙어 있는 카드가 조금 더 정확해진다. 오분류가 회차 평균 + 11.00행에서 6.90행으로 준다. +- **개선되지 않는 것**: 빈 카드는 그대로다. `fit 0건 Context` 는 8.20 → 8.00 으로 회차 + 범위가 겹친다. -계약이 *"사용자가 보는 손실이 움직이는가가 진짜 지표"* 라고 지목한 값이 거의 안 움직인다. -**네 티켓이 판정 경로의 네 층을 전부 재고도 이 8건은 그대로다.** 그 8건은 판정이 아니라 -**입력에 붙을 것이 없는 Context** 이고, 남은 방향은 임베딩 품질(`-199`)이거나 프리셋 -목록 자체(정책 소관)다. +계약이 *"사용자가 보는 손실이 움직이는가가 진짜 지표"* 라고 지목한 값이 거의 움직이지 +않는다. 네 티켓이 판정 경로의 네 층(τ·프롬프트·다수결·프리셋 텍스트)을 전부 +측정하고도 이 8건은 그대로다. 이 8건은 판정의 문제가 아니라 입력에 붙일 키워드가 없는 +Context 다. 남은 개선 방향은 임베딩 품질(`-199`)이거나 프리셋 목록 자체의 개편(정책 +소관)이다. ## 8. 한계와 후속 ### 8.1 이 측정이 보증하지 않는 것 -``` -라벨 밖 3.30행의 정오 E 의 순이득은 양극단 모두 양수라 결론이 여기 안 걸린다. - 그래도 그 행들이 무엇인지 모르는 것은 그대로다 -DE 의 부호 라벨 밖 5.60행에 걸려 있고 이 티켓은 정하지 않는다 -프로브 문장의 편향 42건과 독립이지만 설계자의 어휘 감각과는 독립이 아니다(§4.3) -표시명(display_name) 개정 대상이 아니었다. VIEW_GOOD 의 표시명에 「맛집」이 들어 - 있어 음식 어휘가 겹으로 실리는 것을 확인했으나 화면에 나가는 - 필드라 이 티켓에서 못 고친다 -274·289 DRINK 네 티켓째 못 지운다. E 에서 유사도가 오히려 올랐다(§2.1) -사용자가 보는 손실 fit 0건 Context 8.20 → 8.00. 채택해도 거의 안 움직인다 -``` +- **라벨 밖 3.30행의 정오**: `E` 의 순이득은 양극단 모두 양수라 결론이 여기 걸려 있지 + 않다. 그래도 그 행들이 실제로 적합인지 부적합인지 모른다는 사실은 그대로 남는다. +- **`DE` 의 순이득 부호**: 라벨 밖 5.60행에 걸려 있고, 이 티켓은 정하지 않는다. +- **프로브 문장의 편향**: 홀드아웃 문장은 라벨 42건과 독립이지만, 개정안 설계자의 어휘 + 감각과는 독립이 아니다(§4.3). +- **표시명(display_name)**: 개정 대상이 아니었다. `VIEW_GOOD` 의 표시명에 「맛집」이 + 들어 있어 음식 어휘가 임베딩 입력에 겹으로 실리는 것을 확인했으나, 화면에 노출되는 + 필드라 이 티켓에서 고칠 수 없다. +- **`274`·`289 DRINK`**: 네 티켓째 지우지 못했다. `E` 에서 유사도가 오히려 올랐다(§2). +- **사용자가 보는 손실**: `fit 0건 Context` 8.20 → 8.00. 채택해도 거의 움직이지 않는다. ### 8.2 채택 반영에 필요한 것 — 이 PR 이 하지 않은 것 -`data/keyword_preset.yaml` 의 `examples` 를 고치는 것까지가 이 PR 이다. **배포까지는 -둘이 더 필요하고 둘 다 이 티켓 범위 밖이다.** +이 PR 의 범위는 `data/keyword_preset.yaml` 의 `examples` 수정까지다. 실제 배포까지는 +두 가지가 더 필요하고, 둘 다 이 티켓 범위 밖이다. **① 임베딩 재적재** -`BD-18`(back)이 적어 둔 것이다 — *"프리셋 확장은 임베딩 재생성을 동반하므로 가벼운 -작업이 아니다"*. `preset_embed_text` 가 `examples` 를 포함하므로 `load_presets.py` 를 -다시 돌려야 하고, 그것은 **27개 전부를 UPSERT** 한다(고친 5종만이 아니다). +`BD-18`(back)이 *"프리셋 확장은 임베딩 재생성을 동반하므로 가벼운 작업이 아니다"* 라고 +적어 둔 그 작업이다. `preset_embed_text` 가 `examples` 를 포함하므로 `load_presets.py` +를 다시 돌려야 하고, 그 스크립트는 수정한 5종만이 아니라 27개 프리셋 전부를 UPSERT +한다. ``` 비용 bootstrap 1회 · 임베딩 API 호출 1회(배치 27건). Context 임베딩은 그대로다 -파급 이미 저장된 판정 83행은 **옛 정의로 남는다** — 다시 돌리지 않는 한 +파급 이미 저장된 판정 83행은 다시 돌리지 않는 한 옛 정의 기준으로 남는다 ``` -기존 판정의 재처리 여부는 `keyword-preset.md` §5.2 가 *"Preset 보정 자체는 3~4주차 1회 -분석 후 배포 작업으로 처리하며 자동 반영 경로를 두지 않는다"* 로 규정한 그 「배포 작업」이다. +기존 판정의 재처리 여부는 `keyword-preset.md` §5.2 가 *"Preset 보정 자체는 3~4주차 +1회 분석 후 배포 작업으로 처리하며 자동 반영 경로를 두지 않는다"* 로 규정한 그 「배포 +작업」에 해당한다. **② `version` 을 올릴 방법이 지금 없다** -`ai.keyword_preset.version` 은 **프리셋 목록의 개정 번호**이고 `context_keyword. -preset_version` 이 그것을 기록한다(`keyword-preset.md` §2). 개정 전후 판정을 사후에 -가르려면 이 값이 달라야 한다. **지금은 전부 `1` 이고 올릴 경로가 없다.** +`ai.keyword_preset.version` 은 프리셋 목록의 개정 번호이고, +`context_keyword.preset_version` 이 판정 시점의 그 값을 기록한다(`keyword-preset.md` +§2). 개정 전후의 판정을 사후에 구분하려면 이 값이 달라져야 한다. 그런데 지금은 전부 +`1` 이고 올릴 경로가 없다. ``` load_presets.py:80 int(preset.get("version", 1)) ← yaml 에서 읽는다 keyword_preset.yaml 머리말: "embedding·embedding_profile·version은 여기서 다루지 않습니다" ``` -**시드 파일이 스스로 version 을 자기 소관이 아니라고 규정하는데 코드는 거기서 읽는다.** -그래서 이 PR 은 version 을 건드리지 않았다 — yaml 에 넣으면 머리말과 어긋나고, 코드를 -고치면 `keyword-preset.md` 계약을 건드리는 일이라 별도 판단이 필요하다. +시드 파일이 스스로 version 을 자기 소관이 아니라고 규정하는데, 코드는 그 파일에서 +version 을 읽는다. 서로 모순이다. 그래서 이 PR 은 version 을 건드리지 않았다. yaml 에 +값을 넣으면 머리말 규정과 어긋나고, 코드를 고치면 `keyword-preset.md` 계약을 변경하는 +일이라 별도 판단이 필요하다. -**version 없이 반영하면 개정 전 83행과 개정 후 판정이 같은 `1` 로 남아 구분되지 않는다.** -반영 PR 이 이것부터 정해야 한다. +version 없이 반영하면 개정 전 83행과 개정 후 판정이 같은 `1` 로 남아 구분되지 않는다. +반영 PR 이 이 문제부터 정해야 한다. ### 8.3 측정 후속 **① `DE` 의 라벨 확장** -`DE` 가 새로 고른 라벨 밖 행(회차당 `5.60`, 조합으로는 25종)에 라벨을 붙이면 `DE` 의 -부호가 정해진다. `score_ab.py --dump-unlabeled` 가 그 목록을 본문·프리셋 정의와 함께 -내고 **어느 조건이 골랐는지는 찍지 않는다.** +`DE` 가 새로 고른 라벨 밖 행(회차당 `5.60`행, 조합으로는 25종)에 라벨을 붙이면 `DE` +의 순이득 부호가 정해진다. `score_ab.py --dump-unlabeled` 가 그 목록을 본문·프리셋 +정의와 함께 출력하며, 어느 조건이 그 행을 골랐는지는 출력에 넣지 않는다(라벨 작업자가 +조건을 알면 편향이 생기기 때문이다). -**반드시 개정안을 모르는 사람이 붙여야 한다.** 붙인 뒤 이 하네스를 그대로 다시 돌리면 -(`.preset_desc/` 의 행렬·회차가 남아 있으면 **판정 호출 없이** 재집계된다) 부호가 나온다. +라벨은 반드시 개정안을 모르는 사람이 붙여야 한다. 라벨을 붙인 뒤 이 하네스를 그대로 +다시 돌리면 부호가 나온다. `.preset_desc/` 의 행렬·회차 파일이 남아 있으면 판정 호출 +없이 재집계만으로 된다. **② `k=10` 슬롯 경쟁** -이 티켓이 드러낸 구조다 — 한 프리셋을 좁히면 **다른 프리셋이 후보로 올라온다**(231→241). -프리셋 단위로 고치는 한 이 상호작용은 계속 생기고, 27종을 한 번에 보지 않으면 국소 -개선이 전역에서 상쇄될 수 있다. `E` 에서도 안 고친 3종의 오분류가 늘었다(§3.3). +이 티켓이 드러낸 구조적 문제다. 한 프리셋의 의미 범위를 좁히면 다른 프리셋이 후보로 +올라온다(후보 등장 231→241). 프리셋 단위로 수정하는 한 이 상호작용은 계속 생기고, +27종을 한 번에 보지 않으면 국소 개선이 전역에서 상쇄될 수 있다. 실제로 `E` 에서도 +수정하지 않은 3종의 오분류가 늘었다(§3.3). **③ 다수결 재측정** -`-223` 이 조건으로 남긴 것이다 — *"`description` 을 고쳐 7종이 사라지면 남는 오분류가 -전부 흔들림이 되어 다수결의 몫이 커진다. **이 순서가 중요하다**"*. `E` 가 7행 중 3행을 -지웠으므로 그 조건이 부분적으로 성립했다. 하네스(`tools/judge_vote/`)는 그대로 있다. +`-223` 이 조건부로 남긴 후속이다. `-223` 은 *"`description` 을 고쳐 7종이 사라지면 +남는 오분류가 전부 흔들림이 되어 다수결의 몫이 커진다. 이 순서가 중요하다"* 라고 +적었다. `E` 가 7행 중 3행을 지웠으므로 그 조건이 부분적으로 성립했다. 하네스 +(`tools/judge_vote/`)는 그대로 있다. **④ `tau_grid/score.py` 의 `in_k` 필터** -T69. 고칠 거라면 `in_k` 필터를 없애고 「rank 로 빠짐」을 τ 유실과 **다른 칸**으로 센다. -지금 고치지 않은 이유는 §5 에 있다. **임베딩을 바꾸지 않는 측정에서는 발현하지 않으므로** -(rank 가 고정이다) 급하지 않다. 대신 그런 측정을 할 사람이 이 결함을 모르고 지나가지 -않도록 T69 와 `rank_loss.py` 를 남긴다. +T69 에서 확인한 결함이다. 고친다면 `in_k` 필터를 없애고 「rank 로 빠짐」을 τ 유실과 +다른 칸으로 세는 방식이어야 한다. 지금 고치지 않은 이유는 §5 에 있다. 임베딩을 바꾸지 +않는 측정에서는 rank 가 고정이라 이 결함이 발현하지 않으므로 급하지 않다. 대신 그런 +측정을 할 사람이 이 결함을 모르고 지나가지 않도록 T69 기록과 `rank_loss.py` 를 남긴다. ## 9. 파일 -| | | +| 파일 | 역할 | |---|---| -| `tools/preset_desc/variants.py` | 조건 정본. 개정 내용과 **무엇을 왜 걷었는가** | +| `tools/preset_desc/variants.py` | 조건 정본. 개정 내용과 무엇을 왜 걷어냈는지의 근거 | | `tools/preset_desc/matrix.py` | 조건별 프리셋 27건 재임베딩 + 유사도 행렬 | | `tools/preset_desc/cands.py` | 조건별 후보 집합 이동 (§2) | | `tools/preset_desc/run.py` | 조건 하나를 N회 판정 | -| `tools/preset_desc/split_score.py` | 고친 5종 / 안 고친 22종 분리 집계 (§4.2) | +| `tools/preset_desc/split_score.py` | 수정한 5종 / 수정하지 않은 22종 분리 집계 (§4.2) | | `tools/preset_desc/probe.py` | 라벨 밖 홀드아웃 (§4.3) | | `tools/preset_desc/rank_loss.py` | T69 가 세지 않는 행 (§5) | | `tools/preset_desc/README.md` | 다시 돌리는 방법 | -집계는 **새로 짜지 않았다** — `tools/prompt_ab/score_ab.py` 를 그대로 부른다. 회차 파일 -스키마를 `prompt_ab` 와 같게 둔 이유가 이것이고, 그래야 `-219`·`-223` 과 같은 자를 대고 -있다는 보장이 선다. +집계 코드는 새로 작성하지 않았다. `tools/prompt_ab/score_ab.py` 를 그대로 호출한다. +회차 파일 스키마를 `prompt_ab` 와 같게 둔 이유가 이것이다. 그래야 `-219`·`-223` 과 +같은 기준으로 재고 있다는 보장이 성립한다. ## 10. 검증 -| | | +| 항목 | 내용 | |---|---| | 데이터 | `pinlog-demo`(:15432) · Context 42건(고유 본문 37 + 중복 5) · 프리셋 27 · 현행 판정 83행 | | profile | `openai-text-embedding-3-small-1536-cosine-v1` | -| 판정 모델 | **`gpt-4o-mini`** — 전 회차 실측(`JudgeResult.model`). 설정이 아니라 답한 값이다(T43) | +| 판정 모델 | **`gpt-4o-mini`** — 전 회차 실측(`JudgeResult.model`). 설정값이 아니라 API 가 실제로 답한 값이다(T43) | | GMS 판정 | **1,680회** (4조건 × 10회 × 42). 실패 **0** | | GMS 임베딩 | 배치 **14회** (`matrix` 5 · `probe` 5 · T68 진단 4). 텍스트 329건 | | 테스트 | `430 passed` · line **99.83%**(1163/1165) · branch **98.99%**(196/198) · 게이트 80% 통과 · `ruff check .` 통과 | -| DB 변경 | **없음.** 측정 전 구간에서 `ai.keyword_preset` 을 손대지 않았다 | -| `app/` 변경 | **없음.** 코드는 건드리지 않았다 | +| DB 변경 | **없음.** 측정 전 구간에서 `ai.keyword_preset` 을 변경하지 않았다 | +| `app/` 변경 | **없음.** 서비스 코드는 건드리지 않았다 | ```bash cd ai @@ -682,21 +748,25 @@ $PY tools/tau_grid/score.py --matrix .preset_desc/matrix-E.json ### 검증하지 않은 것 -- **실제 서버 E2E** — `examples` 를 시드에 반영했으나 **부트스트랩 재적재를 돌리지 - 않았다.** DB 의 프리셋은 여전히 개정 전이다. 재적재와 그것을 탄 판정은 §8.2 의 - 반영 작업이다 -- **`version` 처리** — §8.2 ②. 올릴 경로가 지금 없고 이 티켓이 단독으로 정할 것이 아니다 -- **기존 판정 83행의 재처리** — 반영해도 옛 정의로 남는다 -- **`DE` 의 라벨 확장** — 새로 나타난 25종에 라벨을 붙이지 않았다(§1.4). `DE` 의 부호가 - 그래서 미정이다. **`E` 의 결론은 그 라벨에 걸려 있지 않다**(순이득 양극단 모두 양수) -- **라벨 교차 검증** — `-210`·`-219`·`-223` 과 같은 한계다. 판정자가 한 명이고, 이 - 티켓에서는 그 한 명이 개정안을 설계한 사람이기도 하다 -- **다른 벤더** — `gpt-4o-mini` 하나다. `-219`·`-223` 과 같은 한계 -- **고치지 않은 22종의 텍스트** — 같은 원칙을 대면 걷을 것이 있을 수 있다. 재지 않았다 -- **다수결과의 상호작용** — `n=1` 로만 쟀다(§8.3 ③) -- **중복 5쌍의 영향** — 42건 기준으로 냈고 37건 기준으로 다시 내지 않았다 - -산출물은 `.preset_desc/`(58파일, `.gitignore` 대상)에 있다 — 행렬 5개 · 회차 40개 · +- **실제 서버 E2E**: `examples` 를 시드에 반영했으나 부트스트랩 재적재를 돌리지 + 않았다. DB 의 프리셋은 여전히 개정 전이다. 재적재와 그것을 거친 판정은 §8.2 의 반영 + 작업이다. +- **`version` 처리**: §8.2 의 ②. 올릴 경로가 지금 없고, 이 티켓이 단독으로 정할 일이 + 아니다. +- **기존 판정 83행의 재처리**: 개정을 반영해도 기존 판정은 옛 정의 기준으로 남는다. +- **`DE` 의 라벨 확장**: 새로 나타난 25종에 라벨을 붙이지 않았다(§1.4). 그래서 `DE` 의 + 순이득 부호가 미정이다. `E` 의 결론은 그 라벨에 걸려 있지 않다(순이득 양극단 모두 + 양수). +- **라벨 교차 검증**: `-210`·`-219`·`-223` 과 같은 한계다. 판정자가 한 명이고, 이 + 티켓에서는 그 한 명이 개정안을 설계한 사람이기도 하다. +- **다른 벤더**: 판정 모델이 `gpt-4o-mini` 하나다. `-219`·`-223` 과 같은 한계다. +- **수정하지 않은 22종의 텍스트**: 같은 원칙을 적용하면 걷어낼 어휘가 있을 수 있다. + 재지 않았다. +- **다수결과의 상호작용**: `n=1` 로만 쟀다(§8.3 의 ③). +- **중복 5쌍의 영향**: 42건 기준으로 냈고 37건 기준으로 다시 내지 않았다. + +측정 산출물은 `.preset_desc/`(58파일, `.gitignore` 대상)에 있다. 행렬 5개 · 회차 40개 · `cands.json` · `probe.json` · `score-*.json` · `split-*.json` · `rank-loss.json` · -`tau-score-*.json`. **임베딩은 다시 뜨면 다른 값이 나오므로**(T68) 이 리포트의 수치를 -재현하려면 그때의 행렬이 있어야 한다. 후속 ①이 이 디렉터리를 그대로 쓴다. +`tau-score-*.json` 이다. 임베딩은 다시 생성하면 다른 값이 나오므로(T68) 이 리포트의 +수치를 재현하려면 그때의 행렬 파일이 있어야 한다. 후속 ①(`DE` 라벨 확장)이 이 +디렉터리를 그대로 사용한다. diff --git a/docs/implements/2026-08-03-preset-display-name-deploy-chain.md b/docs/implements/2026-08-03-preset-display-name-deploy-chain.md index 1036f92..c89e2a8 100644 --- a/docs/implements/2026-08-03-preset-display-name-deploy-chain.md +++ b/docs/implements/2026-08-03-preset-display-name-deploy-chain.md @@ -1,16 +1,17 @@ -# 표시명 개정의 배포 체인 — 낡은 봉인 값 위에서 운영에 닿은 새 표시명 +# 표시명 개정의 배포 체인 — 봉인 값이 낡은 채로도 새 표시명이 운영 화면까지 배포됐다 - **상태**: 완료 - **날짜**: 2026-08-03 -- **유형**: 검증 — 만든 것이 아니라 **체인이 어떻게 이어지는가**와 미결 판정 하나의 답이다 +- **유형**: 검증 — 만든 것이 아니라 배포 체인이 어떻게 이어지는가와 미결 판정 하나의 답이다 - **대상**: `S15P11A705-292`(프리셋 표시명 명사형 통일). 이 문서는 `S15P11A705-293` 에서 쓴다 - **좌표**: `ai#102`(dev) · `ai#103`(닫힘) · `ai#104`(릴리스) · `infra#185`(이미지 갱신) - **읽는 순서**: §2 가 이 문서의 핵심이다. §1 은 그 판정이 나온 경로, §3 은 다음 릴리스에서 그대로 다시 만나는 것이다. -> **값을 적지 않는다.** 이미지 태그·봉인 값 문자열·측정 수치는 옮기지 않고 **어느 PR·어느 -> 파일을 보라**까지만 남긴다. 근거는 [T70](../troubleshooting/2026-08-03-repeat-incidents.md). -> 커밋 SHA 와 PR 번호는 안 바뀌는 좌표라 그대로 쓴다. +> 값을 적지 않는다. 이미지 태그·봉인 값 문자열·측정 수치는 옮기지 않고 어느 PR·어느 +> 파일을 보라까지만 남긴다. 근거는 +> [T70](../troubleshooting/2026-08-03-repeat-incidents.md). 커밋 SHA 와 PR 번호는 안 +> 바뀌는 좌표라 그대로 쓴다. > `infra` 는 읽기만 했다. 그쪽 변경은 인프라 소관이다. @@ -18,86 +19,82 @@ ## 1. 체인의 단계와 소관 -표시명 개정이 코드에서 운영 화면까지 가는 데 여섯 단계를 지난다. **각 단계의 주체가 -다르고, 우리가 할 수 있는 것은 앞의 둘과 마지막 하나뿐이다.** +표시명 개정이 코드에서 운영 화면까지 가는 데 여섯 단계를 지난다. 각 단계의 주체가 +다르고, 우리가 할 수 있는 것은 앞의 둘과 마지막 하나뿐이다. -``` -① PR → dev 병합 우리 ai#102 -② 릴리스 PR → main 우리 ai#104 — 여기서 두 번 막혔다 (§3) -③ image-publish 자동화 ai-ci.yml 안의 job. main push 조건이다 -④ infra image.tag 갱신 인프라 소관 automation/ai-image-update 브랜치 → infra#185 -⑤ ArgoCD sync 자동화 PreSync hook Job 이 Deployment 앞에 돈다 -⑥ 화면 확인 우리 유일한 검증 수단 (§4) -``` +| 단계 | 주체 | 비고 | +|---|---|---| +| ① PR → dev 병합 | 우리 | `ai#102` | +| ② 릴리스 PR → main | 우리 | `ai#104` — 여기서 두 번 막혔다 (§3) | +| ③ image-publish | 자동화 | `ai-ci.yml` 안의 job. main push 조건이다 | +| ④ infra image.tag 갱신 | 인프라 소관 | `automation/ai-image-update` 브랜치 → `infra#185` | +| ⑤ ArgoCD sync | 자동화 | PreSync hook Job 이 Deployment 앞에 돈다 | +| ⑥ 화면 확인 | 우리 | 유일한 검증 수단 (§4) | -**③이 별도 워크플로가 아니라는 것**이 이 체인에서 가장 자주 오해되는 지점이다. +③이 별도 워크플로가 아니라는 것이 이 체인에서 가장 자주 오해되는 지점이다. `image-publish` 는 CI 워크플로 안의 job 이고 push × `main` 두 조건을 모두 요구한다. -**`dev` 병합만으로는 이미지가 만들어지지 않는다** — 워크플로 목록에서 찾으면 안 보이므로 -「자동화가 고장났다」로 읽기 쉽다. 그 판정의 전수 근거는 [I48](2026-08-03-dev-deploy-gap.md) -에 있다. +`dev` 병합만으로는 이미지가 만들어지지 않는다. 워크플로 목록에서 찾으면 보이지 +않으므로 「자동화가 고장났다」로 읽기 쉽다. 그 판정의 전수 근거는 +[I48](2026-08-03-dev-deploy-gap.md) 에 있다. -**④는 우리 레포 밖이다.** 이미지를 게시하는 것과 그것을 배포에 반영하는 것은 다른 일이고, -후자는 AI 파트 권한이 아니다(`CONTRIBUTING.md` 「Jira와 Git」). +④는 우리 레포 밖이다. 이미지를 게시하는 것과 그것을 배포에 반영하는 것은 다른 +일이고, 후자는 AI 파트 권한이 아니다(`CONTRIBUTING.md` 「Jira와 Git」). ### 시점을 맞추지 않는 자동화 -④의 예정 회차 하나가 **릴리스 병합 직전에 돌아 헛돌았다.** 그 시점에는 아직 새 이미지가 -없었으므로 「갱신할 것이 없다」로 정상 종료했다. 갱신을 실제로 만든 것은 **병합 뒤의 수동 -실행**이다. +④의 예정 회차 하나가 릴리스 병합 직전에 돌아, 실제 갱신 없이 종료했다. 그 시점에는 +아직 새 이미지가 없었으므로 「갱신할 것이 없다」로 정상 종료한 것이다. 갱신을 실제로 +만든 것은 병합 뒤의 수동 실행이다. -**`success` 가 두 번 났는데 뜻이 다르다** — 앞의 것은 「확인했고 바꿀 것이 없었다」, 뒤의 -것은 「바꿨다」다. 다음 예정 회차를 기다렸으면 그만큼 늦었을 뿐 결과는 같았겠지만, **기다리는 -동안 무엇이 진행 중인지 판별할 신호가 없다.** 같은 성질을 [T71](../troubleshooting/2026-08-03-repeat-incidents.md) -이 갈래로 적었다. +`success` 가 두 번 났는데 뜻이 다르다. 앞의 것은 「확인했고 바꿀 것이 없었다」, 뒤의 +것은 「바꿨다」다. 다음 예정 회차를 기다렸으면 그만큼 늦었을 뿐 결과는 같았겠지만, +기다리는 동안 무엇이 진행 중인지 판별할 신호가 없다. 같은 성질을 +[T71](../troubleshooting/2026-08-03-repeat-incidents.md) 이 별도 항목으로 적었다. --- ## 2. 실측으로 확정된 것 — 봉인 값은 판 표기이지 재실행 조건이 아니다 -**이 문서의 핵심이다.** +이 절이 이 문서의 핵심이다. ### 무엇이 관측됐나 -프리셋 파일의 봉인 값(`bootstrap.version`)이 **개정 전 값 그대로인 채로 배포됐다.** -④의 PR 은 이미지 좌표 관련 필드만 고치고 봉인 값은 건드리지 않는다 — 그 자동화의 수정 -대상 필드 목록에 없기 때문이다([I48](2026-08-03-dev-deploy-gap.md) ⑤). +프리셋 파일의 봉인 값(`bootstrap.version`)이 개정 전 값 그대로인 채로 배포됐다. +④의 PR 은 이미지 좌표 관련 필드만 고치고 봉인 값은 건드리지 않는다. 그 자동화의 +수정 대상 필드 목록에 없기 때문이다([I48](2026-08-03-dev-deploy-gap.md) ⑤). -그런데 **새 표시명이 운영 화면에 나왔다.** 즉 부트스트랩이 새 이미지로 다시 돌아 프리셋을 +그런데 새 표시명이 운영 화면에 나왔다. 즉 부트스트랩이 새 이미지로 다시 돌아 프리셋을 새로 적재했다. ### 왜 그렇게 되는가 -``` -hook-delete-policy: BeforeHookCreation 같은 이름의 Job 을 지우고 다시 만든다 -이미지 태그가 바뀌면 sync 가 돈다 PreSync hook 이 새 이미지로 실행된다 -적재가 멱등이다 매번 최신으로 덮는다 -``` +- `hook-delete-policy: BeforeHookCreation` — 같은 이름의 Job 을 지우고 다시 만든다 +- 이미지 태그가 바뀌면 sync 가 돈다 — PreSync hook 이 새 이미지로 실행된다 +- 적재가 멱등이다 — 매번 최신으로 덮는다 -Job 이름에 봉인 값이 들어가므로 **값이 같으면 Job 이름도 같다.** 그러나 같은 이름이 -재실행을 막지 않는다 — 삭제 정책이 먼저 지우고 만들기 때문이다. 근거는 `infra` 차트의 +Job 이름에 봉인 값이 들어가므로 값이 같으면 Job 이름도 같다. 그러나 같은 이름이 +재실행을 막지 않는다. 삭제 정책이 먼저 지우고 만들기 때문이다. 근거는 `infra` 차트의 부트스트랩 Job 템플릿에 있는 세 어노테이션이다. -**따라서 봉인 값은 「이 판으로 적재했다」는 표기이지 부트스트랩 재실행의 조건이 아니다.** +따라서 봉인 값은 「이 판으로 적재했다」는 표기이지 부트스트랩 재실행의 조건이 아니다. ### 왜 이것이 값진가 -그 전에는 **중앙이 「값이 같으면 Job 이 재실행되지 않는다」로 판정했다.** 독립 검증이 그 -논거를 반증했으나 — 삭제 정책이 반대 방향이고, 인프라 문서는 오히려 재실행을 전제로 훅에 -멱등성을 요구한다 — **클러스터를 관측할 경로가 레포 안에 없어 「확인 불가」로 끝났다.** -정정 기록은 `.claude/RECALL-CORRECTIONS.md` 의 검증 절에 있다. +그 전에는 중앙이 「값이 같으면 Job 이 재실행되지 않는다」로 판정했다. 독립 검증이 그 +논거를 반증했으나(삭제 정책이 반대 방향이고, 인프라 문서는 오히려 재실행을 전제로 +훅에 멱등성을 요구한다), 클러스터를 관측할 경로가 레포 안에 없어 「확인 불가」로 +끝났다. 정정 기록은 `.claude/RECALL-CORRECTIONS.md` 의 검증 절에 있다. -**배포가 그 답을 냈다.** 봉인 값이 낡은 채로 새 표시명이 화면까지 온 것이 곧 재실행의 +배포가 그 답을 냈다. 봉인 값이 낡은 채로 새 표시명이 화면까지 온 것이 곧 재실행의 증거다. 추론으로 남아 있던 것이 관측으로 닫혔다. ### 그래도 남는 것 -**봉인 값 표기가 낡은 것은 기록 정합성 문제로 남는다.** 배포를 막지 않을 뿐이다. +봉인 값 표기가 낡은 것은 기록 정합성 문제로 남는다. 배포를 막지 않을 뿐이다. -``` -막지 않는다 재실행은 삭제 정책이 보장하고 적재는 멱등이다 -남는 문제 그 값이 「지금 어느 판이 심겼는가」를 더 이상 말해 주지 않는다 - 그리고 틀려도 실패하는 검사가 없다 -``` +- 배포를 막지 않는다 — 재실행은 삭제 정책이 보장하고 적재는 멱등이다. +- 남는 문제 — 그 값이 「지금 어느 판이 적재됐는가」를 더 이상 말해 주지 않는다. + 그리고 틀려도 실패하는 검사가 없다. 새 값을 무엇으로 할지와 그 산출 방식은 [I48](2026-08-03-dev-deploy-gap.md) 에 있고, 검사를 어느 레포에 둘지는 인프라 소관이 걸려 단독으로 정할 수 없다. @@ -108,41 +105,43 @@ Job 이름에 봉인 값이 들어가므로 **값이 같으면 Job 이름도 같 ### 3-1. 릴리스 PR 의 base 검사 -**`main` 을 base 로 하는 PR 은 `release/*` 또는 `hotfix/*` 만 허용한다.** head 를 `dev` 로 -열었더니 CI 가 막았다(`ai#103`). 브랜치를 다시 만들어 통과시켰다(`ai#104`). +`main` 을 base 로 하는 PR 은 `release/*` 또는 `hotfix/*` 만 허용한다. head 를 `dev` +로 열었더니 CI 가 막았다(`ai#103`). 브랜치를 다시 만들어 통과시켰다(`ai#104`). -**이 검사가 왜 있는지는 워크플로 주석이 적어 두었다** — 일반 작업이 `main` 으로 들어가면 -`dev` 가 뒤처지고 다음 릴리스에서 같은 파일이 충돌한다. 실제로 두 번 났고 두 번 다 사람이 -뒤늦게 발견했다. base 제약은 branch protection 에 기능이 없어 CI 에서 검사한다. +이 검사가 왜 있는지는 워크플로 주석이 적어 두었다. 일반 작업이 `main` 으로 들어가면 +`dev` 가 뒤처지고 다음 릴리스에서 같은 파일이 충돌한다. 실제로 두 번 났고 두 번 다 +사람이 뒤늦게 발견했다. base 제약은 branch protection 에 기능이 없어 CI 에서 +검사한다. > 좌표 — `.github/workflows/ai-ci.yml` 의 `Validate PR base` 스텝과 그 위 주석. -**절차** — 릴리스는 `dev` 를 그대로 열지 말고 **`release/{YYYY-MM-DD}` 브랜치를 먼저 만든다.** +절차는 다음과 같다. 릴리스는 `dev` 를 그대로 열지 말고 `release/{YYYY-MM-DD}` +브랜치를 먼저 만든다. ### 3-2. strict 상태 검사 -**`main` 의 protection 이 head 가 base 를 포함할 것을 요구한다**(required status checks 의 -strict). `main` 에 직접 들어간 변경이 있으면 릴리스 브랜치가 BEHIND 가 되고 **병합이 -거부된다.** +`main` 의 protection 이 head 가 base 를 포함할 것을 요구한다(required status checks +의 strict). `main` 에 직접 들어간 변경이 있으면 릴리스 브랜치가 BEHIND 가 되고 +병합이 거부된다. 브랜치에 `main` 을 병합하고 CI 를 다시 기다려 통과시켰다. 그 병합 커밋이 `ai#104` 에 그대로 남아 있다(`032e391f`). -**절차** — `main` 에 직접 들어간 변경(주로 인프라가 내는 워크플로 수정)이 있으면 릴리스 -브랜치에서 **`main` 을 먼저 병합해 충돌을 해소한 뒤** PR 을 연다. 그 해소를 PR 화면에 -맡기지 않는다 — 어느 쪽을 채택했는지가 커밋 메시지에 남아야 한다(`CONTRIBUTING.md` -「릴리스 시점」). +절차는 다음과 같다. `main` 에 직접 들어간 변경(주로 인프라가 내는 워크플로 수정)이 +있으면 릴리스 브랜치에서 `main` 을 먼저 병합해 충돌을 해소한 뒤 PR 을 연다. 그 +해소를 PR 화면에 맡기지 않는다. 어느 쪽을 채택했는지가 커밋 메시지에 남아야 +한다(`CONTRIBUTING.md` 「릴리스 시점」). -**둘 다 CI 를 다시 기다리게 만든다.** 릴리스 시간을 잡을 때 이 대기를 계산에 넣는다. +둘 다 CI 를 다시 기다리게 만든다. 릴리스 시간을 잡을 때 이 대기를 계산에 넣는다. --- ## 4. 확인 수단의 한계 -**클러스터를 볼 수 없다.** sync 가 돌았는지, PreSync Job 이 성공했는지, DB 에 무엇이 -들어갔는지를 직접 관측할 경로가 우리 레포 안에 없다. **화면 확인이 유일한 검증이다.** +클러스터를 볼 수 없다. sync 가 돌았는지, PreSync Job 이 성공했는지, DB 에 무엇이 +들어갔는지를 직접 관측할 경로가 우리 레포 안에 없다. 화면 확인이 유일한 검증이다. -그래서 **첫 확인이 이르면 아직 안 바뀐 것을 「안 됐다」로 읽는다.** 실제로 그렇게 한 번 +그래서 첫 확인이 이르면 아직 안 바뀐 것을 「안 됐다」로 읽게 된다. 실제로 그렇게 한 번 오판했고 재확인에서 뒤집혔다. ``` @@ -151,9 +150,10 @@ strict). `main` 에 직접 들어간 변경이 있으면 릴리스 브랜치가 ⑥ 화면 그 뒤에야 바뀐다 ``` -**세 단계 사이의 지연을 볼 수 없으므로 「아직인가 실패인가」가 화면만으로는 갈리지 않는다.** -한 번 보고 판정하지 않는다 — 간격을 두고 다시 본다. 이 오판은 [T71](../troubleshooting/2026-08-03-repeat-incidents.md) -의 「최종 전달 성공」 층을 관측할 수단이 없는 데서 온다. +세 단계 사이의 지연을 볼 수 없으므로, 「아직 진행 중인가 실패했는가」가 화면만으로는 +구분되지 않는다. 한 번 보고 판정하지 않고 간격을 두고 다시 본다. 이 오판은 +[T71](../troubleshooting/2026-08-03-repeat-incidents.md) 의 「최종 전달 성공」 층을 +관측할 수단이 없는 데서 온다. -**판정을 남길 때는 관측 시각과 무엇을 봤는지를 함께 적는다** — 화면 확인은 재현되지 않는 -관측이라 나중에 다시 셀 수 없다. +판정을 남길 때는 관측 시각과 무엇을 봤는지를 함께 적는다. 화면 확인은 재현되지 않는 +관측이라 나중에 다시 확인할 수 없기 때문이다. diff --git a/docs/implements/2026-08-03-search-recall-probe.md b/docs/implements/2026-08-03-search-recall-probe.md index 9663fa6..5c081af 100644 --- a/docs/implements/2026-08-03-search-recall-probe.md +++ b/docs/implements/2026-08-03-search-recall-probe.md @@ -1,4 +1,4 @@ -# 본문에 있는 말로 검색해도 안 나온다 — 원인 판별 +# 본문에 있는 말로 검색해도 안 나온다 — 세 이슈의 원인이 서로 다르다는 것을 판별했다 - **티켓**: S15P11A705-255 - **날짜**: 2026-08-03 (측정 10:29 KST) @@ -8,25 +8,25 @@ - **선행**: [검색 결과 컷](2026-07-31-search-cut.md) (`S15P11A705-213`) · [임베딩 4조건](2026-07-31-embedding-grid.md) (`S15P11A705-191`) - **하네스**: `tools/search_cut/recall_probe.py` — 실행 절차는 그 README -- **성격**: 측정만 한다. **컷 값도 모델도 코드도 고치지 않는다.** +- **성격**: 측정만 한다. 컷 값도 모델도 코드도 고치지 않는다. ## 요약 -``` -① 그네 컷이 잘랐다. 풀면 3위 → 컷 조정으로 회복된다 -③ 신한 컷을 풀어도 6위 → 컷으로 못 푼다 -③ 부캠 컷을 풀어도 8위 → 컷으로 못 푼다 -— 그네팟 1위 0.5184. 증상 없음 → 사용자 보고와 일치 +| 질의 | 판정 | 의미 | +|---|---|---| +| 그네 | ① 컷이 잘랐다. 컷을 풀면 3위 | 컷 조정으로 회복된다 | +| 신한 | ③ 컷을 풀어도 6위 | 컷으로 못 푼다 | +| 부캠 | ③ 컷을 풀어도 8위 | 컷으로 못 푼다 | +| 그네팟 | 1위 0.5184. 증상 없음 | 사용자 보고와 일치 | -가른 것 본문에 없는 2자 `치과` 의 top-1 이 0.2953 인데, - 본문에 그대로 있는 `스팟` 은 0.2438 이다. - **짧은 질의에서는 「본문에 있음」이 유사도에 반영되지 않는다.** +판정을 가른 핵심 관측은 다음과 같다. 본문에 없는 2자 질의 `치과` 의 top-1 유사도가 +0.2953 인데, 본문에 그대로 있는 `스팟` 은 0.2438 이다. 즉 짧은 질의에서는 「본문에 +있음」이 유사도에 반영되지 않는다. -셋은 같은 원인이 아니다 `그네` 는 컷 · `부캠` 은 약어 · `신한` 은 짧은 질의 -``` +셋은 같은 원인이 아니다. `그네` 는 컷, `부캠` 은 약어, `신한` 은 짧은 질의가 원인이다. -**티켓이 세운 전제 「셋 중 하나다」는 유지되지만, 「셋 다 같은 원인」은 아니다.** -질의마다 판정이 다르고 처방도 다르다. +티켓이 세운 전제 「셋 중 하나다」는 유지되지만, 「셋 다 같은 원인」은 아니다. 질의마다 +판정이 다르고 처방도 다르다. ## 전제부터 배제했다 — 벡터는 있다 @@ -40,13 +40,13 @@ ai.context_embedding 벡터 42건 · is_deleted 0건 embedding_profile openai-text-embedding-3-small-1536-cosine-v1 (설정과 일치) ``` -**전량이 정상이다.** 넷째 가능성은 로컬에서 배제된다(운영은 §한계 참조). +전량이 정상이다. 넷째 가능성은 로컬에서 배제된다(운영은 §말할 수 없는 것 참조). ## 무엇을 어떻게 쟀는가 ### 데이터 -증상이 보고된 두 본문이 `tools/demo_seed/demo_data.yaml` 에 **그대로 있다.** 운영 데이터를 +증상이 보고된 두 본문이 `tools/demo_seed/demo_data.yaml` 에 그대로 있다. 운영 데이터를 쓰지 않고 로컬 시연 DB(`:15432`)로 재현했다. ``` @@ -59,8 +59,8 @@ embedding_profile openai-text-embedding-3-small-1536-cosine-v1 (설정 ### 측정 대상이 넷이 아니라 스물둘인 이유 -**대상 넷만 재면 원인이 갈리지 않는다.** 「안 나온다」는 관측은 길이·약어·부분어 세 가설 -모두와 양립하므로, 한 축만 바꾼 짝을 만들어야 무엇이 가르는지 보인다. +대상 넷만 재면 원인이 갈리지 않는다. 「안 나온다」는 관측은 길이·약어·부분어 세 가설 +모두와 양립하므로, 한 축만 바꾼 짝을 만들어야 무엇이 원인인지 보인다. | 축 | 무엇을 가르는가 | 짝 | |---|---|---| @@ -70,26 +70,25 @@ embedding_profile openai-text-embedding-3-small-1536-cosine-v1 (설정 | **본문 통제** | 같은 본문에 질의 길이만 바꾸면 | 본문 A 에 7종 · 본문 B 에 10종 | | **무관 기준선** | 짧아서 낮은가, 무관해서 낮은가 | `치과`(2자, 어느 본문에도 없다) | -**마지막 둘을 계약의 최소 비교군에 더했다.** 이유는 §판정에서 드러난다 — 이 둘이 없으면 +마지막 둘을 계약의 최소 비교군에 더했다. 이유는 §판정에서 드러난다. 이 둘이 없으면 「2자 질의의 0.24 가 낮은 값인가」를 판단할 기준이 없다. ### 컷 전후를 함께 낸다 -서비스가 하는 순서 그대로 재구성했다 — SQL `LIMIT 20` 이 먼저, 컷(`τ_abs=0.30` · -`r=0.60`)이 뒤다. `SearchService._cut` 을 `import` 하지 않고 다시 적었다(`import` 하면 -구현이 명세와 달라도 둘이 함께 틀려 재구성이 「일치」한다 — `-213` 이 세운 규칙). +서비스가 하는 순서 그대로 재구성했다. SQL `LIMIT 20` 이 먼저, 컷(`τ_abs=0.30` · +`r=0.60`)이 뒤다. `SearchService._cut` 을 `import` 하지 않고 다시 적었다. `import` +하면 구현이 명세와 달라도 둘이 함께 틀려 재구성이 「일치」하기 때문이다(`-213` 이 +세운 규칙). -**판정을 두 층으로 나눴다.** 「무엇이 잘랐나」 하나로는 부족하기 때문이다. +판정을 두 층으로 나눴다. 「무엇이 잘랐나」 하나로는 부족하기 때문이다. -``` -cut_verdict 무엇이 잘랐나 τ_abs · r · limit · 통과 -cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② limit 밖 / ③ 그 밖 -``` +- `cut_verdict` — 무엇이 잘랐나 (`τ_abs` · `r` · `limit` · 통과) +- `cause` — 컷을 풀면 회복되는가 (① 상위 3위 안 / ② limit 밖 / ③ 그 밖) -`①`과 `③`은 **같은 「τ_abs 가 잘랐다」에 함께 붙는다** — 컷을 풀면 1위인 `스팟` 과 풀어도 +`①`과 `③`은 같은 「τ_abs 가 잘랐다」에 함께 붙는다. 컷을 풀면 1위인 `스팟` 과 풀어도 8위인 `부캠` 이 그렇다. 컷 전 순위가 그 둘을 가른다. 회복 경계를 3위로 잡은 근거는 이 -데이터셋의 기존 기준선이다(`-191` 이 임베딩 4조건 전부에서 top-3 12/12, `-213` 이 「정답이 -전부 3위 안에 있다」). +데이터셋의 기존 기준선이다(`-191` 이 임베딩 4조건 전부에서 top-3 12/12, `-213` 이 +「정답이 전부 3위 안에 있다」). ## 결과 — 질의 × 유사도 @@ -120,16 +119,16 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l | 밥 먹고 산책하면서 쉬어가는 공원 | 18 | 동교어린이공원 | 2 | 0.4573 | 치킨버거 0.5264 | 0.3158 | 포함 | — 통과 | | 신한 부트캠프 친구들과 자주 먹었던 돈카츠 집 | 25 | 카츠요 | 1 | **0.8602** | 카츠요 0.8602 | 0.5161 | 포함 | — 통과 | -**`신한 부캠` 하나만 `τ_abs` 를 넘고 `r` 에 걸렸다**(0.3189 ≥ 0.30 이지만 `0.60×0.5868 = -0.3521` 미만). 4위라 회복 경계 3위를 한 칸 넘겨 `③` 으로 판정됐으나 성격은 `②` 에 가깝다 — -`r` 만 완화하면 나온다. **경계 사례로 명시한다.** +`신한 부캠` 하나만 `τ_abs` 를 넘고 `r` 에 걸렸다(0.3189 ≥ 0.30 이지만 +`0.60×0.5868 = 0.3521` 미만). 4위라 회복 경계 3위를 한 칸 넘겨 `③` 으로 판정됐으나 +성격은 `②` 에 가깝다. `r` 만 완화하면 나온다. 경계 사례로 명시한다. ## 판정 — 세 축이 어떻게 갈렸는가 ### 본문을 고정하면 길이 축이 드러난다 -같은 본문에 질의 길이만 바꿔 던진 결과다. **본문이 같으므로 본문 길이·주제·위치가 전부 -통제된다.** +같은 본문에 질의 길이만 바꿔 던진 결과다. 본문이 같으므로 본문 길이·주제·위치가 전부 +통제된다. | 본문 A — 동교어린이공원(45자) | 자 | 유사도 | | 본문 B — 카츠요(32자) | 자 | 유사도 | |---|---|---|---|---|---|---| @@ -144,13 +143,12 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l | **그네** | **2** | **0.2671** | | **신한** | **2** | **0.2301** | | **스팟** | **2** | **0.2438** | | **부캠** | **2** | **0.2105** | -``` -두 본문 모두 3자 이상은 전부 ≥ 0.3189 2자는 전부 ≤ 0.2818 -τ_abs = 0.30 이 경계가 그 사이에 있다 -``` +두 본문 모두에서 3자 이상 질의는 전부 유사도 0.3189 이상이고, 2자 질의는 전부 +0.2818 이하다. `τ_abs = 0.30` 경계가 정확히 그 사이에 있다. -**길이 축은 실재한다.** 그러나 이것만으로는 「2자면 안 된다」가 되고, 그것은 틀렸다 — -`우주`(0.4199)와 `공원`(0.3670)이 2자로 통과한다. 길이는 필요조건이지 충분조건이 아니다. +길이 축은 실재한다. 그러나 이것만으로는 「2자면 안 된다」가 되고, 그것은 틀렸다. +`우주`(0.4199)와 `공원`(0.3670)이 2자로 통과한다. 길이는 필요조건이지 충분조건이 +아니다. ### 무관 기준선이 「신한」의 답이다 @@ -161,11 +159,11 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l 그네 2자 · 본문 A 에 그대로 있다 · 3위 0.2671 ``` -**본문에 그대로 있는 2자 세 개가 전부, 어느 본문에도 없는 2자의 top-1 보다 낮다.** +본문에 그대로 있는 2자 세 개가 전부, 어느 본문에도 없는 2자의 top-1 보다 낮다. -이것이 이 측정의 핵심 관측이다. `신한` 이 「완전 일치인데 안 걸린다」로 특별해 보였지만, -**완전 일치는 유사도에 유의미하게 기여하지 않는다.** 임베딩은 문자열 포함을 모르고, 질의가 -짧으면 신호가 잡음 대역 안으로 내려앉는다. +이것이 이 측정의 핵심 관측이다. `신한` 이 「완전 일치인데 안 걸린다」로 특별해 +보였지만, 완전 일치는 유사도에 유의미하게 기여하지 않는다. 임베딩은 문자열 포함을 +모르고, 질의가 짧으면 신호가 무관 질의와 같은 대역까지 내려간다. 2자 중 통과하는 둘과 못 하는 셋의 차이는 완전 일치 여부가 아니다. @@ -176,8 +174,8 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l 신한 0.2301 카츠요 "… 신한 부트캠프 친구들과 …" ``` -**본문의 주제를 짚는 2자는 넘고, 부수 정보를 짚는 2자는 못 넘는다.** 이는 관측된 패턴이지 -검증된 기제가 아니다(§말할 수 없는 것). +본문의 주제를 가리키는 2자는 넘고, 부수 정보를 가리키는 2자는 넘지 못한다. 이는 +관측된 패턴이지 검증된 원인이 아니다(§말할 수 없는 것). ### 약어 — `#88` 의 진단이 확증됐다 @@ -186,23 +184,24 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l 부캠 2자 카츠요 8위 0.2105 ③ ``` -**길이 축만으로는 설명되지 않는다.** 같은 2자인 `우주`(0.4199)·`공원`(0.3670)이 통과하고, -같은 대상을 가리키는 `부트캠프` 는 1위다. +길이 축만으로는 설명되지 않는다. 같은 2자인 `우주`(0.4199)·`공원`(0.3670)이 +통과하고, 같은 대상을 가리키는 `부트캠프` 는 1위다. -결정적인 것은 **`부캠` 이 문자열로는 인식된다**는 것이다. +결정적인 것은 `부캠` 이 문자열로는 인식된다는 것이다. ``` 부캠 질의의 상위 쿠로코 0.4102 · 플랜트 0.3304 · 연남칼국수 0.3195 쿠로코 본문 "신한 부캠 당시 자주 가던 라멘집" ← 「부캠」이 그대로 있다 ``` -모델은 `부캠` 이라는 표기를 안다. **`부캠 → 부트캠프` 의미 연결만 못 한다.** `#88` 이 세운 -「본문에 없는 형태라 키워드 검색으로도 안 풀린다」가 실측으로 확증된다. +모델은 `부캠` 이라는 표기를 안다. `부캠 → 부트캠프` 의미 연결만 하지 못한다. `#88` +이 세운 「본문에 없는 형태라 키워드 검색으로도 안 풀린다」가 실측으로 확증된다. -### 부분어 — `#87` 의 BPE 가설은 **반증됐다** +### 부분어 — `#87` 의 BPE 가설은 반증됐다 계약이 「확인 없이 단정하지 마라」고 한 항목이다. 프로파일이 `text-embedding-3-small` -(GMS 를 통한 OpenAI 경로)이므로 토크나이저가 `cl100k_base` 로 특정된다. **실제로 봤다.** +(GMS 를 통한 OpenAI 경로)이므로 토크나이저가 `cl100k_base` 로 특정된다. 실제로 +분해해서 확인했다. ``` '그네' [49706, 76242, 97] @@ -216,9 +215,10 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l | `신한` ↔ `신한 부트캠프` | 있음 (83628, 24486) | | `부캠` ↔ `부트캠프` | 있음 (64189, 168, 118) | -**BPE 는 `그네팟` 을 한 덩어리로 묶지 않는다.** `cl100k_base` 는 한국어를 바이트 단위로 -잘게 쪼개므로 접두 토큰이 그대로 공유된다(`돈카츠` 3자 → 7토큰, `그네팟` 3자 → 5토큰). -`#87` 이 세운 「토큰이 갈려 벡터가 멀어진다」는 **토크나이저 수준에서 성립하지 않는다.** +BPE 는 `그네팟` 을 한 덩어리로 묶지 않는다. `cl100k_base` 는 한국어를 바이트 단위로 +잘게 쪼개므로 접두 토큰이 그대로 공유된다(`돈카츠` 3자 → 7토큰, `그네팟` 3자 → +5토큰). `#87` 이 세운 「토큰이 갈려 벡터가 멀어진다」는 토크나이저 수준에서 성립하지 +않는다. 그러면 `그네` 가 안 되는 이유는 부분어라서가 아니다. @@ -228,13 +228,13 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l 그네팟 3자 동교공원 1위 0.5184 ``` -`그네` 는 **다른 2자 질의와 같은 대역**(0.24~0.28)에 있다. 원인은 부분 문자열이 아니라 -짧은 질의의 신호 부족이며, **`#87` 은 `#86` 과 같은 원인이다.** 다만 컷을 풀면 3위로 -회복되므로 **처방은 다르다**(`①` vs `③`). +`그네` 는 다른 2자 질의와 같은 대역(0.24~0.28)에 있다. 원인은 부분 문자열이 아니라 +짧은 질의의 신호 부족이며, `#87` 은 `#86` 과 같은 원인이다. 다만 컷을 풀면 3위로 +회복되므로 처방은 다르다(`①` vs `③`). ### 「신한」이 카츠요에서만 낮은 이유 — 부분적으로만 답한다 -`신한` 질의는 **0건이 아니다.** 3건이 나오고 셋 다 본문에 「신한」을 가진 Record 다. +`신한` 질의는 0건이 아니다. 3건이 나오고 셋 다 본문에 「신한」을 가진 Record 다. ``` 1위 0.3889 플랜트 "신한 부캠 때 거의 일주일마다 가던 비건 샌드위치 식당" @@ -243,11 +243,11 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l 6위 0.2301 카츠요 "6개월 동안 신한 부트캠프 친구들과 자주 먹었던 돈카츠 집" ← 기대 Record ``` -**검색은 「신한」을 알아본다.** 사용자가 본 것은 「0건」이 아니라 **「카츠요가 없는 3건」** -이다. 이슈 제목의 「안 나온다」는 정확히는 「그 기록이 안 나온다」다. +검색은 「신한」을 알아본다. 사용자가 본 것은 「0건」이 아니라 「카츠요가 없는 3건」이다. +이슈 제목의 「안 나온다」는 정확히는 「그 기록이 안 나온다」다. -상위 셋은 전부 「신한 **부캠**」이고 카츠요만 「신한 **부트캠프**」다. 표현 일치 효과는 -확인된다. +상위 셋은 전부 「신한 부캠」이고 카츠요만 「신한 부트캠프」다. 본문 표현과 질의 표현이 +일치할 때 유사도가 높아지는 효과는 확인된다. ``` 질의 '신한 부캠' 쿠로코 0.5868(1위) · 카츠요 0.3189(4위) @@ -255,7 +255,7 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l ↑ 본문 표현과 맞는 쪽이 높다 ``` -본문 길이는 원인이 아니다 — 플랜트(30자) 0.3889 와 카츠요(32자) 0.2301 이 거의 같은 +본문 길이는 원인이 아니다. 플랜트(30자) 0.3889 와 카츠요(32자) 0.2301 이 거의 같은 길이에서 갈린다. 전체 상관도 약하다. ``` @@ -263,15 +263,16 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l 구절·문장형(6자↑) n=5 ρ 평균 +0.0687 ``` -**여기서 멈춘다. 「왜 「신한 부캠」이 「신한」 단독과 더 가까운가」는 이 측정으로 단정할 수 -없다.** 추론은 있다 — 「부캠」은 모델에 의미가 약한 토막이라 「신한 부캠」 벡터가 「신한」에 -지배되고, 「부트캠프」는 강한 의미를 가져 벡터를 자기 쪽으로 끈다(`부트캠프` 질의가 카츠요를 -1위로 짚는 것과 정합한다). **확인하려면** 본문 표현만 바꾼 대조 Context 를 시딩해 재야 -한다. 이 티켓은 데이터를 바꾸지 않으므로 후속으로 남긴다. +여기서 멈춘다. 「왜 「신한 부캠」이 「신한」 단독과 더 가까운가」는 이 측정으로 단정할 +수 없다. 추론은 있다. 「부캠」은 모델에 의미가 약한 토막이라 「신한 부캠」 벡터가 +「신한」에 지배되고, 「부트캠프」는 강한 의미를 가져 벡터를 자기 쪽으로 +끈다(`부트캠프` 질의가 카츠요를 1위로 가리키는 것과 정합한다). 확인하려면 본문 표현만 +바꾼 대조 Context 를 시딩해 재야 한다. 이 티켓은 데이터를 바꾸지 않으므로 후속으로 +남긴다. ## `③` 은 임베딩으로 풀 수 있는가 -계약이 요구한 답이다. **셋이 서로 다르다.** +계약이 요구한 답이다. 셋이 서로 다르다. | 이슈 | 판정 | 모델 교체로 되는가 | |---|---|---| @@ -279,7 +280,7 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l | `#87` `그네` | ① | **임베딩 문제가 아니다.** 컷만 풀면 3위로 회복된다. 부분 문자열 매칭 자체는 임베딩이 원리상 못 하지만, 이 건은 그 지점에 닿기 전에 컷에서 걸렸다 | | `#88` `부캠` | ③ | **모델 교체로 개선 가능성이 있다.** `부캠 → 부트캠프` 는 의미 연결이므로 임베딩의 영역이고, 모델이 표기 자체는 인식한다. `-199`(`large` 재측정)가 답할 문제다 | -**`#87` 과 `#88` 의 처방이 반대라는 이슈의 판단은 유지된다** — 다만 `#87` 은 키워드 검색 +`#87` 과 `#88` 의 처방이 반대라는 이슈의 판단은 유지된다. 다만 `#87` 은 키워드 검색 이전에 컷 조정으로 먼저 움직인다. ## `-213` 의 한계와 같은 것인가 @@ -291,11 +292,13 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l -255 본문에 있는 2자 0.2438 vs 무관 2자 top-1 0.2953 간격 -0.0515 (단어형 질의) ``` -**같은 구조이고 단어형에서 더 심하다.** `-213` 은 「무관한 꼬리가 정답 대역에 섞인다」였고 -이번은 「본문에 있는 말이 무관 질의보다 낮다」로, 겹침이 역전으로 벌어졌다. +같은 구조이고 단어형에서 더 심하다. `-213` 은 「무관한 꼬리가 정답 대역에 +섞인다」였고 이번은 「본문에 있는 말이 무관 질의보다 낮다」로, 겹침이 역전으로 +벌어졌다. -`-213` 의 결론 **「컷 값으로는 더 못 간다」가 강화된다.** 단어형 질의를 컷으로 살리려면 -`τ_abs` 를 0.24 아래로 내려야 하는데, 그 값은 무관 질의 대역(0.2953)을 통째로 통과시킨다. +`-213` 의 결론 「컷 값으로는 더 못 간다」가 강화된다. 단어형 질의를 컷으로 살리려면 +`τ_abs` 를 0.24 아래로 내려야 하는데, 그 값은 무관 질의 대역(0.2953)을 통째로 +통과시킨다. ## 검증 — 실행한 것과 못 한 것 @@ -311,19 +314,19 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l - **실서버 대조를 하지 않았다.** `-213` 의 `verify_live.py` 가 같은 재구성 규칙으로 27/27 정확 일치를 이미 확인했고, 이 티켓은 그 규칙을 바꾸지 않고 질의만 바꿨다. - 재구성 코드는 `-213` 과 동일한 원칙으로 작성했다(구현 `import` 금지). **다만 이 질의 - 집합으로 실서버를 때려 본 것은 아니다** — 필요하면 `verify_live.py` 를 확장해야 한다. + 재구성 코드는 `-213` 과 동일한 원칙으로 작성했다(구현 `import` 금지). 다만 이 질의 + 집합을 실서버에 보내 본 것은 아니다. 필요하면 `verify_live.py` 를 확장해야 한다. - **운영 DB 를 재지 않았다.** 계약대로 로컬 시연 DB 로만 쟀다. - **테스트를 돌리지 않았다.** 코드 변경이 없다(하네스와 문서만 더했다). ## 말할 수 없는 것 - **「2자면 안 된다」가 아니다.** `우주`·`공원` 이 2자로 통과한다. 경계는 길이 하나가 - 아니며 「본문의 주제를 짚는가」로 갈리는 것으로 **보이나** 그것을 통제한 측정은 없다. - 주제성은 판정자가 본문을 읽고 붙인 사후 해석이다. + 아니며 「본문의 주제를 가리키는가」로 갈리는 것으로 보이나, 그것을 통제한 측정은 + 없다. 주제성은 판정자가 본문을 읽고 붙인 사후 해석이다. - **표본이 작다.** 소유자 1명 · Record 17건 · 질의 22건이다. 순위 상관은 17점짜리이고 단어형·문장형의 ρ 차이(-0.12 vs +0.07)는 이 표본에서 유의하지 않다. -- **「신한」이 카츠요에서만 낮은 정확한 기제를 모른다.** 표현 일치 효과는 관측했으나 +- **「신한」이 카츠요에서만 낮은 정확한 원인을 모른다.** 표현 일치 효과는 관측했으나 왜 「신한 부캠」이 「신한」에 더 가까운지는 답하지 못했다. - **모델 교체의 효과를 재지 않았다.** `#88` 의 「개선 가능성」은 원리 판단이지 실측이 아니다. `-199` 가 답할 문제다. @@ -333,10 +336,10 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l ## 병렬 작업과의 간섭 -`-228` 이 프리셋 `description` 을 고쳐 **프리셋 임베딩을 재생성**한다. 이 측정은 -`ai.context_embedding` 만 읽고 `ai.keyword_preset` 을 건드리지 않으므로 겹치지 않는다. -측정 시각을 남긴다 — **2026-08-03 10:29 KST**. Context 임베딩 42건은 그 시점에 전량 -`COMPLETED` 였다. +`-228` 이 프리셋 `description` 을 고쳐 프리셋 임베딩을 재생성한다. 이 측정은 +`ai.context_embedding` 만 읽고 `ai.keyword_preset` 을 건드리지 않으므로 겹치지 +않는다. 측정 시각을 남긴다. 2026-08-03 10:29 KST 이며, Context 임베딩 42건은 그 +시점에 전량 `COMPLETED` 였다. ## 산출물 @@ -346,17 +349,19 @@ cause 컷을 풀면 회복되는가 ① 상위 3위 안 / ② l | `.search/recall_probe.json` | **영구(커밋)** | 유사도 행렬. 다시 뜨려면 GMS 를 부른다. `.gitignore` 예외에 등록 | | 이 문서 | **영구** | | -`matrix.json` 과 같은 원칙으로 커밋한다 — **Context 본문을 담지 않고** Record 대표 +`matrix.json` 과 같은 원칙으로 커밋한다. Context 본문을 담지 않고 Record 대표 이름(장소명)까지만 담는다. ## 후속으로 남기는 것 -이 티켓은 **처방을 정하지 않는다.** 판정만 낸다. - -- **`#87`(`그네`)은 컷 조정만으로 회복된다** — `τ_abs` 를 얼마로 내릴 것인가는 무관 질의 - 대역(0.2953)과의 교환이고, `-213` 의 격자를 이 질의 집합으로 다시 훑어야 정한다 -- **`#86`·`#88` 은 컷으로 못 푼다.** 하이브리드 검색(`pg_trgm`·`tsvector`)과 임베딩 모델 - 교체(`-199`)가 각각 다른 건을 겨눈다 — **`#86` 은 키워드, `#88` 은 임베딩** -- **「신한 부캠」이 「신한」에 더 가까운 기제** — 본문 표현만 바꾼 대조 Context 를 시딩해 - 재야 한다 -- **`verify_live.py` 확장** — 이 질의 집합의 실서버 대조 +이 티켓은 처방을 정하지 않는다. 판정만 낸다. + +- **`#87`(`그네`)은 컷 조정만으로 회복된다.** `τ_abs` 를 얼마로 내릴 것인가는 무관 + 질의 대역(0.2953)과의 교환이고, `-213` 의 격자를 이 질의 집합으로 다시 훑어야 + 정한다. +- **`#86`·`#88` 은 컷으로 못 푼다.** 하이브리드 검색(`pg_trgm`·`tsvector`)과 임베딩 + 모델 교체(`-199`)가 각각 다른 건을 대상으로 한다. `#86` 은 키워드, `#88` 은 + 임베딩이다. +- **「신한 부캠」이 「신한」에 더 가까운 원인** — 본문 표현만 바꾼 대조 Context 를 + 시딩해 재야 한다. +- **`verify_live.py` 확장** — 이 질의 집합의 실서버 대조. diff --git a/docs/implements/2026-08-03-word-query-cut.md b/docs/implements/2026-08-03-word-query-cut.md index 28eea1a..0f40611 100644 --- a/docs/implements/2026-08-03-word-query-cut.md +++ b/docs/implements/2026-08-03-word-query-cut.md @@ -10,62 +10,59 @@ ## 요약 -``` -채택 τ_abs = 0.24 (단어형) / 0.30 (문장형) · r = 0.60 (갈리지 않는다) - - 단어형 회복 61/71 → 71/71 · 1위 손실 5 → 0 · 정답 누락 11/66 → 2/66 - 문장형 완전 불변 (정답 누락 0/12 · 무관 질의 침묵 11/15) - 대가 단어형 무관 통과 10/45 → 26/45 - -가른 것 문장형 정답 하한 0.3642 vs 단어형 정답 하한 0.2438 - **두 대역이 겹치지 않아 한 값으로는 한쪽이 반드시 손해를 본다** - -0.24 의 정체 「컷 전 1위인 정답을 하나도 잃지 않는 가장 높은 값」. 0.25 부터 깨진다 -``` +| 항목 | 내용 | +|---|---| +| 채택 | `τ_abs = 0.24`(단어형) / `0.30`(문장형) · `r = 0.60`(길이로 가르지 않는다) | +| 단어형 | 회복 61/71 → 71/71 · 1위 손실 5 → 0 · 정답 누락 11/66 → 2/66 | +| 문장형 | 완전 불변 (정답 누락 0/12 · 무관 질의 15건 중 11건 결과 미반환) | +| 대가 | 단어형 무관 통과 10/45 → 26/45 | +| 값을 가른 근거 | 문장형 정답 하한 0.3642 대 단어형 정답 하한 0.2438. 두 대역이 겹치지 않아 한 값으로는 한쪽이 반드시 손해를 본다 | +| 0.24 의 뜻 | 「컷 전 1위인 정답을 하나도 잃지 않는 가장 높은 값」. 0.25 부터 깨진다 | -**계약이 이 티켓을 「교환점 찾기」로 규정했고 그 규정은 옳았다. 다만 교환의 축이 계약이 -지목한 곳이 아니었다** — §전제 정정 참조. +계약이 이 티켓을 「교환점 찾기」로 규정했고 그 규정은 옳았다. 다만 교환의 축이 계약이 +지목한 곳이 아니었다. §전제 정정 참조. ## 전제 정정 — 문장형 회귀는 이 티켓의 리스크가 아니었다 -계약은 「`-213` 의 검증 질의 12건이 그대로 통과해야 한다. **이게 주 리스크다**」로 못박았다. -**격자 전량에서 문장형 정답 누락은 0 이었다.** +계약은 「`-213` 의 검증 질의 12건이 그대로 통과해야 한다. 이것이 주 리스크다」로 +못박았다. 실측 결과, 격자 전량에서 문장형 정답 누락은 0 이었다. ``` 훑은 조합 240개 (τ_abs 20 × r 12) 문장형 정답 누락 0/12 인 조합 240/240 문장형 빈 결과 0/12 인 조합 240/240 ``` -구조적이다. 이 티켓이 움직이는 방향은 컷을 **푸는** 쪽이고, 컷을 풀면 이미 통과하던 것이 -더 잘 통과한다. 문장형 정답 최솟값 0.3642 는 격자 상단(0.34)보다도 위에 있다. +구조적인 결과다. 이 티켓이 움직이는 방향은 컷을 푸는 쪽이고, 컷을 풀면 이미 통과하던 +것이 더 잘 통과한다. 문장형 정답 최솟값 0.3642 는 격자 상단(0.34)보다도 위에 있다. -**진짜 리스크는 다른 열에 있었다 — 무관 질의 침묵이다.** +진짜 리스크는 다른 열에 있었다. 무관 질의에서 결과를 반환하지 않는 성질이다. ``` -τ_abs 문장형 정답 누락 문장형 무관 질의가 결과를 뱉는 수 -0.30 (현행) 0/12 4/15 ← -213 이 얻은 「11/15 침묵」 +τ_abs 문장형 정답 누락 문장형 무관 질의가 결과를 반환하는 수 +0.30 (현행) 0/12 4/15 ← -213 이 얻은 「15건 중 11건 미반환」 0.26 0/12 8/15 -0.24 0/12 10/15 ← 침묵이 5/15 로 반토막 +0.24 0/12 10/15 ← 미반환이 5/15 로 반토막 ``` -`-213` 이 컷을 도입한 목적 자체가 무관 질의 침묵이었다. 단일값을 내리면 **정답은 하나도 -안 잃으면서 그 목적이 무너진다.** 「정답 누락」만 감시했으면 이 손실을 못 봤을 것이다. +`-213` 이 컷을 도입한 목적 자체가 무관 질의에서 결과를 반환하지 않는 것이었다. +단일값을 내리면 정답은 하나도 잃지 않으면서 그 목적이 무너진다. 「정답 누락」만 +감시했으면 이 손실을 보지 못했을 것이다. -이 정정이 채택안을 바꿨다. 문장형 회귀가 정답 누락으로 나타났다면 단일값 인하가 애초에 -불가능했겠지만, 실제로는 **무관 침묵이라는 별도 축의 손실**이라 「가르면 그 손실이 0 이 -된다」는 셋째 길이 열렸다. +이 정정이 채택안을 바꿨다. 문장형 회귀가 정답 누락으로 나타났다면 단일값 인하가 +애초에 불가능했겠지만, 실제로는 무관 질의 미반환이라는 별도 축의 손실이라 「값을 +가르면 그 손실이 0 이 된다」는 셋째 길이 열렸다. ## 무엇을 어떻게 쟀는가 ### `-255` 의 22건으로는 답할 수 없다 -`-255` 는 세 이슈의 **원인을 가르기 위한** 진단 집합이라 정답 있는 질의에 치우쳐 있고 -**무관 통제가 `치과` 하나**다. 컷 값을 정하는 판단은 「무관 대역이 어디까지 올라오는가」가 -전부이므로 그 대역을 1점으로 재면 안 된다. +`-255` 는 세 이슈의 원인을 가르기 위한 진단 집합이라 정답 있는 질의에 치우쳐 있고, +무관 통제가 `치과` 하나다. 컷 값을 정하는 판단은 「무관 대역이 어디까지 +올라오는가」가 전부이므로 그 대역을 1점으로 재면 안 된다. -**`-213` 이 정확히 그 함정을 밟은 기록이 있다.** `personal-search.md §6` 이 무관 질의 -**1건**으로 「간격 +0.2120, 컷 불필요」를 결론 냈고, 15건으로 늘리자 -0.0176 이 되어 결론이 -뒤집혔다. 같은 실수를 반복하지 않는 것이 이 측정 설계의 출발점이다. +`-213` 이 정확히 그 함정을 밟은 기록이 있다. `personal-search.md §6` 이 무관 질의 +1건으로 「간격 +0.2120, 컷 불필요」를 결론 냈고, 15건으로 늘리자 -0.0176 이 되어 +결론이 뒤집혔다. 같은 실수를 반복하지 않는 것이 이 측정 설계의 출발점이다. ``` -255 무관 통제 1건 간격 -0.0515 @@ -74,25 +71,27 @@ ### 기대 정답을 손으로 짝짓지 않는다 -단어형은 질의 하나에 정답이 여럿이다(`라멘` → 2건, `신한` → 6건). 손으로 정하면 판정자 -재량이 결과를 만든다 — `-213` 이 `labels.yaml` 의 한계로 스스로 적은 것이다(「판정자는 한 -명이고 교차 검증이 없다」). +단어형은 질의 하나에 정답이 여럿이다(`라멘` → 2건, `신한` → 6건). 손으로 정하면 +판정자 재량이 결과를 만든다. `-213` 이 `labels.yaml` 의 한계로 스스로 적은 +것이다(「판정자는 한 명이고 교차 검증이 없다」). ``` expect(질의, 소유자) = { 그 소유자의 Record 중 본문에 질의가 그대로 있는 것 } ``` -**이 기준이 이 티켓의 문제 정의와 정확히 일치한다** — `ai#87` 의 요구가 「본문에 있는 -말로 검색하면 그 기록이 나와야 한다」이기 때문이다. `-255` 가 **완전 일치는 유사도에 -유의미하게 기여하지 않는다**를 실측했지만, 그것은 「완전 일치가 정답 기준이 아니다」가 -아니라 **「임베딩이 그 기준을 못 따라간다」**는 뜻이다. 재는 쪽의 기준은 사용자 기대에 둔다. +이 기준이 이 티켓의 문제 정의와 정확히 일치한다. `ai#87` 의 요구가 「본문에 있는 +말로 검색하면 그 기록이 나와야 한다」이기 때문이다. `-255` 가 완전 일치는 유사도에 +유의미하게 기여하지 않는다는 것을 실측했지만, 그것은 「완전 일치가 정답 기준이 +아니다」가 아니라 「임베딩이 그 기준을 따라가지 못한다」는 뜻이다. 재는 쪽의 기준은 +사용자 기대에 둔다. -장소명은 정답 기준에서 뺐다. 임베딩이 받는 것은 `context` 하나뿐이라(`demo_data.yaml` §①) -장소명을 넣으면 **모델에 주지 않은 정보를 기대**하게 된다. 「진우네 초밥」의 본문에 「초밥」이 -없는 것이 그 예이고, 가드가 GMS 를 부르기 전에 그 질의를 잡아냈다. +장소명은 정답 기준에서 뺐다. 임베딩이 받는 것은 `context` +하나뿐이라(`demo_data.yaml` §①) 장소명을 넣으면 모델에 주지 않은 정보를 기대하게 +된다. 「진우네 초밥」의 본문에 「초밥」이 없는 것이 그 예이고, 가드가 GMS 를 부르기 +전에 그 질의를 잡아냈다. -부작용이 이득이었다 — 같은 질의를 소유자 셋에게 던지면 정답 있는 행과 없는 행이 동시에 -생긴다. **통제가 공짜로 늘었다.** +부작용이 이득이었다. 같은 질의를 소유자 셋에게 던지면 정답 있는 행과 없는 행이 +동시에 생긴다. 통제 표본이 추가 비용 없이 늘었다. ### 통제를 두 층으로 나눴다 @@ -105,21 +104,23 @@ cross 96행 같은 단어형인데 그 소유자에겐 없다 본 offtopic 45행 PinLog 범주 밖 (`-213` 무관 5종의 단어형) **무관 통과의 정본** ``` -`cross` 는 무르다(같은 생활권 어휘라 우연히 겹칠 수 있다). 합치면 「무관 통과」가 물러지므로 -표를 가른다 — `-213` 이 `plausible` 을 `irrelevant` 에 밀지 않은 것과 같은 이유다. +`cross` 는 기준이 느슨하다(같은 생활권 어휘라 우연히 겹칠 수 있다). 합치면 「무관 +통과」 지표의 의미가 흐려지므로 표를 가른다. `-213` 이 `plausible` 을 `irrelevant` 에 +합치지 않은 것과 같은 이유다. ### 라벨을 요구하지 않는다 -`sweep.py` 는 `labels.yaml` 전량을 강제한다. 단어형은 (질의 × 소유자) 207행이라 손 라벨이 -현실적이지 않고 **그럴 필요도 없다** — 완료 조건 셋(정답 누락 · 무관 통과 · 빈 결과)은 -라벨 없이 나오고, 라벨이 필요한 것은 `sweep.py` 의 넷째 지표(꼬리 제거율)뿐이다. +`sweep.py` 는 `labels.yaml` 전량을 강제한다. 단어형은 (질의 × 소유자) 207행이라 손 +라벨이 현실적이지 않고, 그럴 필요도 없다. 완료 조건 셋(정답 누락 · 무관 통과 · 빈 +결과)은 라벨 없이 나오고, 라벨이 필요한 것은 `sweep.py` 의 넷째 지표(꼬리 +제거율)뿐이다. -**꼬리 제거율을 포기하는 대신 무관 통제를 45행으로 늘렸다.** 판정자 재량에 덜 기대는 +꼬리 제거율을 포기하는 대신 무관 통제를 45행으로 늘렸다. 판정자 재량에 덜 기대는 교환이다. ### 「1위 손실」을 따로 센다 -측정 도중 추가한 지표이고 **채택값을 이것이 정했다.** +측정 도중 추가한 지표이고, 채택값을 이것이 정했다. ``` τ_abs=0.26 회복 68/71 (96%) ← 숫자만 보면 충분해 보인다 @@ -129,8 +130,9 @@ offtopic 45행 PinLog 범주 밖 (`-213` 무관 5종의 단어형) **무관 야경/host → 언덕 위 야경 식당 0.2552 (1위) ``` -사용자가 「비건」으로 검색하면 플랜트가 **1위인데 결과가 0건**이다. 이것은 컷이 유사도 -순서를 존중하지 않는다는 뜻이고, 회복률 96% 뒤에 숨는다. 회복률보다 앞에 두는 것이 옳다. +사용자가 「비건」으로 검색하면 플랜트가 1위인데 결과가 0건이다. 이것은 컷이 유사도 +순서를 존중하지 않는다는 뜻이고, 회복률 96% 라는 숫자에는 나타나지 않는다. 회복률보다 +앞에 두는 것이 옳다. ## 결과 — 격자 @@ -163,9 +165,10 @@ offtopic 45행 PinLog 범주 밖 (`-213` 무관 5종의 단어형) **무관 | 0.70 | 0/66 | 0 | 71/71 | **45/45** | 0/12 | **15/15** | | 0.80 | 3/66 | 0 | 70/71 | **45/45** | 0/12 | **15/15** | -**`r` 은 무관 통과를 어느 값에서도 한 건도 줄이지 못한다.** `-213` 이 문장형에서 관측한 -구조적 성질(1위를 언제나 남긴다)이 단어형에서 그대로 재현된다. 동시에 단어형 정답 손실도 -`r=0.75` 까지 0 이다 — **`r` 은 이 티켓의 교환에 참여하지 않는다.** 그래서 가르지 않았다. +`r` 은 무관 통과를 어느 값에서도 한 건도 줄이지 못한다. `-213` 이 문장형에서 관측한 +구조적 성질(1위를 언제나 남긴다)이 단어형에서 그대로 재현된다. 동시에 단어형 정답 +손실도 `r=0.75` 까지 0 이다. 즉 `r` 은 이 티켓의 교환에 참여하지 않는다. 그래서 +가르지 않았다. ### 교환점 — 현행 대비 증분 @@ -177,7 +180,7 @@ offtopic 45행 PinLog 범주 밖 (`-213` 무관 5종의 단어형) **무관 | 0.26 | +7 | +10 | +4 | 1.43 | | 0.28 | +4 | +8 | +2 | 2.00 | -교환비만 보면 0.26 이 최선(1.43)이다. **그것을 기각한 것이 「1위 손실 3」이다** — 위 §참조. +교환비만 보면 0.26 이 최선(1.43)이다. 그것을 기각한 근거가 「1위 손실 3」이다. 위 §참조. ### 질의 길이로 가르면 @@ -188,32 +191,33 @@ offtopic 45행 PinLog 범주 밖 (`-213` 무관 5종의 단어형) **무관 | 0.24 | 0.34 | 71/71 | 2/66 | 26/45 | 0/12 | 1/15 | | 0.26 | 0.30 | 68/71 | 5/66 | 20/45 | 0/12 | 4/15 | -**가르는 것의 이득은 오른쪽 끝 열 하나다** — 단어형 열은 τ(문장)이 무엇이든 같고, 문장형 -열은 τ(단어)가 무엇이든 같다. 두 축이 완전히 분리되므로 **가르면 양쪽 다 최적을 쓴다.** +가르는 것의 이득은 오른쪽 끝 열 하나다. 단어형 열은 τ(문장)이 무엇이든 같고, 문장형 +열은 τ(단어)가 무엇이든 같다. 두 축이 완전히 분리되므로 가르면 양쪽 다 최적값을 쓸 +수 있다. -`τ(문장)=0.34` 는 채택하지 않았다. 문장형 무관 통과가 1/15 로 더 좋아지지만 `-213` 이 -안전 상한 0.36 대비 17% 마진으로 0.30 을 고른 판단을 뒤집는 것이고, **이 티켓은 문장형을 -재지 않았다**(`matrix.json` 을 재사용했을 뿐이다). 남의 측정 위에서 남의 판단을 바꾸지 -않는다. +`τ(문장)=0.34` 는 채택하지 않았다. 문장형 무관 통과가 1/15 로 더 좋아지지만 `-213` +이 안전 상한 0.36 대비 17% 마진으로 0.30 을 고른 판단을 뒤집는 것이고, 이 티켓은 +문장형을 재지 않았다(`matrix.json` 을 재사용했을 뿐이다). 다른 티켓의 측정 위에서 그 +티켓의 판단을 바꾸지 않는다. ## 재현성 — 인접 τ 값을 구분할 수 있는가 -**이 절은 `-228` 의 `T68` 이 병합된 뒤 추가했다.** 그 티켓이 임베딩 API 가 **같은 배치 -구성으로도** 결정적이지 않음을 실측했고(`|Δsim|` 최대 **0.0044**), 그 크기가 이 티켓의 -격자 간격(0.01)과 같은 자릿수다. **그대로면 「0.24 와 0.25 의 차이」가 실제 차이인지 -재측정 운인지 갈리지 않는다.** +이 절은 `-228` 의 `T68` 이 병합된 뒤 추가했다. 그 티켓이 임베딩 API 가 같은 배치 +구성으로도 결정적이지 않음을 실측했고(`|Δsim|` 최대 0.0044), 그 크기가 이 티켓의 +격자 간격(0.01)과 같은 자릿수다. 그대로면 「0.24 와 0.25 의 차이」가 실제 차이인지 +재측정 운인지 구분되지 않는다. -`T68` 이 남긴 조언이 이 티켓을 정확히 겨눈다 — *「τ 를 다시 정할 일이 있으면 격자를 +`T68` 이 남긴 조언이 이 티켓에 그대로 적용된다. *「τ 를 다시 정할 일이 있으면 격자를 `0.01` 보다 촘촘하게 두지 않는다」*. 이 격자는 0.01 이라 그 선을 지켰다. ### 두 층을 갈라야 한다 -``` -격자 내부 임베딩을 한 번 떠서 굳히고 오프라인으로 훑는다 → 잡음이 끼지 않는다 -채택값의 절대 `스팟` 0.2438 과 τ=0.24 의 마진 0.0038 → 여기가 노출된다 -``` +- **격자 내부** — 임베딩을 한 번 떠서 고정하고 오프라인으로 훑는다. 잡음이 끼지 + 않는다. +- **채택값의 절대 위치** — `스팟` 0.2438 과 τ=0.24 의 마진 0.0038. 여기가 잡음에 + 노출된다. -**둘째 층이 위험했다.** 마진 0.0038 이 `T68` 의 실측 잡음 0.0044 **보다 작다.** 그대로면 +둘째 층이 위험했다. 마진 0.0038 이 `T68` 의 실측 잡음 0.0044 보다 작다. 그대로면 채택값이 재측정 운에 얹힌 것이 된다. ### 실측했다 — 회차 3개 @@ -230,7 +234,7 @@ offtopic 45행 PinLog 범주 밖 (`-213` 무관 5종의 단어형) **무관 순위가 바뀐 질의 0건 ``` -**`T68` 규모의 흔들림이 이 조건에서는 나타나지 않는다 — 관측된 상한이 21배 작다.** +`T68` 규모의 변동이 이 조건에서는 나타나지 않는다. 관측된 상한이 21배 작다. 경계점은 아예 움직이지 않았다. @@ -251,15 +255,16 @@ offtopic 45행 PinLog 범주 밖 (`-213` 무관 5종의 단어형) **무관 | 0.28 | 3 · 65 · 6 | **일치** | | 0.30 | 5 · 61 · 11 | **일치** | -**답: 이 격자에서 인접 값의 차이는 재측정 운이 아니다.** 흔들림 상한 2×10⁻⁴ 가 격자 -간격보다 두 자릿수 작다. +답은 다음과 같다. 이 격자에서 인접 값의 차이는 재측정 운이 아니다. 변동 상한 +2×10⁻⁴ 가 격자 간격보다 두 자릿수 작다. ### 왜 `T68` 과 다른가 — 단정하지 않는다 -`T68` 은 `(Context, Preset)` 1,134쌍에서 **27개 프리셋 중 3개만** 갈렸다고 적었다(나머지 -24개는 코사인 1.000000). 즉 **특정 텍스트에서만 크게 흔들린다.** 이 티켓의 질의 69건은 -전부 짧은 단어·구절이고 프리셋 `description` 은 긴 문장이라 **텍스트 길이가 관련 있어 -보이나 통제한 측정은 없다.** 여기서 말할 수 있는 것은 조건별 실측값뿐이다. +`T68` 은 `(Context, Preset)` 1,134쌍에서 27개 프리셋 중 3개만 값이 달라졌다고 +적었다(나머지 24개는 코사인 1.000000). 즉 특정 텍스트에서만 크게 변동한다. 이 티켓의 +질의 69건은 전부 짧은 단어·구절이고 프리셋 `description` 은 긴 문장이라, 텍스트 +길이가 관련 있어 보이나 통제한 측정은 없다. 여기서 말할 수 있는 것은 조건별 +실측값뿐이다. ``` `-266` 이 조건 (질의 69건 · 배치 구성 동일) |Δsim| ≤ 0.000209 @@ -270,9 +275,9 @@ offtopic 45행 PinLog 범주 밖 (`-213` 무관 5종의 단어형) **무관 ### 상시 관측으로 남긴다 `T68` 의 처방(`base2` 를 상시 조건으로 두고 매 측정에서 다시 잰다)을 그대로 따랐다. -`word_sweep.py --repro` 가 회차들을 받아 흔들림과 격자 판정 안정성을 낸다. +`word_sweep.py --repro` 가 회차들을 받아 변동 크기와 격자 판정 안정성을 낸다. -**한 번 재고 문서에만 적으면 다음 사람은 그 조건이 아직 유효한지 알 방법이 없다.** +한 번 재고 문서에만 적으면 다음 사람은 그 조건이 아직 유효한지 알 방법이 없다. ```bash python tools/search_cut/word_matrix.py --out .search/word_grid_run2.json @@ -287,14 +292,14 @@ python tools/search_cut/word_sweep.py --repro .search/word_grid.json,.search/wor | 조합 | 기각 근거 | |---|---| -| **0.30 단일** (현행) | 단어형에서 **컷 전 1위 정답 5건**이 0건. `ai#87` 의 증상 자체 | +| **0.30 단일** (현행) | 단어형에서 컷 전 1위 정답 5건이 0건. `ai#87` 의 증상 자체 | | **0.26 단일/분기** | 1위 손실 3건(`스팟`·`비건`·`야경`). 교환비는 가장 좋지만 「1위인데 0건」은 순위 존중 실패라 다른 층의 문제다 | -| **0.24 단일** | 단어형은 같지만 **문장형 무관 침묵이 11/15 → 5/15**. `-213` 의 목적을 무르게 한다 | -| **0.22 단일/분기** | 회복은 0.24 와 같은 71/71 인데 무관 통과가 5건 더 많다(31 vs 26). 살리는 것은 `수다/host` 0.1870(5위) 하나뿐이고 그것은 컷 전 순위가 낮아 **컷으로 살릴 대상이 아니다** | -| **`r` 을 함께 가름** | `r` 은 무관 통과를 한 건도 못 줄이고 단어형 손실도 `r=0.75` 까지 0 이다. 가르면 **재지 않은 축을 코드가 만드는 셈**이다 | -| **`τ(문장)` 을 0.34 로** | 문장형은 이 티켓이 재지 않았다. `-213` 의 마진 판단을 남의 데이터로 뒤집지 않는다 | +| **0.24 단일** | 단어형은 같지만 문장형 무관 질의 미반환이 11/15 → 5/15 로 준다. `-213` 의 목적을 약화시킨다 | +| **0.22 단일/분기** | 회복은 0.24 와 같은 71/71 인데 무관 통과가 5건 더 많다(31 vs 26). 살리는 것은 `수다/host` 0.1870(5위) 하나뿐이고 그것은 컷 전 순위가 낮아 컷으로 살릴 대상이 아니다 | +| **`r` 을 함께 가름** | `r` 은 무관 통과를 한 건도 줄이지 못하고 단어형 손실도 `r=0.75` 까지 0 이다. 가르면 재지 않은 축을 코드가 만드는 셈이다 | +| **`τ(문장)` 을 0.34 로** | 문장형은 이 티켓이 재지 않았다. `-213` 의 마진 판단을 다른 측정의 데이터로 뒤집지 않는다 | -**질의 길이로 가르는 것이 결론이다.** 구현 복잡도는 낮다. +질의 길이로 가르는 것이 결론이다. 구현 복잡도는 낮다. ``` app/core/config.py 설정 키 2개 (하한 · 경계 글자 수) @@ -308,11 +313,11 @@ app/service/search_service.py `_cut(rows, query)` · `_is_word_query` 9줄 - 단어형 54건 × 소유자 3명 = 207행 측정. **GMS 임베딩 배치 1회**(69건) - 격자 240조합(τ_abs 20 × r 12)을 단어형·문장형 **동시에** 훑음. GMS·DB 미호출 -- **재현성 회차 3개**(§재현성) — `T68` 대응. 흔들림 상한 0.000209 · 격자 판정 전 구간 +- **재현성 회차 3개**(§재현성) — `T68` 대응. 변동 상한 0.000209 · 격자 판정 전 구간 3회 일치 · 경계점 `스팟` 스프레드 0.000000. 회차당 GMS 배치 1회씩 추가 - **실서버 대조 87/87 PASS** — 이 브랜치 코드로 띄운 서버(:8002)에 문장형 27건 + - **두 하한에서 결과가 갈리는 단어형 60행**을 던졌다. 갈리지 않는 행은 서버가 무엇을 하든 - 통과하므로 표본에서 뺐다. 재구성 쪽에 길이 분기를 **다시 적어**(구현 `import` 금지, + 두 하한에서 결과가 갈리는 단어형 60행을 던졌다. 갈리지 않는 행은 서버가 무엇을 하든 + 통과하므로 표본에서 뺐다. 재구성 쪽에 길이 분기를 다시 적어(구현 `import` 금지, `-213` 규칙) 「서버가 분기를 실제로 타는가」가 관측되게 했다 - **`ai#87` 증상 회복을 실서버로 직접 확인** @@ -326,37 +331,38 @@ app/service/search_service.py `_cut(rows, query)` · `_is_word_query` 9줄 - `ruff check .` 통과 · `pytest` **441건 통과**(테스트 함수 **11건** 추가 — 단위 9 + API 관통 2) · coverage 게이트 통과(line 99.83% · branch 98.99%, `search_service.py` 100%) - > **수치 정정.** 이 문서는 처음에 「441건 통과(단위 6건 추가)」로 적었고 **당시엔 둘 다 - > 틀렸다** — 그 시점 실측은 **435**였고 「441」은 세지 않고 `435 + 6` 으로 더한 값이며, - > 「6건」은 헬퍼 `_cut_q` 를 테스트로 잘못 센 것이다(실제 함수 5). 검토가 후자를 잡았고 - > 전자는 그 확인 중에 드러났다. **지금 441 인 것은 우연이다** — 검토 대응으로 테스트 - > 6건(API 관통 2 · 단위 4)을 더해 435 → 441 이 됐다. **검증 수치는 재현되므로 틀리면 - > 반드시 드러난다** — 세지 않고 적지 않는다. + > **수치 정정.** 이 문서는 처음에 「441건 통과(단위 6건 추가)」로 적었고 당시엔 둘 + > 다 틀렸다. 그 시점 실측은 435였고 「441」은 세지 않고 `435 + 6` 으로 더한 값이며, + > 「6건」은 헬퍼 `_cut_q` 를 테스트로 잘못 센 것이다(실제 함수 5). 검토가 후자를 + > 잡았고 전자는 그 확인 중에 드러났다. 지금 441 인 것은 우연이다. 검토 대응으로 + > 테스트 6건(API 관통 2 · 단위 4)을 더해 435 → 441 이 됐다. 검증 수치는 재현되므로 + > 틀리면 반드시 드러난다. 세지 않고 적지 않는다. > > 재확인 시 `pytest -q` 를 쓰지 마라. `addopts` 에 이미 `-q` 가 있어 `-qq` 가 되고 - > **요약 줄이 사라진다** — 그것이 이 오류가 오래 남은 경로다. + > 요약 줄이 사라진다. 그것이 이 오류가 오래 남은 경로다. > - > **같은 실수가 이 PR 안에서 두 번 났다.** 한 번은 처음 적을 때, 한 번은 정정한 뒤 - > 테스트를 더 추가하면서다 — 리포트·WORKLOG 는 갱신했는데 **PR 본문만 낡은 값으로 - > 남았다.** 원인은 세지 않은 것이 아니라 **검증 수치가 세 곳에 흩어져 있는데 함께 - > 고치는 절차가 없다**는 것이다. 테스트를 더할 때마다 셋을 함께 본다. + > 같은 실수가 이 PR 안에서 두 번 났다. 한 번은 처음 적을 때, 한 번은 정정한 뒤 + > 테스트를 더 추가하면서다. 리포트·WORKLOG 는 갱신했는데 PR 본문만 낡은 값으로 + > 남았다. 원인은 세지 않은 것이 아니라, 검증 수치가 세 곳에 흩어져 있는데 함께 + > 고치는 절차가 없다는 것이다. 테스트를 더할 때마다 셋을 함께 본다. -- **길이 분기의 호출부 배선을 API 관통 테스트로 고정했다.** `_cut(rows, query)` 의 `query` - 에 기본값이 있어 **`, query` 가 사라져도 `_cut` 은 계속 불리고**, 라인·분기 커버리지가 - 100% 인 채로 모든 단어형 질의가 0.30 으로 돌아간다(`ai#87` 재발). RED 드릴로 확인했다 — - 배선을 끊으니 `test_search_word_query_takes_the_word_floor` 만 실패한다. +- **길이 분기의 호출부 배선을 API 관통 테스트로 고정했다.** `_cut(rows, query)` 의 + `query` 에 기본값이 있어 `, query` 가 사라져도 `_cut` 은 계속 불리고, 라인·분기 + 커버리지가 100% 인 채로 모든 단어형 질의가 0.30 으로 돌아간다(`ai#87` 재발). 배선을 + 일부러 끊어 확인했다. 끊으니 `test_search_word_query_takes_the_word_floor` 만 + 실패한다. ``` 유사도를 두 하한 사이 [0.24, 0.30) 에 둔 것이 요점이다 기존 검색 테스트는 질의와 저장 텍스트가 같아 유사도가 1.0 이라 어느 하한에서도 통과한다 ``` - **「`search_service.py` coverage 100%」가 안전을 보증하지 못하는 자리가 정확히 여기다.** + 「`search_service.py` coverage 100%」가 안전을 보증하지 못하는 자리가 정확히 여기다. 다음 사람이 같은 근거로 안심하지 않도록 적어 둔다. -- **검토가 지적한 나머지도 변이 드릴로 확인했다.** 「테스트를 더했다」와 「그 테스트가 그 - 결함을 잡는다」는 다른 명제라, 넷 다 실제로 어긋내 보고 대응하는 테스트만 실패하는 것을 - 봤다. +- **검토가 지적한 나머지도 변이 확인으로 검증했다.** 「테스트를 더했다」와 「그 + 테스트가 그 결함을 잡는다」는 다른 명제라, 넷 다 실제로 어긋내 보고 대응하는 + 테스트만 실패하는 것을 확인했다. | 변이 | 실패한 테스트 | |---|---| @@ -366,58 +372,61 @@ app/service/search_service.py `_cut(rows, query)` · `_is_word_query` 9줄 | 비상 스위치 가드를 분기 뒤로 | `test_cut_kill_switch_also_disables_the_word_floor` | - **`recall_probe.py` 재판정을 실제로 돌려 확인했다.** 분기를 넣기 전에는 이 프로브가 - `그네`·`스팟`·`산책`·`라멘` 을 「① 컷이 잘랐다」로 찍었다 — **이 티켓이 고친 증상을 계속 - 미해결로 읽는다.** 넣은 뒤 넷 다 「— 통과」이고 `신한`·`부캠` 은 여전히 ③ 다(컷으로 못 - 푸는 것이 맞다). 커밋된 행렬은 md5 로 불변을 확인했다. + `그네`·`스팟`·`산책`·`라멘` 을 「① 컷이 잘랐다」로 찍었다. 즉 이 티켓이 고친 증상을 + 계속 미해결로 읽는 상태였다. 분기를 넣은 뒤 넷 다 「— 통과」이고 `신한`·`부캠` 은 + 여전히 ③ 다(컷으로 못 푸는 것이 맞다). 커밋된 행렬은 md5 로 불변을 확인했다. **못 한 것** - **운영 DB 를 재지 않았다.** 로컬 시연 DB(`:15432`)로만 쟀다 — `-255` 와 같다 - **꼬리 제거율을 재지 않았다.** 라벨을 요구하지 않는 설계의 대가다(§라벨을 요구하지 않는다). - 「무관한 것이 얼마나 줄었나」는 무관 통제 45행의 침묵 수로 대신한다 + 「무관한 것이 얼마나 줄었나」는 무관 통제 45행 중 결과 0건인 수로 대신한다 - **경계 정의를 가르지 못했다.** 아래 §말할 수 없는 것 ## 말할 수 없는 것 -- **공백 구분자를 넓힌 대역은 재지 않았다.** `_is_word_query` 는 공백을 `str.isspace()` - 로 보므로 전각 공백(U+3000)·탭·NBSP 로 띄운 짧은 2어절 질의가 **문장형**(더 세게 자름)으로 - 간다. U+0020 만 보면 이 티켓이 선언한 안전 방향이 **뒤집히므로**(그런 질의가 「공백 없음」이 - 되어 오히려 느슨한 0.24 를 탄다) 넓히는 쪽이 맞다고 판단했지만, **행렬 111행이 전부 공백 - 없는 2~5자라 이 대역의 유사도를 재지는 않았다.** 측정이 아니라 안전 방향으로의 판단이다. - 경계 정의를 재는 후속(`§후속`)이 이 대역도 함께 봐야 한다. +- **공백 구분자를 넓힌 대역은 재지 않았다.** `_is_word_query` 는 공백을 + `str.isspace()` 로 보므로 전각 공백(U+3000)·탭·NBSP 로 띄운 짧은 2어절 질의가 + 문장형(더 세게 자름)으로 간다. U+0020 만 보면 이 티켓이 선언한 안전 방향이 + 뒤집히므로(그런 질의가 「공백 없음」이 되어 오히려 느슨한 0.24 를 탄다) 넓히는 쪽이 + 맞다고 판단했지만, 행렬 111행이 전부 공백 없는 2~5자라 이 대역의 유사도를 재지는 + 않았다. 측정이 아니라 안전 방향으로의 판단이다. 경계 정의를 재는 후속(`§후속`)이 이 + 대역도 함께 봐야 한다. - **경계 5 자는 측정이 아니라 판단이다.** 측정한 단어형이 전부 공백 없는 2~5자이고 문장형이 전부 공백 포함 6자↑라 「글자 수」와 「어절 수」 두 정의가 같은 답을 냈다. 둘이 갈리는 질의(`-255` 의 `신한 부트캠프` — 7자 2어절)가 이 행렬에 없다. 두 조건을 - **함께** 요구해 애매한 질의를 문장형(더 세게 자름)으로 기울인 것이 이 불확실성에 대한 - 대응이고, 값 자체는 `-255` 의 길이 상관이 쓴 ≤5자를 따랐다 -- **0.24 의 마진이 얇다 — 다만 이유가 재측정 잡음은 아니고, 그래서 방아쇠를 명세에 적었다.** - 마진을 넓혀도 경계를 한 점이 정한다는 구조는 사라지지 않는다(0.22 의 경계도 어떤 - 데이터점이 정한다 — 다른 점일 뿐이다). 그래서 **여유가 아니라 재측정 조건**으로 - 대응했다 — `personal-search.md §6.1 「이 값을 다시 재야 하는 때」`에 방아쇠 셋(시연 DB - 재시딩 · Context 수 유의미한 증가 · 임베딩 모델 교체 `-199`)과 절차를 적었다. 최저 정답 `스팟` 0.2438 과의 - 거리가 0.0038 이고, 이것이 `T68` 의 실측 잡음 0.0044 보다 작아 한때 이 값 전체를 - 위협했다. **회차 3개로 재서 이 조건의 흔들림 상한이 0.000209 임을 확인했고**(§재현성) - 경계점 `스팟` 은 스프레드 0.000000 이다 — 마진이 관측 잡음의 18배다. **남는 위험은 - 다른 것이다**: 재시딩·질의 집합 변경·데이터 증가로 이 한 점이 이동하면 `스팟` 이 다시 - 사라진다. **경계를 데이터점 하나가 정한다는 사실 자체**가 약점이고(`-213` 이 상한을 - 데이터점 하나가 정하는 것을 같은 이유로 적었다) 그것은 재현성으로 해소되지 않는다 + 함께 요구해 애매한 질의를 문장형(더 세게 자름)으로 기울인 것이 이 불확실성에 대한 + 대응이고, 값 자체는 `-255` 의 길이 상관이 쓴 ≤5자를 따랐다. +- **0.24 의 마진이 얇다. 다만 이유가 재측정 잡음은 아니고, 그래서 재측정 조건을 + 명세에 적었다.** 마진을 넓혀도 경계를 한 점이 정한다는 구조는 사라지지 않는다(0.22 + 의 경계도 어떤 데이터점이 정한다. 다른 점일 뿐이다). 그래서 여유가 아니라 재측정 + 조건으로 대응했다. `personal-search.md §6.1 「이 값을 다시 재야 하는 때」`에 재측정 + 조건 셋(시연 DB 재시딩 · Context 수 유의미한 증가 · 임베딩 모델 교체 `-199`)과 + 절차를 적었다. 최저 정답 `스팟` 0.2438 과의 거리가 0.0038 이고, 이것이 `T68` 의 + 실측 잡음 0.0044 보다 작아 한때 이 값 전체를 위협했다. 회차 3개로 재서 이 조건의 + 변동 상한이 0.000209 임을 확인했고(§재현성) 경계점 `스팟` 은 스프레드 0.000000 + 이다. 마진이 관측 잡음의 18배다. 남는 위험은 다른 것이다. 재시딩·질의 집합 + 변경·데이터 증가로 이 한 점이 이동하면 `스팟` 이 다시 사라진다. 경계를 데이터점 + 하나가 정한다는 사실 자체가 약점이고(`-213` 이 상한을 데이터점 하나가 정하는 것을 + 같은 이유로 적었다) 그것은 재현성 확인으로 해소되지 않는다. - **무관 통과 26/45 를 사용자가 어떻게 느끼는지 모른다.** 단어형 질의의 절반 이상이 - 결과를 뱉는다. 잰 것은 「정답이 없는 질의에 결과가 남았다」이지 그것이 나쁜 경험인지가 - 아니다 — 짧은 질의에 느슨한 결과를 기대하는 사용자도 있을 수 있다 + 결과를 반환한다. 잰 것은 「정답이 없는 질의에 결과가 남았다」이지 그것이 나쁜 + 경험인지가 아니다. 짧은 질의에 느슨한 결과를 기대하는 사용자도 있을 수 있다. - **표본이 작고 한쪽으로 쏠려 있다.** 소유자 3명 · Record 42건이고 그중 28건이 실제 - 사용자 둘의 기록이다. 단어형 질의 54건은 이 티켓의 작업자가 본문에서 뽑았다 — - 「본문에 있는 말」이라는 기준은 기계적이지만 **어떤 말을 뽑을지**는 재량이었다 -- **`ai#86`·`ai#88` 은 이 티켓이 건드리지 않는다.** `-255` 가 컷으로 못 푼다고 판정했고 - (풀어도 6위·8위) 이 측정도 같다 — `신한` 의 카츠요는 0.2301(6위), `부캠` 의 카츠요는 - 0.2105(8위)라 τ_abs 를 0.24 로 내려도 상위에 오지 않는다 -- **운영에서도 같은지 확인하지 않았다.** 운영 증상이 이 측정과 다르면 그쪽부터 봐야 한다 + 사용자 둘의 기록이다. 단어형 질의 54건은 이 티켓의 작업자가 본문에서 뽑았다. + 「본문에 있는 말」이라는 기준은 기계적이지만 어떤 말을 뽑을지는 재량이었다. +- **`ai#86`·`ai#88` 은 이 티켓이 건드리지 않는다.** `-255` 가 컷으로 못 푼다고 + 판정했고(풀어도 6위·8위) 이 측정도 같다. `신한` 의 카츠요는 0.2301(6위), `부캠` 의 + 카츠요는 0.2105(8위)라 τ_abs 를 0.24 로 내려도 상위에 오지 않는다. +- **운영에서도 같은지 확인하지 않았다.** 운영 증상이 이 측정과 다르면 그쪽부터 봐야 + 한다. ## 병렬 작업과의 간섭 -`-228`(프리셋 `description` 재개)이 같은 시각 돌았다. 이 측정은 `ai.context_embedding` 만 -읽고 `ai.keyword_preset` 을 건드리지 않으므로 겹치지 않는다. **측정 시각 2026-08-03 -10:57 KST** — Context 임베딩 42건이 그 시점 기준이다(`-255` 의 10:29 KST 측정과 같은 -데이터). +`-228`(프리셋 `description` 재개)이 같은 시각 돌았다. 이 측정은 +`ai.context_embedding` 만 읽고 `ai.keyword_preset` 을 건드리지 않으므로 겹치지 +않는다. 측정 시각은 2026-08-03 10:57 KST 이고, Context 임베딩 42건이 그 시점 +기준이다(`-255` 의 10:29 KST 측정과 같은 데이터). ## 산출물 @@ -431,8 +440,8 @@ app/service/search_service.py `_cut(rows, query)` · `_is_word_query` 9줄 | `.search/word_grid_run2.json` · `run3` | **휘발** | 재현성 회차. GMS 를 부르면 다시 뜬다 — `word_grid.json` 과 달리 **보존 가치가 없다**(같은 값을 재는 것이 목적이므로) | | 이 문서 | **영구** | | -`word_grid.json` 은 `matrix.json`·`recall_probe.json` 과 같은 원칙으로 커밋한다 — -**Context 본문을 담지 않고** Record 대표 이름(장소명)까지만 담는다. +`word_grid.json` 은 `matrix.json`·`recall_probe.json` 과 같은 원칙으로 커밋한다. +Context 본문을 담지 않고 Record 대표 이름(장소명)까지만 담는다. ## 후속으로 남기는 것 @@ -444,23 +453,25 @@ app/service/search_service.py `_cut(rows, query)` · `_is_word_query` 9줄 - **`ai#88`(`부캠`)은 임베딩 모델** — `-199`(`large` 재측정)가 답할 문제다 - **사용자 규모가 커졌을 때** — `-213` 이 남긴 것과 같다. 기록이 수백 건이 되면 top-1 이 올라가 `r` 이 더 세게 자르고, 무관 통과도 늘어난다 -- **`verify_live.pick_word_cases` 가 `limit`·`max_chars` 를 리터럴로 쓴다.** 지금 데이터에서는 - 선정이 항상 초집합이라 무해하지만(무공백 질의가 전부 2~5자라 값을 키워도 새로 단어형이 되는 - 행이 없다), **`max_chars` 를 6 이상으로 올리고 6자 질의를 추가하면 새 경계 행을 조용히 - 건너뛴다.** 표본 선정 전용이고 PASS/FAIL 은 설정값으로 계산하므로 판정이 틀리지는 않는다 -- **`pick_word_cases` 의 문서와 코드가 어긋난다.** 독스트링은 「단어형 행렬 207행」이라 적고 - 코드는 `queries`·`offtopic` 111행만 본다(`cross` 96행은 호출 수 때문에 뺐고 `expect_count=0` - 이라 정답이 컷 아래로 떨어질 수 없다). **문서를 코드에 맞추는 쪽이 맞다** — 독자에게 나간 - 숫자(60행·87건)는 틀리지 않았다 +- **`verify_live.pick_word_cases` 가 `limit`·`max_chars` 를 리터럴로 쓴다.** 지금 + 데이터에서는 선정이 항상 초집합이라 무해하지만(무공백 질의가 전부 2~5자라 값을 + 키워도 새로 단어형이 되는 행이 없다), `max_chars` 를 6 이상으로 올리고 6자 질의를 + 추가하면 새 경계 행을 아무 표시 없이 건너뛴다. 표본 선정 전용이고 PASS/FAIL 은 + 설정값으로 계산하므로 판정이 틀리지는 않는다. +- **`pick_word_cases` 의 문서와 코드가 어긋난다.** 독스트링은 「단어형 행렬 + 207행」이라 적고 코드는 `queries`·`offtopic` 111행만 본다(`cross` 96행은 호출 수 + 때문에 뺐고 `expect_count=0` 이라 정답이 컷 아래로 떨어질 수 없다). 문서를 코드에 + 맞추는 쪽이 맞다. 독자에게 나간 숫자(60행·87건)는 틀리지 않았다. ### 언제 다시 재는가 -위 셋을 **방아쇠로 명세에 못박았다** — [`personal-search.md §6.1 「이 값을 다시 재야 하는 -때」`](../spec/personal-search.md). 리포트는 시점 기록이고 명세가 현재 유효한 규칙이므로, -「값이 무엇에 의존하는가」는 그쪽에 두는 것이 맞다. +위 셋을 재측정 조건으로 명세에 +못박았다([`personal-search.md §6.1 「이 값을 다시 재야 하는 때」`](../spec/personal-search.md)). +리포트는 시점 기록이고 명세가 현재 유효한 규칙이므로, 「값이 무엇에 의존하는가」는 +그쪽에 두는 것이 맞다. ``` -방아쇠 시연 DB 재시딩 · Context 수 유의미한 증가 · 임베딩 모델 교체(-199) -행동 word_matrix.py 재실행 → --repro 로 흔들림 먼저 → word_sweep.py 로 격자 재확인 -기준 「컷 전 1위인 정답을 하나도 잃지 않는 가장 높은 값」 (회복률이 아니다) +재측정 조건 시연 DB 재시딩 · Context 수 유의미한 증가 · 임베딩 모델 교체(-199) +행동 word_matrix.py 재실행 → --repro 로 변동 확인 먼저 → word_sweep.py 로 격자 재확인 +기준 「컷 전 1위인 정답을 하나도 잃지 않는 가장 높은 값」 (회복률이 아니다) ``` diff --git a/docs/implements/2026-08-05-short-query-boundary.md b/docs/implements/2026-08-05-short-query-boundary.md index 1fe990d..6637da6 100644 --- a/docs/implements/2026-08-05-short-query-boundary.md +++ b/docs/implements/2026-08-05-short-query-boundary.md @@ -13,29 +13,30 @@ ## 요약 -``` -탈락 지점 app/service/search_service.py:109-123 `_cut` - 질의 22건을 층별로 세고 **실서버와 22/22 일치**로 재구성을 검증했다 +탈락 지점은 `app/service/search_service.py:109-123` 의 `_cut` 이다. 질의 22건을 +층별로 세고 실서버와 22/22 일치로 재구성을 검증했다. - 남은 실패 3건 (소유자 jeongheon · 시연 DB) - 신한 ③ τ_abs 0.2301 < 0.24 컷 전 6위 - 부캠 ③ τ_abs 0.2105 < 0.24 컷 전 8위 - 신한 부캠 ④ r 0.3187 < 0.3520 (=0.6×0.5867) 컷 전 4위 +남은 실패는 3건이다(소유자 jeongheon · 시연 DB). -「2자가 안 걸린다」는 부정확하다 2자 7건 중 5건은 걸린다(그네·스팟·라멘·우주·공원) - 못 걸리는 둘은 **컷 전 순위가 6위·8위**다 +| 질의 | 탈락 층 | 근거 | 컷 전 순위 | +|---|---|---|---| +| 신한 | ③ τ_abs | 0.2301 < 0.24 | 6위 | +| 부캠 | ③ τ_abs | 0.2105 < 0.24 | 8위 | +| 신한 부캠 | ④ r | 0.3187 < 0.3520 (=0.6×0.5867) | 4위 | -경계 정의(글자 수 vs 어절 수)는 이 실패의 원인이 아니다 - `-266` 이 남긴 B·C 대역을 1,128행으로 처음 쟀다. 손실은 C(무공백·길다)에 몰려 - 있고(16/122) 어절 수 정의로 바꾸면 3/122 이 된다. **그러나 사용자가 실제로 칠 - 법한 초점 집합 36행에서는 어느 정의에서도 정답 누락이 0 이다** +「2자가 안 걸린다」는 부정확하다. 2자 7건 중 5건은 +걸린다(그네·스팟·라멘·우주·공원). 걸리지 않는 둘은 컷 전 순위가 6위·8위다. -새로 관측한 것 공백을 떼면 유사도가 내려간다 198짝 평균 -0.0385 · 172/198 하락 - 전각 공백은 더 내려간다 「신한 부캠」 0.3187 → 0.2753 (τ 탈락) -``` +경계 정의(글자 수 vs 어절 수)는 이 실패의 원인이 아니다. `-266` 이 남긴 B·C 대역을 +1,128행으로 처음 쟀다. 손실은 C(무공백·길다)에 몰려 있고(16/122) 어절 수 정의로 +바꾸면 3/122 이 된다. 그러나 사용자가 실제로 칠 법한 초점 집합 36행에서는 어느 +정의에서도 정답 누락이 0 이다. + +새로 관측한 것이 있다. 공백을 떼면 유사도가 내려간다(198짝 평균 -0.0385 · 172/198 +하락). 전각 공백은 더 내려간다(「신한 부캠」 0.3187 → 0.2753 으로 τ 에서 탈락). 각 항목에 **[측정]** 과 **[추론]** 을 붙였다. `-174`→`-191` 에서 추론이 티켓이 되고 -티켓이 계약이 되어 **잘못된 전제 위에 작업이 쌓인** 일이 있었기 때문이다. +티켓이 계약이 되어, 잘못된 전제 위에 작업이 쌓인 일이 있었기 때문이다. ## 전제 정정 — 이것은 첫 측정이 아니라 세 번째다 @@ -45,28 +46,29 @@ > 몇 자부터 걸리는지, 어느 층에서 탈락하는지 — 아무것도 확정돼 있지 않다. > **네가 재는 것이 첫 측정이다.** -**틀렸다.** 레포에 같은 현상을 잰 리포트가 **둘** 있고 둘째는 코드까지 고쳐 병합됐다. +이 전제는 틀렸다. 레포에 같은 현상을 잰 리포트가 둘 있고, 둘째는 코드까지 고쳐 +병합됐다. | | 티켓 | 무엇을 쟀나 | 상태 | |---|---|---|---| -| 2026-08-03 10:29 | `-255` | 질의 22건 × Record 17건. 원인을 세 갈래로 판정 | 병합됨 | +| 2026-08-03 10:29 | `-255` | 질의 22건 × Record 17건. 원인을 세 유형으로 판정 | 병합됨 | | 2026-08-03 10:57 | `-266` | 단어형 54건 × 소유자 3명. `τ_abs` 를 질의 길이로 가름 | 병합됨(`d87c5f5`) | -`-266` 의 코드가 `origin/dev`·`origin/main` 양쪽에 있고, `infra` `apps/dev/ai/values.yaml` -의 `image.tag` 는 `7e72826`(= `ai` main HEAD)이라 **dev 환경에도 반영돼 있다.** -`env: []` 이므로 `SEARCH_*` 오버라이드도 없다 — 코드 기본값 0.24/0.30 이 그대로 뜬다. -[측정] +`-266` 의 코드가 `origin/dev`·`origin/main` 양쪽에 있고, `infra` +`apps/dev/ai/values.yaml` 의 `image.tag` 는 `7e72826`(= `ai` main HEAD)이라 dev +환경에도 반영돼 있다. `env: []` 이므로 `SEARCH_*` 오버라이드도 없다. 코드 기본값 +0.24/0.30 이 그대로 적용된다. [측정] -> 다만 클러스터에 접근할 수 없어 **그 tag 가 실제로 롤아웃됐는지는 확인하지 않았다.** +> 다만 클러스터에 접근할 수 없어 그 tag 가 실제로 롤아웃됐는지는 확인하지 않았다. > 확인한 것은 `infra` 가 pin 한 값까지다. -**그래서 이 티켓의 질문이 달라진다.** 「짧은 질의가 왜 안 되나」가 아니라 **「`-266` -이후에도 남아 있는 것이 무엇인가」**다. 그리고 `-266` 이 스스로 후속으로 남긴 것이 -정확히 `-273` 이다 — *「경계 정의를 가르는 측정. 지금은 안전한 쪽으로 기울여 두었을 +그래서 이 티켓의 질문이 달라진다. 「짧은 질의가 왜 안 되나」가 아니라 「`-266` +이후에도 남아 있는 것이 무엇인가」다. 그리고 `-266` 이 스스로 후속으로 남긴 것이 +정확히 `-273` 이다. *「경계 정의를 가르는 측정. 지금은 안전한 쪽으로 기울여 두었을 뿐이다」*. -중앙의 08-04 관찰 메모(*「2자·약어만 안 걸린다」*)는 `-266` **이후**의 관찰이고, 아래 -층 관측이 그 메모와 대체로 맞다 — 다만 「2자」가 아니라 「2자 중 둘」이다. +중앙의 08-04 관찰 메모(*「2자·약어만 안 걸린다」*)는 `-266` 이후의 관찰이고, 아래 층 +관측이 그 메모와 대체로 맞다. 다만 「2자」가 아니라 「2자 중 둘」이다. ### 계약이 인용한 값 대조 @@ -83,8 +85,8 @@ ### 경로를 코드로 확정했다 -계약이 준 후보 목록을 믿지 않고 읽었다. **「질의 전처리」와 「후보 0건 폴백」은 존재하지 -않는다.** [측정] +계약이 준 후보 목록을 믿지 않고 코드를 직접 읽었다. 「질의 전처리」와 「후보 0건 +폴백」은 존재하지 않는다. [측정] | 층 | 파일·행 | 짧은 질의가 여기서 걸리는가 | |---|---|---| @@ -109,8 +111,8 @@ return (bool(q) ### 층별 잔존 건수 — 실서버와 대조했다 `tools/search_cut/layer_probe.py`. 소유자 `jeongheon`(후보 17건) · `limit=20` · -`τ_sent=0.30` · `τ_word=0.24` · `r=0.60` · 경계 5자. **재구성은 구현을 `import` 하지 -않고 다시 적었다**(`-213` 이 세운 규칙). [측정] +`τ_sent=0.30` · `τ_word=0.24` · `r=0.60` · 경계 5자. 재구성은 구현을 `import` 하지 +않고 다시 적었다(`-213` 이 세운 규칙). [측정] | 질의 | 자 | 어절 | 단어형 | 하한 | ① 후보 | ② LIMIT | ③ τ | ④ r | 실서버 | 기대 sim(순위) | 탈락 | |---|---|---|---|---|---|---|---|---|---|---|---| @@ -137,32 +139,29 @@ return (bool(q) | 밥 먹고 산책하면서 쉬어가는 공원 | 18 | 5 | · | 0.30 | 17 | 17 | 6 | 5 | 5 | 0.4578(2위) | — 통과 | | 신한 부트캠프 친구들과 자주 먹었던 돈카츠 집 | 25 | 7 | · | 0.30 | 17 | 17 | 10 | 1 | 1 | 0.8602(1위) | — 통과 | -``` -실서버 대조 22/22 일치 이 브랜치 코드로 띄운 서버(:8003)에 같은 질의를 던졌다 -탈락 층 집계 — 통과 18 · ③ τ_abs 3 · ④ r 1 -``` +실서버 대조는 22/22 일치했다. 이 브랜치 코드로 띄운 서버(:8003)에 같은 질의를 +던졌다. 탈락 층 집계는 통과 18 · ③ τ_abs 3 · ④ r 1 이다. -**② LIMIT 은 이 데이터에서 한 건도 자르지 않는다.** 후보가 17건뿐이라 그렇고, 기록이 -수백 건이 되면 이야기가 달라진다(`-213` 이 남긴 조건과 같다). [추론] +② LIMIT 은 이 데이터에서 한 건도 자르지 않는다. 후보가 17건뿐이라 그렇고, 기록이 +수백 건이 되면 상황이 달라진다(`-213` 이 남긴 조건과 같다). [추론] ### 세 실패가 서로 다른 층이다 -``` -신한 ③ τ_abs 이자 ④ r 이기도 하다 0.2301 < 0.24 이고 동시에 < 0.2333(=0.6×0.3889) -부캠 ③ τ_abs 만 0.2105 < 0.24. r 기준 0.2461 보다도 낮다 -신한 부캠 ④ r 만 0.3187 은 τ_sent 0.30 을 **넘는다** -``` +- **신한** — ③ τ_abs 이자 ④ r 이기도 하다. 0.2301 < 0.24 이고 동시에 + < 0.2333(=0.6×0.3889). +- **부캠** — ③ τ_abs 만. 0.2105 < 0.24 이고, r 기준 0.2461 보다도 낮다. +- **신한 부캠** — ④ r 만. 0.3187 은 τ_sent 0.30 을 넘는다. -**`신한 부캠` 이 이 표에서 가장 중요한 행이다.** 경계 정의를 어떻게 바꿔도 이 건은 -안 풀린다 — `r` 은 질의 길이로 갈리지 않고(`-266` 이 「참여하지 않는다」로 확인), 1위 -`쿠로코 0.5867` 이 높아서 4위 0.3187 이 상대 컷에 걸린다. [측정] +`신한 부캠` 이 이 표에서 가장 중요한 행이다. 경계 정의를 어떻게 바꿔도 이 건은 +풀리지 않는다. `r` 은 질의 길이로 갈리지 않고(`-266` 이 「참여하지 않는다」로 확인), +1위 `쿠로코 0.5867` 이 높아서 4위 0.3187 이 상대 컷에 걸린다. [측정] ## ② 경계 — 글자 수와 어절 수를 분리해서 ### `-266` 의 행렬로는 두 정의가 구분되지 않는다 `-266` 이 스스로 적은 한계를 재판정으로 확인했다. 같은 행렬(`word_grid.json`, 1어절 -질의 66행)에 정의 셋을 걸면 **결과가 완전히 같다.** [측정] +질의 66행)에 정의 셋을 걸면 결과가 완전히 같다. [측정] | 규칙 | 정답 누락 | 1위 손실 | 빈 결과 | 회복 | 무관 통과 | |---|---|---|---|---|---| @@ -170,21 +169,22 @@ return (bool(q) | chars_only (≤5자) | 2/66 | 0 | 1 | 71/71 | 26/45 | | words_only (1어절) | 2/66 | 0 | 1 | 71/71 | 26/45 | -전부 1어절 2~5자라 세 정의가 같은 답을 낸다. **두 정의를 가르려면 갈리는 대역을 -만들어야 한다.** +전부 1어절 2~5자라 세 정의가 같은 답을 낸다. 두 정의를 가르려면 갈리는 대역을 +만들어야 한다. ### 대역을 만들었다 — 공백만 다른 짝 -본문의 **문장 내 인접 어절쌍**을 전량 뽑아 두 형태로 냈다. 같은 쌍에서 나오므로 -**기대 정답 집합이 같고**, 그래서 차이가 전부 「공백 하나」에 귀속된다. +본문의 문장 내 인접 어절쌍을 전량 뽑아 두 형태로 냈다. 같은 쌍에서 나오므로 기대 +정답 집합이 같고, 그래서 차이가 전부 「공백 하나」에 귀속된다. ``` pair (그네, 공원) → spaced "그네 공원" 5자 · 2어절 · 공백 있음 joined "그네공원" 4자 · 1어절 · 공백 없음 ``` -**질의를 고르지 않았다.** `-266` 이 한계로 적은 재량(*「어떤 말을 뽑을지는 재량이었다」*) -을 없애려고 조건에 맞는 쌍을 **전부** 쟀다. 남는 재량은 「두 어절 모두 2자 이상」 하나다. +질의를 고르지 않았다. `-266` 이 한계로 적은 재량(*「어떤 말을 뽑을지는 +재량이었다」*)을 없애려고 조건에 맞는 쌍을 전부 쟀다. 남는 재량은 「두 어절 모두 2자 +이상」 하나다. ``` 쌍 176종 × 2형태 × 소유자 3명 = 1,128행 (정답 396 · 교차 660 · 무관 72) @@ -210,7 +210,7 @@ GMS 임베딩 376건 (배치 3회) | words2_chars8 | 15/396 | 2 | 3 | 373/380 | 26/72 | 509/660 | | (컷 없음) | 0/396 | 0 | 0 | 380/380 | 72/72 | 660/660 | -손실이 **어느 대역에 있는지**가 표 하나로는 안 보인다. 갈라서 본다. [측정] +손실이 어느 대역에 있는지가 표 하나로는 보이지 않는다. 갈라서 본다. [측정] | 대역 | 행 | 현행 누락 | 현행 1위 손실 | **어절 수 정의 누락** | 1위 손실 | |---|---|---|---|---|---| @@ -219,17 +219,17 @@ GMS 임베딩 376건 (배치 3회) | **C** | **122** | **16/122** | **4** | **3/122** | **1** | | D | 156 | 11/156 | 4 | 11/156 | 4 | -**C 대역이 전부다.** 어절 수 정의로 바꿨을 때 움직이는 것은 C 뿐이고(16→3), B 는 -어절 수 정의에서도 문장형이라 그대로다. B 를 움직이려면 글자 수 정의를 써야 한다 -(전량 42→37, 즉 5건). +C 대역이 전부다. 어절 수 정의로 바꿨을 때 움직이는 것은 C 뿐이고(16→3), B 는 +어절 수 정의에서도 문장형이라 그대로다. B 를 움직이려면 글자 수 정의를 써야 +한다(전량 42→37, 즉 5건). ### 그런데 초점 집합에서는 아무 차이도 없다 전량에는 기능어 쌍(`같이 가서`·`거의 없고`·`당시 자주`)이 섞인다. 본문에서 기계로 -뽑았으므로 「정답 있는 질의」로 세지만 **사용자가 그 말로 검색하지는 않는다.** +뽑았으므로 「정답 있는 질의」로 세지만, 사용자가 그 말로 검색하지는 않는다. -내용어 쌍 16종(36행)을 손으로 골라 나란히 냈다. **이 선정이 이 측정의 재량이고, 전량 -옆에 두는 것이 그것을 감추지 않는 방법이다.** [측정] +내용어 쌍 16종(36행)을 손으로 골라 나란히 냈다. 이 선정이 이 측정의 재량이고, 전량 +옆에 두는 것이 그것을 감추지 않는 방법이다. [측정] | 규칙 | 정답 누락 | 1위 손실 | 빈 결과 | 회복 | 교차 통과 | |---|---|---|---|---|---| @@ -241,7 +241,7 @@ GMS 임베딩 376건 (배치 3회) | words2_chars8 | **0/36** | 0 | 0 | 40/40 | 32/54 | `그네공원`(0.3179 1위) · `비건샌드위치`(0.4162 1위) · `무한도전방영된`(0.3231 1위) · -`신한부트캠프`(0.3676 3위) — **붙여 쓴 내용어 질의는 전부 0.30 을 넘는다.** 하한이 +`신한부트캠프`(0.3676 3위) — 붙여 쓴 내용어 질의는 전부 0.30 을 넘는다. 하한이 느슨해질 필요가 없다. ``` @@ -249,13 +249,13 @@ GMS 임베딩 376건 (배치 3회) `같은분위기가`(0.2170) · `당시자주`(0.1909) · `보이는야경이`(0.1856) … ``` -**두 표가 어긋나는 것이 이 측정의 결론이다** — 경계 정의를 바꾸면 지표는 좋아지지만 -**좋아지는 것이 사용자가 치지 않는 질의다.** [추론 — 「사용자가 무엇을 치는가」를 잰 +두 표가 어긋나는 것이 이 측정의 결론이다. 경계 정의를 바꾸면 지표는 좋아지지만, +좋아지는 것이 사용자가 치지 않는 질의다. [추론 — 「사용자가 무엇을 치는가」를 잰 측정이 아니라 작업자가 내용어를 고른 것이다] ### 짝 대조 — 공백을 떼면 유사도가 내려간다 -이 측정에서 **처음 나온 관측**이고, 경계 정의보다 크다. [측정] +이 측정에서 처음 나온 관측이고, 경계 정의보다 영향이 크다. [측정] ``` joined 정답 유사도 − spaced 정답 유사도 (198짝) @@ -264,16 +264,15 @@ joined 정답 유사도 − spaced 정답 유사도 (198짝) joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 ``` -**붙여 쓰면 모델이 덜 알아본다.** 그래서 C 대역은 두 가지를 함께 겪는다. +붙여 쓰면 모델이 의미를 덜 인식한다. 그래서 C 대역은 두 가지를 함께 겪는다. -``` -① 유사도가 spaced 보다 평균 0.0385 낮다 모델의 성질 -② 6자↑ 라 문장형 0.30 을 탄다 우리 코드의 성질 -``` +- ① 유사도가 spaced 보다 평균 0.0385 낮다 — 모델의 성질 +- ② 6자↑ 라 문장형 하한 0.30 을 적용받는다 — 우리 코드의 성질 -현행 규칙에서 하한이 갈리는 짝은 76건이고, 그중 **결과가 실제로 갈리는 짝이 18건**이다. -방향이 한쪽이 아니다 — 11건은 `spaced` 만 살고 7건은 `joined` 만 산다. 후자는 붙였을 -때 0.24 를 타서 살아난 것이고, 유사도 자체는 여전히 낮다. +현행 규칙에서 하한이 갈리는 짝은 76건이고, 그중 결과가 실제로 갈리는 짝이 18건이다. +방향이 한쪽이 아니다. 11건은 `spaced` 형태만 결과에 남고 7건은 `joined` 형태만 +남는다. 후자는 붙였을 때 단어형 하한 0.24 를 적용받아 결과에 남은 것이고, 유사도 +자체는 여전히 낮다. | 쌍 | 소유자 | spaced | joined | spaced sim | joined sim | |---|---|---|---|---|---| @@ -288,7 +287,7 @@ joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 ### 전각 공백 — `-266` 이 「재지 않았다」고 적은 대역 `_is_word_query` 가 공백을 `str.isspace()` 로 보므로 전각 공백(U+3000)으로 띄운 짧은 -질의는 **문장형**(0.30)으로 간다. `-266` 은 이것을 *「측정이 아니라 안전 방향으로의 +질의는 문장형(0.30)으로 간다. `-266` 은 이것을 *「측정이 아니라 안전 방향으로의 판단」* 이라고 적었다. 3건을 U+0020 짝과 나란히 쟀다. [측정] | 질의 | U+0020 | U+3000 | Δ | 판정 | @@ -297,15 +296,16 @@ joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 | 양갱 파는 | 0.5176(1위) | 0.5138(1위) | -0.0038 | 둘 다 통과 | | **신한 부캠** | 0.3187(4위) | **0.2753(5위)** | -0.0434 | spaced 는 ④ r · **전각은 ③ τ_abs** | -**전각 공백도 유사도를 떨어뜨린다.** 그리고 문장형 0.30 을 타므로 `신한 부캠` 은 -0.2753 으로 τ 에서 잘린다 — 단어형 0.24 를 탔으면 통과했을 값이다. +전각 공백도 유사도를 떨어뜨린다. 그리고 문장형 하한 0.30 을 적용받으므로 +`신한 부캠` 은 0.2753 으로 τ 에서 잘린다. 단어형 하한 0.24 를 적용받았으면 통과했을 +값이다. -`-266` 이 「안전 방향」이라고 부른 선택이 **이 대역에서는 손해로 나타난다.** 다만 +`-266` 이 「안전 방향」이라고 부른 선택이 이 대역에서는 손해로 나타난다. 다만 3건뿐이고 셋 다 같은 소유자다. [측정 — 표본 3건] ### 몇 자부터인가 — 계약이 물은 축 -`-266` 의 1어절 행렬(정답 있는 행만)로 답한다. **하한 두 값과 나란히 읽는다.** [측정] +`-266` 의 1어절 행렬(정답 있는 행만)로 답한다. 하한 두 값과 나란히 읽는다. [측정] | 글자 수 | 행 | 최솟값 | 중앙값 | 최댓값 | ≥0.30 | ≥0.24 | |---|---|---|---|---|---|---| @@ -314,11 +314,9 @@ joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 | 4 | 14 | 0.3187 | 0.3864 | 0.7971 | 14/14 | 14/14 | | 5 | 1 | 0.3423 | 0.3423 | 0.3423 | 1/1 | 1/1 | -``` -답 1어절 기준 **3자부터** 정답이 사실상 전부 하한을 넘는다(0.24 는 18/18, 0.30 은 17/18) - 2자에서만 10/33 이 0.30 미만이고 2/33 이 0.24 미만이다 - **3자 최솟값 0.2854 와 2자 최솟값 0.1870 사이가 경계다** -``` +답은 다음과 같다. 1어절 기준 3자부터 정답이 사실상 전부 하한을 넘는다(0.24 는 +18/18, 0.30 은 17/18). 2자에서만 10/33 이 0.30 미만이고 2/33 이 0.24 미만이다. 3자 +최솟값 0.2854 와 2자 최솟값 0.1870 사이가 경계다. 어절 수 축은 이 티켓의 행렬로 답한다. [측정] @@ -327,8 +325,8 @@ joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 | 1 | 198 | 0.1856 | 0.4099 | 164/198 | 188/198 | | 2 | 198 | 0.2115 | 0.4463 | 179/198 | 195/198 | -**어절이 늘면 유사도가 오른다** — 같은 내용을 담고도 그렇다(짝이므로 내용이 같다). -글자 수와 어절 수 중 더 강한 축은 **어절 수**이고, 실은 둘 다 「질의가 담은 정보량」의 +어절이 늘면 유사도가 오른다. 같은 내용을 담고도 그렇다(짝이므로 내용이 같다). +글자 수와 어절 수 중 더 강한 축은 어절 수이고, 실은 둘 다 「질의가 담은 정보량」의 대리 지표다. [추론] ## ③ 약어는 2자와 같은 원인인가 — 다르다 @@ -342,92 +340,94 @@ joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 같은 대상 4자 부트캠프 0.3254(**1위**) ``` -**「2자라서」로는 갈리지 않는다.** 그리고 둘의 성격도 서로 다르다. +「2자라서」로는 갈리지 않는다. 그리고 둘의 성격도 서로 다르다. | | 증상 | 근거 | 컷으로 풀리나 | |---|---|---|---| -| `부캠` (약어) | `부트캠프` 는 1위인데 `부캠` 은 8위 | `부캠` 질의의 상위가 **본문에 「부캠」이 그대로 있는** 쿠로코(0.4102)·플랜트다. 모델은 표기를 알고 **`부캠→부트캠프` 의미 연결만 못 한다** | **아니다.** τ 를 0.21 아래로 내려도 8위 | -| `신한` (2자 일반) | 본문에 그대로 있는데 6위 | `-255` 의 무관 기준선 — 어느 본문에도 없는 `치과` 의 top-1(0.2953)이 본문에 있는 `스팟`(0.2438)보다 **높다.** 겹침이 아니라 **역전** | **아니다.** 무관 대역을 통째로 통과시켜야 한다 | +| `부캠` (약어) | `부트캠프` 는 1위인데 `부캠` 은 8위 | `부캠` 질의의 상위가 본문에 「부캠」이 그대로 있는 쿠로코(0.4102)·플랜트다. 모델은 표기를 알고 `부캠→부트캠프` 의미 연결만 하지 못한다 | 아니다. τ 를 0.21 아래로 내려도 8위 | +| `신한` (2자 일반) | 본문에 그대로 있는데 6위 | `-255` 의 무관 기준선 — 어느 본문에도 없는 `치과` 의 top-1(0.2953)이 본문에 있는 `스팟`(0.2438)보다 높다. 겹침이 아니라 역전이다 | 아니다. 무관 대역을 통째로 통과시켜야 한다 | `-255` 의 판정(`#86` 신한 = 키워드, `#88` 부캠 = 임베딩 모델)이 현행 코드에서도 유지된다. ## 처방 후보 -각 후보에 **무엇을·왜·비용·부작용**을 적는다. **고르는 것은 이 계약의 일이 아니다.** +각 후보에 무엇을·왜·비용·부작용을 적는다. 고르는 것은 이 계약의 일이 아니다. ### P0 — 아무것도 바꾸지 않는다 - **무엇을**: 없음 -- **왜**: 시연 시나리오 ②의 정본 질의(`비 오는 날 가려고 저장한 곳`, 문장형)는 통과한다. - 남은 실패 3건은 전부 「2자 약어·기관명」이고 **컷으로 풀 수 없다**(순위 4·6·8위). - 경계 정의를 바꿔 좋아지는 것은 초점 집합에서 0건이다 +- **왜**: 시연 시나리오 ②의 정본 질의(`비 오는 날 가려고 저장한 곳`, 문장형)는 + 통과한다. 남은 실패 3건은 전부 「2자 약어·기관명」이고 컷으로 풀 수 없다(순위 + 4·6·8위). 경계 정의를 바꿔 좋아지는 것은 초점 집합에서 0건이다 - **비용**: 0 -- **부작용**: 없음. **다만 `신한`·`부캠` 은 계속 안 나온다** — 이미 열려 있는 `ai#86`· - `ai#88` 이 그것이고, 이 티켓이 그 판정을 바꾸지 않는다 +- **부작용**: 없음. 다만 `신한`·`부캠` 은 계속 나오지 않는다. 이미 열려 있는 + `ai#86`·`ai#88` 이 그것이고, 이 티켓이 그 판정을 바꾸지 않는다 ### P1 — 경계 정의를 어절 수로 바꾼다 (C 대역) - **무엇을**: `app/service/search_service.py:77-82` `_is_word_query` 를 `len(q.split()) == 1` 기준으로. 설정 키 `SEARCH_WORD_QUERY_MAX_CHARS` 의 의미가 바뀐다 - **왜**: C 대역(무공백·길다) 122행에서 정답 누락 16→3 · 1위 손실 4→1 [측정]. - 붙여 쓴 질의는 유사도가 평균 0.0385 낮은데(§짝 대조) 하한은 더 높은 쪽을 탄다 + 붙여 쓴 질의는 유사도가 평균 0.0385 낮은데(§짝 대조) 하한은 더 높은 쪽이 적용된다 - **비용**: 함수 한 줄 + 단위 테스트. 재측정 불필요(행렬이 커밋돼 있어 `boundary_sweep.py` 로 언제든 재판정). 배포 단위는 `ai` 단독 — 공용 계약·스키마 불변 - **부작용**: 무관 통과 12/72→16/72 · 교차 통과 336/660→404/660 [측정]. - **문장형 질의는 전부 2어절 이상이라 영향 없다.** 그러나 **초점 집합에서는 이득이 - 0 이다** — 회복되는 것이 기능어 쌍이라 사용자 이득이 관측되지 않았다 + 문장형 질의는 전부 2어절 이상이라 영향 없다. 그러나 초점 집합에서는 이득이 0 이다. + 회복되는 것이 기능어 쌍이라 사용자 이득이 관측되지 않았다 ### P2 — 질의를 정규화한 뒤 판정한다 (전각·붙임 대역) - **무엇을**: `app/schema/search.py` 또는 `_is_word_query` 앞에서 유니코드 공백을 - U+0020 으로 정규화(NFKC 또는 명시적 치환). **판정에만 쓰고 임베딩 입력은 원문 유지** -- **왜**: 전각 공백 질의가 유사도도 낮고(-0.0038 ~ -0.0590) 하한도 높은 쪽을 탄다. - `신한 부캠` 0.2753 이 그 조합으로 τ 에서 잘렸다 [측정 — 3건] -- **비용**: 몇 줄. 다만 **임베딩 입력까지 정규화하면 벡터가 달라져 재측정이 필요하다** — + U+0020 으로 정규화(NFKC 또는 명시적 치환). 판정에만 쓰고 임베딩 입력은 원문 유지 +- **왜**: 전각 공백 질의가 유사도도 낮고(-0.0038 ~ -0.0590) 하한도 높은 쪽이 + 적용된다. `신한 부캠` 0.2753 이 그 조합으로 τ 에서 잘렸다 [측정 — 3건] +- **비용**: 몇 줄. 다만 임베딩 입력까지 정규화하면 벡터가 달라져 재측정이 필요하다. 판정에만 적용하면 재측정 불필요 - **부작용**: 정규화가 임베딩까지 가면 `-266`·`-213` 의 모든 하한이 무효가 된다. - **판정과 임베딩 입력을 반드시 갈라야 한다.** 표본 3건이라 이득의 크기를 모른다 + 판정과 임베딩 입력을 반드시 갈라야 한다. 표본 3건이라 이득의 크기를 모른다 ### P3 — 키워드 검색 도입 (`-272`, `pg_trgm`·`tsvector`) - **무엇을**: `ai.context` 본문에 대한 문자열 검색을 임베딩 결과와 합친다 -- **왜**: `신한`(6위)·`부캠`(8위)은 **컷 어느 값으로도 못 푼다** — 무관 2자의 top-1 - (0.2953)이 정답 2자(0.2438)보다 높은 역전이라 임계값으로 가를 수 없다(`-255` 실측, - 이 측정에서 재확인). `신한` 은 본문에 **문자열 그대로** 있으므로 문자열 검색의 영역이다 -- **비용**: **가장 크다.** DB 인덱스 · Query 변경 · 두 결과의 결합 규칙(순위 융합)이 +- **왜**: `신한`(6위)·`부캠`(8위)은 컷 어느 값으로도 풀 수 없다. 무관 2자의 + top-1(0.2953)이 정답 2자(0.2438)보다 높은 역전이라 임계값으로 가를 수 없다(`-255` + 실측, 이 측정에서 재확인). `신한` 은 본문에 문자열 그대로 있으므로 문자열 검색의 + 영역이다 +- **비용**: 가장 크다. DB 인덱스 · Query 변경 · 두 결과의 결합 규칙(순위 융합)이 새로 필요하고, `personal-search.md §3·§4` 의 「필터 우선, 벡터 나중」 구조가 바뀐다. - 결합 규칙은 **새로 재야 한다** — 이 티켓의 행렬로는 못 답한다 + 결합 규칙은 새로 재야 한다. 이 티켓의 행렬로는 답할 수 없다 - **부작용**: 문장형 질의에서 문자열 매칭이 잡음을 얹을 수 있다(「자주」·「좋은」). - `부캠` 은 **이 처방으로도 안 풀린다** — 카츠요 본문에 「부캠」이 없다(`#88` 이 스스로 - 적은 것이고 `-255` 가 확증했다) + `부캠` 은 이 처방으로도 풀리지 않는다. 카츠요 본문에 「부캠」이 없기 + 때문이다(`#88` 이 스스로 적은 것이고 `-255` 가 확증했다) ### P4 — `r` 을 질의 길이로 가른다 (또는 완화) - **무엇을**: `SEARCH_TOP_RATIO` 를 단어형/문장형으로 가르거나 값을 내린다 -- **왜**: `신한 부캠` 은 τ 를 넘고 **`r` 에만** 걸린다(0.3187 < 0.3520) [측정]. - 경계 정의를 어떻게 바꿔도 이 건은 안 풀린다 -- **비용**: 설정 키 1개 + 분기. **재측정이 필요하다** — `-266` 이 「`r` 은 이 교환에 - 참여하지 않는다」로 **가르지 않기로 판단**했고, 그 판단을 뒤집으려면 격자를 다시 훑어야 +- **왜**: `신한 부캠` 은 τ 를 넘고 `r` 에만 걸린다(0.3187 < 0.3520) [측정]. + 경계 정의를 어떻게 바꿔도 이 건은 풀리지 않는다 +- **비용**: 설정 키 1개 + 분기. 재측정이 필요하다. `-266` 이 「`r` 은 이 교환에 + 참여하지 않는다」로 가르지 않기로 판단했고, 그 판단을 뒤집으려면 격자를 다시 훑어야 한다(`word_sweep.py` 가 `r` 축을 이미 갖고 있다) -- **부작용**: `r` 은 「1위 대비 급이 다른 꼬리」를 막는 장치다. 풀면 `신한 부트캠프 … - 돈카츠 집`(문장형, 컷 후 1건 → τ 후 10건)처럼 **긴 질의의 결과가 크게 늘어난다** [측정] +- **부작용**: `r` 은 「1위 대비 유사도가 크게 낮은 꼬리」를 막는 장치다. 풀면 + `신한 부트캠프 … 돈카츠 집`(문장형, 컷 후 1건 → τ 후 10건)처럼 긴 질의의 결과가 + 크게 늘어난다 [측정] ### P5 — 임베딩 모델 교체 (`-199`, `large` 재측정) - **무엇을**: `text-embedding-3-small` → `-large` 또는 다른 모델 -- **왜**: `부캠→부트캠프` 는 **의미 연결**이라 임베딩의 영역이고 모델이 표기 자체는 +- **왜**: `부캠→부트캠프` 는 의미 연결이라 임베딩의 영역이고 모델이 표기 자체는 인식한다(`-255` 실측). 짧은 질의의 신호 부족도 모델 차원에 걸린 문제일 수 있다 [추론] -- **비용**: **벡터 전량 재생성.** `-266`·`-213` 의 두 하한이 **모두 무효**가 되어 - 격자를 처음부터 다시 훑어야 한다(`personal-search.md §6.1` 이 이것을 방아쇠로 명시) -- **부작용**: 재측정 전까지 모든 컷 값이 미검증 상태가 된다. 개선 여부는 **재보기 전엔 - 모른다** — `-255` 도 「원리 판단이지 실측이 아니다」로 남겼다 +- **비용**: 벡터 전량 재생성. `-266`·`-213` 의 두 하한이 모두 무효가 되어 격자를 + 처음부터 다시 훑어야 한다(`personal-search.md §6.1` 이 이것을 재측정 조건으로 명시) +- **부작용**: 재측정 전까지 모든 컷 값이 미검증 상태가 된다. 개선 여부는 재보기 전엔 + 모른다. `-255` 도 「원리 판단이지 실측이 아니다」로 남겼다 ## 검증 — 실행한 것과 못 한 것 **실행한 것** - **층별 프로브 22건 · 실서버 대조 22/22 일치.** 이 브랜치 코드로 띄운 서버(:8003). - 재구성에 `_cut`·`_is_word_query` 를 **다시 적었다**(구현 `import` 금지, `-213` 규칙) + 재구성에 `_cut`·`_is_word_query` 를 다시 적었다(구현 `import` 금지, `-213` 규칙) - **경계 행렬 1,128행.** 쌍 176종 × 2형태 × 소유자 3명. GMS 임베딩 376건(배치 3회). 가드 둘이 재기 전에 확인한다 — 무관 통제가 본문에 있는지 전수 대조, 쌍이 어느 소유자 본문에도 없는지 @@ -442,61 +442,63 @@ joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 - **운영 DB 를 재지 않았다.** 계약대로 로컬 시연 DB(`:15432`)만. `-255`·`-266` 과 같다 - **테스트를 돌리지 않았다.** 코드 변경이 없다(하네스·리포트·`.gitignore` 예외만) - **재현성 회차를 내지 않았다.** `-266` 이 `T68` 대응으로 회차 3개를 쟀고 이 조건의 - 흔들림 상한이 0.000209 임을 확인했다. 이 측정은 **채택값을 정하지 않으므로** - 그 정밀도를 요구하지 않는다 — 다만 아래 §말할 수 없는 것에 실제로 본 흔들림을 적는다 + 변동 상한이 0.000209 임을 확인했다. 이 측정은 채택값을 정하지 않으므로 그 정밀도를 + 요구하지 않는다. 다만 아래 §말할 수 없는 것에 실제로 관측한 변동을 적는다 - **클러스터를 확인하지 않았다.** `infra` 가 pin 한 tag 까지만 봤다 ## 말할 수 없는 것 - **초점 집합 16종은 재량이다.** 「사용자가 칠 법한 말」을 작업자가 골랐다. 전량 - 지표를 나란히 둔 것이 그 재량을 감추지 않는 방법이지만, **둘이 어긋날 때 어느 쪽을 - 믿을지는 이 측정이 답하지 않는다** -- **전량에 기능어 쌍이 섞인다.** 재량을 없앤 대가다. `당시 자주`·`거의 없고` 같은 쌍이 - 「정답 있는 질의」로 세지므로 **전량의 정답 누락은 사용자 손실보다 크게 나온다** + 지표를 나란히 둔 것이 그 재량을 감추지 않는 방법이지만, 둘이 어긋날 때 어느 쪽을 + 믿을지는 이 측정이 답하지 않는다. +- **전량에 기능어 쌍이 섞인다.** 재량을 없앤 대가다. `당시 자주`·`거의 없고` 같은 + 쌍이 「정답 있는 질의」로 세지므로 전량의 정답 누락은 사용자 손실보다 크게 나온다. - **괄호 분리의 부작용.** `구운연어덮밥(구연덮)이` 를 어절로 쪼개면 `구운연어덮밥 구연덮` 이라는 쌍이 생긴다. 질의로 성립하지 않지만 전량 원칙상 남겼다 -- **전각 공백 대역은 3건이다.** 방향(내려간다)은 셋 다 같지만 크기는 -0.0038 ~ -0.0590 - 으로 15배 차이가 난다. **왜 그런지 모른다** -- **「붙여 쓰면 유사도가 내려간다」의 기제를 모른다.** 관측은 198짝이고 방향은 - 일관되지만(172/198), 토크나이저 수준에서 확인하지 않았다. `-255` 가 `cl100k_base` 로 - `그네`↔`그네팟` 접두 토큰 공유를 실측한 것과 같은 작업을 이 대역에는 하지 않았다 +- **전각 공백 대역은 3건이다.** 방향(내려간다)은 셋 다 같지만 크기는 + -0.0038 ~ -0.0590 으로 15배 차이가 난다. 왜 그런지 모른다. +- **「붙여 쓰면 유사도가 내려간다」의 원인을 모른다.** 관측은 198짝이고 방향은 + 일관되지만(172/198), 토크나이저 수준에서 확인하지 않았다. `-255` 가 `cl100k_base` + 로 `그네`↔`그네팟` 접두 토큰 공유를 실측한 것과 같은 작업을 이 대역에는 하지 + 않았다. - **표본이 작고 한쪽으로 쏠려 있다.** 소유자 3명 · Record 42건이고 층 프로브는 - `jeongheon`(17건) 하나다. `-255`·`-266` 과 같은 데이터다 + `jeongheon`(17건) 하나다. `-255`·`-266` 과 같은 데이터다. - **유사도가 실행마다 미세하게 움직인다.** 같은 질의를 두 번 뜬 값이 `스팟` 0.2438→0.2439 · `우주` 0.4199→0.4200 로 1×10⁻⁴ 대에서 갈렸다. `-266` 이 잰 - 흔들림 상한(0.000209)과 같은 자릿수이고 **어떤 판정도 뒤집지 않았지만**, 이 리포트의 - 4자리 수치는 그 폭 안에서 읽어야 한다 + 변동 상한(0.000209)과 같은 자릿수이고 어떤 판정도 뒤집지 않았지만, 이 리포트의 + 4자리 수치는 그 폭 안에서 읽어야 한다. - **사용자 만족을 재지 않았다.** 잰 것은 유사도·순위·건수다. 「무관 통과 16/72 가 - 26/72 이 되면 나빠지는가」는 이 측정 밖이다 + 26/72 이 되면 나빠지는가」는 이 측정 밖이다. - **`-272`(키워드 검색)의 결합 규칙을 재지 않았다.** P3 의 비용 추정은 구조를 읽은 - 것이지 측정이 아니다 [추론] -- **운영에서도 같은지 모른다.** 운영 증상이 이 측정과 다르면 그쪽부터 봐야 한다 + 것이지 측정이 아니다 [추론]. +- **운영에서도 같은지 모른다.** 운영 증상이 이 측정과 다르면 그쪽부터 봐야 한다. ## 범위 밖에서 본 것 -계약이 「발견하면 패킷에 적고 끝낸다」로 둔 것이다. **티켓을 만들지 않았다.** +계약이 「발견하면 패킷에 적고 끝낸다」로 둔 것이다. 티켓을 만들지 않았다. -- **`.env` 의 `DATABASE_URL` 이 `:5433` 을 가리킨다.** 07-27 잔재이고 그쪽에는 데이터가 - 없다. `matrix.py` 계열 가드(T33)가 잡아 주지만, 가드가 없는 도구를 새로 쓰면 - 「컷이 아무것도 자르지 않는다」를 결론으로 낼 수 있다. README 가 이미 경고한다 +- **`.env` 의 `DATABASE_URL` 이 `:5433` 을 가리킨다.** 07-27 잔재이고 그쪽에는 + 데이터가 없다. `matrix.py` 계열 가드(T33)가 잡아 주지만, 가드가 없는 도구를 새로 + 쓰면 「컷이 아무것도 자르지 않는다」를 결론으로 낼 수 있다. README 가 이미 경고한다. - **주 레포 `.venv` 에 `Pillow`·`python-multipart` 가 없었다.** `origin/dev` 의 `#108`(이미지 기반 장소 제안)이 추가한 의존성이고 `requirements.lock` 에는 있다. - 서버 기동이 `ModuleNotFoundError` 로 죽어 lock 값(`pillow==12.3.0` · - `python-multipart==0.0.32`)으로 설치했다. **`.venv` 를 공유하는 다른 세션에 영향이 간다** + 서버 기동이 `ModuleNotFoundError` 로 실패해 lock 값(`pillow==12.3.0` · + `python-multipart==0.0.32`)으로 설치했다. `.venv` 를 공유하는 다른 세션에 영향이 + 간다. - **`KAKAO_REST_API_KEY` 가 필수 설정인데 개발자 로컬 `.env` 에 없다.** - `get_settings()` 가 `ValidationError` 로 죽는다. 이 측정은 카카오를 쓰지 않으므로 - 더미 값을 환경변수로 주고 돌렸다 + `get_settings()` 가 `ValidationError` 로 실패한다. 이 측정은 카카오를 쓰지 않으므로 + 더미 값을 환경변수로 주고 돌렸다. - > **정정.** 이 문단은 처음에 「`.env`·`.env.example` 에 없다」로 적었고 **`.env.example` - > 쪽이 틀렸다** — `23행: KAKAO_REST_API_KEY=CHANGME` 로 있다. 중앙이 잡았고 실물로 - > 재확인했다. `.env`(개발자 로컬, gitignore)에 없는 것은 맞다. + > **정정.** 이 문단은 처음에 「`.env`·`.env.example` 에 없다」로 적었고 + > `.env.example` 쪽이 틀렸다. `23행: KAKAO_REST_API_KEY=CHANGME` 로 있다. 중앙이 + > 잡았고 실물로 재확인했다. `.env`(개발자 로컬, gitignore)에 없는 것은 맞다. > - > **실제로 이 키가 빠져 있는 곳은 배포 Secret 목록이다** — + > 실제로 이 키가 빠져 있는 곳은 배포 Secret 목록이다. > `infra policy/sealedsecrets/ai-dev.yaml:18-25` 의 `ownerSecretKeys` 7개 > (`GMS_API_KEY` · `GMS_BASE_URL` · `INTERNAL_SHARED_SECRET` · > `PINLOG_EMBEDDING_{MODEL,DIMENSION,DISTANCE,PROFILE}`)에 들어 있지 않다. - > **이 측정의 범위 밖이고 확인만 했다** — 필수 설정이 배포 Secret 에 없을 때 - > 기동에서 무엇이 되는지는 재지 않았다(`-154` 의 Secret handoff 계약이 다루는 영역이다). + > 이 측정의 범위 밖이고 확인만 했다. 필수 설정이 배포 Secret 에 없을 때 기동에서 + > 무엇이 되는지는 재지 않았다(`-154` 의 Secret handoff 계약이 다루는 영역이다). ## 산출물 @@ -509,18 +511,19 @@ joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 | `.search/layer_probe.json` | **영구(커밋)** | 층 판정과 실서버 응답 건수 | | 이 문서 | **영구** | | -`boundary_grid.json` 은 `word_grid.json` 과 같은 원칙으로 커밋한다 — **Context 본문을 -담지 않고** Record 대표 이름(장소명)까지만 담는다. 행이 많아 들여쓰기 없이 저장한다. +`boundary_grid.json` 은 `word_grid.json` 과 같은 원칙으로 커밋한다. Context 본문을 +담지 않고 Record 대표 이름(장소명)까지만 담는다. 행이 많아 들여쓰기 없이 저장한다. ## 후속으로 남기는 것 -- **처방 선택은 이 티켓이 하지 않는다.** P0~P5 중 무엇을 할지는 별도 계약이다 +- **처방 선택은 이 티켓이 하지 않는다.** P0~P5 중 무엇을 할지는 별도 계약이다. - **초점 집합과 전량이 어긋나는 것을 어떻게 다룰지** — 「사용자가 무엇을 치는가」를 - 재는 방법이 없으면 이 어긋남은 다음 측정에서도 반복된다 -- **전각 공백 대역을 넓혀서** — 3건으로는 크기를 모른다. `boundary_matrix.py` 에 - `fullwidth` 형태를 세 번째로 넣으면 176종 전량에서 잴 수 있다(GMS 배치 2회 추가) -- **`신한 부캠` 의 `r` 탈락** — `-266` 이 「`r` 은 참여하지 않는다」로 가르지 않았는데 - 이 건은 `r` 에만 걸린다. `word_sweep.py` 의 `r` 축을 이 질의로 다시 봐야 한다 -- **「붙여 쓰면 내려간다」의 기제** — 토크나이저 실측(`cl100k_base`)이 남았다 -- **`ai#86`·`ai#88` 은 여전히 열려 있다.** `-255` 의 판정이 유지된다 — `#86` 은 키워드, - `#88` 은 임베딩 모델 + 재는 방법이 없으면 이 어긋남은 다음 측정에서도 반복된다. +- **전각 공백 대역을 넓혀서 재기** — 3건으로는 크기를 모른다. `boundary_matrix.py` 에 + `fullwidth` 형태를 세 번째로 넣으면 176종 전량에서 잴 수 있다(GMS 배치 2회 추가). +- **`신한 부캠` 의 `r` 탈락** — `-266` 이 「`r` 은 참여하지 않는다」로 가르지 + 않았는데 이 건은 `r` 에만 걸린다. `word_sweep.py` 의 `r` 축을 이 질의로 다시 봐야 + 한다. +- **「붙여 쓰면 내려간다」의 원인** — 토크나이저 실측(`cl100k_base`)이 남았다. +- **`ai#86`·`ai#88` 은 여전히 열려 있다.** `-255` 의 판정이 유지된다. `#86` 은 + 키워드, `#88` 은 임베딩 모델이다. diff --git a/docs/proposals/P1-immutable-context.md b/docs/proposals/P1-immutable-context.md index 4ff758c..dbae580 100644 --- a/docs/proposals/P1-immutable-context.md +++ b/docs/proposals/P1-immutable-context.md @@ -1,4 +1,4 @@ -# P1: Context 불변 모델 (버전 컬럼 제거) +# P1: Context 를 불변 엔티티로 정의하고 버전 컬럼을 제거한다 - **상태**: Accepted - **날짜**: 2026-07-23 @@ -7,39 +7,39 @@ ## 맥락 -초기 설계는 Context 수정 중 발생하는 경합(사용자가 본문을 고치는 동안 AI가 임베딩·키워드를 만드는 상황)을 막으려고 `context_version`(또는 `body_version`) 컬럼을 두고, 임베딩·키워드·검색 결과를 버전과 함께 저장·비교했다. 그러면 모든 AI 테이블에 버전이 전파되고, 검색 SQL·저장 로직마다 "지금 이 결과가 최신 본문 것인가"를 버전으로 판정해야 한다. 방어 로직이 버전 비교로 곳곳에 퍼진다. +사용자가 Context 본문을 고치는 동안 AI가 같은 Context 의 임베딩과 키워드를 만들고 있으면, 완성된 임베딩이 수정 전 본문을 가리키는 경합이 생긴다. 초기 설계는 이 경합을 막으려고 `context_version`(또는 `body_version`) 컬럼을 두고, 임베딩·키워드·검색 결과를 버전과 함께 저장하고 비교하는 방식이었다. 이 방식에서는 모든 AI 테이블에 버전 컬럼이 전파된다. 검색 SQL 과 저장 로직마다 "지금 이 결과가 최신 본문의 것인가"를 버전으로 판정해야 한다. 결국 방어 로직이 버전 비교의 형태로 코드 곳곳에 퍼진다. ## 결정 -Context를 **불변(immutable) 엔티티**로 정의한다. +Context 를 **불변(immutable) 엔티티**로 정의한다. ```text 동일한 context_id는 항상 동일한 Context 본문을 의미한다. ``` -- 본문을 in-place로 UPDATE하지 않는다. -- 수정 = **구 Context 소프트 삭제 + 신 Context INSERT**(새 `context_id`). 두 동작은 한 Core 트랜잭션. -- 신 Context INSERT를 삭제보다 **먼저** 실행한다. -- `context_version`·`body_version` 등 본문 세대 컬럼을 **전면 제거**한다. +- 본문을 같은 행에서 UPDATE 하지 않는다. +- 수정은 구 Context 를 소프트 삭제하고 신 Context 를 새 `context_id`로 INSERT 하는 두 동작으로 처리한다. 두 동작은 하나의 Core 트랜잭션 안에서 실행한다. +- 신 Context INSERT 를 구 Context 삭제보다 **먼저** 실행한다. +- `context_version`·`body_version` 등 본문 세대를 나타내는 컬럼을 **전면 제거**한다. ## 근거 -- **방어해야 할 가변 상태 자체를 없앤다.** 같은 `context_id`가 항상 같은 본문이면, "이 임베딩이 최신 본문 것인가"라는 질문이 성립하지 않는다. 버전 비교 로직이 통째로 사라진다. -- **stale 결과 차단을 한 곳으로 모은다.** 수정으로 무효가 된 진행 중 작업은 `ai.context_ai_state`의 `CANCELLED`로 걸러진다([P4](P4-is-deleted-cancelled.md)) — 버전이 아니라 상태로. -- **"마지막 Context는 삭제 불가" 가드를 우회 없이 통과한다.** 신 Context를 삭제보다 먼저 넣으면, 삭제 시점에 Context가 최소 하나 남아 가드가 자연히 만족된다. 가드에 수정 경로 특례를 두지 않아도 된다. +- **방어해야 할 가변 상태 자체를 없앤다.** 같은 `context_id`가 항상 같은 본문을 가리키면, "이 임베딩이 최신 본문의 것인가"라는 질문 자체가 성립하지 않는다. 그 질문에 답하기 위한 버전 비교 로직이 통째로 사라진다. +- **오래된 결과의 차단을 한 곳으로 모은다.** 수정으로 무효가 된 진행 중 작업은 버전 비교가 아니라 상태로 걸러진다. `ai.context_ai_state`가 `CANCELLED`로 전이되면 그 작업의 결과는 저장되지 않는다([P4](P4-is-deleted-cancelled.md)). +- **"마지막 Context 는 삭제 불가" 가드를 우회 없이 통과한다.** 신 Context 를 삭제보다 먼저 넣으면, 삭제를 실행하는 시점에 Context 가 최소 하나 남아 있어 가드 조건이 자연히 만족된다. 가드에 수정 경로만을 위한 특례를 두지 않아도 된다. -## 버린 대안 +## 채택하지 않은 대안 -- **`context_version` 유지**: 경합은 막지만 버전이 전 테이블·전 쿼리로 퍼지고, 수정마다 버전 증가·전파·비교를 관리해야 한다. 불변 모델이 같은 목표를 더 적은 상태로 달성한다. -- **본문 in-place UPDATE + 재처리 트리거**: 수정 순간 임베딩·키워드가 잠시 옛 본문과 불일치하고, 그 창(window)을 버전이나 락으로 가려야 한다. 불변 모델은 그 창이 없다(새 id는 처음부터 PENDING). +- **`context_version` 유지**: 경합은 막지만 버전이 모든 테이블과 모든 쿼리로 퍼지고, 수정할 때마다 버전 증가·전파·비교를 관리해야 한다. 불변 모델이 같은 목표를 더 적은 상태로 달성한다. +- **본문을 같은 행에서 UPDATE 하고 재처리를 트리거**: 수정 직후 임베딩·키워드가 잠시 수정 전 본문과 불일치하는 기간이 생기고, 그 기간을 버전이나 락으로 가려야 한다. 불변 모델에는 그 기간이 없다. 새 `context_id`는 처음부터 PENDING 상태로 시작하기 때문이다. ## 영향 -- 모든 `ai` 테이블에서 버전 컬럼 제거([back#3 마이그레이션](https://github.com/Team-PinLog/back/pull/3)이 반영). -- FastAPI 구현: 저장 불변식이 `status == PROCESSING`으로 단순화, 검색 SQL·키워드 저장에서 버전 조건 삭제([deletion-race-control.md](../spec/deletion-race-control.md)). -- Front: Context 수정 시 응답의 `context_id`가 바뀐다 → 새 id 반영, 구 id 캐시키 금지(`docs/static/05-1` §1). +- 모든 `ai` 테이블에서 버전 컬럼을 제거한다([back#3 마이그레이션](https://github.com/Team-PinLog/back/pull/3)이 반영). +- FastAPI 구현에서 저장 불변식이 `status == PROCESSING` 확인 하나로 단순해지고, 검색 SQL 과 키워드 저장에서 버전 조건이 삭제된다([deletion-race-control.md](../spec/deletion-race-control.md)). +- Front 는 Context 수정 시 응답의 `context_id`가 바뀐다는 점을 반영해야 한다. 새 id 를 반영하고, 구 id 를 캐시 키로 쓰지 않는다(`docs/static/05-1` §1). ## 검증 -- 공용 계약·draft 문서에서 `context_version`/`body_version` 잔존 0건 확인(rebase 검증 스크립트). -- 구현 명세 전반이 버전 없는 전제로 재작성됨([ai#1](https://github.com/Team-PinLog/ai/pull/1)). +- 공용 계약과 draft 문서에서 `context_version`/`body_version` 잔존 0건을 확인했다(rebase 검증 스크립트). +- 구현 명세 전반이 버전 없는 전제로 재작성됐다([ai#1](https://github.com/Team-PinLog/ai/pull/1)). diff --git a/docs/proposals/P26-keyword-preset-judgment.md b/docs/proposals/P26-keyword-preset-judgment.md index c9a999b..04006c6 100644 --- a/docs/proposals/P26-keyword-preset-judgment.md +++ b/docs/proposals/P26-keyword-preset-judgment.md @@ -8,42 +8,42 @@ ## 맥락 -Context 본문에 붙일 Keyword를 고정 프리셋에서 고른다. 세 가지를 정해야 했다. (1) 프리셋 구성(개수·범주·공개 등급), (2) 임베딩 후보 검색 파라미터(TOP-K·유사도 하한), (3) LLM 판정 프롬프트와 출력 스키마. 이 결정들은 문서상 추정이 아니라 **실측(eval 하네스)** 으로 보정했다. +Context 본문에 붙일 Keyword 는 고정 프리셋에서 고른다. 세 가지를 정해야 했다. (1) 프리셋 구성(개수·범주·공개 등급), (2) 임베딩 후보 검색 파라미터(TOP-K 와 유사도 하한), (3) LLM 판정 프롬프트와 출력 스키마. 이 결정들은 문서상 추정으로 정하지 않고 **eval 하네스로 실측**해 보정했다. ## 결정 ### 프리셋 구성 (27개) -- 범주: `COMPANION`(6) / `ACTIVITY`(8) / `ATMOSPHERE`(7) / `SITUATION`(6). **지역·장소 범주 제외**. -- 필드: `id`(명시적 고정) · `code` · `display_name` · `category` · `description`(의미 범위) · `examples`(구어체 3~5, 키워드 단어 없는 문장 ≥1) · `visibility`. -- 공개 등급: `PUBLIC` / `PRIVATE_ONLY` / `BLOCKED`. **MVP에 BLOCKED 없음.** 개인 유추 소지가 있는 `WITH_COLLEAGUES`·`ANNIVERSARY`는 `PRIVATE_ONLY`. +- 범주는 `COMPANION`(6) / `ACTIVITY`(8) / `ATMOSPHERE`(7) / `SITUATION`(6)의 네 가지다. **지역·장소 범주는 제외**한다. +- 각 프리셋의 필드는 `id`(명시적 고정) · `code` · `display_name` · `category` · `description`(의미 범위) · `examples` · `visibility`다. `examples`는 구어체 문장 3~5개이고, 그중 키워드 단어가 등장하지 않는 문장을 1개 이상 포함한다. +- 공개 등급은 `PUBLIC` / `PRIVATE_ONLY` / `BLOCKED`의 세 단계다. **MVP 에는 BLOCKED 등급인 프리셋이 없다.** 개인을 유추할 소지가 있는 `WITH_COLLEAGUES`·`ANNIVERSARY`는 `PRIVATE_ONLY`로 둔다. ### 후보 검색 -- `KEYWORD_CANDIDATE_TOP_K = 10`, **유사도 하한 0.30**. -- 후보 0개면 LLM 미호출·선택 0개로 정상 완료. +- `KEYWORD_CANDIDATE_TOP_K = 10`으로 하고, **유사도 하한은 0.30**으로 한다. +- 후보가 0개면 LLM 을 호출하지 않고 선택 0개로 정상 완료 처리한다. ### LLM 판정 -- 구조화 출력 `{selected: [{keywordId, confidence}]}`. `keywordId`는 **후보 id enum으로 제약**, 후보 밖 id는 조용히 폐기. -- 프롬프트에 **부대시설/서비스 언급 제외 규칙** 포함(예: "주차가 넓어서"만으로 `SPACIOUS` 선택 금지). +- 출력은 구조화 형식 `{selected: [{keywordId, confidence}]}`를 강제한다. `keywordId`는 **후보로 전달한 id 의 enum 으로 제약**하고, 후보에 없는 id 가 반환되면 오류로 처리하지 않고 폐기한다. +- 프롬프트에 **부대시설/서비스 언급 제외 규칙**을 포함한다. 예를 들어 "주차가 넓어서"라는 언급만으로 `SPACIOUS`를 선택하는 것을 금지한다. ## 근거 (eval 실측) -- **프리셋 독립성 OK** — 테스트 A: cosine ≥ 0.9 병합 후보 **0건**. 최근접도 `WITH_PARTNER↔DATE_COURSE` 0.578로 별개. → 병합·삭제 불필요. -- **커버리지 건전** — 테스트 B: 미매칭율 2.9%, 쏠림 max-share 10%·Gini 0.286, 사각지대 0. top-1 분포상 진짜 매칭은 대체로 0.45+, 무관 입력은 0.30 부근에서 갈림. -- **하한 0.30 유지** — 0.35로 올리면 "시험기간에 살다시피"(간접 표현, STUDY_WORK) 같은 약한 임베딩(0.30~0.35)이 유실된다. 하한을 올리는 대신 **프롬프트로 정밀도를 보완**한다. -- **판정 계층이 임베딩 노이즈를 교정** — 테스트 C-1: "여자친구랑…"에서 top-1 후보가 `WITH_FAMILY`(0.485)였으나 판정이 기각하고 `WITH_PARTNER` 선택. 스키마 위반·파싱 실패·과잉 선택(>3) 각 0건. -- **판정 모델 확정 — 테스트 C-2**: 확정 프롬프트로 3사 4모델 비교(35샘플). 정확도(스키마·선택 분포)는 4모델 사실상 동일 → 태스크가 "후보에서 고르기"라 경량 tier로 충분. `gemini-2.5-flash`(thinkingBudget=0)가 최속(1.12s)·최소 토큰(25314)으로 최우수. gpt-5-nano 탈락(최장 지연·최다 토큰). confidence는 전 모델 변별력 낮음. +- **프리셋 간 독립성이 확인됐다.** 테스트 A 에서 cosine 유사도 0.9 이상으로 겹쳐 병합 대상이 되는 프리셋 쌍은 **0건**이었다. 가장 가까운 쌍인 `WITH_PARTNER`와 `DATE_COURSE`도 유사도 0.578 로 서로 별개 개념으로 구분됐다. 따라서 프리셋 병합이나 삭제는 필요 없다. +- **커버리지가 건전하다.** 테스트 B 에서 어느 프리셋에도 매칭되지 않은 샘플 비율(미매칭율)은 2.9%였다. 특정 프리셋으로의 쏠림은 최대 점유율 10%, Gini 계수 0.286 으로 낮았고, 어떤 샘플도 받지 못하는 사각지대 프리셋은 0개였다. top-1 유사도 분포를 보면 실제로 맞는 매칭은 대체로 0.45 이상에 있고, 무관한 입력은 0.30 부근에서 갈렸다. +- **하한은 0.30 을 유지한다.** 하한을 0.35 로 올리면 "시험기간에 살다시피" 같은 간접 표현(정답은 STUDY_WORK)이 유실된다. 이런 표현의 임베딩 유사도가 0.30~0.35 구간에 있기 때문이다. 하한을 올려 정밀도를 얻는 대신 **프롬프트로 정밀도를 보완**하는 쪽을 택했다. +- **판정 계층이 임베딩의 오류를 교정한다.** 테스트 C-1 에서 "여자친구랑…"이라는 입력의 top-1 후보는 `WITH_FAMILY`(유사도 0.485)였다. LLM 판정이 이를 기각하고 `WITH_PARTNER`를 선택했다. 스키마 위반, 파싱 실패, 셋을 넘는 과잉 선택(>3)은 각 0건이었다. +- **판정 모델을 테스트 C-2 로 확정했다.** 확정 프롬프트로 3사 4모델을 35샘플로 비교했다. 스키마 준수와 선택 분포로 본 정확도는 4모델이 사실상 동일했다. 태스크가 "주어진 후보에서 고르기"라서 경량 tier 모델로도 충분하기 때문이다. `gemini-2.5-flash`(thinkingBudget=0)가 최단 지연(1.12s)과 최소 토큰(25314)으로 가장 우수했다. gpt-5-nano 는 지연이 가장 길고 토큰을 가장 많이 써서 탈락했다. confidence 값은 모든 모델에서 변별력이 낮았다. -## 버린 대안 +## 채택하지 않은 대안 -- **유사도 하한 0.35+**: 정밀도는 오르나 간접 표현 재현율이 급락. 프롬프트 정밀화가 더 나은 지점. -- **confidence를 강한 랭킹 신호로 사용**: 판정 모델(gpt-5-mini)의 confidence가 과신 경향(mean 0.94, 낮은 변별력)이라 MVP에서 신뢰 신호로 부적합. +- **유사도 하한 0.35 이상**: 정밀도는 오르지만 간접 표현의 재현율이 급락한다. 프롬프트를 정밀화하는 쪽이 더 나은 절충점이다. +- **confidence 를 강한 랭킹 신호로 사용**: 판정 모델(gpt-5-mini)의 confidence 가 평균 0.94 로 과신 경향을 보였고 변별력이 낮아, MVP 에서 신뢰할 신호로 부적합하다. ## 영향 -- 확정 프롬프트는 eval 브랜치 `tools/keyword_eval/prompts/keyword_judgment.md`. E 구현의 `/context/process` 판정부에 그대로 투입한다. -- 후보 검색 하한·TOP-K는 `/search` 및 키워드 후보 생성에 반영. -- **판정 모델 확정: `gemini-2.5-flash`(thinkingBudget=0)** — 테스트 C-2 5개 지표(스키마·선택 분포·confidence·지연·토큰) 기준 최적. 차선은 `gpt-5-mini`(안정하나 reasoning으로 느림), `claude-haiku-4-5`(빠르나 입력 토큰 큼). 호출 방식은 responseSchema(네이티브 구조화 출력) — function-calling은 2.5-flash에서 malformed. E 구현 `/context/process` 판정부에 이 모델 + 확정 프롬프트를 투입한다(ai#6에서 반영). +- 확정 프롬프트는 eval 브랜치의 `tools/keyword_eval/prompts/keyword_judgment.md`에 있다. E 구현의 `/context/process` 판정부에 그대로 투입한다. +- 후보 검색 하한과 TOP-K 는 `/search` 및 키워드 후보 생성에 반영한다. +- **판정 모델은 `gemini-2.5-flash`(thinkingBudget=0)로 확정했다.** 테스트 C-2 의 5개 지표(스키마·선택 분포·confidence·지연·토큰) 기준으로 최적이었다. 차선은 `gpt-5-mini`(안정적이지만 reasoning 때문에 느림)와 `claude-haiku-4-5`(빠르지만 입력 토큰이 큼)다. 호출 방식은 responseSchema(네이티브 구조화 출력)를 쓴다. function-calling 방식은 2.5-flash 에서 형식이 깨진 응답(malformed)이 나왔기 때문이다. E 구현 `/context/process` 판정부에 이 모델과 확정 프롬프트를 투입한다(ai#6에서 반영). ## 검증 -- eval 하네스 A/B/C-1 실행 + `REPORT.md`(근거 수치 원본). 팀이 프리셋 안 보고 쓴 샘플로 교체 시 재측정하면 유효성이 올라간다(현 샘플은 self-reference 편향, 경향 해석). +- eval 하네스 A/B/C-1 을 실행했고 근거 수치 원본은 `REPORT.md`에 있다. 현재 샘플은 프리셋을 알고 있는 작성자가 만든 것이라 self-reference 편향이 있으므로 경향 해석에 한정한다. 팀이 프리셋을 보지 않고 쓴 샘플로 교체해 재측정하면 유효성이 올라간다. diff --git a/docs/proposals/P4-is-deleted-cancelled.md b/docs/proposals/P4-is-deleted-cancelled.md index 6736c5c..a226df6 100644 --- a/docs/proposals/P4-is-deleted-cancelled.md +++ b/docs/proposals/P4-is-deleted-cancelled.md @@ -1,4 +1,4 @@ -# P4: 즉시 파기 대신 `is_deleted` + `CANCELLED` 마커 +# P4: 파생 데이터를 즉시 파기하지 않고 `is_deleted` 마커와 `CANCELLED` 상태로 정리한다 - **상태**: Accepted - **날짜**: 2026-07-23 @@ -7,38 +7,38 @@ ## 맥락 -Context·Record가 삭제되거나 수정으로 구 Context가 무효화될 때, 파생된 임베딩(`ai.context_embedding`)과 키워드(`ai.context_keyword`)를 어떻게 정리할지 정해야 한다. 두 갈래가 있었다. +Context·Record 가 삭제되거나 수정으로 구 Context 가 무효화될 때, 파생된 임베딩(`ai.context_embedding`)과 키워드(`ai.context_keyword`)를 어떻게 정리할지 정해야 한다. 두 방식이 후보였다. -- **즉시 파기**: 삭제 즉시 파생 행을 물리 `DELETE`. -- **마커**: `is_deleted = true`로 표시하고, 진행 중 AI 작업 상태를 `CANCELLED`로 전이. +- **즉시 파기**: 삭제 즉시 파생 행을 물리 `DELETE` 한다. +- **마커**: `is_deleted = true`로 표시하고, 진행 중 AI 작업의 상태를 `CANCELLED`로 전이한다. -문제는 **삭제와 AI 처리가 경합**한다는 점이다. FastAPI가 임베딩을 계산하는 도중 사용자가 Context를 지우면, 계산이 끝나 저장하려는 순간 대상은 이미 삭제 대상이다. +문제는 **삭제와 AI 처리가 경합**한다는 점이다. FastAPI 가 임베딩을 계산하는 도중 사용자가 Context 를 지우면, 계산이 끝나 저장하려는 순간 대상 Context 는 이미 삭제된 상태다. ## 결정 -- `ai.context_embedding.is_deleted`(일반 컬럼)를 **소프트 삭제 마커**로 둔다. 삭제 시 즉시 물리 `DELETE`하지 않는다. +- `ai.context_embedding.is_deleted`(일반 컬럼)를 **소프트 삭제 마커**로 둔다. 삭제 시 즉시 물리 `DELETE` 하지 않는다. - 진행 중 작업은 `ai.context_ai_state`를 `CANCELLED`로 전이해 무효화한다. -- 저장 직전 `FOR UPDATE`로 상태를 재검사하고, `PROCESSING`이 아니면(=`CANCELLED` 등) 결과를 **조용히 폐기**한다. `is_deleted`는 복구하지 않는다. +- 저장 직전 `FOR UPDATE`로 상태를 재검사한다. 상태가 `PROCESSING`이 아니면(`CANCELLED`로 전이된 경우 등) 계산 결과를 오류 없이 폐기한다. 이때 `is_deleted`는 복구하지 않는다. - 검색은 `is_deleted = false`를 필터로 건다. -- 물리 삭제(하드 삭제)의 시점·주체는 별도 정책(회원 탈퇴 등)으로 분리한다. +- 물리 삭제(하드 삭제)의 시점과 주체는 회원 탈퇴 등 별도 정책으로 분리해 정한다. ## 근거 -- **경합을 상태로 흡수한다.** 대상 행이 사라지는 대신 마커로 남아, 진행 중 작업은 "저장 직전 상태 확인 → CANCELLED면 폐기"라는 한 규칙으로 정리된다. 행이 물리적으로 사라지면 이 확인 자체가 NULL·예외 처리로 복잡해진다. -- **검색 정확성과 분리.** `is_deleted=false` 필터 + 검색 결과의 Core 재검증으로 노출을 막으므로, 물리 삭제를 급히 할 이유가 없다. -- **책임 경계 유지.** `is_deleted`/`CANCELLED`는 Spring이 쓴다. FastAPI는 `core.*`에 접근하지 않고, 자신이 만든 결과를 저장 직전 상태로만 판단한다. +- **경합을 상태로 흡수한다.** 대상 행이 사라지는 대신 마커로 남으므로, 진행 중 작업은 "저장 직전 상태를 확인하고, `CANCELLED`면 폐기한다"는 한 규칙으로 정리된다. 행이 물리적으로 사라지면 이 확인 자체가 NULL 처리와 예외 처리로 복잡해진다. +- **검색 정확성과 분리된다.** `is_deleted = false` 필터와 검색 결과에 대한 Core 재검증이 삭제된 Context 의 노출을 막는다. 따라서 물리 삭제를 급히 실행할 이유가 없다. +- **책임 경계를 유지한다.** `is_deleted`와 `CANCELLED`는 Spring 이 쓴다. FastAPI 는 `core.*` 테이블에 접근하지 않고, 자신이 만든 결과를 저장할지 여부를 저장 직전의 상태로만 판단한다. -## 버린 대안 +## 채택하지 않은 대안 -- **즉시 파기(DELETE)**: 진행 중 작업이 참조하던 행이 사라져 경합 처리가 NULL·재조회로 번지고, 검색 재검증 로직과 얽힌다. - - 단, 팀원(MINYONG)은 즉시 파기를 선호했다. 이 선호는 **버려진 게 아니라**, 아직 열린 "회원 탈퇴 시 AI 파생 데이터 물리 삭제 시점" 결정에서 **'즉시 삭제(안 A)' 지지 의견**으로 이관해 기록한다. 경합 방어(이 ADR)와 개인정보 파기(탈퇴 정책)는 다른 문제다. +- **즉시 파기(DELETE)**: 진행 중 작업이 참조하던 행이 사라지므로 경합 처리가 NULL 처리와 재조회로 번지고, 검색 재검증 로직과 얽힌다. + - 단, 팀원(MINYONG)은 즉시 파기를 선호했다. 이 선호는 **버려진 것이 아니다.** 아직 열려 있는 "회원 탈퇴 시 AI 파생 데이터 물리 삭제 시점" 결정에서 **'즉시 삭제(안 A)' 지지 의견**으로 이관해 기록한다. 이 ADR 이 다루는 경합 방어와 탈퇴 정책이 다루는 개인정보 파기는 서로 다른 문제이기 때문이다. ## 영향 -- `ai.context_embedding`은 `is_deleted`를 일반 컬럼으로 갖고 PK는 `context_id` 단독([back#3 마이그레이션](https://github.com/Team-PinLog/back/pull/3) — 복합 PK면 UPSERT `ON CONFLICT (context_id)`가 깨짐). -- 열린 결정: 회원 탈퇴 시 물리 삭제 시점(개인정보 정책). `back/docs/ai/deletion-cancellation.md` 및 `docs` 미결 목록 참조. +- `ai.context_embedding`은 `is_deleted`를 일반 컬럼으로 갖고 PK 는 `context_id` 단독이다([back#3 마이그레이션](https://github.com/Team-PinLog/back/pull/3)). 복합 PK 로 만들면 UPSERT 의 `ON CONFLICT (context_id)`가 동작하지 않기 때문이다. +- 열린 결정: 회원 탈퇴 시 물리 삭제 시점(개인정보 정책). `back/docs/ai/deletion-cancellation.md` 및 `docs` 미결 목록을 참조한다. ## 검증 -- 구현 명세에서 삭제·수정 경합이 `CANCELLED` 중심으로 재작성됨([deletion-race-control.md](../spec/deletion-race-control.md)). -- 계약·draft에서 "즉시 파기"·"HNSW" 잔존 0건 확인. +- 구현 명세에서 삭제·수정 경합 처리가 `CANCELLED` 중심으로 재작성됐다([deletion-race-control.md](../spec/deletion-race-control.md)). +- 계약과 draft 문서에서 "즉시 파기"·"HNSW" 잔존 0건을 확인했다. diff --git a/docs/proposals/P43-s1-judgment-recovery.md b/docs/proposals/P43-s1-judgment-recovery.md index a26eb9e..7a76135 100644 --- a/docs/proposals/P43-s1-judgment-recovery.md +++ b/docs/proposals/P43-s1-judgment-recovery.md @@ -1,4 +1,4 @@ -# P43: S1 구현 판단 변경·기각 대안 복원 +# P43: 종료된 S1 세션의 구현 판단 변경과 기각 대안을 복원해 기록한다 - **상태**: Accepted (복원 기록) - **날짜**: 2026-07-28 (복원 — 실제 2026-07-23~27) @@ -6,34 +6,59 @@ - **관련 PR/커밋**: S1 세션 전반(ai#5~#24) - **근거**: `pinlog/.claude/state/S1-RECOVERY-PACKET.md` -> 종료된 S1 세션의 **판단 변경 10건·기각 대안 9건**을 복원한다. 개별 계약 결정은 이미 P1~P41에 있고, 여기는 *"무엇에서 무엇으로, 왜 바뀌었고, 무엇을 왜 버렸는가"*를 남긴다. -> 출처: `기록복원`(transcript) · `추정`. 근거 없는 판단 이유는 창작하지 않는다. +> 종료된 S1 세션의 **판단 변경 10건과 기각 대안 9건**을 복원한다. 개별 계약 결정은 이미 P1~P41 에 있다. 이 문서는 "무엇에서 무엇으로 바뀌었는가, 왜 바뀌었는가, 무엇을 왜 버렸는가"만 남긴다. +> 각 항목의 출처는 transcript 에서 확인한 `기록복원` 또는 근거 발화가 없는 `추정`으로 구분한다. 근거 없는 판단 이유는 창작하지 않는다. ## 판단 변경 10건 (전부 `기록복원`) -| # | 변경 | 계기·근거 | -|---|---|---| -| 1 | `search_path = ai` → `ai, public` | API 3건 401 → `type "vector" does not exist`로 단계적 좁혀짐. 처음엔 register_vector 타이밍으로 보고 **명시 캐스트로 방어**하려다, 캐스트를 넣자 진짜 원인(search_path)이 노출 → **캐스트 원복**하고 search_path만 고침(app 유일 변경). "이전 로컬 검증이 통과한 건 우연히 첫 커넥션만 register됐기 때문" (T21) | -| 2 | lock `uv pip compile` → `--universal` | 병합 후 main CI 19초 실패(`No matching distribution for pywin32`). Windows lock이 플랫폼 마커 없이 Windows 전용 고정 (T19) | -| 3 | PYTHONPATH 수동 → `pyproject pythonpath=["."]` | `ModuleNotFoundError: app`. **"로컬 PYTHONPATH 수동 설정이 CI 조건 차이를 가렸다"**고 자인, PYTHONPATH 없이 재현해 27 통과 확인 후 올림 (T20) | -| 4 | 로컬 `.venv` 3.14 → 3.12 재생성 | 환경 3분할(로컬/CI/미래) 인식 | -| 5 | 인계 문서 작성 → 인계 취소 | 사용자 지시 변경. 인계 브랜치 2개 삭제 | -| 6 | 문서 전수 감사 자가 수행 → 문서화 세션 위임 | 역할 분리 지시. 자기 구현 지식이 필요해 위임 불가한 것만 처리 | -| 7 | `/search` contextId "개인정보 경계" 우려 → 문제 없음 | contextId는 본인 데이터 식별자·수신자는 내부 Spring·본문은 여전히 Spring이 core에서 조회. **id 없이는 Spring이 조립을 못 해 원칙이 무력화** | -| 8 | 시나리오 5 "text 대조 WARN" 전제 → call 0·state 불변 | 커밋 전 코드 확인 시 **text 대조·WARN 경로가 실제로 없었음**. app 무변경 원칙을 지켜 단언을 코드 실상에 맞춤 | -| 9 | "pgvector 0.8.1 = back과 일치" → 무효화, 0.8.5+digest 권고 | back#31이 compose를 올림. 조사 끝에 digest까지 동일 고정을 권고했으나 **값 변경은 하지 않고 종료** → 권고는 `S15P11A705-122`에서 반영(`0.8.5-pg16`+digest) | -| 10 | 공유 워킹트리 커밋 → 격리 worktree | `git add`한 3파일 커밋에 타 세션 파일 15개가 섞여 push, force-push 직전 브랜치 전환으로 무산 (T25) | +### 1. `search_path`를 `ai`에서 `ai, public`으로 변경 + +API 3건이 401 응답을 반환했고, 조사 결과 `type "vector" does not exist` 오류로 단계적으로 좁혀졌다. 처음에는 register_vector 호출 타이밍이 원인이라고 보고 SQL 에 명시 캐스트를 넣어 방어하려 했다. 그런데 캐스트를 넣자 진짜 원인이 search_path 설정이라는 사실이 드러났다. 그래서 **캐스트를 원복**하고 search_path 만 고쳤다. 이것이 app 코드의 유일한 변경이다. 이전 로컬 검증이 통과했던 이유는 "우연히 첫 커넥션만 register 됐기 때문"으로 확인됐다 (T21). + +### 2. lock 생성을 `uv pip compile`에서 `--universal`로 변경 + +병합 후 main CI 가 19초 만에 `No matching distribution for pywin32` 오류로 실패했다. Windows 에서 생성한 lock 파일이 플랫폼 마커 없이 Windows 전용 패키지를 고정하고 있었기 때문이다 (T19). + +### 3. PYTHONPATH 수동 설정을 `pyproject pythonpath=["."]`로 변경 + +CI 에서 `ModuleNotFoundError: app`이 발생했다. "로컬에서 PYTHONPATH 를 수동으로 설정해 둔 것이 CI 와의 조건 차이를 가렸다"고 스스로 인정했다. PYTHONPATH 없이 로컬에서 재현해 테스트 27건 통과를 확인한 뒤 올렸다 (T20). + +### 4. 로컬 `.venv`를 Python 3.14 에서 3.12 로 재생성 + +로컬·CI·미래 스택의 세 환경이 서로 다르다는 점(환경 3분할)을 인식하고 CI 와 버전을 맞췄다. + +### 5. 인계 문서 작성을 중단하고 인계를 취소 + +사용자 지시가 변경됐다. 작성 중이던 인계 브랜치 2개를 삭제했다. + +### 6. 문서 전수 감사를 자가 수행에서 문서화 세션 위임으로 변경 + +역할 분리 지시에 따랐다. 자기 구현 지식이 필요해서 위임할 수 없는 부분만 직접 처리했다. + +### 7. `/search`의 contextId 에 대한 "개인정보 경계" 우려를 철회 + +contextId 는 본인 데이터의 식별자이고, 수신자는 내부 Spring 이며, 본문은 여전히 Spring 이 core 에서 조회한다. 오히려 **id 없이는 Spring 이 응답을 조립하지 못해** 최소 전달 원칙이 무력화된다. 따라서 문제가 없다고 판단을 바꿨다. + +### 8. 시나리오 5 의 "text 대조 WARN" 전제를 "call 0·state 불변"으로 수정 + +커밋 전에 코드를 확인해 보니 **text 대조와 WARN 경로가 실제로 코드에 없었다.** app 무변경 원칙을 지키기 위해 코드를 바꾸는 대신 문서의 단언을 코드 실상에 맞췄다. + +### 9. "pgvector 0.8.1 = back 과 일치" 판단을 무효화하고 0.8.5+digest 를 권고 + +back#31 이 compose 의 pgvector 버전을 올려서 기존 판단의 전제가 깨졌다. 조사 끝에 digest 까지 동일하게 고정하라고 권고했다. 다만 **값 변경은 직접 하지 않고 세션을 종료**했고, 권고는 `S15P11A705-122`에서 반영됐다(`0.8.5-pg16`+digest). + +### 10. 공유 워킹트리 커밋을 격리 worktree 로 변경 + +`git add`한 3파일을 커밋했는데 다른 세션의 파일 15개가 함께 섞여 push 됐다. force-push 를 하기 직전에 브랜치가 전환되어 무산됐다 (T25). 이후 격리 worktree 를 쓰는 것으로 바꿨다. ## 기각한 대안 9건 -| 대안 | 기각 이유 | 출처 | -|---|---|---| -| 롤링 `pg16` 태그 | 재현성 파괴, 통일 비용 0 | `기록복원` | -| Preset Cache TTL 재적재 구현 | `architecture.md §5`가 "재시작으로만"으로 확정 | `기록복원` | -| 크로스레포 스키마 자동 diff | cross-repo 체크아웃 비용, MVP 과함 → back PR 템플릿 체크 + 스냅샷 헤더로 대체(back 템플릿 수정은 back 소관이라 **제안만**) | `기록복원` | -| 스냅샷 헤더 주석만 두는 안 | "코드가 스냅샷을 추월할 때만" 잡히고 **back이 앞선 구간은 침묵** | `기록복원` | -| CONTRIBUTING 지금 작성 | 규약 실체가 이미 docs/에 있고 1인 시점엔 과함 → README로 갈음 | `기록복원` | -| 기능별 폴더 분리 | 기능이 하나라 과설계(YAGNI). 계층이 이미 확장 축 | `기록복원` | -| 무거운 ML 의존성 처리 지금 결정 | 스택 확정 시점(합류)이 맞음. 원칙만 메모 | `기록복원` | -| `$3::vector` 명시 캐스트 방어 | **원복함** — 근본 원인이 search_path였음이 드러나 불필요해짐 | `기록복원` | -| SQLAlchemy 계열 | asyncpg가 채택됐으나 **대비 대안이 정확히 무엇이었는지, 기각 사유 발화는 없음** | **`추정`** | +- **롤링 `pg16` 태그 사용** — 재현성을 깨뜨리고, 버전을 통일하는 비용이 0 이라서 기각했다. (`기록복원`) +- **Preset Cache TTL 재적재 구현** — `architecture.md §5`가 "재시작으로만 재적재"로 이미 확정했다. (`기록복원`) +- **크로스레포 스키마 자동 diff** — cross-repo 체크아웃 비용이 들고 MVP 에는 과하다. back PR 템플릿의 체크 항목과 스냅샷 헤더로 대체했다. back 템플릿 수정은 back 파트 소관이라 **제안만** 했다. (`기록복원`) +- **스냅샷 헤더 주석만 두는 안** — 코드가 스냅샷보다 앞설 때만 어긋남이 잡히고, **back 이 앞선 구간에서는 아무 경고도 나오지 않는다.** (`기록복원`) +- **CONTRIBUTING 을 지금 작성** — 규약의 실체가 이미 docs/ 에 있고, 1인 개발 시점에는 과하다. README 로 갈음했다. (`기록복원`) +- **기능별 폴더 분리** — 기능이 하나뿐이라 과설계다(YAGNI). 계층 구조가 이미 확장 축 역할을 한다. (`기록복원`) +- **무거운 ML 의존성 처리를 지금 결정** — 스택이 확정되는 시점(팀 합류)에 정하는 것이 맞다. 원칙만 메모해 두었다. (`기록복원`) +- **`$3::vector` 명시 캐스트 방어** — **원복했다.** 근본 원인이 search_path 로 드러나 캐스트가 불필요해졌다. (`기록복원`) +- **SQLAlchemy 계열** — asyncpg 가 채택됐다. 다만 **비교 대상 대안이 정확히 무엇이었는지, 기각 사유를 말한 발화는 transcript 에 없다.** (**`추정`**) diff --git a/docs/proposals/P44-ai-repository-governance.md b/docs/proposals/P44-ai-repository-governance.md index 83e6ab9..da69c2d 100644 --- a/docs/proposals/P44-ai-repository-governance.md +++ b/docs/proposals/P44-ai-repository-governance.md @@ -9,12 +9,12 @@ ## 결정 백엔드 레포의 Jira-first, TDD 증거, 영구 문서, 리뷰 대화 해결 원칙을 AI -레포의 Python/FastAPI 환경에 맞게 수용한다. AI의 단일 기준은 -[`CONTRIBUTING.md`](../../CONTRIBUTING.md)이며, 도구별 안내 문서는 이 기준을 +레포의 Python/FastAPI 환경에 맞게 수용한다. AI 레포의 단일 기준은 +[`CONTRIBUTING.md`](../../CONTRIBUTING.md)다. 도구별 안내 문서는 이 기준을 복제하지 않고 읽기 순서만 제공한다. Jira, GitHub, 레포 영구 문서만 공동 진실 원천으로 사용한다. 개인 Claude/Codex -세션, control board, hook과 로컬 설정은 팀 상태로 사용하지 않는다. +세션, control board, hook 과 로컬 설정은 팀 상태로 사용하지 않는다. ## 수용 항목과 이유 @@ -37,7 +37,7 @@ Jira, GitHub, 레포 영구 문서만 공동 진실 원천으로 사용한다. | 백엔드 문서의 전체 복제 | 중복된 규칙은 시간이 지나며 서로 달라진다. | | 개인 `.claude` board·session·hook 공유 | 로컬 종속, 노후화, 개인 설정 노출 위험이 있다. | -스키마 변경 시에는 Flyway 검사를 복제하는 대신 연결된 백엔드·AI Jira와 PR, +스키마 변경 시에는 Flyway 검사를 복제하는 대신, 연결된 백엔드·AI Jira 와 PR, 백엔드 migration, AI `tests/schema/ai_snapshot.sql` 동기화를 리뷰에서 확인한다. ## 리스크와 완화 @@ -52,21 +52,23 @@ Jira, GitHub, 레포 영구 문서만 공동 진실 원천으로 사용한다. | Feed와 운영체계 변경 충돌 | 별도 티켓·브랜치·worktree로 병렬 진행하고 운영체계 병합 후 최신 `dev`를 반영한다. | | 문서와 실제 GitHub 설정 불일치 | 설정을 바꾼 사람이 **같은 작업에서** `CONTRIBUTING.md` 병합 조건 절을 갱신하고, 변경 직후 API로 재조회해 문서와 대조한다. | -※ 이 문서 작성 시점(2026-07-28)의 브랜치는 `main` 단일 구성이었다. 2단 구성 -(`dev` 통합 / `main` 배포) 전환은 `S15P11A705-154`·`S15P11A705-158`에서 이뤄졌고, -위 표의 브랜치 서술은 그 결과 상태다. 브랜치 규칙의 정본은 +이 문서를 작성한 시점(2026-07-28)의 브랜치는 `main` 단일 구성이었다. +`dev` 통합 / `main` 배포의 2단 구성 전환은 `S15P11A705-154`·`S15P11A705-158`에서 +이뤄졌고, 위 표의 브랜치 서술은 그 전환이 끝난 뒤의 상태다. 브랜치 규칙의 정본은 [`CONTRIBUTING.md`](../../CONTRIBUTING.md)다. -※ **"문서와 실제 GitHub 설정 불일치" 리스크는 실제로 발생했다.** `ai#42` 병합 후 -required status checks에 `ai-ci / embedding profile parity`가 추가됐으나 정본이 -갱신되지 않아, 낡은 값이 `CONTRIBUTING.md`·`workflow.md`·이 문서로 퍼졌다 -(`S15P11A705-158`에서 정정). 원래의 완화("API로 재조회")는 **설정을 읽는 것까지만** -다루고 문서를 고치는 주체를 정하지 않아 구멍이 남았으므로 위 표를 그에 맞게 고쳤다. +**"문서와 실제 GitHub 설정 불일치" 리스크는 실제로 발생했다.** `ai#42` 병합 후 +required status checks 에 `ai-ci / embedding profile parity`가 추가됐는데 정본이 +갱신되지 않았다. 그 결과 낡은 값이 `CONTRIBUTING.md`·`workflow.md`·이 문서로 퍼졌다 +(`S15P11A705-158`에서 정정). 원래의 완화 방안("API로 재조회")은 **설정을 읽는 것까지만** +다루고, 읽은 결과로 문서를 고치는 주체를 정하지 않았다. 그 구멍 때문에 불일치가 +전파됐으므로, 위 표의 완화 문구를 문서 갱신 주체까지 포함하도록 고쳤다. ## 단계적 coverage 기준 -S15P11A705-108의 최초 기준선은 Python 3.12, 52 tests에서 line 76.95% -(474/616), branch 62.5% (55/88)다. 이 단계에서는 보고서만 CI artifact로 -보존하고 낮은 수치로 실패시키지 않는다. +S15P11A705-108의 최초 기준선은 Python 3.12, 52 tests 기준으로 line 76.95% +(측정 대상 616줄 중 474줄 실행), branch 62.5% (분기 88개 중 55개 통과)다. +이 단계에서는 보고서만 CI artifact 로 보존하고, 수치가 낮다는 이유로 CI 를 +실패시키지 않는다. 테스트 보강과 line·branch 80% 게이트 활성화는 후속 S15P11A705-110에서 수행한다. diff --git a/docs/proposals/P45-public-config-in-code.md b/docs/proposals/P45-public-config-in-code.md index 7d36841..4bdff33 100644 --- a/docs/proposals/P45-public-config-in-code.md +++ b/docs/proposals/P45-public-config-in-code.md @@ -21,9 +21,9 @@ PINLOG_JUDGE_MODEL · KEYWORD_CANDIDATE_TOP_K · SIMILARITY_FLOOR · PROCESSING_EXPIRY_SEC ``` -**공개 값 중 EMBEDDING 넷만 기본값이 없었다.** 나머지는 이미 코드 기본값을 갖고 있었으므로, 이 결정은 새 규칙을 만든 것이 아니라 **어긋나 있던 넷을 규칙에 맞춘 것**이다. +**공개 값 중 EMBEDDING 넷만 기본값이 없었다.** 나머지는 이미 코드 기본값을 갖고 있었다. 따라서 이 결정은 새 규칙을 만든 것이 아니라 **어긋나 있던 넷을 기존 규칙에 맞춘 것**이다. -## 왜 — 원 결정을 뒤집는 근거 +## 원 결정을 뒤집는 근거 [model-profile.md §2.1](../spec/model-profile.md)은 원래 이렇게 적혀 있었다. @@ -32,15 +32,15 @@ ### 그 논거는 "주입이 필수"라는 전제 위에 있다 -주입이 필수일 때만 "누락"이 성립한다. 주입을 **덮어쓰기**로 돌리면 기본값으로 뜨는 것이 정상 동작이므로 누락이라는 상태 자체가 없어진다. 전제가 사라지면 결론도 사라진다. +주입이 필수일 때만 "누락"이라는 상태가 성립한다. 주입을 **덮어쓰기**로 바꾸면 기본값으로 기동하는 것이 정상 동작이므로, 누락이라는 상태 자체가 없어진다. 전제가 성립하지 않으므로 그 위의 결론도 유지할 이유가 없다. ### 반대로 원 결정의 대가가 실측됐다 -값이 배포 설정에만 있으면 **교체가 git 이력도 리뷰도 남기지 않는다.** +값이 배포 설정에만 있으면 **값을 교체해도 git 이력과 리뷰가 남지 않는다.** -`PINLOG_EMBEDDING_PROFILE` 변경은 [§3.2](../spec/model-profile.md)에 따라 **기존 임베딩을 전부 조회 대상에서 빼는** 결정이다. 그런데 그것이 GitHub Secret 콘솔 편집 한 번으로 가능했다 — PR 없이, 리뷰 없이, "누가 언제 왜 바꿨나"의 기록 없이. +`PINLOG_EMBEDDING_PROFILE` 변경은 [§3.2](../spec/model-profile.md)에 따라 **기존 임베딩을 전부 조회 대상에서 빼는** 결정이다. 그런데 그 변경이 GitHub Secret 콘솔 편집 한 번으로 가능했다. PR 도 리뷰도 거치지 않고, "누가 언제 왜 바꿨나"의 기록도 남지 않는다. -**공개 값을 비밀처럼 다루면 보안은 늘지 않고 감시만 줄어든다.** 모델명·차원·거리함수·프로필 식별자는 이미 공개 문서 [P32](README.md)에 적혀 있어 숨겨진 적이 없다. +**공개 값을 비밀처럼 다루면 보안은 늘지 않고 감시만 줄어든다.** 모델명·차원·거리함수·프로필 식별자는 이미 공개 문서 [P32](README.md)에 적혀 있어 숨겨진 적이 없기 때문이다. ### 불일치 탐지는 그대로다 @@ -53,45 +53,38 @@ 양쪽 값을 로그에 남긴다 ``` -원 논거가 걱정한 *"조용한 Profile 불일치"*는 이 둘이 막는다. 기본값의 유무와 무관하다. +원 논거가 걱정한 "조용한 Profile 불일치"는 이 두 검사가 막는다. 기본값의 유무와 무관하다. ## 기각한 대안 ### `config/*.yaml` 계층 도입 -`config/default.yaml` · `development.yaml` · `local.yaml`로 공개 설정을 분리하는 안. 공개/비공개 분리라는 목적은 같게 달성한다. +`config/default.yaml` · `development.yaml` · `local.yaml`로 공개 설정을 분리하는 안이다. 공개/비공개 분리라는 목적은 같게 달성한다. -**기각 이유는 설정 소스가 셋이 되기 때문이다.** 지금은 `config.py` 기본값 → 환경변수 둘뿐이고 우선순위가 자명하다. YAML 레이어를 더하면 `yaml → env → Secret` 셋이 되고, 장애를 볼 때마다 *"지금 이 값이 어디서 왔나"*를 역추적해야 한다. 얻는 것(파일로 환경별 분리)보다 잃는 것(추적 가능성)이 크다. +**기각한 이유는 설정 소스가 셋이 되기 때문이다.** 지금은 `config.py` 기본값과 환경변수 둘뿐이고 우선순위가 자명하다. YAML 레이어를 더하면 `yaml → env → Secret` 셋이 되고, 장애를 볼 때마다 "지금 이 값이 어디서 왔나"를 역추적해야 한다. 얻는 것(파일로 환경별 분리)보다 잃는 것(값 출처의 추적 가능성)이 크다. -`pydantic-settings`를 이미 쓰고 있어 **기본값을 필드에 적는 것만으로 같은 이익이 나온다** — 정본이 코드에 있고, 변경이 PR을 거치며, 환경변수로 덮을 수 있다. +`pydantic-settings`를 이미 쓰고 있으므로 **기본값을 필드에 적는 것만으로 같은 이익이 나온다.** 정본이 코드에 있고, 변경이 PR 을 거치며, 환경변수로 덮을 수 있다. ### Secret 에 그대로 두기 (원 상태 유지) -Infra 가 주입 경로를 하나로 요구했으므로 그 요구를 따르는 안. **기각한 이유는 위 "원 결정의 대가"** 그대로다. +Infra 가 주입 경로를 하나로 요구했으므로 그 요구를 따르는 안이다. **기각한 이유는 위 "원 결정의 대가"에 적은 그대로다.** 교체가 이력과 리뷰 없이 가능해진다. -다만 이 기각은 Infra 요구를 거스르지 않는다 — 값이 이미지에 들어 있으므로 **주입할 것이 없어진다.** 실험·롤백으로 덮어써야 하면 ConfigMap 으로 넣으면 되고, 그 경로에 암호화가 필요 없다. +다만 이 기각은 Infra 요구를 거스르지 않는다. 값이 이미지에 들어 있으므로 **주입할 것 자체가 없어진다.** 실험이나 롤백으로 덮어써야 하면 ConfigMap 으로 넣으면 되고, 그 경로에는 암호화가 필요 없다. ## 여파 -| | 무엇이 바뀌나 | -|---|---| -| `app/core/config.py` | EMBEDDING 넷에 기본값. `alias` 는 유지되므로 환경변수 덮어쓰기 경로는 그대로 | -| `.env.example` | 비밀/공개 두 절로 나누고 각 절의 규칙을 명시 | -| `model-profile.md §2.1` | *"기본값을 넣지 않는다"* → *"기본값이 정본이다"*. 개정 사실과 근거를 인용문으로 남김 | -| `seal-runtime-secrets.yml` | 런타임 owner 값 **3종**만 봉인하고 Infra PR token은 action 인증에만 쓴다. EMBEDDING 넷은 주입하지 않는다 | -| GitHub Actions Secret | 등록된 EMBEDDING 넷은 **지우지 않아도 된다** — workflow 가 읽지 않을 뿐이다 | +- `app/core/config.py` — EMBEDDING 넷에 기본값을 넣는다. `alias`는 유지되므로 환경변수 덮어쓰기 경로는 그대로 동작한다. +- `.env.example` — 비밀/공개 두 절로 나누고 각 절의 규칙을 명시한다. +- `model-profile.md §2.1` — "기본값을 넣지 않는다"를 "기본값이 정본이다"로 개정한다. 개정 사실과 근거를 인용문으로 남긴다. +- `seal-runtime-secrets.yml` — 런타임 owner 값 **3종**만 봉인하고 Infra PR token 은 action 인증에만 쓴다. EMBEDDING 넷은 주입하지 않는다. +- GitHub Actions Secret — 이미 등록된 EMBEDDING 넷은 **지우지 않아도 된다.** workflow 가 그 값을 읽지 않을 뿐이다. -**모델을 교체할 때**의 절차가 이렇게 바뀐다. - -``` -전 GitHub Secret 콘솔에서 4개 수정 → 재배포 이력·리뷰 없음 -후 config.py 4줄 수정 → PR → 리뷰 → 머지 → 재배포 이력·리뷰 있음 -``` +**모델을 교체할 때**의 절차가 이렇게 바뀐다. 변경 전에는 GitHub Secret 콘솔에서 4개 값을 수정하고 재배포했으므로 이력과 리뷰가 남지 않았다. 변경 후에는 `config.py`의 4줄을 수정해 PR·리뷰·머지를 거쳐 재배포하므로 이력과 리뷰가 남는다. ## 감수하는 것 -**Spring 과의 이중화가 남는다.** [§2](../spec/model-profile.md)의 "단일 정본"은 Spring 과 FastAPI 가 같은 값을 갖게 하려는 것인데, 코드 기본값을 두면 나중에 Spring 쪽에도 리터럴이 생길 수 있다. +**Spring 과의 이중화가 남는다.** [§2](../spec/model-profile.md)의 "단일 정본"은 Spring 과 FastAPI 가 같은 값을 갖게 하려는 것인데, 코드 기본값을 두면 나중에 Spring 쪽에도 같은 값의 리터럴이 생길 수 있다. -현재는 문제가 되지 않는다 — **Spring 은 아직 `embeddingProfile` 을 다루지 않는다**(레포 전수 검색 0건). 검색 연동(`S15P11A705-135`)에서 붙을 때 §2.2 의 런타임 대조가 그 역할을 하지만, **어느 쪽이 정본인지는 그때 정해야 한다.** 지금 정하지 않는다 — 소비자가 없는 상태에서 정한 규칙은 붙일 때 다시 뒤집힌다. +현재는 문제가 되지 않는다. **Spring 은 아직 `embeddingProfile`을 다루지 않는다**(레포 전수 검색 0건). 검색 연동(`S15P11A705-135`)에서 Spring 이 이 값을 다루게 되면 §2.2 의 런타임 대조가 불일치를 잡는 역할을 하지만, **어느 쪽이 정본인지는 그때 정해야 한다.** 지금 정하지 않는 이유는, 소비자가 없는 상태에서 정한 규칙은 실제로 붙일 때 다시 뒤집히기 때문이다. -**기본값이 낡을 수 있다.** 배포 환경이 실제로 다른 모델을 쓰는데 코드 기본값을 안 고치면, 주입을 잊었을 때 낡은 profile 로 뜬다. 이는 §3.1 이 런타임에 잡지만 그 전까지는 드러나지 않는다. **덮어쓰기를 상시로 쓰지 않는 것**이 이 결정의 전제다 — 상시 덮어쓰기가 필요해지면 그 값은 공개가 아니라 환경 종속이므로 분류를 다시 봐야 한다. +**기본값이 낡을 수 있다.** 배포 환경이 실제로 다른 모델을 쓰는데 코드 기본값을 고치지 않으면, 주입을 잊었을 때 낡은 profile 로 기동한다. 이 상태는 §3.1 의 런타임 대조가 잡지만 그 전까지는 드러나지 않는다. **덮어쓰기를 상시 운영 수단으로 쓰지 않는 것**이 이 결정의 전제다. 상시 덮어쓰기가 필요해지면 그 값은 공개 설정이 아니라 환경 종속 설정이므로 분류를 다시 봐야 한다. diff --git a/docs/proposals/P47-keyword-preset-label-axis.md b/docs/proposals/P47-keyword-preset-label-axis.md index 79e9040..f43e569 100644 --- a/docs/proposals/P47-keyword-preset-label-axis.md +++ b/docs/proposals/P47-keyword-preset-label-axis.md @@ -7,15 +7,15 @@ - **관련 티켓**: `S15P11A705-228` · `S15P11A705-269` · `S15P11A705-270` · `S15P11A705-271` · `S15P11A705-292` - **근거 리포트**: [implements/2026-08-03-preset-description.md](../implements/2026-08-03-preset-description.md) · [troubleshooting/2026-08-03-preset-description.md](../troubleshooting/2026-08-03-preset-description.md)(T68·T69) -> **§6(표시 라벨)은 반영이 끝났고, 나머지는 여전히 제안이다.** 표시명 명사화는 `S15P11A705-292`로 실행돼 `dev`와 `main`에 들어갔다 — 그 절은 제안이 아니라 기록이다. 문서 전체를 `Accepted`로 올리려면 §7 이후가 실행되고 §11의 실측이 있어야 한다. +> **§6(표시 라벨)은 반영이 끝났고, 나머지는 여전히 제안이다.** 표시명 명사화는 `S15P11A705-292`로 실행돼 `dev`와 `main`에 들어갔다. 따라서 그 절은 제안이 아니라 반영된 결과의 기록이다. 문서 전체를 `Accepted`로 올리려면 §7 이후가 실행되고 §11의 실측이 있어야 한다. > -> §4의 선결 조건은 「판을 구분할 경로」에서 **「재처리할 수단」**으로 바뀌었다. 개정 때마다 기존 판정을 전부 재처리하기로 정해졌기 때문이다(§12.1). +> §4의 선결 조건은 「개정 전후의 프리셋 정의를 구분할 경로」에서 **「재처리할 수단」**으로 바뀌었다. 개정 때마다 기존 판정을 전부 재처리하기로 정해졌기 때문이다(§12.1). -## 0. 읽는 법 — 값이 아니라 좌표 +## 0. 읽는 법 — 값 대신 정본 위치를 적는다 **이 문서에는 측정 수치·현행 상수·코드 행 번호·배포 상태 값이 없다.** 대신 그것들이 어디에 있는지를 가리킨다. -값은 시점에 묶이는데 문서는 그것을 따라가지 않는다. 낡은 값이 문서에 박히면 나중에 고칠 때 한 곳을 놓치고, 그 한 곳이 다음 결정의 근거가 된다. 이 프로젝트에서 실제로 세 번 일어났다 — 배포 상태 값이 하루 만에 낡았고, 인용한 코드 위치가 바로 다음 줄을 놓쳤으며, 조건이 두 번 바뀐 수치가 그대로 재사용됐다. +값은 기록한 시점에 묶이는데, 문서는 값의 변화를 따라가지 못한다. 낡은 값이 문서에 박히면 나중에 고칠 때 한 곳을 놓치고, 그 놓친 한 곳이 다음 결정의 근거가 된다. 이 프로젝트에서 실제로 세 번 일어났다. 배포 상태 값이 하루 만에 낡았고, 인용한 코드 위치가 바로 다음 줄을 놓쳤으며, 측정 조건이 두 번 바뀐 수치가 그대로 재사용됐다. | 무엇 | 정본 | |---|---| @@ -32,7 +32,7 @@ 구조를 갈아엎지 않는다. 네 가지를 바꾼다. 1. 네 축(`COMPANION`·`ACTIVITY`·`ATMOSPHERE`·`SITUATION`)의 의미를 문서에서 재정의한다. -2. 모든 프리셋의 `display_name`을 명사 또는 명사구로 통일한다 — **넷 중 이것만 반영됐다**(§6). +2. 모든 프리셋의 `display_name`을 명사 또는 명사구로 통일한다. **넷 중 이것만 반영됐다**(§6). 3. `examples`에서 다른 축의 신호를 제거한다. 4. 표시용 텍스트와 판정용 텍스트를 분리하고, 세트 단위 릴리스를 추적한다. @@ -44,9 +44,9 @@ - **3층 구조** — YAML 정본 → DB 테이블 → 프로세스 캐시. 프리셋 변경이 코드 곳곳에 흩어지지 않고 한 파일과 한 절차로 통제된다. - **불변 계약** — `id`·`code`는 바꾸지 않는다. 표시명은 바꿀 수 있다. 이 분리가 이번 개정을 가능하게 하는 유일한 장치다. -- **스냅샷 일관성** — 한 요청이 시작할 때 잡은 프리셋 스냅샷을 후보 검색부터 저장까지 끝까지 쓴다. 재적재가 끼어도 한 판정 안에서 판이 섞이지 않는다. +- **스냅샷 일관성** — 한 요청이 시작할 때 잡은 프리셋 스냅샷을 후보 검색부터 저장까지 끝까지 쓴다. 재적재가 끼어도 한 판정 안에서 서로 다른 시점의 프리셋 정의가 섞이지 않는다. - **공개 안전성** — LLM이 자유 키워드를 만들지 않고 후보 ID에서만 고른다. `visibility`를 SQL 단계에서 거른다. `BLOCKED`는 적재와 응답 조립 양쪽에서 막힌다. 추천 집계가 표시명이 아니라 `code` 기준이다. -- **적재 0건 시 기동 실패** — 프리셋 없이 뜬 서버는 모든 Context를 「키워드 없음」으로 조용히 완료시킨다. +- **적재 0건 시 기동 실패** — 프리셋 없이 뜬 서버는 모든 Context를 오류 없이 「키워드 없음」으로 완료시켜 버리기 때문에, 적재된 프리셋이 하나도 없으면 기동 자체를 실패시킨다. 이 개정은 위 다섯을 전제로 성립한다. 특히 **추천 집계의 `code` 기준**이 표시명 개정의 안전장치다. @@ -54,7 +54,7 @@ **Feed 응답 Keyword의 개수 상한·정렬·동점 규칙은 `back` 레포의 Accepted 결정(P46)이 정본이며 이 제안의 범위 밖이다.** 그 결정은 `category.order → sort_order → id` 방향을 명시적으로 검토하고 기각했고, 라운드로빈을 고른 정량 근거를 남겼다. -**여기에 규칙을 다시 적지 않는다.** 남의 확정 규칙을 복사해 두면 그쪽이 바뀔 때 이쪽이 어긋난다 — §0과 같은 이유다. +**여기에 규칙을 다시 적지 않는다.** 다른 파트의 확정 규칙을 복사해 두면 그쪽이 바뀔 때 이쪽 사본이 어긋난다. §0에서 값을 적지 않는 것과 같은 이유다. 정렬을 `id`에서 떼어내자는 문제의식 자체는 유효하며, **YAML 스키마 쪽(§9의 `sort_order`)에서만 다룬다.** @@ -84,25 +84,25 @@ 표시명·`description`·`examples` 셋은 **임베딩 입력과 판정 프롬프트 양쪽에 모두 들어간다.** 임베딩 입력은 `preset_embed_text`가 조립하고, 판정 프롬프트는 `keyword_service`가 만든 후보 dict를 `llm_client`가 후보 줄로 렌더한다. 그 줄에 표시명·category·`description`·`examples`가 전부 실린다. -따라서 UI 친화적인 표현이 두 경로를 동시에 오염시킨다. **임베딩 입력만 바꾸는 것으로는 분리가 되지 않는다** — 프롬프트 조립도 함께 고쳐야 하고, 그러면 이 변경은 임베딩 실험이 아니라 **프롬프트 실험의 대상**이 된다. +따라서 UI 친화적인 표현이 두 경로를 동시에 오염시킨다. **임베딩 입력만 바꾸는 것으로는 분리가 되지 않는다.** 프롬프트 조립도 함께 고쳐야 하고, 그렇게 되면 이 변경은 임베딩 실험이 아니라 **프롬프트 실험의 대상**이 된다. ### E. `id`가 겸하는 세 역할 -`id`가 DB PK이면서, 대역으로 category 블록을 나타내고, 표시 순서의 간접 기준이 된다. 식별자와 분류·정렬이 묶여 있어 중간 삽입과 축 순서 변경이 어렵다. +`id`가 DB PK이면서, 번호 대역으로 category 블록을 나타내고, 표시 순서의 간접 기준이 된다. 식별자와 분류·정렬이 묶여 있어 중간 삽입과 축 순서 변경이 어렵다. -### F. 프리셋 판 식별의 취약 +### F. 프리셋 정의 세트 식별의 취약 행 단위 `version`과 세트 단위 release가 구분되지 않는다. 판정 결과에 남는 값만으로는 **어떤 YAML 내용과 어떤 임베딩 소스로 판정했는지** 되짚기 어렵다. -**행 `version`을 올릴 경로 자체가 없다.** DB에 컬럼이 있고 판정 결과가 그 값을 참조하지만, 적재 코드는 그 값을 **YAML에서 읽는다.** 그런데 YAML 헤더 주석은 `version`을 「여기서 다루지 않는다」고 스스로 밝히고 있고, 실제로 어느 항목도 그 필드를 갖고 있지 않다. 문서와 코드가 서로를 가리키며 아무도 그 필드를 올리지 않는다. 관련 티켓은 `S15P11A705-269`다. +**행 `version`을 올릴 경로 자체가 없다.** DB에 컬럼이 있고 판정 결과가 그 값을 참조하지만, 적재 코드는 그 값을 **YAML에서 읽는다.** 그런데 YAML 헤더 주석은 `version`을 「여기서 다루지 않는다」고 스스로 밝히고 있고, 실제로 어느 항목도 그 필드를 갖고 있지 않다. 문서와 코드가 서로를 가리키기만 할 뿐, 어느 쪽도 그 필드를 올리지 않는다. 관련 티켓은 `S15P11A705-269`다. -**다만 이 축의 우선순위는 낮다.** 개정 때마다 기존 판정을 전부 재처리하기로 정해졌으므로(§12.1) 「어느 판정이 옛 정의로 남았는가」를 운영에서 골라낼 일이 줄어든다. 남는 용도는 평가 재현 — 특정 측정이 어떤 YAML과 어떤 임베딩 프로필 위에서 나왔는지 되짚는 것이다. +**다만 이 축의 우선순위는 낮다.** 개정 때마다 기존 판정을 전부 재처리하기로 정해졌으므로(§12.1), 「어느 판정이 옛 정의로 남았는가」를 운영에서 골라낼 일이 줄어든다. 남는 용도는 평가 재현이다. 즉 특정 측정이 어떤 YAML과 어떤 임베딩 프로필 위에서 나왔는지 되짚는 것이다. ### G. 배포 Job에서만 드러나는 계약 -**「무엇이 검증되지 않는가」를 이 문서에 나열하지 않는다.** 테스트가 늘면 그 목록이 즉시 낡고, 실제로 이미 검증되고 있는 항목을 「없다」고 적은 전례가 있다. **현재 검증 범위는 `tests/`의 테스트 코드가 정본이다.** +**「무엇이 검증되지 않는가」를 이 문서에 나열하지 않는다.** 테스트가 늘면 그 목록이 즉시 낡는다. 실제로 이미 검증되고 있는 항목을 「없다」고 적은 전례가 있다. **현재 검증 범위는 `tests/`의 테스트 코드가 정본이다.** -방향만 적는다 — **배포 Job에서만 드러나는 계약이 있다면 통합 테스트로 앞당긴다.** 아직 없는 것으로 확인된 축은 둘이다. +방향만 적는다. **배포 Job에서만 드러나는 계약이 있다면 통합 테스트로 앞당긴다.** 아직 테스트가 없는 것으로 확인된 축은 둘이다. - 기존 `id ↔ code` 매핑의 불변성 - embedding profile 전환과 롤백 @@ -119,11 +119,11 @@ | 재스캔이 그 행을 집는 경로 | 재스캔 스케줄러가 잡는 상태에 완료 상태가 들어 있지 않다. 되돌리지 못하면 스케줄러의 시야 밖에 남는다 | | 속도 조절과 재시도 예산 규칙 | 한 번에 밀면 게이트웨이 한도에 걸린다. 그때 재시도 예산이 소진되면 그 Context가 실패로 굳어 되살아나지 않는다 | -부재의 범위와 그 파급은 [ai#100](https://github.com/Team-PinLog/ai/issues/100)이 다룬다. 그 이슈가 다루는 「되살리는 경로가 없다」와 이 절의 「되돌리는 경로가 없다」는 같은 구멍의 두 면이다. +부재의 범위와 그 파급은 [ai#100](https://github.com/Team-PinLog/ai/issues/100)이 다룬다. 그 이슈가 다루는 「실패한 Context를 되살리는 경로가 없다」와 이 절의 「완료를 되돌리는 경로가 없다」는 같은 부재를 서로 다른 방향에서 본 것이다. **이것이 §7 이후를 반영하기 전에 만들어야 하는 것이다.** 수단 없이 프리셋을 고치면 옛 정의로 판정된 결과가 그대로 남고, 그것을 갱신할 방법이 남지 않는다. 「정할 것」이 아니라 「만들 것」이므로 §13 0단계는 구현 항목이다. -행 `version`을 올릴 경로가 없다는 사실은 §3-F에 둔다. 전부 재처리가 전제면 판을 구분하는 일은 선결 조건의 위상을 갖지 않는다. +행 `version`을 올릴 경로가 없다는 사실은 §3-F에 둔다. 전부 재처리가 전제라면, 개정 전후의 정의를 구분하는 일은 선결 조건의 위상을 갖지 않는다. ## 5. 축의 재정의 @@ -138,7 +138,7 @@ enum 값은 즉시 바꾸지 않고 **문서상의 의미만** 재정의한다. 핵심은 둘이다. `ATMOSPHERE`를 좁은 「분위기」가 아니라 **장소 특성**으로, `SITUATION`을 막연한 「상황」이 아니라 **방문 맥락**으로 정의한다. -장기적으로 breaking change가 가능해지면 `VISIT_ACTIVITY`·`PLACE_ATTRIBUTE`·`VISIT_CONTEXT` 같은 enum 개명을 검토할 수 있다. **지금은 권장하지 않는다** — enum 값은 AI·DB·백엔드·평가 하네스에 동시에 박혀 있다. +장기적으로 breaking change가 가능해지면 `VISIT_ACTIVITY`·`PLACE_ATTRIBUTE`·`VISIT_CONTEXT` 같은 enum 개명을 검토할 수 있다. **지금은 권장하지 않는다.** enum 값은 AI·DB·백엔드·평가 하네스에 동시에 박혀 있기 때문이다. ## 6. 표시 라벨 개정 — 반영 완료 @@ -236,7 +236,7 @@ SITUATION — 방문 맥락 `ALONE`에 대해 초안이 내놓았던 「내부 의미 라벨과 표시 라벨을 가르는」 안은 채택되지 않았다. 그 분리 자체는 §8에 그대로 남는다. -`TRENDY`는 표시만 정했고 **`code`와 뜻의 간극은 그대로다** — §14가 감수 항목으로 안는다. +`TRENDY`는 표시만 정했고 **`code`와 뜻의 간극은 그대로다.** 이 간극은 §14가 감수 항목으로 안는다. ## 7. examples 개정안 @@ -251,7 +251,7 @@ SITUATION — 방문 맥락 | `ATMOSPHERE` | 관찰된 장소 특성 | 방문 목적·동행자·행사 | | `SITUATION` | 행사·날씨·시간 제약 | 특정 활동·동행자에 과도하게 기댄 표현 | -기존 규칙은 그대로 유지한다 — 키워드 단어를 그대로 쓰지 않은 예문을 최소 하나 넣고, 지나치게 짧고 범용적인 문장을 피하며, 다른 프리셋의 대표 어휘를 불필요하게 넣지 않는다. **시설 특성은 Keyword가 아니라 Place metadata 소관인지 먼저 따진다.** +기존 규칙은 그대로 유지한다. 키워드 단어를 그대로 쓰지 않은 예문을 최소 하나 넣고, 지나치게 짧고 범용적인 문장을 피하며, 다른 프리셋의 대표 어휘를 불필요하게 넣지 않는다. **시설 특성은 Keyword가 아니라 Place metadata 소관인지 먼저 따진다.** ### 7.2 우선 수정 대상 @@ -409,7 +409,7 @@ presets: ### 10.1 적재 전 검증 -지금은 `id`를 바꾸고 같은 `code`를 유지하면 DB 제약 위반으로 전체 트랜잭션이 실패한다. 실패 자체는 옳지만 **임베딩 호출과 DB 작업 뒤에 터진다.** 그 앞에서 잡는다. +지금은 `id`를 바꾸고 같은 `code`를 유지하면 DB 제약 위반으로 전체 트랜잭션이 실패한다. 실패 자체는 옳지만 **임베딩 호출과 DB 작업이 끝난 뒤에야 실패가 드러난다.** 그 앞에서 잡는다. ```bash python -m app.bootstrap.validate_presets @@ -420,13 +420,13 @@ python -m app.bootstrap.load_presets --dry-run dry-run은 `ADD`·`UPDATE`·`DEPRECATE`·`UNCHANGED`·`ERROR`를 구분해 낸다. -### 10.2 tombstone — 지금 뚫려 있는 구멍 +### 10.2 tombstone — 프리셋을 비활성으로 내릴 경로가 현재 없다 **`is_active`를 끌 경로가 없다.** -적재는 모든 항목을 **항상 활성으로** 넣는다(`app/bootstrap/load_presets.py`). YAML에서 항목을 지우면 그 행이 UPSERT 대상에서 빠질 뿐이고, **DB에는 활성인 채로 남아 캐시가 계속 적재한다.** 폐기가 삭제가 아니라 `is_active=false`라는 계약은 문서에는 있으나 그 값을 false로 만드는 코드가 없다. +적재는 모든 항목을 **항상 활성으로** 넣는다(`app/bootstrap/load_presets.py`). YAML에서 항목을 지우면 그 행이 UPSERT 대상에서 빠질 뿐이고, **DB에는 활성인 채로 남아 캐시가 계속 적재한다.** 폐기가 삭제가 아니라 `is_active=false`라는 계약은 문서에는 있으나, 그 값을 false로 만드는 코드가 없다. -따라서 tombstone·`status` 계약은 「있으면 좋은 것」이 아니라 **현재의 구멍을 막는 것**이다. 계약은 다음과 같이 둔다. +따라서 tombstone·`status` 계약은 「있으면 좋은 것」이 아니라 **현재 빠져 있는 경로를 채우는 것**이다. 계약은 다음과 같이 둔다. - 단순 누락을 자동 삭제·자동 비활성으로 처리하지 않는다. - `status: DEPRECATED`를 **명시한 항목만** `is_active=false`로 내린다. @@ -437,7 +437,7 @@ dry-run은 `ADD`·`UPDATE`·`DEPRECATE`·`UNCHANGED`·`ERROR`를 구분해 낸 행의 version과 세트의 release를 가른다(§3-F). 세트 쪽에 담을 것은 source hash·`schema_version`·`embedding_profile`·항목 수·적재 시각이다. 판정 결과에는 단순 숫자보다 **release 식별자나 source hash**를 남긴다. -**이 항목의 근거는 재처리 방침이 정해지면서 약해졌다.** 원래 근거는 「어느 판정이 옛 정의인가」를 운영에서 골라내는 것이었는데, 전부 재처리하면 그 용도가 사라진다(§12.1). 남는 용도는 평가 재현이므로 **§13에서 뒤로 물린다.** +**이 항목의 근거는 재처리 방침이 정해지면서 약해졌다.** 원래 근거는 「어느 판정이 옛 정의인가」를 운영에서 골라내는 것이었는데, 전부 재처리하면 그 용도가 사라진다(§12.1). 남는 용도는 평가 재현이므로 **적용 순서(§13)에서 후순위로 미룬다.** ### 10.4 임베딩 프로필 공존 @@ -447,13 +447,13 @@ dry-run은 `ADD`·`UPDATE`·`DEPRECATE`·`UNCHANGED`·`ERROR`를 구분해 낸 ## 11. 후보 선정과 평가 -### 11.1 후보 슬롯 경쟁 +### 11.1 후보 자리의 경쟁 후보는 **상위 K개와 절대 하한**으로 제한된다. 값은 `app/core/config.py`가 정본이다. -슬롯이 유한하므로 **한 프리셋을 좁히면 그 자리에 다른 프리셋이 올라온다.** 프리셋 하나를 잘 고쳐도 전역 집계에서 상쇄될 수 있다. **개정 효과는 프리셋별이 아니라 전역으로 재야 한다.** +후보 목록의 자리가 유한하므로, **한 프리셋의 의미를 좁히면 비게 된 자리에 다른 프리셋이 올라온다.** 프리셋 하나를 잘 고쳐도 전역 집계에서는 그 효과가 다른 프리셋의 변동과 상쇄될 수 있다. **개정 효과는 프리셋별이 아니라 전역으로 재야 한다.** -또한 특정 축이 후보를 독점할 수 있고, LLM은 후보 밖 ID를 고를 수 없으므로 **후보 단계의 누락은 판정 단계에서 복구되지 않는다.** +또한 특정 축이 후보를 독점할 수 있다. LLM은 후보 밖 ID를 고를 수 없으므로 **후보 단계의 누락은 판정 단계에서 복구되지 않는다.** ### 11.2 비교할 후보 선정 방식 @@ -483,15 +483,15 @@ C가 후보인 이유는 **프리셋의 절대 수가 작기 때문**이다. 비 `description`은 §7.3대로 전 조건에서 고정한다. -**`N`은 이미 반영됐다.** 실측 시점의 기준선은 `BASE`가 아니라 `N`이고, 실제로 가르는 축은 `N ↔ NE`다. `BASE`·`E`는 개정 전 라벨을 되돌려야 재현되므로 필요할 때만 복원한다 — 그 복원 여부가 §11.5의 반복 회차만큼이나 비용을 가른다. +**`N`은 이미 반영됐다.** 실측 시점의 기준선은 `BASE`가 아니라 `N`이고, 실제로 결과를 가를 비교 축은 `N ↔ NE`다. `BASE`·`E`는 개정 전 라벨을 되돌려야 재현되므로 필요할 때만 복원한다. 그 복원 여부가 §11.5의 반복 회차만큼이나 측정 비용에 큰 영향을 준다. 지표는 전체 오분류·프리셋별 precision·recall·F1·축별 오분류·`Candidate Recall@K`·fit 0건 Context 수·과다 선택 Context 수·`PRIVATE_ONLY` 노출 회귀, 그리고 주요 혼동쌍(`VIEW_GOOD ↔ MEAL`·`DESSERT`, `COMPANION ↔ ACTIVITY`, `ANNIVERSARY ↔ CELEBRATION`, `DATE_COURSE ↔ WITH_PARTNER`, `QUICK_STOP ↔ MEAL`)이다. ### 11.5 측정 재현성과 반복 회차 -**임베딩 API는 같은 입력·같은 배치 구성에도 완전히 결정적이지 않다.** 흔들림의 크기는 T68이 실측했고, 그 리포트가 정본이다. +**임베딩 API는 같은 입력·같은 배치 구성에도 완전히 결정적이지 않다.** 같은 텍스트를 다시 임베딩하면 조금 다른 벡터가 나올 수 있다. 흔들림의 크기는 T68이 실측했고, 그 리포트가 정본이다. -이 개정은 임베딩을 바꾸므로 후보 순위 자체가 움직인다. 따라서 **조건당 반복 회차를 평가 계획에 넣고**, 개정이 만든 변화가 흔들림보다 큰지를 먼저 확인한 뒤에 채택을 판단한다. **1회 측정으로 채택을 가르지 않는다.** +이 개정은 임베딩을 바꾸므로 후보 순위 자체가 움직인다. 따라서 **조건당 반복 회차를 평가 계획에 넣고**, 개정이 만든 변화가 임베딩의 흔들림보다 큰지를 먼저 확인한 뒤에 채택을 판단한다. **1회 측정으로 채택을 가르지 않는다.** ## 12. 개정에 따라붙는 것 @@ -499,7 +499,7 @@ C가 후보인 이유는 **프리셋의 절대 수가 작기 때문**이다. 비 **프리셋을 고치면 이미 저장된 판정을 전부 재처리한다.** 범위를 골라내지 않는다. 이것은 열린 질문이 아니라 정해진 방침이다. -그래서 「어느 Context가 옛 정의로 판정된 것인가」를 가려낼 필요가 없어지고, 판을 구분하는 일이 선결 조건에서 내려온다(§3-F). +그래서 「어느 Context가 옛 정의로 판정된 것인가」를 가려낼 필요가 없어지고, 개정 전후의 정의를 구분하는 일이 선결 조건에서 내려온다(§3-F). **남는 질문은 「하는가」가 아니라 「어떻게」다.** 계약에 운영 재처리 경로가 있고([`spec/state-machine.md`](../spec/state-machine.md), 공용 계약의 운영 재처리 절) 완료된 State를 되돌리는 주체는 백엔드인데, **그 수단이 없다.** 무엇이 없는지는 §4에 있고, 부재의 범위는 [ai#100](https://github.com/Team-PinLog/ai/issues/100)이 다룬다. @@ -507,14 +507,14 @@ C가 후보인 이유는 **프리셋의 절대 수가 작기 때문**이다. 비 표본에서 **거의 선택되지 않는 프리셋이 있다.** 선택 분포는 평가·판정 리포트가 정본이다. -표시명을 고쳐도 **안 붙는 프리셋은 그대로 안 붙는다.** 그 프리셋이 필요한 개념을 못 담고 있는 것인지, 다른 프리셋에 흡수되는 것인지, 표본에 그 상황이 없는 것인지를 먼저 갈라야 한다. **유지·병합·폐기 판단이 라벨 개정보다 앞선다.** +선택되지 않던 프리셋은 표시명을 고쳐도 계속 선택되지 않는다. 그 프리셋이 필요한 개념을 못 담고 있는 것인지, 다른 프리셋에 흡수되는 것인지, 표본에 그 상황이 없는 것인지를 먼저 갈라야 한다. **유지·병합·폐기 판단이 라벨 개정보다 앞선다.** ### 12.3 표시명 개정의 파급 둘 표시명은 UI 문자열로만 쓰이지 않는다. 백엔드의 Keyword 조회(`ContextKeywordRepository`)에 둘이 걸린다. 1. **응답 정렬이 표시명 기준이다.** 표시명을 고치면 그 응답의 Keyword 순서가 바뀐다. -2. **표시값 중복을 흡수하는 경로가 있다.** 같은 조회가 표시값 기준으로 중복을 제거하므로, **서로 다른 프리셋이 같은 표시명을 가지면 하나가 조용히 사라진다.** 표시명이 서로 다른 것은 계약이 아니라 우연이며, §6.2의 반영 세트도 그 성질을 유지한다. +2. **표시값 중복을 흡수하는 경로가 있다.** 같은 조회가 표시값 기준으로 중복을 제거한다. 따라서 **서로 다른 프리셋이 같은 표시명을 가지면 둘 중 하나가 아무 경고 없이 응답에서 빠진다.** 표시명이 서로 다른 것은 계약이 아니라 우연이며, §6.2의 반영 세트도 그 성질을 유지한다. **이것은 백엔드 코드를 고치자는 제안이 아니다.** 표시명을 고칠 때 그쪽에 파급이 있다는 사실이다. §6 반영에서 이미 한 번 일어났고, 남은 개정에도 그대로 따라붙는다. @@ -526,10 +526,10 @@ C가 후보인 이유는 **프리셋의 절대 수가 작기 때문**이다. 비 ### 0단계 — 선결 조건 -- **§4의 재처리 수단을 만든다** — 완료된 State를 되돌리는 경로, 재스캔이 그것을 집는 경로, 속도 조절과 재시도 예산 규칙 셋이 함께여야 한다. +- **§4의 재처리 수단을 만든다.** 완료된 State를 되돌리는 경로, 재스캔이 그것을 집는 경로, 속도 조절과 재시도 예산 규칙 셋이 함께여야 한다. - §12.2의 저빈도 프리셋 처분을 정한다. -판 구분 경로와 재처리 방침은 여기서 빠진다 — 결정으로 해소됐다(§3-F·§12.1). +개정 전후의 정의를 구분할 경로와 재처리 방침은 여기서 빠진다. 두 항목 모두 결정으로 이미 해소됐기 때문이다(§3-F·§12.1). **이 둘이 끝나기 전에는 1단계를 시작하지 않는다.** @@ -540,7 +540,7 @@ C가 후보인 이유는 **프리셋의 절대 수가 작기 때문**이다. 비 - 축 오염 `examples` 수정(§7) - `id`·`code`·`category`·`visibility` 유지 - 전체 재임베딩 -- §11.4의 비교군을 §11.5의 반복 회차로 측정 — 기준선은 라벨이 반영된 현재 상태다 +- §11.4의 비교군을 §11.5의 반복 회차로 측정한다. 기준선은 라벨이 반영된 현재 상태다 반영 조건 — 전체 오분류가 악화되지 않고, fit 0건 Context가 늘지 않으며, 주요 혼동쌍이 개선되고, visibility 회귀가 없다. @@ -562,12 +562,12 @@ C가 후보인 이유는 **프리셋의 절대 수가 작기 때문**이다. 비 - 임베딩 별도 테이블과 프로필 공존·롤백(§10.4) - §11.2의 후보 선정 방식 비교 -- 세트 release와 source hash 추적(§10.3) — 이 단계 안에서도 뒤다. 운영 용도가 빠지고 평가 재현만 남았다 +- 세트 release와 source hash 추적(§10.3)은 이 단계 안에서도 마지막에 둔다. 운영 용도가 빠지고 평가 재현 용도만 남았기 때문이다 ## 14. 감수하는 것 - **재임베딩과 재평가 비용이 매번 따라온다.** 표시명이 임베딩과 프롬프트 양쪽에 들어가는 한, 라벨 한 글자 수정도 전체 재측정 대상이다. §8의 분리가 끝나야 이 비용이 줄어든다. - **표본이 작다.** 평가 표본이 커지면 저빈도 프리셋 판단(§12.2)과 혼동쌍 개선 판정이 달라질 수 있다. 그때 이 문서의 결론이 아니라 **판정 기준**을 다시 봐야 한다. -- **enum을 그대로 둔다.** §5는 문서상의 재정의라 코드의 enum 이름과 의미가 계속 어긋난 채 남는다. `TRENDY`가 그 대표이고, 표시가 `감성`으로 확정되면서 그 간극이 화면에 고정됐다. 이름과 뜻이 어긋난 채 오래 두면 새로 합류한 사람이 이름을 믿는다. -- **§6은 재평가 없이 먼저 나갔다.** 표시 계층만 손대고 식별자·의미 정의를 그대로 둔 범위였기 때문이고, 대가로 **라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 남았다.** 그 몫이 §11에 그대로 있다. 남은 단계에는 같은 예외를 적용하지 않는다. +- **enum을 그대로 둔다.** §5는 문서상의 재정의라 코드의 enum 이름과 의미가 계속 어긋난 채 남는다. `TRENDY`가 그 대표이고, 표시가 `감성`으로 확정되면서 그 간극이 화면에 고정됐다. 이름과 뜻이 어긋난 채 오래 두면, 새로 합류한 사람이 enum 이름을 실제 의미로 오해한다. +- **§6은 재평가 없이 먼저 나갔다.** 표시 계층만 손대고 식별자·의미 정의를 그대로 둔 범위였기 때문이다. 대가로 **라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 남았다.** 그 측정은 §11의 계획에 그대로 남아 있다. 남은 단계에는 같은 예외를 적용하지 않는다. - **이 문서는 `Accepted`가 아니다.** §6은 반영됐지만 §7 이후가 미실행이고 §11의 실측이 없다. 부분 반영은 문서의 상태를 올리지 않는다. diff --git a/docs/proposals/P5-exact-cosine.md b/docs/proposals/P5-exact-cosine.md index d0f5df0..e11f051 100644 --- a/docs/proposals/P5-exact-cosine.md +++ b/docs/proposals/P5-exact-cosine.md @@ -1,4 +1,4 @@ -# P5: 정확 cosine 검색 (HNSW/IVFFlat 미도입) +# P5: 정확 cosine 검색을 사용하고 HNSW/IVFFlat 근사 인덱스를 도입하지 않는다 - **상태**: Accepted - **날짜**: 2026-07-23 @@ -7,32 +7,32 @@ ## 맥락 -개인 검색은 사용자의 자연어 질의를 임베딩해 그 사용자의 Context 임베딩과 cosine 유사도로 매칭한다. pgvector는 정확(exact) 스캔과 근사(ANN: HNSW, IVFFlat) 인덱스를 모두 제공한다. 어느 쪽을 쓸지 정해야 한다. +개인 검색은 사용자의 자연어 질의를 임베딩해, 그 사용자의 Context 임베딩과 cosine 유사도로 매칭한다. pgvector 는 정확(exact) 스캔과 근사(ANN: HNSW, IVFFlat) 인덱스를 모두 제공한다. 어느 쪽을 쓸지 정해야 한다. ## 결정 -**정확(exact) cosine 검색**을 사용한다. HNSW·IVFFlat ANN 인덱스는 **도입하지 않는다.** +**정확(exact) cosine 검색**을 사용한다. HNSW·IVFFlat 같은 ANN 인덱스는 **도입하지 않는다.** -- 검색은 사용자 스코프(`user_id`)로 좁힌 뒤 `is_deleted = false`·상태 필터를 적용하고 정확 cosine으로 정렬한다. +- 검색은 사용자 스코프(`user_id`)로 대상을 좁힌 뒤 `is_deleted = false`와 상태 필터를 적용하고, 남은 후보를 정확 cosine 으로 정렬한다. - 벡터 컬럼에 ANN 인덱스를 만들지 않는다. 인덱스는 `user_id`·`is_deleted` 등 스칼라 조건용만 둔다. ## 근거 -- **후보 규모가 작다.** 검색은 **한 사용자의 Context**로 한정되므로, 정확 스캔 대상이 개인의 기록 수준(수십~수천)이다. 이 규모에서 ANN의 속도 이점은 미미하고, 정확 스캔이 recall 100%다. -- **구현·운영이 단순하다.** ANN은 인덱스 빌드, `m`/`ef_construction`(HNSW)·`lists`/`probes`(IVFFlat) 같은 파라미터, recall-속도 트레이드오프 튜닝을 요구한다. MVP에 이 비용은 과하다. -- **정확도 손실이 없다.** 개인 검색은 "내 기록을 정확히 되찾기"가 목적이라 근사 검색의 miss가 체감 품질을 직접 깎는다. +- **후보 규모가 작다.** 검색 대상이 **한 사용자의 Context**로 한정되므로, 정확 스캔이 훑는 행 수는 개인의 기록 수준(수십~수천 건)이다. 이 규모에서 ANN 의 속도 이점은 미미하다. 반면 정확 스캔은 조건에 맞는 행을 하나도 놓치지 않는다(recall 100%). +- **구현·운영이 단순하다.** ANN 은 인덱스 빌드가 필요하고, HNSW 는 `m`/`ef_construction`, IVFFlat 은 `lists`/`probes` 같은 파라미터 튜닝을 요구하며, recall 과 속도 사이의 트레이드오프도 조정해야 한다. MVP 단계에 이 비용은 과하다. +- **정확도 손실이 없다.** 개인 검색의 목적은 "내 기록을 정확히 되찾기"다. 근사 검색이 정답을 놓치면 사용자가 자기 기록을 못 찾는 것이므로, 체감 품질이 직접 깎인다. -## 버린 대안 +## 채택하지 않은 대안 -- **HNSW**: 대규모 전역 벡터 검색에 유리하지만 개인 스코프에선 이점이 없고, 빌드·파라미터·recall 튜닝 비용만 남는다. (초기 문서에 있었으나 제거) -- **IVFFlat**: 마찬가지로 대규모 전제. `lists`/`probes` 튜닝과 재색인 부담. +- **HNSW**: 대규모 전역 벡터 검색에 유리하지만 개인 스코프에서는 이점이 없고, 인덱스 빌드·파라미터·recall 튜닝 비용만 남는다. 초기 문서에 포함되어 있었으나 제거했다. +- **IVFFlat**: HNSW 와 마찬가지로 대규모 검색을 전제로 한 방식이다. `lists`/`probes` 튜닝과 재색인 부담이 있다. ## 영향 -- 데이터·트래픽이 크게 성장해 개인 스코프 스캔이 병목이 되면 재검토한다(그때 HNSW/IVFFlat를 스코프 필터와 함께 도입할지 평가). -- 인덱스 설계는 스칼라 조건 중심([back#3](https://github.com/Team-PinLog/back/pull/3) `V101__ai_indexes.sql`). +- 데이터와 트래픽이 크게 성장해 개인 스코프 스캔이 병목이 되면 이 결정을 재검토한다. 그 시점에 HNSW/IVFFlat 를 스코프 필터와 함께 도입할지 평가한다. +- 인덱스 설계는 스칼라 조건 중심이다([back#3](https://github.com/Team-PinLog/back/pull/3) `V101__ai_indexes.sql`). ## 검증 -- 구현 명세 `personal-search.md`가 정확 cosine·사용자 스코프·필터 전제로 작성됨. -- 계약·draft에서 HNSW/IVFFlat 잔존 0건 확인. +- 구현 명세 `personal-search.md`가 정확 cosine·사용자 스코프·필터 전제로 작성됐다. +- 계약과 draft 문서에서 HNSW/IVFFlat 잔존 0건을 확인했다. diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index 34f7c03..115a3ad 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -244,7 +244,7 @@ TX1이 통과했더라도 TX3에서 다시 검사해야 하는 이유는 [deleti ### 6.3 접속 권한과 스키마 - DB 롤은 `ai` 스키마에 대한 권한만 가집니다. `core.*` 권한을 부여하지 않습니다. -- 커넥션의 `search_path`를 `ai, public`으로 고정합니다. `core`를 경로에서 제외해 실수로 다른 스키마를 참조하지 못하게 하되, `public`은 pgvector VECTOR 타입 해석과 `register_vector`가 의존하는 확장 소재라 포함합니다(빼면 멀티 커넥션에서 타입 해석 실패 — T21). +- 커넥션의 `search_path`를 `ai, public`으로 고정합니다. `core`를 경로에서 제외해 실수로 다른 스키마를 참조하지 못하게 합니다. 반면 `public`은 포함합니다. pgvector VECTOR 타입 해석과 `register_vector`가 의존하는 확장이 `public`에 있어, 빼면 멀티 커넥션 환경에서 타입 해석이 실패하기 때문입니다(T21). - 애플리케이션 기동 시 DDL을 실행하지 않습니다. 테이블이 없으면 기동 실패로 처리하고, 스키마 생성은 back의 migration을 기다립니다. - `ai.context_embedding.is_deleted`는 읽기만 합니다. FastAPI의 UPSERT는 이 컬럼을 @@ -275,8 +275,8 @@ AI 파트에 새 기능(챗봇, GraphRAG 등)이 합류할 것을 전제로, 기 - **계층을 유지합니다.** 새 기능도 `api → service → {repository, cache, client}`를 따릅니다. 기능이 커지면 각 계층 안에서 **기능별 파일 또는 서브패키지**(예: `service/chat/`)로 나누고, - 계층 구조 자체를 갈아엎지 않습니다. 지금은 기능이 하나라 평면이며, 분리 시점은 실제 합류 때 - 판단합니다(선행 분리는 과설계). + 계층 구조 자체를 갈아엎지 않습니다. 지금은 기능이 하나라 평면 구조이며, 분리 시점은 실제 + 합류 때 판단합니다. 기능이 오기 전에 미리 나누는 것은 과잉 설계입니다. - **기존 내부 엔드포인트 계약을 보호합니다.** `/internal/v1/{context,search}`의 요청·응답 스키마는 Spring 연동 계약입니다. 새 기능이 여기에 임의 필드를 덧붙이지 않습니다. 계약 변경이 필요하면 `static/05_AI_설계.md` 개정을 거칩니다(예: `/search` 응답 `contextId` 추가는 이 절차를 밟았습니다). diff --git a/docs/spec/context-processing.md b/docs/spec/context-processing.md index 6425c9e..38db2dd 100644 --- a/docs/spec/context-processing.md +++ b/docs/spec/context-processing.md @@ -92,10 +92,10 @@ WHERE context_id = :context_id; 중단 조건: -- 행이 없음 — Spring이 아직 State를 커밋하지 않았거나 이미 정리됨. 저장하지 않고 종료. -- 두 status가 모두 CANCELLED — 삭제되었거나 수정으로 대체된 구 Context. 종료 +- 행이 없음. Spring이 아직 State를 커밋하지 않았거나 이미 정리된 경우입니다. 저장하지 않고 종료합니다. +- 두 status가 모두 CANCELLED. 삭제되었거나 수정으로 대체된 구 Context입니다. 종료합니다 (검증 시나리오 6). -- 두 status가 모두 진행 불가(COMPLETED/FAILED/CANCELLED 조합) — 할 일 없음. 종료. +- 두 status가 모두 진행 불가(COMPLETED/FAILED/CANCELLED 조합). 할 일이 없으므로 종료합니다. 사전 검사에서 통과했다는 사실은 **아무것도 보장하지 않습니다.** 통과 직후에 Spring이 Context를 삭제할 수 있으므로, 저장 직전 잠금 검사를 반드시 다시 수행합니다 @@ -120,9 +120,9 @@ Keyword 단계는 `embedding_status = 'COMPLETED'` 조건을 추가하고 대상 영향 행 수 0이 의미하는 것: -- 다른 워커가 방금 PROCESSING으로 전환함 → 중복 실행 방지 (계약 §13.1의 멱등성 근거) -- 상태가 CANCELLED로 바뀜 → 삭제되었거나 수정으로 대체된 구 Context -- 상태가 COMPLETED/FAILED로 바뀜 → 처리 대상 아님 +- 다른 워커가 방금 PROCESSING으로 전환한 경우. 중복 실행이 방지됩니다(계약 §13.1의 멱등성 근거). +- 상태가 CANCELLED로 바뀐 경우. 삭제되었거나 수정으로 대체된 구 Context입니다. +- 상태가 COMPLETED/FAILED로 바뀐 경우. 처리 대상이 아닙니다. `PROCESSING` 재선점을 만료된 작업으로 한정하는 이유는 [state-machine.md](state-machine.md) §3.1을 참조합니다. @@ -216,12 +216,12 @@ Embedding 저장과 동일한 구조의 트랜잭션입니다. ## 6. FastAPI가 하지 않는 것 -- `retry_count` 증가 — Spring 재스캔의 책임입니다. -- `PENDING`이나 `CANCELLED`로의 전이 — Spring만 수행합니다. -- 재시도 소진 Finalizer의 `FAILED` — Spring만 수행합니다(계약 §6.4, §10.4). -- `is_deleted` 변경 — Spring만 수행합니다. -- `core.*` 조회 — Context 본문은 요청 본문으로만 받습니다. 재시도 시 해당 Context가 아직 +- `retry_count` 증가. Spring 재스캔의 책임입니다. +- `PENDING`이나 `CANCELLED`로의 전이. Spring만 수행합니다. +- 재시도 소진 Finalizer의 `FAILED`. Spring만 수행합니다(계약 §6.4, §10.4). +- `is_deleted` 변경. Spring만 수행합니다. +- `core.*` 조회. Context 본문은 요청 본문으로만 받습니다. 재시도 시 해당 Context가 아직 삭제되지 않았는지 확인하고 요청을 다시 보내는 것도 Spring의 책임입니다(계약 §10.3). Context가 불변이므로 재요청의 `text`는 최초 요청과 동일합니다. -- Context 수정 처리 — 수정은 Spring의 Core 트랜잭션에서 구 Context 삭제와 신 Context 생성으로 +- Context 수정 처리. 수정은 Spring의 Core 트랜잭션에서 구 Context 삭제와 신 Context 생성으로 이루어집니다. FastAPI는 그 결과로 도착한 **새 `context_id` 요청**만 봅니다(계약 §5.3). diff --git a/docs/spec/cost-estimate.md b/docs/spec/cost-estimate.md index faa961d..c4bad09 100644 --- a/docs/spec/cost-estimate.md +++ b/docs/spec/cost-estimate.md @@ -6,8 +6,9 @@ > [failure-recovery.md](failure-recovery.md) §3.4). 1순위가 `gpt-4o-mini`이고 > `gemini-2.5-flash`는 2순위입니다. 따라서 아래 §3의 단일 모델 추정은 **하한**이며 실제 > 구성은 호출마다 다를 수 있습니다. Anthropic 경로(3순위)는 prompt 토큰이 2.4배입니다 -> (1973 대 732). 재계산하지 않은 이유는 §4 주석과 같습니다 — GMS의 프로바이더별 크레딧 -> 환산율표를 확보하지 못했고, 그 전에는 모델 간 비용 비교가 추정에 추정을 쌓는 일이 됩니다. +> (1973 대 732). 그럼에도 재계산하지 않은 이유는 §4 주석과 같습니다. GMS의 프로바이더별 +> 크레딧 환산율표를 확보하지 못했고, 환산율표 없이 하는 모델 간 비용 비교는 추정에 +> 추정을 쌓는 일이 되기 때문입니다. > 실제 구성은 토큰 로그(`PINLOG_TOKEN_LOG`)의 `vendor`·`model`로 사후 집계할 수 있습니다. # 운영 비용 추정 (토큰 → 비용) @@ -38,7 +39,7 @@ | 항목 | 입력 tok | 출력 tok | 근거 | |---|---|---|---| | Embedding(본문) | 30 ~ 100 | — | 짧은 한국어 문장 가정(측정 대상 아님) | -| 판정 `gemini-2.5-flash` | 717 | 27 | C-2 실측(24398/34, 916/34) | +| 판정 `gemini-2.5-flash` | 717 | 27 | C-2 실측. 총 입력 24,398 tok ÷ 34회(24398/34), 총 출력 916 tok ÷ 34회(916/34) | 판정 입력 717 tok에는 SYSTEM 프롬프트 + 후보 10개 메타(display_name·description· examples)가 포함됩니다. 출력 27 tok은 `selected` 배열(평균 1.29개)입니다. @@ -69,9 +70,10 @@ Embedding : 50 × 0.02/1e6 = $0.0000010 | 10,000 | $2.84 | | 100,000 | $28.4 | -본문 길이 민감도(임베딩 tok)는 전체 비용에서 미미합니다 — 판정이 비용의 99%를 -차지하므로, 본문이 30이든 100이든 Context당 비용은 $0.00028 부근입니다. -후보 0건(판정 스킵) 비율만큼 실제 비용은 위 상한보다 내려갑니다. +본문 길이가 임베딩 토큰 수를 바꾸더라도 전체 비용에는 거의 영향이 없습니다. +판정이 비용의 99%를 차지하므로, 본문이 30 tok이든 100 tok이든 Context당 비용은 +$0.00028 부근입니다. 후보가 0건이어서 판정을 건너뛰는 비율만큼 실제 비용은 위 +상한보다 내려갑니다. ## 4. GMS 크레딧 환산 공식 (단가표 확보 시 대입) @@ -103,5 +105,5 @@ C-2 총 토큰(35샘플 34호출)으로 본 상대 순위입니다. gemini가 ## 6. 남은 것 -- GMS 모델별 크레딧 단가표 확보 → §4 대입, 청구 기준 비용 확정. -- 실사용 로그로 판정률(후보 0건 비율)·평균 본문 토큰 측정 → 상한이 아닌 기대비용 산정. +- GMS 모델별 크레딧 단가표를 확보하면 §4 공식에 대입해 청구 기준 비용을 확정합니다. +- 실사용 로그로 판정률(후보 0건 비율)과 평균 본문 토큰을 측정하면, 상한이 아닌 기대 비용을 산정할 수 있습니다. diff --git a/docs/spec/deletion-race-control.md b/docs/spec/deletion-race-control.md index 06d87b3..eb95449 100644 --- a/docs/spec/deletion-race-control.md +++ b/docs/spec/deletion-race-control.md @@ -8,7 +8,7 @@ ## 1. 무엇을 막는가 FastAPI가 Context를 처리하는 동안 Spring은 그 Context를 삭제할 수 있습니다. -모델 호출은 수 초가 걸리므로 이 창은 항상 열려 있습니다. +모델 호출은 수 초가 걸리므로, 처리 도중에 삭제가 끼어들 수 있는 시간 간격이 항상 존재합니다. ```text FastAPI: 처리 시작 ─────── Embedding API 호출 ─────── 저장 시도 @@ -72,7 +72,7 @@ FastAPI가 `context_id`로 State를 찾았다면, 그 State는 요청이 들고 | 시점 | 모델 호출 전 | 결과 저장 직전 | | 잠금 | 없음 | `SELECT ... FOR UPDATE` | | 목적 | 불필요한 API 비용 차단 | 정합성 보장 | -| 놓치면 | 돈이 샌다 | 데이터가 깨진다 | +| 놓치면 | 불필요한 모델 호출 비용이 발생한다 | 삭제된 Context의 결과가 저장된다 | | 생략 가능? | 기능상 가능(비용 증가) | 불가 | ### 3.1 사전 검사 (cheap pre-check) diff --git a/docs/spec/failure-recovery.md b/docs/spec/failure-recovery.md index 09a5daf..706afa6 100644 --- a/docs/spec/failure-recovery.md +++ b/docs/spec/failure-recovery.md @@ -143,14 +143,14 @@ Circuit Breaker를 열어 이후 요청을 즉시 중단시키고, 그 구간의 정상 트래픽이 로그를 덮어 실패 행을 찾을 수 없습니다. 2. **집계는 다음 호출이 밀어냅니다.** 타이머가 아니므로 유휴 상태에서는 아무것도 나오지 않고, 대신 종료 시 마지막 창을 `flush`합니다. -3. **재선점은 `WARNING`입니다.** §2.3의 "영향 행 수 0"과 달리 정상 경로가 아닙니다 — - 앞선 처리가 만료 안에 끝나지 못했다는 뜻이고 원인은 프로세스 종료 아니면 GMS 지연 - 둘뿐입니다. 신규 시작은 남기지 않습니다. +3. **재선점은 `WARNING`입니다.** §2.3의 "영향 행 수 0"과 달리 정상 경로가 아닙니다. + 재선점은 앞선 처리가 만료 안에 끝나지 못했다는 뜻이고, 원인은 프로세스 종료 아니면 + GMS 지연 둘뿐입니다. 신규 시작은 남기지 않습니다. 4. **credential·endpoint·요청 본문은 어느 레벨에서도 남기지 않습니다.** [probe.py](../../app/api/probe.py)가 세운 기준을 로그에 그대로 적용합니다. 요청 본문에는 - 사용자가 쓴 Context 원문이 들어 있습니다. **벤더·모델 이름은 남깁니다** — 공개 설정이고 - ([P45](../proposals/P45-public-config-in-code.md)) 어느 경로가 막혔는지가 곧 원인입니다. - **응답 본문은 마스킹해서 남깁니다** — §2.6. + 사용자가 쓴 Context 원문이 들어 있습니다. **벤더·모델 이름은 남깁니다.** 공개 설정이고 + ([P45](../proposals/P45-public-config-in-code.md)) 어느 경로가 막혔는지가 곧 원인이기 + 때문입니다. **응답 본문은 마스킹해서 남깁니다**(§2.6). `/metrics` 엔드포인트는 이 범위가 아닙니다. metrics·alert·부하는 `infra`의 `docs/ai-serving.md` 검증 순서 7이 **prod 승격 전 별도 승인** 항목으로 정했습니다. @@ -180,7 +180,7 @@ resp.text[:200] → TransientError/PermanentError 메시지 → 다섯 곳의 [`app/core/redact.py`](../../app/core/redact.py)가 정본이며 셋으로 요약됩니다. 1. **자격 증명이 endpoint보다 먼저입니다.** `sk-`·`AIza`·JWT·`Bearer`·`key=value` 형태의 - 값을 지우고 **이름은 남깁니다** — 어느 종류의 키가 문제였는지가 재발급 대상을 가릅니다. + 값을 지우고 **이름은 남깁니다.** 어느 종류의 키가 문제였는지가 재발급 대상을 가르기 때문입니다. 2. **endpoint는 URL과 맨 호스트 양쪽을 지웁니다.** 벤더 식별은 `app.client.gms`의 `vendor=`가 이미 하므로 이 자리를 지워도 진단이 줄지 않습니다. 3. **마스킹이 절단보다 먼저입니다.** 순서를 뒤집으면 200자 경계에 걸친 키의 앞부분이 @@ -190,15 +190,15 @@ resp.text[:200] → TransientError/PermanentError 메시지 → 다섯 곳의 가리면 위 표를 사람이 관리해야 하고, 트레이스백 경로는 애초에 호출부가 없습니다. `tests/test_log_redaction.py`가 이 단일 원천을 AST로 지킵니다. -응답 **본문**(HTTP 응답 payload)에는 여전히 아무것도 싣지 않습니다 — §2.5의 고정 문구 -한 줄입니다. 이 절이 정하는 것은 로그뿐입니다. +응답 **본문**(HTTP 응답 payload)에는 여전히 아무것도 싣지 않습니다. 본문은 §2.5의 고정 +문구 한 줄입니다. 이 절이 정하는 것은 로그뿐입니다. ### 2.5 API 층 — 분류를 HTTP 상태로 `S15P11A705-220`에서 정했습니다. §2.1·§2.2는 **Context 처리 경로**(백그라운드)의 상태 반영을 정하지만, 검색은 사용자 요청 경로라 바꿀 상태가 없고 **분류가 곧 응답**입니다. -그 변환 규칙이 없어서 분류된 실패가 전부 `500`으로 나갔습니다 — 임베딩 `502`가 검색 -`500`이 된 `ai#69`가 그것입니다. 분류(`errors.py`)도 재시도(`retry.py`)도 맞았고, +그 변환 규칙이 없어서 분류된 실패가 전부 `500`으로 나갔습니다. 임베딩 `502`가 검색 +`500`이 된 `ai#69`가 그 사례입니다. 분류(`errors.py`)도 재시도(`retry.py`)도 맞았고, **그 예외가 응답이 되는 지점만 비어 있었습니다.** | 분류 | 응답 | 뜻 | @@ -245,16 +245,16 @@ SQLSTATE 군 단위로 그었고, 애매한 것은 분류하지 않고 `500`에 판단이 갈린 곳 셋을 남깁니다. -1. **`OSError`를 접속 단계에서만 번역합니다.** 접속 실패는 `asyncpg` 예외가 **아닙니다** - (실측: `ConnectionRefusedError`·`socket.gaierror`·`TimeoutError` — 전부 stdlib - `OSError`). 풀 획득 타임아웃도 `TimeoutError`이고 Python 3.11부터 그것이 `OSError`의 +1. **`OSError`를 접속 단계에서만 번역합니다.** 접속 실패는 `asyncpg` 예외가 **아닙니다.** + 실측한 `ConnectionRefusedError`·`socket.gaierror`·`TimeoutError`는 전부 stdlib + `OSError`였습니다. 풀 획득 타임아웃도 `TimeoutError`이고 Python 3.11부터 그것이 `OSError`의 하위 타입입니다. 그런데 `OSError`는 DB와 무관한 코드도 던지므로, 커넥션을 넘겨준 뒤의 블록에서는 `asyncpg` 예외만 번역합니다. 2. **`asyncpg.InterfaceError`를 분류하지 않습니다.** 이 한 타입이 `connection is closed` (수명주기)와 `the server expects 2 arguments, 1 was passed`(우리 결함)를 함께 씁니다. 통째로 일시 오류로 두면 후자가 `503` 뒤에 영구히 숨습니다. 수명주기 쪽은 정상 경로에서 - 나오지 않습니다 — 풀에서 갓 받은 커넥션이 죽어 있으면 질의는 `08003`으로 끝나고 그쪽은 - 이미 일시 오류입니다. + 나오지 않습니다. 풀에서 갓 받은 커넥션이 죽어 있으면 질의는 `08003`으로 끝나고 그쪽은 + 이미 일시 오류이기 때문입니다. 3. **`53100` 디스크 가득 참까지 일시적으로 둡니다.** 재시도로 낫지 않으므로 논쟁적입니다. 그러나 우리 질의의 결함이 아니고, 분류 체계에 세 번째 칸이 없습니다. `500`으로 두면 "우리 코드가 깨졌다"로 잘못 가리키므로 자원 군(`53xxx`) 전체를 일시적으로 둡니다. @@ -263,15 +263,15 @@ SQLSTATE 군 단위로 그었고, 애매한 것은 분류하지 않고 `500`에 repository 함수마다 거는 방식은 `pool.acquire()`가 그 밖이라 **이 절이 겨냥한 "DB 연결 실패"를 놓칩니다.** -응답 본문 규칙은 위와 같습니다 — 고정 문구 한 줄. **DSN에는 DB 비밀번호가 들어 있고 +응답 본문 규칙은 위와 같이 고정 문구 한 줄입니다. **DSN에는 DB 비밀번호가 들어 있고 접속 실패 예외에는 host·port가 섞일 수 있으므로**, 분류 예외의 메시지에는 **타입 이름과 SQLSTATE만** 담습니다. SQLSTATE를 남기는 것은 공개 어휘이면서 *"풀이 고갈됐다"*와 *"DB가 재기동 중이다"*를 가르는 유일한 값이기 때문이고, 그 한 줄은 분류가 붙는 순간 -`app.core.db` 로거가 남깁니다(일시 `WARNING`, 영구 `ERROR` — §2.4). +`app.core.db` 로거가 남깁니다(일시 `WARNING`, 영구 `ERROR`, §2.4). **`S15P11A705-229`에서 이 한계를 메웠습니다.** 그때까지 두 핸들러의 응답 본문은 `embedding upstream ...` 고정 문구 하나뿐이라, DB에서 비롯한 `503`·`502`도 임베딩을 -가리키는 문구를 답했습니다. 상태 코드와 로그는 정확했지만 본문만 사실과 달랐습니다 — +가리키는 문구를 답했습니다. 상태 코드와 로그는 정확했지만 본문만 사실과 달랐습니다. 아래 「응답 본문」 절이 그 개정입니다. 변환은 `app/main.py`의 예외 핸들러 **한 곳**에서만 합니다. 라우터가 개별적으로 잡으면 @@ -292,7 +292,7 @@ SQLSTATE만** 담습니다. SQLSTATE를 남기는 것은 공개 어휘이면서 `S15P11A705-220`은 고정 문구 한 줄을 정했고, `S15P11A705-229`가 그 한 줄을 **층 이름별로 둘로 갈랐습니다.** DB 축이 §2.1이 이미 규정한 대로 `503`/`502`를 정확히 내면서도(위 `DB 축` 절), 본문만 `embedding upstream ...`를 답해 DB 실패가 게이트웨이 -실패로 읽혔기 때문입니다 — 상태 코드·로그는 원인을 가리키는데 본문만 어긋나 있었습니다. +실패로 읽혔기 때문입니다. 상태 코드와 로그는 원인을 가리키는데 본문만 어긋나 있었습니다. ```json { "detail": "embedding upstream unavailable" } @@ -301,26 +301,27 @@ SQLSTATE만** 담습니다. SQLSTATE를 남기는 것은 공개 어휘이면서 { "detail": "database rejected the request" } ``` -어느 문구가 나가는지는 예외 타입이 정합니다 — `DatabaseTransientError`/ -`DatabasePermanentError`(`-221`이 둔 하위 타입)면 `database ...`, 그 밖의 +어느 문구가 나가는지는 예외 타입이 정합니다. `DatabaseTransientError`/ +`DatabasePermanentError`(`-221`이 둔 하위 타입)면 `database ...`이고, 그 밖의 `TransientError`/`PermanentError`면 `embedding upstream ...`입니다. 핸들러는 여전히 두 개뿐이고(`app/main.py`), 각 핸들러 안에서 `isinstance` 하나로 문구만 -가릅니다 — **상태 코드 분기, 재시도 정책, DB 오류 분류(`db_errors.py`)는 이 개정의 +가릅니다. **상태 코드 분기, 재시도 정책, DB 오류 분류(`db_errors.py`)는 이 개정의 대상이 아닙니다.** - **원인 값은 여전히 싣지 않습니다.** 업스트림 예외 메시지에는 게이트웨이 응답 200자가 들어 있고(`embedding_client._embed_batch`), DB 예외 메시지에는 타입 이름과 SQLSTATE가 - 들어 있습니다(`db_errors.py`) — 어느 쪽도 본문에 옮기지 않습니다. 네 문구 모두 값이 + 들어 있습니다(`db_errors.py`). 어느 쪽도 본문에 옮기지 않습니다. 네 문구 모두 값이 없는 고정 상수입니다. [probe.py](../../app/api/probe.py)가 무인증 경로에 세운 기준을 - 이 경로에도 적용합니다 — 공유 시크릿 뒤이지만 응답은 `back` 로그를 거쳐 흘러갑니다. + 이 경로에도 적용합니다. 공유 시크릿 뒤의 경로이지만 응답은 `back` 로그를 거쳐 흘러가기 + 때문입니다. - **검색어를 되비추지 않습니다.** 사용자가 쓴 문장입니다. - 같은 이유로 핸들러 로그도 예외 **타입 이름만** 남깁니다(§2.4 원칙 4). 원인 추적은 업스트림 축은 `app.client.gms`의 `status=... outcome=...` 한 줄이, DB 축은 `app.core.db` 로거의 SQLSTATE 한 줄이(위) 합니다. - **왜 원인까지 담지 않고 층 이름만 가르는가.** `back`은 이 본문의 값을 하나도 읽지 - 않습니다(아래 「`back`이 이 응답을 어떻게 받는가」) — 사용자 화면은 이 개정으로 + 않으므로(아래 「`back`이 이 응답을 어떻게 받는가」) 사용자 화면은 이 개정으로 바뀌지 않습니다. 값어치는 AI 쪽 로그·대시보드에서 "임베딩 게이트웨이가 죽었나 DB가 - 죽었나"를 상태 코드·로그 없이도 본문만 보고 가릴 수 있다는 데 있고, 그 이상(원인 + 죽었나"를 상태 코드·로그 없이도 본문만 보고 가릴 수 있다는 데 있습니다. 그 이상(원인 메시지·SQLSTATE)을 싣는 것은 `-205`가 막은 값 누출과 같은 형태라 하지 않습니다. 핸들러가 없던 동안에는 예외가 uvicorn까지 올라가 **트레이스백에 업스트림 응답 본문이 @@ -369,9 +370,9 @@ FastAPI에는 이를 복구할 장치가 없고, 있을 필요도 없습니다. ### 3.4 판정 LLM 벤더 폴백 `S15P11A705-175`에서 추가했습니다. 판정 LLM은 **우선순위가 있는 벤더 체인**으로 호출하며, -일시적 오류일 때 다음 벤더로 넘어갑니다. Embedding은 대상이 아닙니다 — 프로바이더가 바뀌면 -벡터 공간이 달라져 기존 데이터와 비교가 불가능하고, 그것을 막는 장치가 -`embedding_profile`입니다([model-profile.md](model-profile.md) §3.2). +일시적 오류일 때 다음 벤더로 넘어갑니다. Embedding은 폴백 대상이 아닙니다. 프로바이더가 +바뀌면 벡터 공간이 달라져 기존 데이터와 비교가 불가능하고, 그것을 막는 장치가 +`embedding_profile`이기 때문입니다([model-profile.md](model-profile.md) §3.2). 근거는 2026-07-30 실측입니다. 같은 시각·같은 GMS 키로 같은 판정 작업을 던졌을 때 Gemini 경로만 `429`를 냈고 OpenAI·Anthropic 경로는 한 번도 막히지 않았습니다. @@ -388,7 +389,7 @@ Gemini 경로만 `429`를 냈고 OpenAI·Anthropic 경로는 한 번도 막히 시도 예산을 나눠 쓰는 것이 §3.2의 상한 때문입니다. 벤더마다 3회씩 재시도하면 최악이 3벤더 × 3시도 × 90s = 810s로 PROCESSING 만료를 넘고, 그러면 재스캔이 아직 살아 있는 판정을 중복 실행합니다. 같은 벤더에 백오프를 걸고 다시 던지는 것보다 막히지 않은 다른 -경로로 넘어가는 편이 성공 확률도 높습니다 — 위 실측이 그 근거입니다. +경로로 넘어가는 편이 성공 확률도 높습니다. 위 실측이 그 근거입니다. 실제로 응답한 모델은 `ai.context_keyword_analysis.model_profile`과 토큰 로그 (`PINLOG_TOKEN_LOG`)에 남습니다. 폴백이 걸리면 설정의 1순위와 다르므로, 설정값을 기록하면 diff --git a/docs/spec/integration-tests.md b/docs/spec/integration-tests.md index 978b866..8614eac 100644 --- a/docs/spec/integration-tests.md +++ b/docs/spec/integration-tests.md @@ -62,7 +62,7 @@ Spring의 수정 트랜잭션 결과를 `ai` 스키마에 재현한 뒤, 구· 단언: -- 구 `context_id`의 저장 시도가 거부됨(→ 시나리오 2와 같은 경로). +- 구 `context_id`의 저장 시도가 거부됨(시나리오 2와 같은 경로). - 신 `context_id` 요청이 정상 처리되어 자신의 Embedding·Keyword를 만듦. - 신 Context 처리가 구 Context의 Embedding을 **재사용하지 않음**. Embedding Client 호출 횟수 == 1로 단언합니다([partial-resume.md](partial-resume.md) §2). @@ -259,9 +259,10 @@ HTTP 계층을 검증하는 단위 테스트는 이 절의 범위 밖**이며 HT 두 계층은 검증 대상이 다릅니다. 파이프라인 테스트가 묻는 것은 *"상태 전이가 옳은가"*이고, client 단위 테스트가 묻는 것은 *"상태 코드가 어떤 오류 타입이 되는가"*입니다. 후자는 -정의상 인터페이스 레벨 Fake로 볼 수 없습니다 — Fake는 이미 `TransientError`/`PermanentError`를 -받아서 던지므로, 매핑 자체가 Fake의 입력에 숨어 버립니다. 그 공백이 **429를 영구 오류로, -LLM 401을 일시 오류로** 둔 채 남긴 원인이었습니다(`S15P11A705-121`). +정의상 인터페이스 레벨 Fake로 검증할 수 없습니다. Fake는 이미 `TransientError`/`PermanentError`를 +받아서 던지므로, 상태 코드에서 오류 타입으로 가는 매핑 자체가 Fake의 입력에 숨어 버립니다. +바로 그 공백이 **429를 영구 오류로, LLM 401을 일시 오류로** 둔 채 남긴 원인이었습니다 +(`S15P11A705-121`). 이 구분을 명시적으로 적어 둡니다. 이 절이 *"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake를 씁니다"*라고만 말해서, client 단위 테스트의 HTTP 목이 명세 위반인지가 `S15P11A705-121` @@ -270,8 +271,8 @@ LLM 401을 일시 오류로** 둔 채 남긴 원인이었습니다(`S15P11A705-1 경계는 하나입니다. **`app/client/` 밖의 코드는 HTTP를 몰라야 하고, 따라서 그 코드를 검증하는 테스트도 HTTP를 몰라야 합니다.** HTTP 목이 파이프라인 테스트에 등장하면 그것은 -계층 위반의 신호이며(client 경계가 새고 있다), 반대로 client 단위 테스트에 인터페이스 -Fake가 등장하면 아무것도 검증하지 않는 테스트입니다. +client 계층의 경계가 지켜지지 않고 있다는 신호입니다. 반대로 client 단위 테스트에 +인터페이스 Fake가 등장하면 아무것도 검증하지 않는 테스트입니다. 이하 규칙은 위 표의 "적용" 행에 대한 것입니다. diff --git a/docs/spec/keyword-preset.md b/docs/spec/keyword-preset.md index 8184388..a899080 100644 --- a/docs/spec/keyword-preset.md +++ b/docs/spec/keyword-preset.md @@ -19,8 +19,8 @@ Context Embedding 타인에게 공개되는 Keyword의 안전성은 "Preset이 사전 정의 목록"이라는 전제에 의존합니다. 구현이 이 전제를 깨는 지점은 두 곳이며, 둘 다 코드로 막습니다. -1. LLM에게 후보 밖 값을 만들 수 있는 여지를 주는 것 → 구조화 출력 + 후보 ID 제약(§4) -2. 반환값을 검증 없이 저장하는 것 → 매핑 단계 폐기(§4.3) +1. LLM에게 후보 밖 값을 만들 수 있는 여지를 주는 것. 구조화 출력과 후보 ID 제약으로 막습니다(§4). +2. 반환값을 검증 없이 저장하는 것. 매핑 단계 폐기로 막습니다(§4.3). ## 2. Preset Cache @@ -121,10 +121,12 @@ selected = [s for s in llm_result.selected if s.keyword_id in candidate_ids] ### 4.4 판정 비결정성 -판정 결과는 **결정적이지 않습니다.** 같은 입력이라도 경계 사례는 호출마다 달라질 수 있으며 -(실측 40~60%, `thinkingBudget=0`에도), 저장이 delete-insert(§5)이므로 재처리 시 키워드 집합이 -바뀔 수 있습니다. 이는 **허용되는 동작**입니다 — 재처리 경로가 실제로 드물기 때문입니다(부분 -재개는 `COMPLETED`를 건너뛰고, 수정은 새 `context_id`, 재스캔은 미완료 단계만 다룸). +판정 결과는 **결정적이지 않습니다.** 같은 입력이라도 경계 사례는 호출마다 결과가 달라질 +수 있습니다. 실측에서 경계 사례의 결과 변동 비율은 40~60%였고, `thinkingBudget=0`으로 +두어도 마찬가지였습니다. 저장이 delete-insert(§5)이므로 재처리 시 키워드 집합이 바뀔 수 +있습니다. 이는 **허용되는 동작**입니다. 재처리 경로가 실제로 드물기 때문입니다. 부분 +재개는 `COMPLETED`를 건너뛰고, 수정은 새 `context_id`로 처리되며, 재스캔은 미완료 단계만 +다룹니다. ## 5. 결과 저장 diff --git a/docs/spec/model-profile.md b/docs/spec/model-profile.md index 029f3af..ca67f2b 100644 --- a/docs/spec/model-profile.md +++ b/docs/spec/model-profile.md @@ -50,11 +50,11 @@ class Settings(BaseSettings): > 실패입니다 — 기본값이 있으면 배포 설정 누락이 조용한 Profile 불일치로 나타납니다"* 였습니다. > > 그 논거는 **주입이 필수**라는 전제 위에 있었습니다. 주입을 덮어쓰기로 돌리면 "누락"이라는 -> 상태 자체가 없어지므로 전제가 사라집니다. 반대로 원 결정의 대가가 실측됐습니다 — 값이 +> 상태 자체가 없어지므로 전제가 사라집니다. 반대로 원 결정의 대가가 실측됐습니다. 값이 > 배포 설정에만 있으면 **교체가 git 이력도 리뷰도 남기지 않습니다.** Profile 변경은 기존 > 임베딩을 전부 조회 대상에서 빼는 결정인데(§3.2) 그것이 콘솔 편집 한 번으로 가능했습니다. > -> 불일치 탐지는 그대로입니다 — 어긋난 조합은 `_profile_consistency`가 기동 시, +> 불일치 탐지는 그대로입니다. 어긋난 조합은 `_profile_consistency`가 기동 시에 잡고, > 요청과의 불일치는 §3.1이 런타임에 잡습니다. 전환 근거는 [P45](../proposals/P45-public-config-in-code.md). ### 2.2 Spring과의 동기화 @@ -73,7 +73,7 @@ Profile은 세 지점에서 비교됩니다. 각각 동작이 다릅니다. 배포 설정이 어긋난 상태이며, 어떤 벡터를 만들어도 저장된 벡터와 비교할 수 없습니다. -- 질의 Embedding을 호출하지 않습니다. 비용만 쓰고 쓸 수 없는 벡터가 됩니다. +- 질의 Embedding을 호출하지 않습니다. 호출해도 비용만 쓰고 쓸 수 없는 벡터가 되기 때문입니다. - 빈 결과를 반환하지 않습니다. 빈 결과는 "일치하는 기록이 없음"으로 보여 설정 오류를 숨깁니다. - `422`로 거부하고 양쪽 Profile 값을 로그에 남깁니다. Spring은 이를 AI 검색 실패로 처리하되, 기본 기능(저장·조회·발행)에는 영향을 주지 않습니다. @@ -124,9 +124,9 @@ Profile은 "이 벡터를 저 벡터와 비교해도 되는가"를 판별하는 올리지 않는 경우: -- 프롬프트나 LLM 모델 변경 — Keyword 판정에만 영향을 주며 벡터 공간과 무관합니다. +- 프롬프트나 LLM 모델 변경. Keyword 판정에만 영향을 주며 벡터 공간과 무관합니다. 이 값은 `ai.context_keyword_analysis.model_profile`에 별도로 기록합니다. -- Preset의 `display_name` 변경 — 표시 문구는 벡터에 영향을 주지 않습니다. +- Preset의 `display_name` 변경. 표시 문구는 벡터에 영향을 주지 않습니다. 단 `description`·`examples` 변경은 Preset Embedding 재생성 대상입니다. Profile 접미사를 올리면 기존 Context Embedding과 Preset Embedding이 모두 재생성 대상이 됩니다. diff --git a/docs/spec/partial-resume.md b/docs/spec/partial-resume.md index 0c517f3..b8fd55b 100644 --- a/docs/spec/partial-resume.md +++ b/docs/spec/partial-resume.md @@ -93,12 +93,13 @@ flowchart TD 다만 그 값이 없으면 `context_embedding`에서 **DB로 재조회**합니다(`keyword_service._resolve_vector`). > **판단 변경(2-worker 경합 방어).** 원래 계약은 *"후보 검색을 위해 Embedding을 다시 조회하지 - > 않는다"*였다 — 한 요청이 재사용 판정에서 벡터를 항상 손에 쥔다는 단일 워커 전제였다. 실제 - > 구현은 **다른 워커가 임베딩을 완료해** 이 요청의 재사용 판정에는 `carried`가 `None`으로 오는 - > 경합 경로를 방어하려 fallback 재조회를 넣었다(`_resolve_vector`가 `carried is None`일 때 - > `context_embedding_repo.load_vector` 호출). 즉 **코드가 맞고 이 문장이 낡았다** — 재조회는 - > 정상 경합 대응이지 "벡터 재사용" 원칙의 위반이 아니다. (출처: `기록복원` — 종료 S1 세션; - > `직접확인` — `keyword_service._resolve_vector:108-115`) + > 않는다"*였다. 이 문장은 한 요청이 재사용 판정에서 벡터를 항상 손에 쥔다는 단일 워커 + > 전제 위에 있었다. 실제 구현은 **다른 워커가 임베딩을 완료해** 이 요청의 재사용 판정에는 + > `carried`가 `None`으로 오는 경합 경로를 방어하려 fallback 재조회를 넣었다 + > (`_resolve_vector`가 `carried is None`일 때 `context_embedding_repo.load_vector` 호출). + > 즉 **코드가 맞고 이 문장이 낡았다.** 재조회는 정상 경합 대응이지 "벡터 재사용" 원칙의 + > 위반이 아니다. (출처: 종료된 이전 작업 세션(S1)의 기록 복원으로 경위를 확인했고, + > `keyword_service._resolve_vector:108-115` 코드에서 직접 확인했다) ## 4. 재개할 수 없는 조합 @@ -107,7 +108,7 @@ flowchart TD | COMPLETED | PENDING | **재개.** 벡터 재사용, Keyword만 수행 | | PENDING | PENDING | 전체 수행 | | COMPLETED | FAILED | 아무것도 하지 않음. FAILED는 재스캔 대상이 아니며 PROCESSING으로 직접 전이 불가 | -| FAILED | PENDING | Keyword만 시도. 다만 재사용 2조건을 만족하는 Embedding이 없으므로 벡터가 없어 판정 불가 → Keyword 단계도 시작하지 않음 | +| FAILED | PENDING | Keyword만 시도. 다만 재사용 2조건을 만족하는 Embedding이 없어 판정에 쓸 벡터가 없으므로, Keyword 단계도 시작하지 않음 | | COMPLETED | COMPLETED | 할 일 없음 | | COMPLETED | PROCESSING (만료) | **재개.** 벡터 재사용, stale Keyword 작업 재선점 | | CANCELLED | CANCELLED | 처리·저장 대상 아님. 삭제되었거나 수정으로 대체된 구 Context | diff --git a/docs/spec/personal-search.md b/docs/spec/personal-search.md index 63f905c..902187b 100644 --- a/docs/spec/personal-search.md +++ b/docs/spec/personal-search.md @@ -15,8 +15,8 @@ POST /internal/v1/search 응답값: `recordId`, `contextId`, `similarity` 목록 `userId`는 필수이며 **검색 범위 필터로만** 사용합니다. FastAPI는 인증을 판단하지 않습니다. -반환된 Record ID는 `ai` 스키마 기준 결과이므로 소유권·삭제 여부·활성 Context 존재 여부는 -Spring이 Core 기준으로 다시 검증합니다(계약 §9.5). +반환된 Record ID는 `ai` 스키마 기준 결과입니다. 따라서 소유권, 삭제 여부, 활성 Context +존재 여부는 Spring이 Core 기준으로 다시 검증합니다(계약 §9.5). ## 2. 질의 Embedding @@ -27,7 +27,7 @@ Spring이 Core 기준으로 다시 검증합니다(계약 §9.5). - Embedding 호출은 요청당 정확히 1회입니다. 요청의 `embeddingProfile`이 서버 설정 Profile과 다르면 질의 벡터를 저장된 벡터와 비교할 수 -없으므로 검색을 수행하지 않고 요청을 거부합니다([model-profile.md](model-profile.md)). +없습니다. 이 경우 검색을 수행하지 않고 요청을 거부합니다([model-profile.md](model-profile.md)). ## 3. 필터 우선, 벡터 나중 @@ -36,9 +36,9 @@ MVP는 **정확 cosine 검색**을 사용합니다. > **HNSW와 IVFFlat을 사용하지 않습니다.** ANN 인덱스는 MVP 제외 범위이며, > 데이터 증가 이후의 확장 항목입니다(계약 §9.4, §15.2, §15.3). -정확 검색이므로 후보 행 수가 곧 비용입니다. 따라서 벡터 연산 전에 스칼라 조건으로 후보를 -최대한 좁힙니다. `user_id`가 가장 강한 필터이며, `ai.context_embedding`이 -`user_id`·`record_id`를 비정규화해 들고 있는 이유가 이것입니다. +정확 검색에서는 벡터 연산을 수행하는 후보 행 수가 곧 비용입니다. 따라서 벡터 연산 전에 +스칼라 조건으로 후보를 최대한 좁힙니다. `user_id`가 가장 강하게 후보를 줄이는 필터입니다. +`ai.context_embedding`이 `user_id`·`record_id`를 비정규화해 들고 있는 이유가 이것입니다. ```text user_id 일치 @@ -50,9 +50,9 @@ user_id 일치 필터 목록은 계약 §9.3과 동일합니다. Context가 불변이므로 본문 버전을 대조하는 조건은 없습니다. -ANN 인덱스가 없으므로 순서를 바꾸면 사용자 전체 벡터를 스캔하게 됩니다. -Query를 작성할 때 필터 조건이 벡터 연산 아래로 내려가지 않도록, 필터를 CTE로 분리하거나 -서브쿼리에 고정합니다. +ANN 인덱스가 없으므로, 필터보다 벡터 연산이 먼저 실행되면 사용자 전체 벡터를 스캔하게 +됩니다. Query를 작성할 때 필터 조건이 벡터 연산 아래로 내려가지 않도록, 필터를 CTE로 +분리하거나 서브쿼리에 고정합니다. ## 4. Query @@ -77,8 +77,8 @@ LIMIT :limit; ``` `DISTINCT ON (record_id)`은 안쪽 `ORDER BY record_id, similarity DESC` 기준으로 Record별 -첫 행(=최고 유사도 Context)만 남긴다. 이 대표 Context의 `context_id`를 함께 반환해 Spring이 -core에서 본문을 조회·조립할 수 있게 한다(본문 자체는 반환하지 않는다, §6). +첫 행, 즉 최고 유사도 Context만 남깁니다. 이 대표 Context의 `context_id`를 함께 반환해 +Spring이 core에서 본문을 조회·조립할 수 있게 합니다. 본문 자체는 반환하지 않습니다(§6). 조건별 역할: @@ -93,11 +93,12 @@ core에서 본문을 조회·조립할 수 있게 한다(본문 자체는 반환 거리 기준을 cosine으로 고정하는 근거는 Embedding Profile입니다. `CANCELLED` 제외는 `embedding_status = 'COMPLETED'` 조건에 이미 포함됩니다. -`is_deleted`와 CANCELLED는 서로를 대체하지 않는 두 개의 방어선이므로 두 조건을 모두 유지합니다. +그럼에도 `is_deleted` 조건을 함께 유지합니다. `is_deleted`와 CANCELLED는 서로를 대체하지 +않는 두 개의 방어선이기 때문입니다. ### 수정으로 대체된 구 Context -Context 수정은 구 Context 삭제와 신 Context 생성의 조합이므로(계약 §4.2, §5.3), +Context 수정은 구 Context 삭제와 신 Context 생성의 조합입니다(계약 §4.2, §5.3). 따라서 구 Context는 위 두 조건 **모두**에 걸려 검색에서 제외됩니다. ```text @@ -116,7 +117,8 @@ Context 수정은 구 Context 삭제와 신 Context 생성의 조합이므로( 여러 Context가 매칭되어도 Record는 한 번만 반환됩니다(검증 시나리오 20). - Record 유사도는 그 Record에 속한 Context 유사도 중 **최댓값**입니다. `DISTINCT ON`이 안쪽 `ORDER BY similarity DESC`로 최댓값 행을 고르므로 평균·합계를 쓰지 않습니다. - Context는 서로 독립적인 저장 이유이므로, 하나만 강하게 일치해도 그 Record는 찾는 대상입니다. + Context는 서로 독립적인 저장 이유이므로, 하나만 강하게 일치해도 그 Record는 사용자가 + 찾는 대상입니다. - 그 최고 유사도 Context의 `context_id`를 대표값으로 함께 반환합니다. Spring이 어느 Context가 매칭됐는지 알아야 core에서 본문을 조회해 응답을 조립할 수 있기 때문입니다. - `LIMIT`은 집계(DISTINCT ON) **후에** 적용합니다. 집계 전에 자르면 서로 다른 Record 수가 @@ -139,8 +141,8 @@ Context 수정은 구 Context 삭제와 신 Context 생성의 조합이므로( 조립합니다. id가 없으면 어느 Context가 매칭됐는지 알 수 없어 조립이 성립하지 않습니다. FastAPI는 id만 반환하고 `core`를 읽지 않으므로 스키마 경계는 유지됩니다. - Keyword를 함께 반환하지 않습니다. Keyword Visibility에 따른 노출 판단은 Spring이 합니다. -- 검색 결과에 **두 개의 컷**을 겁니다(§6.1). 노출 여부의 최종 판단은 여전히 Spring의 몫이며, - 이 컷은 「보여줄지」가 아니라 「후보로 넘길 가치가 있는지」를 가릅니다. +- 검색 결과에 **두 개의 컷**을 겁니다(§6.1). 노출 여부의 최종 판단은 여전히 Spring의 몫입니다. + 이 컷이 가리는 것은 「보여줄지」가 아니라 「후보로 넘길 가치가 있는지」입니다. ## 6.1 결과 컷 — `τ_abs`와 `r` @@ -149,42 +151,48 @@ Context 수정은 구 Context 삭제와 신 Context 생성의 조합이므로( SEARCH_SIMILARITY_FLOOR τ_abs = 0.30 절대 하한 — 문장형 질의 SEARCH_SIMILARITY_FLOOR_WORD τ_abs = 0.24 절대 하한 — 단어형 질의 - SEARCH_TOP_RATIO r = 0.60 1위 대비 상대 하한 (갈리지 않는다) + SEARCH_TOP_RATIO r = 0.60 1위 대비 상대 하한 (질의 유형과 무관하게 동일) 단어형 = 앞뒤 공백을 뗀 뒤 내부에 공백이 없고 SEARCH_WORD_QUERY_MAX_CHARS(5) 자 이하 (공백은 유니코드 전체 — 전각·탭 포함) ``` -두 조건을 **함께** 요구하는 것이 안전 장치입니다 — 어느 하나만 보면 경계 밖 질의가 낮은 -하한을 타고, 둘 다 보면 애매한 질의가 문장형(**더 세게 자름**) 쪽으로 기웁니다. 공백을 -U+0020 하나로 좁히면 그 방향이 **뒤집힙니다**(전각 공백으로 띄운 2어절 질의가 「공백 없음」이 -되어 오히려 느슨해집니다). 요청 스키마에 정규화가 없어 원문이 그대로 도달합니다. +단어형 판정이 두 조건(내부 공백 없음, 5자 이하)을 **함께** 요구하는 것이 안전 장치입니다. +어느 한 조건만 검사하면 단어형 경계 밖의 질의가 더 낮은 단어형 하한을 적용받게 됩니다. +두 조건을 함께 검사하면 애매한 질의는 문장형으로 분류되는데, 문장형 하한이 더 높으므로 +더 엄격한 쪽으로 기웁니다. 공백 판정을 U+0020 하나로 좁히면 이 방향이 **뒤집힙니다**. +전각 공백으로 띄운 2어절 질의가 「공백 없음」으로 분류되어, 오히려 느슨한 단어형 하한을 +적용받기 때문입니다. 요청 스키마에는 공백 정규화가 없어 사용자가 입력한 원문이 그대로 +판정에 도달합니다. -**`SEARCH_SIMILARITY_FLOOR_WORD` 는 끄는 스위치가 아닙니다.** 아래 「0으로 두면 꺼진다」는 -`SEARCH_SIMILARITY_FLOOR`·`SEARCH_TOP_RATIO` 두 키의 성질이며, 그 둘이 0이면 **단어형을 -포함해** 컷 전체가 꺼집니다(비상 스위치가 분기보다 앞입니다). 단어형 하한만 0으로 두면 -`r` 이 남아 계속 자릅니다. +**`SEARCH_SIMILARITY_FLOOR_WORD` 는 컷 전체를 끄는 스위치가 아닙니다.** 아래에 적은 +「0으로 두면 꺼진다」는 `SEARCH_SIMILARITY_FLOOR`·`SEARCH_TOP_RATIO` 두 키의 성질입니다. +그 두 키가 0이면 단어형을 포함해 컷 전체가 꺼집니다. 비상 스위치 검사가 문장형·단어형 +분기보다 앞에 있기 때문입니다. 단어형 하한만 0으로 두면 `r` 컷이 남아 계속 자릅니다. -**`τ_abs` 는 질의 길이로 갈립니다**(S15P11A705-266, [§단어형 하한의 근거](#단어형-하한의-근거-s15p11a705-266)). -아래 표가 `τ_abs` 의 한계로 적은 「질의마다 다른 유사도 대역을 따라가지 못한다」가 단어형 -질의에서 실제 손실로 드러났습니다. `r` 은 갈리지 않습니다 — 상대 컷이라 대역 차이를 자동으로 -흡수합니다. +**`τ_abs` 는 질의 길이에 따라 다른 값을 적용합니다**(S15P11A705-266, +[§단어형 하한의 근거](#단어형-하한의-근거-s15p11a705-266)). 아래 표에서 `τ_abs` 의 한계로 +적은 「질의마다 다른 유사도 대역을 따라가지 못한다」가 단어형 질의에서 실제 손실로 +드러났기 때문입니다. `r` 은 질의 유형에 따라 갈리지 않습니다. 1위 대비 상대 컷이므로 +질의별 유사도 대역의 차이를 자동으로 흡수합니다. -둘 중 하나를 0으로 두면 그 컷이 꺼집니다. **하나가 다른 하나를 대체하지 않습니다** — -서로 다른 실패 모드를 막습니다. +`τ_abs` 와 `r` 중 하나를 0으로 두면 그 컷만 꺼집니다. **하나가 다른 하나를 대체하지 +않습니다.** 두 컷은 서로 다른 실패 상황을 막습니다. | | `τ_abs`가 막는 것 | `r`이 막는 것 | |---|---|---| -| 상황 | 이 사용자에게 **관련 기록이 아예 없다** | 관련 기록은 있는데 **꼬리가 길다** | +| 상황 | 이 사용자에게 **관련 기록이 아예 없다** | 관련 기록은 있는데 **낮은 유사도 결과가 길게 붙는다** | | 없으면 | 「자동차 엔진오일 정비소」에 카페·라멘집 17건이 나온다 | 1위 0.82 아래로 0.14까지 줄줄이 붙는다 | | 다른 쪽으로 되나 | `r`은 1위를 **언제나** 남기므로 0건을 만들 수 없다 | `τ_abs`는 질의마다 다른 유사도 대역을 따라가지 못한다 | -`LIMIT` **뒤에** 겁니다. 두 컷 모두 유사도 하위만 자르므로 §4 Query의 `WHERE`에 넣은 -것과 결과가 같고(유사도 단조), 그렇다면 이미 고정된 Query를 건드리지 않는 쪽이 낫습니다. -정확 검색이라 스캔 비용도 달라지지 않습니다. +컷은 `LIMIT` **뒤에** 적용합니다. 두 컷 모두 유사도 하위만 자르므로, §4 Query의 `WHERE`에 +넣어도 결과가 같습니다(유사도에 대해 단조롭기 때문입니다). 결과가 같다면 이미 고정된 +Query를 건드리지 않는 쪽이 낫습니다. 정확 검색이라 어느 쪽이든 스캔 비용도 달라지지 +않습니다. -`r`의 기준이 되는 1위는 **컷 전** 결과의 1위입니다. 컷 후 재계산하면 기준이 살아남은 -것의 1위로 옮겨가 아무것도 더 잘리지 않는 자기충족 컷이 됩니다. +`r`의 기준이 되는 1위는 **컷 전** 결과의 1위입니다. 컷 후에 1위를 다시 계산하면 기준이 +살아남은 결과의 1위로 옮겨 가고, 그 기준으로는 아무것도 더 잘리지 않습니다. 컷이 자기 +기준을 스스로 무력화하는 구조가 되므로 컷 전 1위로 고정합니다. ### 값의 근거 (S15P11A705-213) @@ -194,50 +202,54 @@ U+0020 하나로 좁히면 그 방향이 **뒤집힙니다**(전각 공백으로 ```text 채택값 τ_abs=0.30 · r=0.60 정답 누락 0/12 · 빈 결과 0/12 - 꼬리 제거 76.3%(비관) · 무관 질의 11/15 침묵 + 꼬리 제거 76.3%(비관) · 무관 질의 15건 중 11건 무노출(11/15) 안전 상한 τ_abs=0.36 · r=0.80 이 이상에서 정답이 사라진다 ``` -**상한에 붙이지 않은 이유**: 두 축의 상한을 **같은 데이터점 하나**가 정합니다 — 질의 -「친구들이랑 피자에 맥주 마신 곳」의 정답이 3위 `sim=0.3642`·`r=0.807`입니다. 그 한 점이 -흔들리면 두 축이 동시에 무너지므로 각각 마진을 뒀습니다(τ_abs 17% · r 25%). +**상한에 붙이지 않은 이유**: 두 축의 상한을 **같은 데이터점 하나**가 정하기 때문입니다. +질의 「친구들이랑 피자에 맥주 마신 곳」의 정답이 3위 `sim=0.3642`·`r=0.807`입니다. 그 한 +점이 흔들리면 두 축이 동시에 무너지므로 각각 마진을 뒀습니다(τ_abs 17% · r 25%). ### 단어형 하한의 근거 (S15P11A705-266) 위 값은 **문장형 질의 12건으로 정했습니다.** 단어형 질의(`그네`·`비건`)로 다시 재자 -0.30 이 **컷 전 1위인 정답**까지 잘라내고 있었습니다(`ai#87`). 단어형 54건 × 소유자 3명 -(정답 66행 · 무관 통제 45행)으로 격자를 다시 훑은 결과입니다. 하네스는 +0.30 이 **컷 전 1위인 정답**까지 잘라내고 있었습니다(`ai#87`). 그래서 단어형 54건 × +소유자 3명(정답 66행 · 무관 통제 45행)으로 격자를 다시 훑었습니다. 하네스는 `tools/search_cut/word_matrix.py`·`word_sweep.py`, 리포트는 [implements/2026-08-03-word-query-cut.md](../implements/2026-08-03-word-query-cut.md). -```text -두 대역이 겹치지 않는다 문장형 정답 하한 0.3642 단어형 정답 하한 0.2438 +측정 결과, 문장형과 단어형의 정답 유사도 대역이 겹치지 않았습니다. 문장형 정답의 하한은 +0.3642 이고 단어형 정답의 하한은 0.2438 입니다. 하나의 `τ_abs` 로 두 대역을 함께 다룰 수 +없다는 뜻입니다. +```text 0.30 단일 단어형에서 컷 전 1위 정답 5건이 0건이 된다 -0.24 단일 그 5건이 살아나지만 문장형 무관 질의 침묵이 11/15 → 5/15 로 무너진다 -가른다 단어형 회복 71/71 · 1위 손실 0 · 문장형 완전 불변 +0.24 단일 그 5건이 살아난다. 그러나 문장형 무관 질의의 무노출이 + 15건 중 11건에서 15건 중 5건으로 무너진다 (11/15 → 5/15) +값을 가른다 단어형 정답 회복 71/71 · 1위 손실 0 · 문장형 결과는 완전 불변 ``` -**0.24 는 「컷 전 1위인 정답을 하나도 잃지 않는 가장 높은 값」입니다** — 0.25 부터 -깨집니다. 최저 정답이 `스팟` 0.2438 이라 마진이 0.0038 뿐이고, **경계를 데이터점 하나가 -정한다는 것**이 이 값의 알려진 약점입니다(리포트 +**0.24 는 「컷 전 1위인 정답을 하나도 잃지 않는 가장 높은 값」입니다.** 0.25 부터 그 +조건이 깨집니다. 최저 정답이 `스팟` 0.2438 이라 마진이 0.0038 뿐입니다. **경계를 +데이터점 하나가 정한다는 것**이 이 값의 알려진 약점입니다(리포트 [§말할 수 없는 것](../implements/2026-08-03-word-query-cut.md#말할-수-없는-것)). -그 마진이 `T68`(`-228`, 임베딩 비결정성 `|Δsim|` 최대 0.0044)보다 작아 회차 3개로 -직접 쟀습니다 — **이 조건의 흔들림 상한은 0.000209 이고 경계점 `스팟` 은 스프레드 -0.000000, 격자 판정은 τ=0.20~0.30 전 구간에서 3회 일치**합니다(리포트 §재현성). +그 마진 0.0038 은 `T68`(`-228` 에서 실측한 임베딩 비결정성, `|Δsim|` 최대 0.0044)보다 +작습니다. 그래서 회차 3개로 이 조건의 흔들림을 직접 쟀습니다. **이 조건의 흔들림 상한은 +0.000209 이고, 경계점 `스팟` 은 스프레드 0.000000 이며, 격자 판정은 τ=0.20~0.30 전 +구간에서 3회 일치**합니다(리포트 §재현성). 즉 재측정 잡음은 이 경계를 위협하지 않습니다. ### 이 값을 다시 재야 하는 때 `0.24` 는 **데이터점 하나**(`스팟` → 동교어린이공원 0.2438)가 정합니다. 재측정 잡음은 -위협이 아니지만(위 문단) **그 점이 이동하면 값이 무너집니다** — 여유를 두어도 경계를 -한 점이 정한다는 구조는 사라지지 않으므로, 마진을 넓히는 대신 **언제 다시 재는지를 +위협이 아니지만(위 문단) **그 점이 이동하면 값이 무너집니다.** 마진을 넓혀도 경계를 한 +점이 정한다는 구조는 사라지지 않습니다. 그래서 마진을 넓히는 대신 **언제 다시 재는지를 명시합니다.** -| 방아쇠 | | +| 방아쇠 | 이유 | |---|---| | 시연 DB 재시딩 | Context 본문이 같아도 배치가 달라지면 유사도가 움직입니다 | -| Context 수의 유의미한 증가 | `top-1` 이 올라가 `r` 이 더 세게 자르고 무관 통과도 함께 늘어납니다(`-213` 이 남긴 것과 같은 조건) | +| Context 수의 유의미한 증가 | `top-1` 유사도가 올라가 `r` 컷이 더 세게 자르고, 무관 결과의 통과도 함께 늘어납니다(`-213` 이 남긴 것과 같은 조건) | | 임베딩 모델 교체 (`S15P11A705-199`) | 벡터 공간이 바뀌므로 두 하한 **모두** 무효입니다 | ```bash @@ -248,19 +260,21 @@ python tools/search_cut/word_sweep.py --repro .search/word_grid.json,.search/wor python tools/search_cut/word_sweep.py # 격자 재확인 ``` -**회차 판정이 갈리기 시작하면 그 구간의 인접 값 비교를 신뢰하지 말고 결론을 보류합니다** -(`T68` 이 `base2` 를 상시 조건으로 둔 것과 같은 이유). 채택 기준은 「컷 전 **1위**인 정답을 -하나도 잃지 않는 가장 높은 값」입니다 — 회복률이 아닙니다. +재측정에서 **회차 간 판정이 갈리기 시작하면, 그 구간의 인접 값 비교를 신뢰하지 말고 +결론을 보류합니다**(`T68` 이 `base2` 를 상시 조건으로 둔 것과 같은 이유입니다). 채택 +기준은 「컷 전 **1위**인 정답을 하나도 잃지 않는 가장 높은 값」입니다. 회복률이 아닙니다. -**경계 5 자는 측정이 아니라 판단입니다.** 측정한 단어형이 전부 공백 없는 2~5자라 -「글자 수」와 「어절 수」 두 정의가 같은 답을 냈습니다. 두 조건을 **함께** 요구해 애매한 -질의가 문장형(더 세게 자름) 쪽으로 기울게 했습니다. +**단어형 경계 5 자는 측정이 아니라 판단입니다.** 측정한 단어형 질의가 전부 공백 없는 +2~5자였기 때문에, 「글자 수」와 「어절 수」 두 정의가 같은 답을 냈고 측정으로는 둘을 +가를 수 없었습니다. 대신 두 조건을 **함께** 요구해, 애매한 질의가 더 세게 자르는 +문장형 쪽으로 기울게 했습니다. ### 이전 판단을 뒤집습니다 -직전 판까지 이 문서는 「컷오프를 적용하지 않는다」였고, 근거는 무관 질의 **1건**의 실측 -(top-1 0.3143, 관련 질의 top-1 최소 0.5263과 간격 +0.2120)이었습니다. 무관 질의를 15건으로 -늘리자 **그 간격이 사라졌습니다.** +직전 판까지 이 문서는 「컷오프를 적용하지 않는다」였습니다. 근거는 무관 질의 **1건**의 +실측이었습니다. 그 질의의 top-1 유사도가 0.3143 이고, 관련 질의 top-1 의 최솟값 0.5263 +과의 간격이 +0.2120 이라 컷 없이도 구분된다고 본 것입니다. 무관 질의를 15건으로 늘리자 +**그 간격이 사라졌습니다.** ```text 무관 질의 top-1 최댓값 0.3819 (「치과 임플란트 상담 받을 곳」 → 연남칼국수) @@ -268,16 +282,17 @@ python tools/search_cut/word_sweep.py # 격자 재확인 간격 -0.0176 겹친다 ``` -**어떤 `τ_abs`도 무관 질의를 전부 침묵시키면서 정답을 전부 살릴 수는 없습니다.** 이전 -판단이 본 여유는 표본 1건의 우연이었습니다. 다만 그것이 「그러므로 컷을 걸지 말자」를 -뜻하지는 않습니다 — 컷이 없으면 무관 질의에 **보유 기록 전량**이 반환되고, 실측에서는 -`τ_abs=0.30` 하나로 무관 질의 15건 중 11건이 0건이 되면서 정답은 하나도 잃지 않았습니다. +**어떤 `τ_abs`도 무관 질의를 전부 무노출로 만들면서 정답을 전부 살릴 수는 없습니다.** +이전 판단이 본 여유는 표본 1건의 우연이었습니다. 다만 그것이 「그러므로 컷을 걸지 +말자」를 뜻하지는 않습니다. 컷이 없으면 무관 질의에 **보유 기록 전량**이 반환됩니다. +실측에서는 `τ_abs=0.30` 하나로 무관 질의 15건 중 11건이 0건이 되면서 정답은 하나도 +잃지 않았습니다. ## 6.2 오류 응답 -검색은 요청당 정확히 1회 Embedding을 호출하므로(§2), 실패의 대부분이 그 호출입니다. -업스트림 실패는 **분류에 따라 상태 코드가 갈립니다** — -[failure-recovery.md §2.5](failure-recovery.md)가 정본이고 여기서는 검색 경로 기준으로만 +검색은 요청당 정확히 1회 Embedding을 호출하므로(§2), 실패의 대부분이 그 호출에서 +발생합니다. 업스트림 실패는 **분류에 따라 상태 코드가 갈립니다.** +[failure-recovery.md §2.5](failure-recovery.md)가 정본이고, 여기서는 검색 경로 기준으로만 요약합니다. | 상황 | 응답 | @@ -288,11 +303,11 @@ python tools/search_cut/word_sweep.py # 격자 재확인 | 공유 시크릿 불일치 | `401` | | 그 밖 | `500` — 우리 코드의 결함 | -`422`만 본문에 값을 싣습니다. 어느 쪽 설정을 고쳐야 하는지가 두 값의 비교에서만 나오고, -그것이 `back`이 유일하게 파싱하는 필드이기 때문입니다(`AiSearchClient.translate`). -나머지는 고정 문구 한 줄이며 **credential·endpoint·검색어를 싣지 않습니다.** +`422`만 본문에 값을 싣습니다. 어느 쪽 설정을 고쳐야 하는지가 두 Profile 값의 비교에서만 +나오고, 그것이 `back`이 유일하게 파싱하는 필드이기 때문입니다(`AiSearchClient.translate`). +나머지 응답은 고정 문구 한 줄이며 **credential·endpoint·검색어를 싣지 않습니다.** -계약 테스트는 `tests/test_api_error_contract.py`이며 업스트림 상태 코드부터 응답 상태 +계약 테스트는 `tests/test_api_error_contract.py`이며, 업스트림 상태 코드부터 응답 상태 코드까지 한 요청으로 관통해 고정합니다. ## 7. 하지 않는 것 diff --git a/docs/spec/state-machine.md b/docs/spec/state-machine.md index bff4e6d..e2170f6 100644 --- a/docs/spec/state-machine.md +++ b/docs/spec/state-machine.md @@ -148,9 +148,9 @@ if not start.started: 9(중복 요청), 18(재스캔 후보 선택 후 삭제)의 기대 동작입니다. - repository는 `rowcount`와 **UPDATE 직전의 단계 상태**를 함께 반환하고, 중단 여부는 service가 판단합니다. 직전 상태는 신규 시작(`PENDING`)과 만료 재선점(`PROCESSING`)을 - 가르기 위한 것입니다 — 두 경우가 같은 UPDATE를 타고 같은 `1`을 돌려주므로 `rowcount` - 만으로는 구분되지 않는데, 재선점은 **앞선 처리가 만료 안에 끝나지 못했다**는 관측 - 가치가 있는 사건입니다(`S15P11A705-197`). + 가르기 위한 것입니다. 두 경우가 같은 UPDATE를 타고 같은 `1`을 돌려주므로 `rowcount` + 만으로는 구분되지 않습니다. 그런데 재선점은 **앞선 처리가 만료 안에 끝나지 못했다**는, + 관측 가치가 있는 사건입니다(`S15P11A705-197`). - 0인 이유를 알기 위해 다시 SELECT하지 않습니다. 이유를 알아도 할 일이 달라지지 않습니다. 위의 직전 상태는 추가 조회가 아니라 **같은 UPDATE 문장의 CTE**이며, `1`이 무엇이었는지를 알기 위한 것입니다. 이 규칙은 그대로입니다. diff --git a/docs/troubleshooting/2026-07-23-fastapi-local-verification.md b/docs/troubleshooting/2026-07-23-fastapi-local-verification.md index e27b5f1..c550bb6 100644 --- a/docs/troubleshooting/2026-07-23-fastapi-local-verification.md +++ b/docs/troubleshooting/2026-07-23-fastapi-local-verification.md @@ -1,4 +1,4 @@ -# FastAPI 로컬 검증 중 겪은 문제 (T16~T18) +# FastAPI 로컬 검증에서 겪은 인코딩·드라이버 경계 문제 (T16~T18) - **상태**: 해결됨 - **날짜**: 2026-07-23 @@ -7,46 +7,46 @@ ## T16 — `.env` UTF-8 BOM으로 첫 키 파싱 실패 -**증상**: pydantic-settings가 `DATABASE_URL` 필드를 "missing"으로 판정해 기동 실패. 나머지 키(`GMS_API_KEY` 등)는 정상 인식. +**증상**: pydantic-settings가 `DATABASE_URL` 필드를 "missing"으로 판정해 기동에 실패했다. 나머지 키(`GMS_API_KEY` 등)는 정상 인식됐다. -**원인**: PowerShell 5.1의 `Set-Content -Encoding UTF8`은 **BOM(EF BB BF)을 파일 앞에 붙인다**. 첫 줄 키 이름이 `DATABASE_URL`이 되어 매칭에 실패한다. 둘째 줄부터는 정상이라 첫 키만 누락되는 형태로 드러난다. +**원인**: PowerShell 5.1의 `Set-Content -Encoding UTF8`은 **BOM(EF BB BF)을 파일 앞에 붙인다**. 그 결과 첫 줄 키 이름이 `DATABASE_URL`이 되어 키 매칭에 실패한다. 둘째 줄부터는 정상이므로, 첫 키만 누락되는 형태로 드러난다. **해결**: BOM 없이 기록한다. ```powershell $enc = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllLines("$PWD\.env", $lines, $enc) ``` -검증: 첫 3바이트가 `68,65,84`(“DAT”)인지 확인(BOM이면 `239,187,191`). +검증은 파일의 첫 3바이트가 `68,65,84`(“DAT”)인지 확인하는 방식으로 한다. BOM이 붙어 있으면 `239,187,191`이 나온다. ## T17 — pgvector가 VECTOR 컬럼을 `Vector` 객체로 반환 **증상**: Preset 캐시 적재 시 `np.asarray(row["embedding"], dtype=np.float32)`에서 -`TypeError: float() argument must be a string or a real number, not 'Vector'`. +`TypeError: float() argument must be a string or a real number, not 'Vector'`가 발생했다. -**원인**: `pgvector.asyncpg.register_vector`가 VECTOR 컬럼을 numpy가 아니라 `pgvector.Vector` 객체로 디코드한다. `np.asarray`가 이를 처리하지 못한다. +**원인**: `pgvector.asyncpg.register_vector`가 VECTOR 컬럼을 numpy 배열이 아니라 `pgvector.Vector` 객체로 디코드한다. `np.asarray`가 이 객체를 처리하지 못한다. -**해결**: `to_numpy()`(또는 `to_list()`)로 변환. 방어적으로 감싼다. +**해결**: `to_numpy()`(또는 `to_list()`)로 변환한다. 반환형이 달라질 수 있으므로 방어적으로 감싼다. ```python def _to_array(value) -> np.ndarray: if hasattr(value, "to_numpy"): return value.to_numpy().astype(np.float32) return np.asarray(value, dtype=np.float32) ``` -바인딩(쓰기) 방향은 `list[float]`를 그대로 넘겨도 되며, 디코드(읽기) 방향에서만 발생한다. +이 문제는 디코드(읽기) 방향에서만 발생한다. 바인딩(쓰기) 방향은 `list[float]`를 그대로 넘겨도 된다. ## T18 — asyncpg `now() - $2` interval 타입 추론 실패 **증상**: 상태 전이 UPDATE의 `updated_at < now() - $2`에서 -`UndefinedFunctionError: operator does not exist: timestamp with time zone < interval`. +`UndefinedFunctionError: operator does not exist: timestamp with time zone < interval`이 발생했다. -**원인**: asyncpg는 prepared statement 준비 시 파라미터 타입을 값이 아니라 **SQL 문맥**으로 추론한다. `now() - $2`에서 `$2`가 미정이면 PostgreSQL이 `timestamptz - timestamptz = interval`로 골라 `now() - $2`가 interval이 되고, 좌변 `timestamptz`와 비교가 성립하지 않는다(파이썬에서 `timedelta`를 넘겨도 준비 단계에선 무관). +**원인**: asyncpg는 prepared statement를 준비할 때 파라미터 타입을 전달된 값이 아니라 **SQL 문맥**으로 추론한다. `now() - $2`에서 `$2`의 타입이 미정이면 PostgreSQL이 `timestamptz - timestamptz = interval` 해석을 골라 `now() - $2` 전체가 interval 타입이 된다. 그러면 좌변 `timestamptz`와의 비교가 성립하지 않는다. 파이썬에서 `timedelta`를 넘겨도 소용이 없다. 타입 결정은 값이 전달되기 전인 준비 단계에서 끝나기 때문이다. **해결**: 파라미터에 명시적 캐스트를 준다. ```sql AND updated_at < now() - $2::interval ``` -`timedelta`는 interval로 인코딩되므로 `$2::interval`과 호환된다. +`timedelta`는 interval 로 인코딩되므로 `$2::interval`과 호환된다. ## 공통 교훈 -세 건 모두 **로컬 실행 없이 코드 리뷰만으로는 드러나지 않는** 런타임/드라이버 경계 문제였다. pgvector·asyncpg·PowerShell 인코딩은 재발 가능성이 높아, 신규 구현 시 실제 컨테이너 + 실제 드라이버로 최소 1회 end-to-end를 돌려 확인한다. +세 건 모두 **로컬 실행 없이 코드 리뷰만으로는 드러나지 않는** 런타임과 드라이버 경계의 문제였다. pgvector·asyncpg·PowerShell 인코딩은 재발 가능성이 높다. 신규 구현 시에는 실제 컨테이너와 실제 드라이버로 최소 1회 end-to-end 를 돌려 확인한다. diff --git a/docs/troubleshooting/2026-07-24-e3-ci-and-search-path.md b/docs/troubleshooting/2026-07-24-e3-ci-and-search-path.md index 733bd4d..5c3f2c0 100644 --- a/docs/troubleshooting/2026-07-24-e3-ci-and-search-path.md +++ b/docs/troubleshooting/2026-07-24-e3-ci-and-search-path.md @@ -1,4 +1,4 @@ -# E3 CI·런타임 이슈 (T19~T21) +# E3 CI 자동화에서 드러난 플랫폼 종속 lock·모듈 경로·search_path 문제 (T19~T21) - **날짜**: 2026-07-24 - **상태**: 해결됨 @@ -7,15 +7,15 @@ ## T19 — Windows에서 만든 lock의 플랫폼 종속 패키지가 Linux CI 설치를 깨뜨림 -**증상**: E3-PR1 병합 후 main push로 처음 실행된 `ai-ci`가 `Install dependencies`에서 19초 만에 실패. +**증상**: E3-PR1 병합 후 main push로 처음 실행된 `ai-ci`가 `Install dependencies` 단계에서 19초 만에 실패했다. ``` ERROR: Could not find a version that satisfies the requirement pywin32==312 (from versions: none) ERROR: No matching distribution found for pywin32==312 ``` -**원인**: `requirements-dev.lock`을 Windows에서 `uv pip compile`로 생성하면서 **플랫폼 마커 없이** Windows 전용 패키지를 고정했다. `testcontainers → docker SDK`가 Windows에서 `pywin32`를 의존하고, `colorama`도 마찬가지다. ubuntu 러너의 `pip install -r`가 마커 없는 `pywin32`를 강제 설치하려다 실패한다. +**원인**: `requirements-dev.lock`을 Windows에서 `uv pip compile`로 생성하면서 **플랫폼 마커 없이** Windows 전용 패키지를 고정했다. `testcontainers → docker SDK` 경로가 Windows에서 `pywin32`를 의존하고, `colorama`도 마찬가지다. ubuntu 러너의 `pip install -r`가 마커 없는 `pywin32`를 강제로 설치하려다 실패한다. -**해결**: `--universal`로 재생성해 `sys_platform` 마커를 포함시킨다. +**해결**: `--universal` 옵션으로 lock 을 재생성해 `sys_platform` 마커를 포함시킨다. ```bash uv pip compile --universal requirements.txt -o requirements.lock uv pip compile --universal requirements-dev.txt -o requirements-dev.lock @@ -25,45 +25,45 @@ uv pip compile --universal requirements-dev.txt -o requirements-dev.lock pywin32==312 ; sys_platform == 'win32' colorama==0.4.6 ; sys_platform == 'win32' ``` -Linux CI·Docker는 마커를 평가해 이들을 스킵한다. +Linux CI·Docker 는 마커를 평가해 이 패키지들을 건너뛴다. ## T20 — CI 러너에서 pytest가 app 모듈을 못 찾음 -**증상**: T19 수정 후 다음 실행의 `pytest`에서 17초 실패. +**증상**: T19 수정 후 다음 실행의 `pytest`가 17초 만에 실패했다. ``` ImportError while loading conftest '/home/runner/work/ai/ai/tests/conftest.py'. E ModuleNotFoundError: No module named 'app' ``` -**원인**: pytest는 `conftest.py`가 있는 디렉토리(`tests/`)를 sys.path에 넣지만 레포 루트(`app`의 부모)는 넣지 않는다. 로컬에서는 `PYTHONPATH=<루트>`를 수동 설정해 검증했던 것이 이 조건 차이를 가렸다. +**원인**: pytest 는 `conftest.py`가 있는 디렉토리(`tests/`)를 sys.path 에 넣지만, 레포 루트(`app`의 부모 디렉토리)는 넣지 않는다. 로컬에서는 `PYTHONPATH=<루트>`를 수동으로 설정한 채 검증했기 때문에 CI 와의 이 조건 차이가 가려져 있었다. -**해결**: `pyproject.toml`에 pytest의 `pythonpath`를 설정해 루트를 sys.path에 넣는다. +**해결**: `pyproject.toml`에 pytest 의 `pythonpath` 옵션을 설정해 레포 루트를 sys.path 에 넣는다. ```toml [tool.pytest.ini_options] pythonpath = ["."] ``` -검증은 `PYTHONPATH` 없이 재현: `pytest -q` → 27 passed. +검증은 `PYTHONPATH` 환경변수 없이 재현했다. `pytest -q` 실행 결과 27 passed 를 확인했다. -## T21 — search_path=ai 단독이 public을 제외해 VECTOR 타입·register_vector가 실패 ※ E3 최우선 발견 +## T21 — search_path=ai 단독이 public을 제외해 VECTOR 타입·register_vector가 실패 (E3 최우선 발견) -**증상**: 통합 테스트에서 SearchService가 쓰는 pool 커넥션에서만 검색이 실패. +**증상**: 통합 테스트에서 SearchService 가 쓰는 pool 커넥션에서만 검색이 실패했다. ``` asyncpg.exceptions.UndefinedFunctionError: operator does not exist: public.vector <=> unknown # ::vector 캐스트를 넣자 asyncpg.exceptions.UndefinedObjectError: type "vector" does not exist ``` -**원인**: `app/core/db.py`가 커넥션 초기화에서 `SET search_path = ai`만 설정했다. PostgreSQL에서 `search_path`를 명시하면 **public이 암묵 포함되지 않는다**. pgvector 확장은 `public` 스키마에 설치되므로(`public.vector`), `ai` 단독 경로에서는 `vector` 타입 이름 해석과 `register_vector`(타입 OID 조회)가 **일부 커넥션에서 실패**한다. 이전 로컬 검증(단일 요청)이 통과했던 것은 우연히 첫 커넥션만 register된 상태였기 때문이고, 멀티 커넥션을 쓰는 통합 테스트가 이 결함을 드러냈다. +**원인**: `app/core/db.py`가 커넥션 초기화에서 `SET search_path = ai`만 설정했다. PostgreSQL 에서 `search_path`를 명시하면 **public 이 암묵적으로 포함되지 않는다**. pgvector 확장은 `public` 스키마에 설치되므로(`public.vector`), `ai` 단독 경로에서는 `vector` 타입 이름 해석과 `register_vector`(타입 OID 조회)가 **일부 커넥션에서 실패**한다. 이전의 로컬 검증(단일 요청)이 통과했던 것은 우연히 첫 커넥션만 register 된 상태였기 때문이다. 멀티 커넥션을 쓰는 통합 테스트가 이 결함을 드러냈다. -**해결**: `search_path`에 `public`을 포함한다. `ai` 우선 + `public`(확장 소재). +**해결**: `search_path`에 `public`을 포함한다. `ai`를 우선으로 두고 확장이 있는 `public`을 뒤에 둔다. ```python # app/core/db.py await conn.execute("SET search_path = ai, public") await register_vector(conn) ``` -`core`는 여전히 경로에서 제외되므로, 실수로 `core.*`를 참조하는 것을 막는 원래 방어 의도는 유지된다. 이 보정은 프로덕션 코드(`app/core/db.py`)의 견고성 개선이며, 로컬 검증에서 우연히 가려졌던 결함을 테스트가 잡아낸 사례다. +`core` 스키마는 여전히 경로에서 제외되므로, 실수로 `core.*`를 참조하는 것을 막으려던 원래의 방어 의도는 유지된다. 이 보정은 프로덕션 코드(`app/core/db.py`)의 견고성 개선이며, 로컬 검증에서 우연히 가려졌던 결함을 테스트가 잡아낸 사례다. ## 공통 교훈 -- **워크플로를 바꾸는 PR은 병합 후 첫 실행이 곧 첫 검증이다.** PR CI는 base(main)의 기존 워크플로로 돌기 때문에, 새 워크플로(lock 설치·Jira 검증·pytest 정비)는 병합된 뒤에야 처음 실행된다. 이런 PR은 병합 직후 main CI 확인을 필수 단계로 둔다. -- **로컬에서 환경변수를 수동 설정해 검증하면 CI와의 조건 차이가 가려진다.** T20은 `PYTHONPATH` 우회가, T21은 단일 커넥션이 결함을 숨겼다. 로컬 검증은 CI와 같은 조건(마커 없는 env, 멀티 커넥션)으로 재현한다. +- **워크플로를 바꾸는 PR은 병합 후 첫 실행이 곧 첫 검증이다.** PR CI 는 base(main)의 기존 워크플로로 돌기 때문에, 새 워크플로(lock 설치·Jira 검증·pytest 정비)는 병합된 뒤에야 처음 실행된다. 이런 PR 은 병합 직후 main CI 확인을 필수 단계로 둔다. +- **로컬에서 환경변수를 수동 설정해 검증하면 CI와의 조건 차이가 가려진다.** T20 은 `PYTHONPATH` 우회가, T21 은 단일 커넥션이 결함을 숨겼다. 로컬 검증은 CI 와 같은 조건(마커 없는 env, 멀티 커넥션)으로 재현한다. diff --git a/docs/troubleshooting/2026-07-27-e2e-env-issues.md b/docs/troubleshooting/2026-07-27-e2e-env-issues.md index 52d7c7e..2054808 100644 --- a/docs/troubleshooting/2026-07-27-e2e-env-issues.md +++ b/docs/troubleshooting/2026-07-27-e2e-env-issues.md @@ -9,7 +9,7 @@ ## T22 — `.env`가 CRLF라 셸로 값을 뽑으면 `\r`이 섞여 JSON이 깨짐 -**증상**: `.env`에서 시크릿·Profile을 뽑아 `curl`로 API를 호출하니 본문 파싱이 실패한다. +**증상**: `.env`에서 시크릿·Profile 값을 뽑아 `curl`로 API 를 호출하니 본문 파싱이 실패했다. ```bash SECRET=$(grep '^INTERNAL_SHARED_SECRET=' .env | cut -d= -f2-) @@ -17,9 +17,9 @@ curl -X POST .../internal/v1/search -H "X-Internal-Secret: $SECRET" -d "{...\"em # {"detail":"There was an error parsing the body"} ``` -**원인**: `.env`가 **CRLF 줄바꿈**이다. `cut`은 `\r`까지 값에 포함시키므로 헤더 값과 JSON 문자열 안에 제어문자가 들어간다. JSON 파서는 문자열 리터럴 내부의 raw `\r`을 거부한다. +**원인**: `.env`가 **CRLF 줄바꿈**으로 저장되어 있다. `cut`은 줄 끝의 `\r`까지 값에 포함시키므로, 헤더 값과 JSON 문자열 안에 제어문자가 들어간다. JSON 파서는 문자열 리터럴 내부의 raw `\r`을 거부한다. -증상이 헷갈리는 이유는 **일부 요청이 통과하기 때문**이다. `\r`이 마지막 필드에 들어가면 깨지지만, ASCII만 쓰는 짧은 본문이나 값이 헤더에만 쓰이는 요청은 그대로 성공한다. 실제로 Profile 불일치 422 확인은 통과했고 정상 검색만 실패했다. +증상이 헷갈리는 이유는 **일부 요청이 통과하기 때문**이다. `\r`이 마지막 필드에 들어가면 본문이 깨지지만, ASCII 만 쓰는 짧은 본문이나 값이 헤더에만 쓰이는 요청은 그대로 성공한다. 실제로 Profile 불일치 422 확인은 통과했고 정상 검색만 실패했다. **해결**: 셸로 뽑을 때 `\r`을 제거한다. @@ -27,13 +27,13 @@ curl -X POST .../internal/v1/search -H "X-Internal-Secret: $SECRET" -d "{...\"em SECRET=$(grep '^INTERNAL_SHARED_SECRET=' .env | cut -d= -f2- | tr -d '\r\n') ``` -더 나은 해결은 **셸로 `.env`를 파싱하지 않는 것**이다. 파이썬 스크립트에서 `app.core.config.get_settings()`를 쓰면 pydantic-settings가 CRLF를 정상 처리하며, 값 노출 위험도 없다. `tools/e2e/`의 드라이버는 전부 이 방식이다. +더 나은 해결은 **셸로 `.env`를 파싱하지 않는 것**이다. 파이썬 스크립트에서 `app.core.config.get_settings()`를 쓰면 pydantic-settings 가 CRLF 를 정상 처리하며, 값이 셸 밖으로 노출될 위험도 없다. `tools/e2e/`의 드라이버는 전부 이 방식이다. -**관련**: [T16](2026-07-23-fastapi-local-verification.md)(`.env` UTF-8 BOM이 첫 키 파싱을 깨뜨림)과 같은 계열 — **`.env` 파일의 바이트 표현이 파서마다 다르게 해석된다.** +**관련**: [T16](2026-07-23-fastapi-local-verification.md)(`.env` UTF-8 BOM 이 첫 키 파싱을 깨뜨림)과 같은 계열의 문제다. **`.env` 파일의 바이트 표현이 파서마다 다르게 해석된다.** -## T23 — 앱 `Database`를 쓰지 않으면 `register_vector` 미등록으로 벡터가 문자열로 디코딩됨 ※ 시딩 재발 주의 +## T23 — 앱 `Database`를 쓰지 않으면 `register_vector` 미등록으로 벡터가 문자열로 디코딩됨 (시딩 재발 주의) -**증상**: 프리셋을 직접 읽는 스크립트가 캐시 적재에서 터진다. +**증상**: 프리셋을 직접 읽는 스크립트가 캐시 적재에서 예외로 중단됐다. ``` File "app/cache/preset_cache.py", line 22, in _to_array @@ -41,11 +41,11 @@ File "app/cache/preset_cache.py", line 22, in _to_array ValueError: could not convert string to float: '[0.05609131,0.008399963,-0.039398193,...]' ``` -**원인**: 스크립트가 `asyncpg.connect()`를 직접 호출했다. pgvector의 VECTOR 컬럼은 **커넥션마다 `register_vector()`로 타입을 등록해야** `Vector` 객체로 디코딩되며, 등록하지 않으면 asyncpg가 **텍스트 표현 그대로**(`'[0.05, ...]'` 문자열) 돌려준다. `_to_array`는 `Vector`(→`to_numpy()`) 또는 리스트를 기대하므로 문자열에서 실패한다. +**원인**: 스크립트가 `asyncpg.connect()`를 직접 호출했다. pgvector 의 VECTOR 컬럼은 **커넥션마다 `register_vector()`로 타입을 등록해야** `Vector` 객체로 디코딩된다. 등록하지 않으면 asyncpg 가 **텍스트 표현 그대로**(`'[0.05, ...]'` 문자열) 돌려준다. `_to_array`는 `Vector`(`to_numpy()` 경유) 또는 리스트를 기대하므로 문자열에서 실패한다. -**제품 결함으로 오인하기 쉽다.** 실패하는 코드(`PresetCache.load`)는 프로덕션 코드이고, 스택 트레이스에 스크립트가 등장하지 않는다. +**제품 결함으로 오인하기 쉽다.** 실패하는 코드(`PresetCache.load`)는 프로덕션 코드이고, 스택 트레이스에 스크립트가 등장하지 않기 때문이다. -**진단 방법**: **같은 코드가 두 경로에서 다르게 동작하는지 본다.** `app/main.py`의 lifespan은 같은 `PresetCache.load`로 27건을 정상 적재했다(기동 로그 `preset cache loaded: 27 presets`). 그렇다면 차이는 코드가 아니라 **연결 방식**이다. `app/core/db.py`는 커넥션 초기화에서 두 가지를 한다: +**진단 방법**: **같은 코드가 두 경로에서 다르게 동작하는지 본다.** `app/main.py`의 lifespan 은 같은 `PresetCache.load`로 27건을 정상 적재했다(기동 로그 `preset cache loaded: 27 presets`). 그렇다면 차이는 코드가 아니라 **연결 방식**이다. `app/core/db.py`는 커넥션 초기화에서 두 가지를 한다: ```python await conn.execute("SET search_path = ai, public") @@ -66,15 +66,15 @@ async with db.acquire() as conn: await db.disconnect() ``` -우회하지 않는 편이 **검증으로서도 더 충실하다** — 앱과 동일한 연결 설정(search_path·타입 등록)을 쓰게 되므로 검증 대상과 실행 환경이 일치한다. +우회하지 않는 편이 **검증으로서도 더 충실하다.** 앱과 동일한 연결 설정(search_path·타입 등록)을 쓰게 되므로 검증 대상과 실행 환경이 일치한다. -**예외**: 벡터 컬럼을 읽지 않는 스크립트(상태 조회·PENDING 행 삽입 등)는 raw `asyncpg`로도 동작한다. `tools/e2e/run_pipeline.py`가 그 경우다. 다만 **벡터를 한 번이라도 읽는 순간 재발**하므로, 시딩 스크립트는 처음부터 `Database`를 쓰는 편이 안전하다. +**예외**: 벡터 컬럼을 읽지 않는 스크립트(상태 조회·PENDING 행 삽입 등)는 raw `asyncpg`로도 동작한다. `tools/e2e/run_pipeline.py`가 그 경우다. 다만 **벡터를 한 번이라도 읽는 순간 같은 문제가 재발**하므로, 시딩 스크립트는 처음부터 `Database`를 쓰는 편이 안전하다. -**관련**: [T17](2026-07-23-fastapi-local-verification.md)(VECTOR 컬럼이 `Vector` 객체로 반환됨 — 디코드 방향), [T21](2026-07-24-e3-ci-and-search-path.md)(`search_path`에 `public` 누락 시 타입 해석 실패). 셋 다 **pgvector 타입 해석은 커넥션 단위 상태**라는 같은 뿌리다. +**관련**: [T17](2026-07-23-fastapi-local-verification.md)(VECTOR 컬럼이 `Vector` 객체로 반환됨 — 디코드 방향), [T21](2026-07-24-e3-ci-and-search-path.md)(`search_path`에 `public` 누락 시 타입 해석 실패). 셋 다 **pgvector 타입 해석은 커넥션 단위 상태**라는 같은 원리에서 나온 문제다. ## T24 — Git Bash + `curl`에서 한글 본문이 깨짐 (ASCII는 통과) -**증상**: 컨테이너 API를 `curl`로 확인하는데 한글 질의만 실패한다. +**증상**: 컨테이너 API 를 `curl`로 확인하는데 한글 질의만 실패했다. ```bash curl -X POST .../search -d "{\"query\":\"비 오는 날 아늑한 곳\",...}" @@ -84,7 +84,7 @@ curl -X POST .../search -d '{"query":"x","embeddingProfile":"wrong-v1"}' # HTTP 422 ← ASCII 본문은 정상 통과 ``` -**원인**: Windows Git Bash에서 명령줄 인자에 담긴 한글이 UTF-8로 전달되지 않는다(콘솔 코드페이지·MSYS 인자 변환). 서버는 UTF-8 JSON을 기대하므로 파싱에 실패한다. **서버 문제가 아니다** — 같은 요청을 Python `httpx`로 보내면 정상이다. +**원인**: Windows Git Bash 에서 명령줄 인자에 담긴 한글이 UTF-8 로 전달되지 않는다(콘솔 코드페이지·MSYS 인자 변환). 서버는 UTF-8 JSON 을 기대하므로 파싱에 실패한다. **서버 문제가 아니다.** 같은 요청을 Python `httpx`로 보내면 정상 동작한다. T22와 **증상이 완전히 동일**해서 혼동하기 쉽다. 구분법: @@ -100,12 +100,12 @@ import httpx r = httpx.post(url, headers=H, json={"query": "비 오는 날 아늑한 곳", ...}) ``` -`httpx`의 `json=`은 UTF-8로 직렬화하고 `Content-Type`도 맞춰 준다. `tools/e2e/`의 드라이버가 전부 이 방식이며, `curl`은 `/health`나 HTTP 코드 확인 같은 ASCII 경로에만 쓴다. +`httpx`의 `json=`은 UTF-8 로 직렬화하고 `Content-Type`도 맞춰 준다. `tools/e2e/`의 드라이버가 전부 이 방식이며, `curl`은 `/health`나 HTTP 코드 확인 같은 ASCII 경로에만 쓴다. -파일 경유(`curl -d @body.json`, UTF-8로 저장)도 가능하지만, 검증 스크립트를 어차피 Python으로 쓰게 되므로 실익이 없다. +파일 경유(`curl -d @body.json`, UTF-8 로 저장)도 가능하지만, 검증 스크립트를 어차피 Python 으로 쓰게 되므로 실익이 없다. ## 공통 교훈 -- **`.env`는 셸로 파싱하지 않는다.** BOM(T16)·CRLF(T22) 모두 셸 텍스트 처리에서만 터졌다. `get_settings()`를 쓰면 세 문제가 한꺼번에 사라지고 값이 로그에 노출될 위험도 줄어든다. -- **검증 스크립트는 앱의 인프라 객체를 재사용한다.** T23은 `Database`를 우회해서 생긴 문제였다. 우회하면 검증 대상과 실행 환경이 달라져, 통과해도 무엇을 통과시킨 것인지 불분명해진다. -- **같은 증상이 두 원인에서 나온다.** T22와 T24는 응답 메시지가 동일하다. 재현 조건을 좁히는 최소 실험(ASCII 본문으로 한 번 더)이 원인 분기를 가른다. +- **`.env`는 셸로 파싱하지 않는다.** BOM(T16)·CRLF(T22) 모두 셸 텍스트 처리에서만 문제가 됐다. `get_settings()`를 쓰면 세 문제가 한꺼번에 사라지고 값이 로그에 노출될 위험도 줄어든다. +- **검증 스크립트는 앱의 인프라 객체를 재사용한다.** T23 은 `Database`를 우회해서 생긴 문제였다. 우회하면 검증 대상과 실행 환경이 달라져, 검증이 통과해도 무엇을 통과시킨 것인지 불분명해진다. +- **같은 증상이 두 원인에서 나온다.** T22와 T24는 응답 메시지가 동일하다. 재현 조건을 좁히는 최소 실험(ASCII 본문으로 한 번 더 보내기)이 원인 분기를 가른다. diff --git a/docs/troubleshooting/2026-07-28-shared-worktree-and-env-cache.md b/docs/troubleshooting/2026-07-28-shared-worktree-and-env-cache.md index 411f0df..98c9e08 100644 --- a/docs/troubleshooting/2026-07-28-shared-worktree-and-env-cache.md +++ b/docs/troubleshooting/2026-07-28-shared-worktree-and-env-cache.md @@ -1,30 +1,30 @@ -# 멀티세션 워킹트리 오염 · import 시점 `.env` 캐시 +# 멀티세션 워킹트리 공유가 만든 커밋 오염 · import 시점 `.env` 캐시가 만든 인증 실패 - **상태**: 해결됨 (워크플로·부팅 교정) - **날짜**: 2026-07-28 (복원 등재 — 실제 발생 2026-07-24~27) - **관련**: 메모리 `pinlog-multisession-worktree`, [S1 복원 리포트](../implements/2026-07-28-s1-implementation-recovery.md) - **레이어**: git 워크플로 · 앱 부팅 -> 종료된 S1 세션이 겪었으나 레포 troubleshooting에는 없던 2건을 복원한다. 전부 `기록복원`. +> 종료된 S1 세션이 겪었으나 레포 troubleshooting 에는 없던 2건을 복원한다. 전부 transcript 에서 확인한 `기록복원`이다. ## T25 — 멀티세션이 단일 워킹트리를 공유해 커밋 오염 -**증상**: 내 3파일 커밋에 **타 세션 파일 15개가 섞여** push됨. force-push 직전 다른 세션이 브랜치를 전환해 커밋 무산. +**증상**: 내가 `git add`한 3파일의 커밋에 **다른 세션이 만든 파일 15개가 섞여** push 됐다. force-push 로 되돌리려던 직전에 다른 세션이 브랜치를 전환해 그 커밋 자체가 무산됐다. -**근본 원인**: 여러 세션이 **하나의 git 워킹트리·인덱스·HEAD를 공유**한다. `git add`·`checkout`·커밋이 세션 간에 경쟁한다. +**근본 원인**: 여러 세션이 **하나의 git 워킹트리·인덱스·HEAD 를 공유**하고 있었다. 이 상태에서는 `git add`·`checkout`·커밋이 세션 간에 경쟁한다. -**사전 미발견 이유**: 단일 세션 전제의 git 워크플로. 다른 세션은 이미 worktree 격리를 쓰고 있었으나 그 사실이 공유되지 않았다. +**사전에 발견하지 못한 이유**: git 워크플로가 단일 세션을 전제하고 있었다. 다른 세션은 이미 worktree 격리를 쓰고 있었으나 그 사실이 공유되지 않았다. -**해결**: 멀티 세션에서 **격리 `git worktree`를 기본**으로. 커밋 전 `git branch --show-current` 확인, `git add`는 개별 파일(`-A` 금지), 원격 병합은 로컬 전환 없이 `gh pr merge`로. +**해결**: 멀티 세션 환경에서는 **격리 `git worktree`를 기본**으로 한다. 커밋 전에 `git branch --show-current`로 현재 브랜치를 확인하고, `git add`는 개별 파일 단위로 한다(`-A` 금지). 원격 병합은 로컬 브랜치 전환 없이 `gh pr merge`로 한다. > 이 사건은 웹 대화 시점에 협업 트러블슈팅(T25~T29)으로 예약됐던 5건 중 첫 항목이다. 여기서 T25로 확정하며, 나머지 4건(미작성·MVP 이후)은 이번 범위 밖이다. -## T26 — 모듈 레벨 `create_app()` import 시점 `.env` 캐시 → API 3건만 401 +## T26 — 모듈 레벨 `create_app()`이 import 시점에 `.env`를 캐시해 API 3건만 401 -**증상**: API 테스트 **3건만 401**(나머지는 통과라 원인이 혼동됨). +**증상**: API 테스트 중 **3건만 401** 응답을 받았다. 나머지 테스트는 통과해서 원인이 혼동됐다. -**근본 원인**: `main.py`의 모듈 레벨 `app = create_app()`가 **import 시점**에 `get_settings()`로 `.env`를 읽어 캐시한다. 테스트가 secret을 바꿔도 이미 캐시된 값이 쓰인다. +**근본 원인**: `main.py`의 모듈 레벨 `app = create_app()`가 **import 시점**에 `get_settings()`로 `.env`를 읽어 캐시한다. 테스트가 나중에 secret 값을 바꿔도 이미 캐시된 값이 계속 쓰인다. -**사전 미발견 이유**: 로컬에 `.env`가 있어 값이 **우연히 맞았다**. CI엔 `.env`가 없어 conftest가 placeholder env를 선주입하는데, 그 값과 테스트 기대가 어긋난 지점만 401로 드러났다. +**사전에 발견하지 못한 이유**: 로컬에는 `.env`가 있어 캐시된 값이 **우연히 테스트 기대와 맞았다**. CI 에는 `.env`가 없어 conftest 가 placeholder env 를 먼저 주입하는데, 그 값과 테스트 기대가 어긋난 지점만 401로 드러났다. -**해결**: `settings` fixture에서 `get_settings()` 캐시를 재설정(재사용 방지), conftest에서 placeholder env를 import 전에 선주입. (관련: T16·T22 계열 — 환경/인코딩이 일부 요청만 깨뜨려 혼동시키는 패턴) +**해결**: `settings` fixture 에서 `get_settings()` 캐시를 재설정해 캐시 재사용을 막고, conftest 에서 placeholder env 를 import 전에 먼저 주입한다. 관련 사례는 T16·T22 계열이다. 환경·인코딩 문제가 일부 요청만 깨뜨려 진단을 혼동시키는 같은 패턴이다. diff --git a/docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md b/docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md index 147ccfe..1c7d2d3 100644 --- a/docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md +++ b/docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md @@ -1,6 +1,6 @@ # GMS 판정 쿼터 · 콘솔 인코딩으로 인한 시딩 중단 -- **상태**: 해결됨 (T27은 회피 불가 — 설계로 흡수, T28은 코드 수정) +- **상태**: 해결됨 (T27은 회피 불가라 설계로 흡수, T28은 코드 수정) - **날짜**: 2026-07-30 (T27은 2026-07-29 실측을 등재) - **관련**: [데모 시딩 구현](../implements/2026-07-29-demo-seeding.md), `S15P11A705-58`, `S15P11A705-174` - **레이어**: 외부 API 쿼터 · 도구 실행 환경 @@ -11,20 +11,20 @@ ## T27 — GMS 판정 쿼터는 상수가 아니다 (시점·경로 의존) -> **2026-07-30 정정.** 초판이 *"지속적으로 분당 2건 안팎만 통과한다"*로 단정했다. -> **틀린 서술이다** — 같은 코드가 다음 날 분당 30건 이상을 통과시켰다. +> **2026-07-30 정정.** 초판이 "지속적으로 분당 2건 안팎만 통과한다"로 단정했다. +> **틀린 서술이다.** 같은 코드가 다음 날 분당 30건 이상을 통과시켰다. > 아래 07-29 관측치는 폐기하지 않고 **그날의 값**으로 보존한다. -**증상**: 판정 호출을 몰아서 던지면 `TransientError: llm error: 429`. 그 Context의 -`keyword_status`가 `PROCESSING`에 남고, 조용히 돌아오므로 **실패했다는 신호가 없다.** +**증상**: 판정 호출을 몰아서 던지면 `TransientError: llm error: 429`가 발생한다. 그 Context 의 +`keyword_status`가 `PROCESSING`에 남는데, 오류 신호 없이 돌아오므로 **실패했다는 신호가 없다.** **진짜 비용은 429 자체가 아니라 그 뒤의 10분이다.** `keyword_service`는 429를 받으면 상태를 `PROCESSING`으로 둔 채 돌아오고, `PROCESSING_EXPIRY_SEC`(기본 600초)이 -지나야 재선점한다. **429 한 번이 그 Context를 10분간 얼린다.** +지나야 다시 선점할 수 있다. 따라서 **429 한 번이 그 Context 를 10분간 재처리 불가 상태로 묶는다.** ### 관측 — 하루 만에 15배가 달라졌다 -**2026-07-29** — 15초 간격 12회. 앞 6회가 전부 429, 3분에 6건 통과(분당 약 2건). +**2026-07-29** — 15초 간격 12회. 앞 6회가 전부 429였고, 3분에 6건 통과(분당 약 2건)였다. ``` 17.7s 429 97.2s 429 162.5s 429 @@ -33,7 +33,7 @@ 64.8s 429 ``` -**2026-07-30** — 같은 코드·같은 키. +**2026-07-30** — 같은 코드·같은 키로 다시 측정했다. | 조건 | 결과 | |---|---| @@ -42,49 +42,49 @@ | 동시 10건 | 10/10 성공 · 1.7초 | | 실제 시딩 37건 `--pace 1` | **42초** · 429 0건 | -**근본 원인**: GMS는 SSAFY **공용** 게이트웨이다. 쿼터가 우리 전용 할당이 아니라 +**근본 원인**: GMS 는 SSAFY **공용** 게이트웨이다. 쿼터가 우리 전용 할당이 아니라 타 팀 사용량에 좌우되고, **프로바이더 경로별로 따로 걸린다.** 같은 날 같은 시각에 -OpenAI·Anthropic 경로는 12/12로 한 번도 막히지 않은 반면 Gemini만 막혔다 +OpenAI·Anthropic 경로는 12/12로 한 번도 막히지 않은 반면 Gemini 만 막혔다 (`S15P11A705-175` 실측표). -**사전 미발견 이유**: 한 시점의 측정을 상수로 읽었다. 공용 자원인 줄 알면서도 +**사전에 발견하지 못한 이유**: 한 시점의 측정을 상수로 읽었다. 공용 자원인 줄 알면서도 "우리가 재면 그 값"이라고 적었다. **해결** -- `--pace` 기본값을 **1초**로 둔다. 선제적 감속은 한산한 날의 시간만 버린다 -- 혼잡할 때의 방어는 `retry.py` 지수 백오프 + 회수 루프 두 겹으로 남긴다 -- **`--pace`는 429를 실제로 본 뒤에 올린다** -- 근본 대책은 벤더 폴백이다 — `S15P11A705-175` +- `--pace` 기본값을 **1초**로 둔다. 선제적으로 속도를 늦추는 것은 게이트웨이가 한산한 날의 시간만 버린다. +- 혼잡할 때의 방어는 `retry.py` 지수 백오프와 회수 루프의 두 겹으로 남긴다. +- **`--pace`는 429를 실제로 본 뒤에 올린다.** +- 근본 대책은 벤더 폴백이며, `S15P11A705-175`에서 다룬다. -운영에는 `S15P11A705-159`의 재스캔 Scheduler가 같은 역할을 한다(5분 주기). +운영에는 `S15P11A705-159`의 재스캔 Scheduler 가 같은 회수 역할을 한다(5분 주기). `back#104` 병합 이후로는 로컬에서도 그것이 돈다. -**이 항목에서 배울 것은 쿼터 값이 아니라 그것을 상수로 적지 말라는 것이다.** +**이 항목에서 배울 것은 쿼터 값이 아니라, 공용 게이트웨이의 쿼터를 상수로 적지 말라는 것이다.** -## T28 — 콘솔이 cp949면 로그 한 글자에 시딩이 죽는다 +## T28 — 콘솔이 cp949면 로그 한 글자에 시딩이 중단된다 -**증상**: `seed.py --reset` 실행 직후 +**증상**: `seed.py --reset` 실행 직후 예외가 발생했다. ``` UnicodeEncodeError: 'cp949' codec can't encode character '—' in position 27 ``` -**손실이 큰 이유는 죽은 위치다.** `--reset`이 기존 데이터를 **이미 지운 뒤** -첫 로그 출력에서 죽는다. DB에는 member만 남고 Context가 0건인 상태가 되고, -복구는 전량 재시딩이다. 이번에는 GMS 호출 전이라 비용 손실이 없었을 뿐이다. +**손실이 큰 이유는 중단된 위치다.** `--reset`이 기존 데이터를 **이미 지운 뒤** +첫 로그 출력에서 예외가 난다. DB 에는 member 만 남고 Context 가 0건인 상태가 되고, +복구하려면 전량 재시딩해야 한다. 이번에는 GMS 호출 전이라 API 비용 손실이 없었을 뿐이다. -**근본 원인**: Windows에서 파이썬 stdout이 콘솔 코드페이지(여기서는 cp949)를 따른다. +**근본 원인**: Windows 에서 파이썬 stdout 이 콘솔 코드페이지(여기서는 cp949)를 따른다. `log()`가 `print`를 그대로 부르므로 `—`·`←` 같은 글자에서 예외가 난다. -문서와 로그 문자열이 한글·기호를 쓰는 이 레포에서는 사실상 시한폭탄이다. +문서와 로그 문자열이 한글·기호를 쓰는 이 레포에서는 언제든 같은 예외가 재발할 수 있는 상태였다. -**사전 미발견 이유**: 대화형 터미널에서 실행하면 인코딩이 UTF-8로 잡히는 경우가 많다. +**사전에 발견하지 못한 이유**: 대화형 터미널에서 실행하면 인코딩이 UTF-8 로 잡히는 경우가 많다. 백그라운드·파이프 실행에서만 드러난다. **해결**: 호출자가 `PYTHONIOENCODING=utf-8`을 기억해야 하는 구조를 없앤다. -- `seed.py`·`verify.py`가 시작 시 `sys.stdout.reconfigure(encoding="utf-8", errors="replace")` -- `log()`에 `UnicodeEncodeError` 최후 방어 — 로그가 작업을 죽이는 것보다 글자가 깨지는 편이 낫다 +- `seed.py`·`verify.py`가 시작 시 `sys.stdout.reconfigure(encoding="utf-8", errors="replace")`를 실행한다. +- `log()`에 `UnicodeEncodeError` 최후 방어를 둔다. 로그 출력이 작업 전체를 중단시키는 것보다 글자가 깨지는 편이 낫기 때문이다. -**같은 계열**: T22(`.env` CRLF)·T24(curl 한글 본문). 셋 다 **인코딩이 일부만 +**같은 계열**: T22(`.env` CRLF)·T24(curl 한글 본문). 셋 다 **인코딩이 일부 경우만 깨뜨려 원인을 혼동시키는** 패턴이다. diff --git a/docs/troubleshooting/2026-07-31-db-error-pitfalls.md b/docs/troubleshooting/2026-07-31-db-error-pitfalls.md index 14e4b8d..88d64cc 100644 --- a/docs/troubleshooting/2026-07-31-db-error-pitfalls.md +++ b/docs/troubleshooting/2026-07-31-db-error-pitfalls.md @@ -1,19 +1,19 @@ # DB 오류 분류의 경계를 그으며 만난 함정 (2026-07-31) `S15P11A705-221` — DB 실패를 `TransientError`/`PermanentError` 로 분류하고, 그것을 -**로컬에서 실제로 DB 를 멈춰** 확인하는 과정에서 걸린 셋이다. +**로컬에서 실제로 DB 를 멈춰** 확인하는 과정에서 만난 세 문제다. 셋 다 **증상이 원인을 가리키지 않는다.** 첫째는 테스트가 전부 초록인데 티켓이 해결되지 -않고, 둘째는 재현 코드가 재현하려던 지점에 닿기 전에 죽고, 셋째는 셸 문제가 앱의 결함처럼 +않는다. 둘째는 재현 코드가 재현하려던 지점에 닿기 전에 실패한다. 셋째는 셸 문제가 앱의 결함처럼 보인다. 관련 구현 기록: [db-error-classification](../implements/2026-07-31-db-error-classification.md) --- -## T53. 「DB 접속 실패」는 `asyncpg` 예외가 **아니다** — 분류표를 asyncpg 만 보고 짜면 티켓 본체를 놓친다 +## T53. 「DB 접속 실패」는 `asyncpg` 예외가 **아니라서**, asyncpg 만 보고 분류표를 짜면 티켓 본체를 놓친다 -티켓 본문은 *"repository 층에서 asyncpg 예외를 `TransientError`/`PermanentError` 로 분류"* +티켓 본문은 "repository 층에서 asyncpg 예외를 `TransientError`/`PermanentError` 로 분류" 라고 적었다. 그대로 하면 이렇게 된다. ```python @@ -21,7 +21,7 @@ except asyncpg.PostgresConnectionError: # 08xxx — 연결 예외 raise DatabaseTransientError(...) ``` -**그리고 DB 접속 실패는 여전히 500 으로 나간다.** 실측하면 이렇다(asyncpg 0.31.0). +**그런데 이렇게 해도 DB 접속 실패는 여전히 500 으로 나간다.** 실측하면 이렇다(asyncpg 0.31.0). | 상황 | 실제 타입 | 계층 | |---|---|---| @@ -31,28 +31,28 @@ except asyncpg.PostgresConnectionError: # 08xxx — 연결 예외 | **커넥션 풀 획득 타임아웃** | `TimeoutError` | stdlib `OSError` | `08xxx` `PostgresConnectionError` 는 **이미 붙어 있던 연결이 끊길 때** 서버가 보내는 -SQLSTATE 다. 애초에 붙지 못하는 실패는 서버가 아무 말도 하지 않으므로 SQLSTATE 가 없고, +SQLSTATE 다. 애초에 붙지 못하는 실패에서는 서버가 아무 응답도 보내지 않으므로 SQLSTATE 가 없고, 파이썬 소켓 계층의 예외가 그대로 올라온다. **이 함정이 위험한 이유는 테스트가 초록이기 때문이다.** 분류표를 검증하는 테스트는 자연히 `asyncpg.*` 타입으로 짜게 되고, 그 전부가 통과한다. 티켓의 완료 조건인 -*"DB 연결 실패가 503 으로 나간다"* 만 조용히 거짓으로 남는다. +"DB 연결 실패가 503 으로 나간다"만 드러나지 않은 채 거짓으로 남는다. -두 가지가 따라온다. +두 가지 원칙이 따라온다. 1. **`OSError` 를 접속 단계에서만 번역한다.** 커넥션을 넘겨준 뒤의 블록에는 호출부 코드가 함께 들어오고, 거기서 나온 `OSError` 는 DB 의 것이 아닐 수 있다. 2. **분류를 repository 함수가 아니라 세션 경계에 건다.** repository 함수에 데코레이터를 - 걸면 `pool.acquire()` 가 그 밖이라 접속 실패가 애초에 그 안으로 들어오지 않는다. + 걸면 `pool.acquire()` 가 그 밖에 있어, 접속 실패가 애초에 데코레이터 안으로 들어오지 않는다. -풀 획득 타임아웃이 `OSError` 로 잡히는 것은 **Python 버전 사실 하나**에 걸려 있다 — 3.11 -부터 `asyncio.TimeoutError` 가 내장 `TimeoutError` 와 같은 객체이고 그것이 `OSError` 의 -하위 타입이다. 조용히 깨질 수 있으므로 테스트로 못박아 두었다. +풀 획득 타임아웃이 `OSError` 로 잡히는 것은 **Python 버전 사실 하나**에 걸려 있다. 3.11 +부터 `asyncio.TimeoutError` 가 내장 `TimeoutError` 와 같은 객체이고, 그것이 `OSError` 의 +하위 타입이다. 버전이 바뀌면 알아차리지 못한 채 깨질 수 있으므로 테스트로 못박아 두었다. -## T54. `min_size=1` 풀에서는 「요청 중 DB 불가」를 재현할 수 없다 — 기동에서 죽는다 +## T54. `min_size=1` 풀에서는 「요청 중 DB 불가」를 재현할 수 없다 — 요청 전에 기동이 먼저 실패한다 -닿지 않는 주소로 `Database` 를 만들어 요청을 넣어 보려 했더니, 요청은커녕 픽스처에서 -죽었다. +닿지 않는 주소로 `Database` 를 만들어 요청을 넣어 보려 했더니, 요청은커녕 픽스처 +단계에서 실패했다. ```python db = Database("postgresql://u:p@127.0.0.1:1/pinlog") @@ -60,12 +60,12 @@ await db.connect() # ← 여기서 ConnectionRefusedError ``` `asyncpg.create_pool` 이 `min_size` 만큼 **즉시** 커넥션을 연다. 운영 `Database.connect()` -는 `min_size=1` 이므로 접속이 깨지면 lifespan 이 실패한다 — 그건 **기동 실패**이지 이 -티켓이 겨냥한 경로가 아니다. +는 `min_size=1` 이므로 접속이 깨지면 lifespan 이 실패한다. 그것은 **기동 실패**이지 이 +티켓이 겨냥한 「요청 처리 중 DB 불가」 경로가 아니다. `connect()` 를 건너뛰고 `_pool` 을 비워 두는 것도 답이 아니다. 그러면 -`RuntimeError("Database pool not initialized")` 가 나고, 그건 우리 결함이라 **500 이 맞다** — -재현하려던 것과 다른 것을 측정하게 된다. +`RuntimeError("Database pool not initialized")` 가 난다. 그것은 우리 쪽 초기화 결함이라 **500 이 맞는 +응답**이므로, 재현하려던 것과 다른 것을 측정하게 된다. `min_size=0` 이 접속 시점을 첫 `acquire()` 로 미룬다. @@ -75,8 +75,9 @@ class _LazyPoolDatabase(Database): self._pool = await asyncpg.create_pool(self._dsn, min_size=0, max_size=1) ``` -이것이 *서버가 떠 있는 동안 DB 가 닿지 않게 되는* 순간(풀 재충전 실패·DB 재기동)을 요청 -안으로 옮긴다. **풀·드라이버·예외는 전부 실물이다** — 예외를 만들어 주입하는 것과 다르다. +이 장치가 *서버가 떠 있는 동안 DB 가 닿지 않게 되는* 순간(풀 재충전 실패·DB 재기동)을 요청 +안으로 옮긴다. **풀·드라이버·예외는 전부 실물이다.** 예외 객체를 만들어 주입하는 방식과는 검증하는 +대상이 다르다. > 실서버 대조에서는 이 장치가 필요 없다. `docker stop` 으로 DB 를 멈추면 운영 풀도 재충전을 > 시도하다 같은 `ConnectionRefusedError` 를 낸다(실측). 장치가 필요한 것은 **테스트가 @@ -91,16 +92,16 @@ class _LazyPoolDatabase(Database): HTTP 400 ``` -`SearchRequest` 스키마를 의심하게 되는 응답이다. 실제로는 셸이 본문을 UTF-8 로 보내지 -않아서다. +`SearchRequest` 스키마를 의심하게 되는 응답이다. 실제 원인은 셸이 본문을 UTF-8 로 보내지 +않은 것이다. ```bash curl -d '{"query":"카페", ...}' # 400 — 본문 바이트가 깨진다 curl -d '{"query":"cafe", ...}' # 200 — 같은 엔드포인트·같은 헤더 ``` -질의어만 ASCII 로 바꿔 같은 요청이 통과하면 **엔드포인트가 아니라 본문 바이트**가 원인이다. +질의어만 ASCII 로 바꾼 같은 요청이 통과하면 **엔드포인트가 아니라 본문 바이트**가 원인이다. 확실히 하려면 본문을 UTF-8 파일에 써서 `--data-binary @file` 로 넘긴다. 이 티켓처럼 **한글이 검증 대상이 아닌** 대조에서는 ASCII 질의로 충분하다. 검색 품질을 -보는 작업이라면 파일 경로를 써야 한다 — 거기서는 질의어 자체가 측정 대상이다. +보는 작업이라면 파일 경로를 써야 한다. 그런 작업에서는 질의어 자체가 측정 대상이기 때문이다. diff --git a/docs/troubleshooting/2026-07-31-docs-index-check.md b/docs/troubleshooting/2026-07-31-docs-index-check.md index 6c59d1b..c303586 100644 --- a/docs/troubleshooting/2026-07-31-docs-index-check.md +++ b/docs/troubleshooting/2026-07-31-docs-index-check.md @@ -1,4 +1,4 @@ -# 문서 색인 검사 — 표가 표로 안 보인다 · 「색인에 있다」의 범위 +# 문서 색인 검사 — merge=union 이 표를 끊는다 · 「색인에 있다」의 판정 범위 - **날짜**: 2026-07-31 - **리포트**: [문서 색인 정합 CI](../implements/2026-07-31-docs-index-check.md) @@ -11,12 +11,12 @@ 에서도 표처럼 보인다. **실제.** `docs/implements/README.md` 의 두 표가 `I27` 이후 6곳에서 끊겨 있었다. -GFM 은 **빈 줄에서 표를 끝내므로** 그 뒤 행들은 표가 아니라 파이프 문자 문단으로 -렌더된다. GitHub 에서 README 를 **렌더된 상태로** 열어야만 보인다. +GFM 은 **빈 줄에서 표를 끝내므로** 그 뒤 행들은 표가 아니라 파이프 문자가 섞인 일반 +문단으로 렌더된다. GitHub 에서 README 를 **렌더된 상태로** 열어야만 보인다. **원인.** 병렬 브랜치가 각각 「빈 줄 + 새 행」을 표 끝에 붙였고 `merge=union` 이 양쪽을 -그대로 이어 붙였다. union 은 **추가만 할 때 안전하다는 통념**이 있는데(T56 은 *갱신*이 -위험하다고 했다), 추가하는 줄에 빈 줄이 딸려 있으면 추가만으로도 표가 깨진다. +그대로 이어 붙였다. union 은 **추가만 할 때 안전하다는 통념**이 있다(T56 은 *갱신*이 +위험하다고 했다). 그러나 추가하는 줄에 빈 줄이 딸려 있으면 추가만으로도 표가 깨진다. **왜 아무도 못 봤나.** 색인은 **읽으려고** 여는 파일이 아니라 **고치려고** 여는 파일이다. 고칠 때는 에디터로 열고, 에디터는 표를 표로 그리지 않으므로 깨진 것이 @@ -29,13 +29,13 @@ GFM 은 **빈 줄에서 표를 끝내므로** 그 뒤 행들은 표가 아니라 색인을 고쳤으면 GitHub 에서 렌더된 화면을 한 번 본다 ``` -이 검사는 이것을 잡지 않는다 — 섹션 안의 표 행을 줄 단위로 읽으므로 빈 줄이 있어도 -정상 판정한다. **일부러 그렇게 뒀다.** 렌더 결과를 검사하려면 마크다운 파서가 필요하고, -그러면 색인 정합 검사가 아니라 마크다운 린터가 된다. +이번에 만든 색인 검사는 이 깨짐을 잡지 않는다. 섹션 안의 표 행을 줄 단위로 읽으므로 +빈 줄이 있어도 정상으로 판정한다. **일부러 그렇게 뒀다.** 렌더 결과까지 검사하려면 +마크다운 파서가 필요하고, 그러면 색인 정합 검사가 아니라 마크다운 린터가 된다. ## T65 — 「색인에 있는가」를 README 전체 링크로 재면 검사가 통과한다 -**증상.** 검사를 만들어 돌렸는데 위반 3건이 아니라 1건만 나온다. 「고아가 하나뿐인가」로 +**증상.** 검사를 만들어 돌렸는데 위반이 3건이 아니라 1건만 나온다. 「고아 문서가 하나뿐인가」로 읽고 넘어가기 쉽다. **원인.** 「색인에 있다」를 *README 어디에든 그 파일로 가는 링크가 있다* 로 재면, @@ -48,12 +48,12 @@ GFM 은 **빈 줄에서 표를 끝내므로** 그 뒤 행들은 표가 아니라 2026-07-31-judge-prompt-rule.md 어느 표에도 없었다 ← 이것만 걸린다 ``` -07-31 사고 `3`(번호 재조정 때 전수 표만 고치고 파일 표를 잊는다)이 바로 이 모양이므로, -링크를 전부 모으는 검사는 **자기가 겨눈 사고를 못 잡는다.** +07-31 사고 `3`(번호 재조정 때 전수 표만 고치고 파일 표를 잊는다)이 바로 이 형태다. +따라서 README 전체의 링크를 전부 모으는 검사는 **원래 잡으려던 그 사고를 잡지 못한다.** **처방.** 판정 범위를 **파일 표 섹션으로 좁힌다**(`## 개별 문서` / `## 개별 리포트` 아래, 다음 `## ` 앞까지). 두 표가 서로 다른 축이라는 사실(리포트 §6)이 여기서 먼저 -드러났다 — 전수 표는 **문서 목록이 아니다.** +드러났다. 전수 표는 **문서 목록이 아니다.** **일반화.** 「X 가 색인에 있는가」를 물을 때 **어느 표가 색인인지**를 먼저 정해야 한다. 정하지 않으면 검사는 가장 느슨한 해석을 택하고, 느슨한 해석은 원래 잡으려던 것을 diff --git a/docs/troubleshooting/2026-07-31-error-contract-pitfalls.md b/docs/troubleshooting/2026-07-31-error-contract-pitfalls.md index 2cac160..a4db01c 100644 --- a/docs/troubleshooting/2026-07-31-error-contract-pitfalls.md +++ b/docs/troubleshooting/2026-07-31-error-contract-pitfalls.md @@ -1,10 +1,10 @@ # 오류 응답 계약을 검증하며 만난 함정 (2026-07-31) `S15P11A705-220` — 업스트림 실패를 HTTP 상태로 바꾸는 계약을 넣고, 그것을 **로컬에서 실제로 -502 를 만들어** 확인하는 과정에서 걸린 셋이다. +502 를 만들어** 확인하는 과정에서 만난 문제들이다. -셋 다 「검증 자체」의 함정이지 대상 코드의 결함이 아니다. **그래서 더 오래 걸린다** — -증상이 검증 대상의 문제처럼 보인다. +앞의 세 건은 「검증 자체」의 함정이지 대상 코드의 결함이 아니다. **그래서 더 오래 걸린다.** +증상이 검증 대상의 문제처럼 보이기 때문이다. 관련 구현 기록: [search-error-contract](../implements/2026-07-31-search-error-contract.md) @@ -13,21 +13,21 @@ ## T50. `httpx.ASGITransport` 는 기본값이 예외를 **응답으로 바꾸지 않는다** "핸들러가 없으면 500 이 나간다"를 테스트로 단언하려 했는데 500 이 오지 않았다. -`pytest.raises` 도 아닌 자리에서 **예외가 테스트로 그대로 튀었다.** +`pytest.raises` 도 아닌 자리에서 **예외가 테스트 코드까지 그대로 전파됐다.** ``` app.core.errors.TransientError: embedding error: 502 provider says no ``` `raise_app_exceptions` 의 기본값이 `True` 라서다. ASGI 앱이 예외를 던지면 transport 가 -그것을 다시 던진다. **uvicorn 은 그 자리에서 500 을 만든다** — 즉 기본값 그대로 쓰면 -테스트가 운영과 다른 것을 본다. +그것을 다시 던진다. 반면 **uvicorn 은 그 자리에서 500 응답을 만든다.** 즉 기본값 그대로 쓰면 +테스트가 운영과 다른 동작을 보게 된다. ```python httpx.ASGITransport(app=app, raise_app_exceptions=False) # 500 을 단언하려면 이것 ``` -기본값이 유용한 경우도 있다. RED 단계에서 **어느 예외가 어디까지 샜는지** 트레이스백으로 +기본값이 유용한 경우도 있다. RED 단계에서 **어느 예외가 어디까지 전파됐는지** 트레이스백으로 바로 보였고, 그것이 "분류는 맞고 변환만 없다"를 확인해 줬다. 두 값을 목적에 따라 쓴다. > 「500 이 나가는가」를 보려면 `False`, 「무엇이 새는가」를 보려면 `True`. @@ -35,15 +35,15 @@ httpx.ASGITransport(app=app, raise_app_exceptions=False) # 500 을 단언하 ## T51. 로컬 GMS 스텁도 URL 에 `/gmsapi/` 가 있어야 앱이 뜬다 게이트웨이를 대신할 스텁을 `http://127.0.0.1:8099/v1` 로 띄우고 `GMS_BASE_URL` 을 그리로 -돌렸더니 **서버가 기동에서 죽었다.** +돌렸더니 **서버가 기동에 실패했다.** ``` SettingsError: GMS_BASE_URL 형식 오류 — '/gmsapi/' 세그먼트가 없습니다. ``` `Settings._gms_base_url_shape` 가 기동 시 검사한다. 판정 클라이언트가 그 세그먼트 앞을 잘라 -Gemini 네이티브 root 를 파생하기 때문이고, 없으면 **임베딩만 동작하고 judge 가 조용히 -실패**하는 비대칭 장애가 된다 — 검사가 옳다. +Gemini 네이티브 root 를 파생하기 때문이다. 세그먼트가 없으면 **임베딩만 동작하고 judge 는 +오류 신호 없이 실패**하는 비대칭 장애가 되므로, 이 기동 검사는 옳다. 스텁이 그 경로 모양을 그대로 흉내내면 된다. @@ -56,18 +56,18 @@ GMS_BASE_URL="http://127.0.0.1:8099/gmsapi/api.openai.com/v1" ``` **게이트웨이를 로컬로 대체하는 모든 재현이 이 제약을 받는다.** 스텁 URL 을 임의로 정하면 -증상이 「스텁이 안 불린다」가 아니라 「앱이 안 뜬다」로 나와 한 단계 멀어진다. +증상이 「스텁이 안 불린다」가 아니라 「앱이 안 뜬다」로 나와, 원인에서 한 단계 멀어진다. ## T52. 시연 DB 비밀번호는 `pinlog-local` 이다 — `.env` 값과 다르다 T33 이 `ai/.env` 의 `DATABASE_URL` 이 07-27 잔재 `:5433` 을 가리킨다는 것을 남겼다. -포트를 `:15432` 로 고쳐 붙었는데도 이번엔 이렇게 죽었다. +포트를 `:15432` 로 고쳐 접속이 됐는데도 이번에는 이렇게 실패했다. ``` asyncpg.exceptions.InvalidPasswordError: password authentication failed for user "pinlog" ``` -**포트만 바꾸면 안 된다.** `.env` 의 DSN 은 사용자·비밀번호도 죽은 하네스 것이다. +**포트만 바꾸면 안 된다.** `.env` 의 DSN 은 사용자·비밀번호도 이미 폐기된 하네스의 것이다. ``` .env postgresql://pinlog:pinlog@localhost:5433/pinlog @@ -81,13 +81,13 @@ docker inspect pinlog-demo-postgres-1 --format '{{range .Config.Env}}{{println . ``` `-174` §7 절차가 매 명령에 DSN 전체를 덮어써서 지금까지 드러나지 않았다. **DSN 의 일부만 -고치는 방식이 이 함정을 만든다** — 전체를 덮어쓰거나 전부 확인한다. +고치는 방식이 이 함정을 만든다.** 전체를 덮어쓰거나 전부 확인한다. ## T56 — `merge=union` 은 같은 행을 **갱신**해도 두 판을 남긴다 **증상** 색인 표에 같은 문서가 두 번 나온다. 번호도 링크도 정상인데 행만 둘이다. -**원인** `merge=union` 은 충돌을 「양쪽 다 채택」으로 푼다. 새 행을 각자 추가한 경우에는 그것이 정확히 원하는 동작이다. 그런데 **한쪽이 기존 행을 고치면** git 은 그것을 「지운 것 + 새로 넣은 것」으로 보고, 지운 쪽을 되살린다. +**원인** `merge=union` 은 충돌을 「양쪽 다 채택」으로 푼다. 새 행을 각자 추가한 경우에는 그것이 정확히 원하는 동작이다. 그런데 **한쪽이 기존 행을 고치면** git 은 그것을 「지운 것 + 새로 넣은 것」으로 보고, 지운 쪽 행을 되살린다. ``` -219 작업 중 … 사전 기준 (T43~T46) @@ -101,5 +101,4 @@ merge=union 결과 두 줄 다 awk -F'|' '/^\| \[/ {print $2}' docs/troubleshooting/README.md | sort | uniq -d ``` -같은 날 `T43` 번호 충돌(T48)과 원인이 다르다 — 그쪽은 두 브랜치가 **같은 번호를 각자 잡은 것**이고, 이쪽은 **한 브랜치가 자기 행을 갱신한 것**이다. 전자는 union 이 못 막고 후자는 union 이 만든다. - +같은 날 발생한 `T43` 번호 충돌(T48)과는 원인이 다르다. 그쪽은 두 브랜치가 **같은 번호를 각자 잡은 것**이고, 이쪽은 **한 브랜치가 자기 행을 갱신한 것**이다. 전자는 union 이 못 막고 후자는 union 이 만든다. diff --git a/docs/troubleshooting/2026-07-31-judge-prompt-ab.md b/docs/troubleshooting/2026-07-31-judge-prompt-ab.md index a68e67e..ad810a3 100644 --- a/docs/troubleshooting/2026-07-31-judge-prompt-ab.md +++ b/docs/troubleshooting/2026-07-31-judge-prompt-ab.md @@ -1,15 +1,15 @@ -# 판정 프롬프트 A/B 측정 — 죽은 설정 키·라벨 커버리지·조건 노출 +# 판정 프롬프트 A/B 측정 — 읽히지 않는 설정 키·라벨 커버리지·라벨 작업의 조건 노출 `S15P11A705-219`. 구현 리포트는 [판정 프롬프트 규칙](../implements/2026-07-31-judge-prompt-rule.md)에 있다. -이 문서는 **무엇에 걸렸나**만 적는다. +이 문서는 측정 중 무엇에 걸렸는지만 적는다. 선행 `-210` 의 함정은 [T37~T39](2026-07-31-tau-measurement.md) 에 있고, 이 측정은 그 T39(대조군 없이 재판정 차이를 읽지 마라)를 전제로 설계했다. --- -## T43 — 죽은 `.env` 키가 「무엇으로 쟀나」를 조용히 바꿔 적게 한다 +## T43 — 죽은 `.env` 키가 「무엇으로 쟀나」를 모르는 사이에 다르게 적게 한다 **상태: 해결됨**(측정 시 벤더를 인자로 고정하고 실제 응답 모델을 회차 파일에 남긴다) @@ -32,11 +32,11 @@ PINLOG_JUDGE_MODEL=gemini-2.5-flash 명시한다. `.env` 에 `PINLOG_JUDGE_CHAIN` 이 없으므로 `config.py` 의 기본 체인이 쓰이고, 그 1순위가 `openai:gpt-4o-mini` 다(`-176` 실측으로 정한 순서 — 100%·0.91s). -`.env` 를 열어 「판정 모델이 무엇인가」를 확인하면 **있지도 않은 설정을 읽고 확신하게 +`.env` 를 열어 「판정 모델이 무엇인가」를 확인하면 **읽히지도 않는 설정을 읽고 확신하게 된다.** 값이 그럴듯해서(실제로 체인 2순위다) 어긋남이 드러나지 않는다. -키 개명 자체는 이미 알려져 있었다 — `-96·-77` 감사가 "전달본이 낡았다"로 남겼고, 배포 -봉인 대상이 아니라 **영향 범위를 「로컬 `.env` 뿐」으로 판단하고 넘겼다.** 그 판단이 +키 개명 자체는 이미 알려져 있었다. `-96·-77` 감사가 "전달본이 낡았다"로 남겼고, 배포 +봉인 대상이 아니라서 **영향 범위를 「로컬 `.env` 뿐」으로 판단하고 넘겼다.** 그 판단이 맞았다. 다만 로컬 `.env` 가 곧 **측정 환경**이라는 것이 계산에 없었다. 배포는 멀쩡한데 리포트의 조건 표가 틀린다. @@ -53,8 +53,8 @@ rec["models"] = {result.model: n} # JudgeResult.model — 실제로 답한 `.env` 를 보고 조건 표를 쓰지 않는다. 폴백이 있는 한 **설정 1순위와 답한 모델은 언제든 갈라진다**(`llm_client` 도크스트링이 같은 이유로 `JudgeResult.model` 을 저장에 우선한다). -`.env` 의 죽은 줄은 이 티켓에서 지우지 않았다 — 측정 worktree 의 사본이고, 원본은 -개인 환경 파일이라 레포가 소유하지 않는다. **`.env.example` 은 이미 옳다.** +`.env` 의 죽은 줄은 이 티켓에서 지우지 않았다. 그 파일은 측정 worktree 의 사본이고, 원본은 +개인 환경 파일이라 레포가 소유하지 않기 때문이다. **`.env.example` 은 이미 옳다.** --- @@ -74,9 +74,9 @@ rec["models"] = {result.model: n} # JudgeResult.model — 실제로 답한 ### 원인 `tau_grid/labels.yaml` 은 **현행 τ=0.30 판정이 낸 83행에 대한** 라벨이다. τ 스윕에서는 -그것으로 충분했다 — τ 를 올리면 후보가 **빠지기만** 하므로 새 행이 생기지 않는다. +그것으로 충분했다. τ 를 올리면 후보가 **빠지기만** 하므로 새 행이 생기지 않기 때문이다. -프롬프트는 다르다. 판정을 실제로 다시 부르므로 **없던 조합이 생긴다.** A·B·C 를 5회씩 +프롬프트 측정은 다르다. 판정을 실제로 다시 부르므로 **없던 조합이 생긴다.** A·B·C 를 5회씩 돌린 결과 라벨 밖 행이 24종 나왔고, 그중에는 현행 판정이 **0행이던 Context** 에 넷이 붙은 경우도 있었다(`286` — 본문이 맛 불평뿐인 글에 `ALONE`·`QUIET`·`LIVELY`·`TRENDY`). @@ -86,7 +86,7 @@ rec["models"] = {result.model: n} # JudgeResult.model — 실제로 답한 ### 처방 -원본은 **한 줄도 고치지 않는다** — 고치면 `-210` 의 수치와 비교가 끊긴다. 별도 파일로 +원본 라벨 파일은 **한 줄도 고치지 않는다.** 고치면 `-210` 의 수치와 비교가 끊긴다. 별도 파일로 커버리지만 넓히고, 기준은 원본 머리말 그대로 쓴다. ``` @@ -106,7 +106,7 @@ score_ab.py 그래도 남는 것은 unlabeled 로 세 ### 증상 -증상이 없다. **그것이 이 항목을 남기는 이유다** — 어긋난 티가 어디에도 나지 않고, +증상이 없다. **그것이 이 항목을 남기는 이유다.** 어긋난 티가 어디에도 나지 않고, 집계는 정상으로 돌며, 개선 폭만 부풀어 나온다. ### 원인 @@ -126,7 +126,7 @@ T44 을 처리하려면 라벨 밖 행에 사람이 라벨을 붙여야 한다. python tools/prompt_ab/score_ab.py --dump-unlabeled ``` -본문과 프리셋 `description` 만 보고 붙인다. 그래도 남는 한계가 있고 숨기지 않는다 — +본문과 프리셋 `description` 만 보고 붙인다. 그래도 남는 한계가 있고 숨기지 않는다. **라벨을 붙인 사람이 개정 프롬프트를 설계한 사람이기도 하다.** 「어느 쪽이 골랐나」는 가렸지만 「개정안이 노린 유형이 무엇인가」는 이미 알고 있다. 그 사실을 `labels_extra.yaml` 머리말에 적어 두었다. @@ -151,14 +151,14 @@ p 값을 새로 계산해 그쪽만 보고한다 ### 원인 -「범위 비중첩」은 회차 5개라는 제약에서 나온 보수적인 자다. 표본이 늘면 극단값도 늘어 -**오히려 통과하기 어려워진다** — n 이 커질수록 좋아져야 할 기준이 반대로 움직인다. -그래서 표본을 늘리면 자를 바꿔야 하는데, 그 교체가 **결과를 본 뒤에** 일어난다는 것이 +「범위 비중첩」은 회차 5개라는 제약에서 나온 보수적인 판정 기준이다. 표본이 늘면 극단값도 늘어 +**오히려 통과하기 어려워진다.** 표본 수가 커질수록 좋아져야 할 기준이 반대로 움직이는 것이다. +그래서 표본을 늘리면 기준을 바꿔야 하는데, 그 교체가 **결과를 본 뒤에** 일어난다는 것이 문제다. ### 처방 -자를 바꾸는 것 자체는 정당하다. 바꾸는 **시점과 방식**을 고정한다. +기준을 바꾸는 것 자체는 정당하다. 바꾸는 **시점과 방식**을 고정한다. ``` 표본을 늘린다 A·C 를 10회씩. 조건을 바꾸지 않고 같은 하네스로만 더 돌린다 @@ -166,7 +166,7 @@ p 값을 새로 계산해 그쪽만 보고한다 앞의 자를 지우지 않는다 범위 비중첩 결과를 그대로 함께 낸다 ``` -`score_ab.py` 가 둘을 나란히 찍는다. **나중에 더한 자가 통과했다고 앞의 자를 무르면 +`score_ab.py` 가 둘을 나란히 찍는다. **나중에 더한 기준이 통과했다고 앞의 기준을 무르면 그것은 기준을 결과에 맞춘 것이다.** ``` @@ -174,7 +174,7 @@ p 값을 새로 계산해 그쪽만 보고한다 [보강 기준] 순열검정 p<0.05 … ``` -효과 없음(B)은 추가로 재지 않았다 — 평균 차이가 0.6행이라 표본을 늘려 갈릴 값이 +효과 없음(B)은 추가로 재지 않았다. 평균 차이가 0.6행이라 표본을 늘려도 갈릴 값이 아니고, 그 판단도 **더 재기 전에** 내려 두었다. --- @@ -200,7 +200,7 @@ unfit n=10 min 0.30 ### 원인 **그 값은 판정 1회의 산물이다.** DB 에 있는 것은 시딩 때 한 번 돌린 결과이고, 여러 번 -돌려 안정된 값이 아니다. 같은 조건으로 42건을 한 번 더 판정해 받아 보면 그림이 사라진다. +돌려 안정된 값이 아니다. 같은 조건으로 42건을 한 번 더 판정해 받아 보면 그 분리가 사라진다. ``` fit n=62 min 0.60 @@ -213,8 +213,8 @@ unfit n=8 min 0.60 선택뿐 아니라 confidence 도 비결정적이다.** T39 는 「어떤 키워드를 골랐나」가 흔들리는 것을 봤고, 이것은 「얼마나 확신하나」도 같이 흔들린다는 것이다. -DB 조회는 GMS 를 부르지 않아 비용이 0 이다. 그래서 **여러 번 볼 생각을 안 하게 된다** — -싼 것이 함정이다. +DB 조회는 GMS 를 부르지 않아 비용이 0 이다. 그래서 **여러 번 볼 생각을 안 하게 된다.** +비용이 들지 않는 조회라는 점이 오히려 함정이 된다. ### 처방 @@ -228,7 +228,7 @@ python tools/prompt_ab/run.py --variant A --reps 1 --start-rep 11 --outdir .prom `--outdir` 를 나누는 것이 중요하다. 확인용 회차를 본실험 디렉터리에 두면 집계 n 이 바뀌어 이미 낸 결론이 흔들린다. -회차 파일에 `confidences` 를 남기게 해 두었다 — **다시 부르지 않으면 얻을 수 없는 값** +회차 파일에 `confidences` 를 남기게 해 두었다. **다시 부르지 않으면 얻을 수 없는 값** 이고, 후속이 재측정 없이 회차 반복분을 볼 수 있다. --- @@ -270,7 +270,7 @@ grep -c "^| T" docs/troubleshooting/README.md # 여기서 다음 번호를 재번호는 **역순이 아니라 매핑 한 번으로** 한다. `T40→T43` 을 먼저 적용하면 원래 `T43` 이던 것과 뒤섞인다. 정규식 하나로 각 매치를 독립 치환하면 그 순서 문제가 없다. -색인 표에는 **남의 행이 섞여 있으므로** 전체 치환을 걸면 안 된다. 자기 행만 골라 바꾼다. +색인 표에는 **다른 티켓의 행이 섞여 있으므로** 전체 치환을 걸면 안 된다. 자기 행만 골라 바꾼다. --- @@ -280,7 +280,7 @@ grep -c "^| T" docs/troubleshooting/README.md # 여기서 다음 번호를 ### 증상 -T48 의 재번호 스크립트가 `KeyError` 로 죽었다. 고쳐서 다시 돌리고 결과를 확인하니 +T48 의 재번호 스크립트가 `KeyError` 로 중단됐다. 고쳐서 다시 돌리고 결과를 확인하니 **232줄짜리 문서가 0줄**이었다. 스크립트는 아무것도 쓰지 않았는데 내용이 사라졌다. ### 원인 @@ -292,10 +292,10 @@ open(p, 'w', encoding='utf-8').write(sub(s)) # ↑ 여기서 파일이 truncate 된다 ↑ 여기서 예외가 난다 ``` -`'w'` 모드는 여는 즉시 파일을 0바이트로 만든다. 그다음 `sub(s)` 가 던지면 **쓰기가 +`'w'` 모드는 여는 즉시 파일을 0바이트로 만든다. 그다음 `sub(s)` 가 예외를 던지면 **쓰기가 일어나지 않은 채 빈 파일만 남는다.** 예외 메시지는 `KeyError` 이지 파일 손실을 말하지 -않으므로, 스크립트를 고쳐 다시 돌리면 이번에는 **빈 파일을 읽어 빈 결과를 쓴다** — -두 번째 실행은 조용히 성공한다. +않으므로, 스크립트를 고쳐 다시 돌리면 이번에는 **빈 파일을 읽어 빈 결과를 쓴다.** +두 번째 실행은 아무 오류 없이 성공한다. `git` 에 커밋되어 있어 복구했다. 커밋 전이었으면 그대로 잃는다. @@ -311,7 +311,7 @@ open(tmp, 'w', encoding='utf-8', newline='').write(out) os.replace(tmp, p) ``` -`newline=''` 을 양쪽에 준다 — 없으면 CRLF 파일이 LF 로 바뀌어 diff 가 파일 전체로 +`newline=''` 을 읽기와 쓰기 양쪽에 준다. 없으면 CRLF 파일이 LF 로 바뀌어 diff 가 파일 전체로 번진다(`docs/` 에는 CRLF 파일이 섞여 있다). **문서를 스크립트로 고칠 때는 커밋한 뒤에 돌린다.** 이 건이 복구된 이유가 그것뿐이다. diff --git a/docs/troubleshooting/2026-07-31-judge-vote.md b/docs/troubleshooting/2026-07-31-judge-vote.md index 788f0c7..d973bec 100644 --- a/docs/troubleshooting/2026-07-31-judge-vote.md +++ b/docs/troubleshooting/2026-07-31-judge-vote.md @@ -1,8 +1,8 @@ -# 판정 n회 다수결 측정 — 사라진 선행 산출물·실패와 빈 선택의 혼동·전수 검정·색인 잔재 +# 판정 n회 다수결 측정 — 선행 산출물이 남아 있지 않았고, 실패와 빈 선택은 구분해야 하며, 순열검정은 전수 계산이고, 번호는 병합 뒤에 붙인다 `S15P11A705-223`. 구현 리포트는 -[판정 다수결](../implements/2026-07-31-judge-vote.md)에 있다. 이 문서는 **무엇에 -걸렸나**만 적는다. +[판정 다수결](../implements/2026-07-31-judge-vote.md)에 있다. 이 문서는 측정 중 +무엇에 걸렸는지만 적는다. 선행 `-210` 의 함정은 [T37~T39](2026-07-31-tau-measurement.md), `-219` 의 함정은 [T43~T49](2026-07-31-judge-prompt-ab.md) 에 있다. @@ -26,8 +26,9 @@ 둘 다 커밋되지 않는 로컬 산출물이다. `-219` 는 worktree 안에서 측정했고 그 worktree 가 정리되면서 회차 파일 1,092호출분이 함께 사라졌다. `.tau/` 는 `.gitignore` 에 명시적으로 -들어 있고(본문 전문을 담아서 — 의도된 것이다), **`.prompt_ab/` 는 `.gitignore` 에 -아예 없었다.** 없어서 남은 것이 아니라, 없어서 아무도 그 파일의 수명을 정하지 않았다. +들어 있다. 본문 전문을 담는 파일이라 남기지 않는 것이 의도된 결정이었다. 반면 +**`.prompt_ab/` 는 `.gitignore` 에 아예 없었다.** 목록에 없어서 남은 것이 아니라, +목록에 없어서 아무도 그 파일의 수명을 정하지 않았다. 「하네스에 기록을 넣어 두었다」는 **코드에 기록 기능을 넣었다**는 뜻이었고, 읽는 쪽은 **데이터가 남아 있다**로 읽는다. 리포트를 쓸 때 그 둘이 같은 문장으로 보인다. @@ -35,15 +36,15 @@ ### 처방 `.gitignore` 에 `.prompt_ab/` 와 `.judge_vote/` 를 명시했다. **커밋하자는 것이 -아니다** — 무시 목록에 이름이 있으면 그 파일이 무엇이고 왜 안 남기는지가 한 줄로 +아니다.** 무시 목록에 이름이 있으면 그 파일이 무엇이고 왜 안 남기는지가 한 줄로 적히고, 다음 사람이 「없어졌다」가 아니라 「원래 안 남는다」로 읽는다. 후속에 산출물을 물려줄 때는 **경로와 수명을 함께 적는다.** 「기록을 넣어 두었다」가 -아니라 「`.prompt_ab/runs/`(gitignore, worktree 수명)에 회차별로 남는다 — 없으면 +아니라 「`.prompt_ab/runs/`(gitignore, worktree 수명)에 회차별로 남는다. 없으면 `run.py` 를 다시 돌려야 한다」로 적는다. -재측정 비용은 42호출 × 30회 = 1,260호출이었다. `matrix.json` 은 DB 만 읽으므로 공짜로 -복원됐다(GMS 호출 0). +재측정 비용은 42호출 × 30회 = 1,260호출이었다. `matrix.json` 은 DB 만 읽으므로 추가 +비용 없이 복원됐다(GMS 호출 0). --- @@ -53,7 +54,7 @@ ### 증상 -`-219` 의 `run.py` 는 회차 중 한 건이 실패해도 회차를 버리지 않는다. 그 자체는 옳다 — +`-219` 의 `run.py` 는 회차 중 한 건이 실패해도 회차를 버리지 않는다. 그 자체는 옳다. 42건 중 1건 때문에 41건의 GMS 호출을 버리는 것이 더 큰 손실이다. 계약도 이것을 미리 지목했다("실패해도 평균에 그대로 넣는다 — 다수결에서는 문제가 될 수 있다"). @@ -72,7 +73,8 @@ selections[str(cid)] = [] # 후보 0개 → LLM 안 부름. "빈 선택"이 다수결은 「고르지 않았다」를 정보로 쓴다. 그래서 「고르지 않았다」와 「묻지 못했다」를 구분하지 못하면 규칙이 무너진다. 판정 1회짜리 집계에서는 이 둘이 똑같이 「이 Context 에 -키워드가 안 붙었다」라서 구분할 이유가 없었고, `-219` 는 실패 0 이라 발현되지도 않았다. +키워드가 안 붙었다」라서 구분할 이유가 없었고, `-219` 는 실패가 0건이라 이 문제가 +발현되지도 않았다. ### 처방 @@ -89,7 +91,7 @@ selections[str(cid)] = [] # 후보 0개 → LLM 안 부름. "빈 선택"이 `tests/test_judge_vote.py::test_quorum_missed_keeps_processing_for_rescan` 이 그것을 고정한다. -측정 쪽에서도 같다 — `compose.py` 는 `cid in selections` 로 「그 회차가 실제로 답했나」를 +측정 쪽에서도 같은 규칙을 쓴다. `compose.py` 는 `cid in selections` 로 「그 회차가 실제로 답했나」를 보고, 정족수 미달인 Context 는 합성 회차의 `failures` 로 넘겨 집계에서 빠지게 한다. --- @@ -114,7 +116,7 @@ n1 10개 대 n3 10개 C(20,10) = 184,756 수 초 n1 30개 대 n3 10개 C(40,10) = 847,660,528 끝나지 않는다 ``` -접어서 만드는 측정은 **조건마다 관측 수가 자연히 달라진다** — 회차 30개에서 n=1 은 30개, +회차를 접어서 만드는 측정에서는 **조건마다 관측 수가 자연히 달라진다.** 회차 30개에서 n=1 은 30개, n=3 은 10개, n=5 는 6개가 나온다. `-219` 는 조건마다 회차를 따로 돌려 수를 맞췄으므로 이 상황이 생기지 않았다. @@ -123,8 +125,8 @@ n=3 은 10개, n=5 는 6개가 나온다. `-219` 는 조건마다 회차를 따 `compose.py --cap N` 으로 조건당 관측 수에 상한을 둔다. 기술 수치(평균·표준편차·범위)는 관측 전부로 내고, **순열검정을 걸 때만** 수를 맞춘다. -검정을 무르지 않는다 — `-219` 가 "나중에 더한 자가 통과했다고 앞의 자를 무르면 그것은 -기준을 결과에 맞춘 것"이라고 적었다. 자를 바꾸는 것과 표본을 맞추는 것은 다르다. +검정 기준 자체는 바꾸지 않는다. `-219` 가 "나중에 더한 자가 통과했다고 앞의 자를 무르면 그것은 +기준을 결과에 맞춘 것"이라고 적었다. 판정 기준을 바꾸는 것과 표본 수를 맞추는 것은 다르다. --- @@ -153,7 +155,7 @@ n=3 은 10개, n=5 는 6개가 나온다. `-219` 는 조건마다 회차를 따 보면 마지막이 `T49` 라 그대로 잡으면 `T50`~`T52` 와 또 충돌한다(`merge=union` 이 행 갱신을 어떻게 되살리는지는 `-221` 이 T56 에 적었다). -**둘째 — 그래서 본문에서 셌는데도 틀렸다.** 본문을 `grep` 해 `T52` 를 확인하고 +**둘째, 그래서 본문에서 셌는데도 틀렸다.** 본문을 `grep` 해 `T52` 를 확인하고 `T53` 부터 잡았다. 그 시점에는 맞았다. 그런데 **작업하는 35분 사이에 `-221` 이 병합됐다.** 본문에서 세든 색인에서 세든, 세는 시점이 PR 시점보다 앞이면 병렬 티켓을 볼 방법이 없다. @@ -161,7 +163,7 @@ n=3 은 10개, n=5 는 6개가 나온다. `-219` 는 조건마다 회차를 따 **세는 위치가 아니라 세는 시점이 문제다.** `T48` 이 이미 그렇게 적었고 (*"PR 직전 `origin/dev` 로 rebase 한 **뒤에** 번호를 확정한다"*) 이 티켓이 그 처방의 -필요성을 실증했다 — 색인을 고치고 본문에서 세는 것만으로는 **모자란다.** +필요성을 실증했다. 색인을 고치고 본문에서 세는 것만으로는 **모자란다.** ```bash git fetch origin dev && git merge origin/dev # 먼저 당긴다 @@ -170,8 +172,8 @@ grep -oE "^\| I[0-9]+" docs/implements/README.md | grep -oE "I[0-9]+" | sort - ``` 그러므로 **문서 번호는 마지막에 붙인다.** 본문을 쓰는 동안은 번호를 확정하지 말고, -병합 뒤 매핑 한 번으로 치환한다(역순 치환은 뒤섞인다 — T48). `I` 번호도 같이 본다 — -이번에 `T` 와 `I` 가 **함께** 충돌했다. +병합 뒤 매핑 한 번으로 치환한다(역순으로 치환하면 뒤섞인다. T48 참조). `I` 번호도 같이 본다. +이번에는 `T` 와 `I` 가 **함께** 충돌했다. 색인의 낡은 행은 이 PR 에서 정정했다. 다만 그것은 부수적인 수리이고, 충돌을 막은 것은 색인 수리가 아니라 병합 뒤 재번호다. diff --git a/docs/troubleshooting/2026-07-31-local-e2e-and-ci-pitfalls.md b/docs/troubleshooting/2026-07-31-local-e2e-and-ci-pitfalls.md index fce2d85..a9053b7 100644 --- a/docs/troubleshooting/2026-07-31-local-e2e-and-ci-pitfalls.md +++ b/docs/troubleshooting/2026-07-31-local-e2e-and-ci-pitfalls.md @@ -4,7 +4,7 @@ `dev` → `main` 릴리스와 CI 검사를 넣으면서 만난 것들이다. **여덟 개 중 넷은 「기대한 출력이 없다」가 증상이었고 원인은 제각각이었다.** 그것이 -이 문서를 한 편으로 묶은 이유다 — 하나를 겪으면 다음 것을 오진하게 된다. +이 문서를 한 편으로 묶은 이유다. 하나를 겪으면 다음 것을 오진하게 된다. 관련 구현 기록: [seed-guard](../implements/2026-07-31-seed-guard.md) · [gms-call-observability](../implements/2026-07-31-gms-call-observability.md) · @@ -47,9 +47,9 @@ uvicorn app.main:app --port 8000 > .demo/uvicorn.log 2>&1 기록을 3건 만들고 로그를 봤더니 `gms call` 도 `gms window` 도 없었다. 계측이 실동작하지 않는다고 판정했는데 **틀렸다.** -설계가 그렇다 — 성공 호출은 DEBUG 고, 분모는 60초 창 집계 한 줄만 INFO 이며, -**창을 닫는 것은 타이머가 아니라 다음 호출**이다. 3건이 3초 안에 들어가면 같은 창에 -담기고 그 창은 아직 열려 있다. +로그 설계가 원래 그렇게 되어 있다. 성공 호출은 DEBUG 레벨이고, 분모는 60초 창 집계 한 줄만 +INFO 로 나가며, **창을 닫는 것은 타이머가 아니라 다음 호출**이다. 3건이 3초 안에 들어가면 +같은 창에 담기고 그 창은 아직 열려 있으므로 집계 줄이 나오지 않는다. 60초를 기다린 뒤 1건을 더 넣자 그 자리에서 나왔다. @@ -58,7 +58,7 @@ INFO app.client.gms gms window window=324s calls=7 fail=0 fail_pct=0 avg_ms=1940 max_ms=2219 ok=7 [embedding ok=4] [judge:openai ok=3] ``` -**`[judge:openai]` 가 이 한 줄의 값이다** — 폴백 체인 1순위가 실제로 선택된 것을 보여준다. +**이 한 줄에서 눈여겨볼 것은 `[judge:openai]` 다.** 폴백 체인 1순위가 실제로 선택된 것을 보여준다. > 검증이 실패했는지 검증 방법이 틀렸는지를 먼저 갈라야 한다. 이 날 훅·CI 셋이 > **자기 테스트를 통과하고 실전에서 무력했기 때문에** 같은 증상을 결함으로 읽는 @@ -66,7 +66,7 @@ INFO app.client.gms gms window window=324s calls=7 fail=0 fail_pct=0 ## T32. `back` jar 이 낡으면 Flyway 가 기동을 거부한다 -`back` 을 당긴 뒤 옛 jar 로 띄우면 이렇게 죽는다. +`back` 을 당긴 뒤 옛 jar 로 띄우면 기동이 이렇게 실패한다. ``` Detected applied migration not resolved locally: 6 @@ -87,10 +87,10 @@ cd back && ./gradlew bootJar 로컬 pgvector 가 둘이고 `.env` 는 **07-27 잔재인 `:5433`** 을 가리킨다. 시연 정본은 `:15432`(`pinlog-demo-postgres-1`)다. -`real-data-e2e §7` 절차가 매 명령에 `DATABASE_URL` 을 덮어써서 지금까지 안 터졌다. -**한 번 빠뜨리면 조용히 다른 DB 에 붙는다.** +`real-data-e2e §7` 절차가 매 명령에 `DATABASE_URL` 을 덮어써서 지금까지 문제가 드러나지 +않았다. **덮어쓰기를 한 번 빠뜨리면 아무 경고 없이 다른 DB 에 붙는다.** -`seed.py` 의 preflight 가 접속 대상을 찍고 `:5433` 이면 BLOCK 하므로 이제 조용히 +`seed.py` 의 preflight 가 접속 대상을 출력하고 `:5433` 이면 BLOCK 하므로 이제 모르고 지나가지는 않는다([seed-guard](../implements/2026-07-31-seed-guard.md)). 기본값 자체는 그대로다. @@ -99,8 +99,8 @@ cd back && ./gradlew bootJar 소셜 OAuth 는 콜백 URL 이 운영 기준이라 로컬에서 돌지 않는다. 데모 JWT 키로 토큰을 만들어 쿠키에 심으면 우회된다. -**`access_token` 만 심으면 안 된다.** 프론트는 그것을 읽지 못하고(HttpOnly 전제) -별도 표시 쿠키로 판정한다. +**`access_token` 만 심으면 안 된다.** 프론트는 그 쿠키를 읽지 못하고(HttpOnly 전제) +별도 표시 쿠키로 로그인 여부를 판정한다. ``` access_token= back 인증용 @@ -109,8 +109,8 @@ XSRF-TOKEN=<임의> CSRF 인터셉터용 ``` **값이 정확히 `1` 이어야 한다.** `front` 의 `getIsLoggedIn.ts` 가 -`document.cookie.split('; ').includes('logged_in=1')` 로 문자열 일치를 본다 — -`logged_in=true` 는 안 걸린다. +`document.cookie.split('; ').includes('logged_in=1')` 로 문자열 일치를 본다. +`logged_in=true` 는 걸리지 않는다. `vite.config.ts` 의 proxy target 도 기본이 `https://pin-log.com` 이라 로컬 back 을 쓰려면 `http://localhost:8080` 으로 바꿔야 한다(프론트 파트 소유 파일이므로 커밋하지 않는다). @@ -139,6 +139,6 @@ from core.social_account sa order by 3 desc; `hotfix/*` 로 `main` 에 직접 올려 해소했다(그 브랜치명이 검사가 허용하는 첫 사례이기도 하다). -같은 파일에서 하나 더 — `ai-ci.yml` 전체 텍스트에 `:main` 이 있으면 +같은 파일에서 하나를 더 겪었다. `ai-ci.yml` 전체 텍스트에 `:main` 이 있으면 `test_ci_image_publish_contract` 가 실패한다(이미지 태그 오염 방지). 오류 메시지에 쓴 `::error::main ...` 이 그 패턴에 걸렸다. **검사가 옳고 문구가 틀렸다.** diff --git a/docs/troubleshooting/2026-07-31-log-redaction-pitfalls.md b/docs/troubleshooting/2026-07-31-log-redaction-pitfalls.md index 7fdad62..a5871b3 100644 --- a/docs/troubleshooting/2026-07-31-log-redaction-pitfalls.md +++ b/docs/troubleshooting/2026-07-31-log-redaction-pitfalls.md @@ -5,7 +5,7 @@ --- -## T61 — 에코 여부를 완전 일치로 재면 「안 샌다」가 거짓으로 나온다 +## T61 — 에코 여부를 완전 일치로 재면 「요청 값이 노출되지 않는다」는 거짓 결론이 나온다 **증상.** 요청 값에 마커 `PINLOG-CTX-MARKER-205-abcdef`를 심고 400을 유발한 뒤 `MARKER in response.text`로 검사했다. 일곱 케이스 전부 `False`였다. **「요청 값은 @@ -17,13 +17,15 @@ Invalid value: 'PIN...def'. Supported values are: 'system', 'assistant', 'user', … ``` -OpenAI가 **앞 3자와 뒤 3자만 남기고 잘라서** 되돌린다. 부분 문자열이 아니므로 완전 일치는 -빠져나간다. 요청 값 round-trip은 실재했고, 그 사실이 마스킹 규칙의 근거 전체였다 — -「관측된 것만 지운다」와 「되돌아올 수 있는 자리를 막는다」가 여기서 갈린다. +OpenAI 가 **앞 3자와 뒤 3자만 남기고 잘라서** 되돌린다. 원래 마커의 부분 문자열이 아니므로 +완전 일치 검사는 이것을 잡지 못한다. 요청 값이 응답으로 되돌아오는 일(round-trip)은 실재했고, +그 사실이 마스킹 규칙의 근거 전체였다. 「관측된 것만 지운다」는 방침과 「되돌아올 수 있는 +자리를 막는다」는 방침이 여기서 갈린다. **대책.** 에코 검사는 완전 일치로 끝내지 않는다. `marker in body`가 `False`여도 **본문 원문을 반드시 눈으로 본다.** 자동으로 잡으려면 마커의 앞/뒤 n자(n=3,4,6)를 함께 찾는다. -잘린 자격 증명도 자격 증명이라는 것이 같은 이야기다(마스킹을 절단보다 먼저 두는 근거). +잘린 자격 증명도 자격 증명이라는 판단도 같은 관찰에서 나왔다. 이것이 마스킹을 절단보다 +먼저 적용하는 근거다. --- @@ -39,16 +41,16 @@ context-length 오류가 아니라 이것이 왔다. 세 경로 모두 같은 모양이었다. `model` 필드는 정상적으로 들어 있었다. **원인 추정.** 게이트웨이가 라우팅을 위해 본문에서 `model`을 꺼내는데, 본문이 일정 크기를 -넘으면 그 파싱이 실패해 **"모델이 없다"로 보고한다.** 실제 원인(본문 크기)과 문구가 전혀 -무관하다. +넘으면 그 파싱이 실패해 **"모델이 없다"로 보고하는 것으로 보인다.** 실제 원인(본문 크기)과 +오류 문구가 전혀 무관하다. -**왜 중요한가.** 이 400은 `classify_http_status`가 **`PermanentError`로 분류**한다 — -재시도 없이 단계가 `FAILED`로 간다. 문구만 보면 `PINLOG_JUDGE_CHAIN`의 모델명을 의심하게 -되는데 설정은 멀쩡하다. 아주 긴 Context가 들어오면 **모델명 오류로 오진할 수 있다.** +**왜 중요한가.** 이 400은 `classify_http_status`가 **`PermanentError`로 분류**한다. +재시도 없이 해당 단계가 `FAILED`로 간다. 문구만 보면 `PINLOG_JUDGE_CHAIN`의 모델명을 의심하게 +되는데 설정은 멀쩡하다. 아주 긴 Context 가 들어오면 **모델명 오류로 오진할 수 있다.** **대책.** 「모델이 없다」는 400을 보면 설정을 고치기 전에 **요청 본문 크기부터** 본다. 같은 모델명으로 짧은 요청이 통과하면 원인은 모델명이 아니다. 이 티켓의 범위 밖이라 -고치지 않았다 — 운영에서 실제로 나면 별도 티켓. +고치지 않았다. 운영에서 실제로 발생하면 별도 티켓으로 다룬다. --- @@ -61,12 +63,12 @@ context-length 오류가 아니라 이것이 왔다. gms_roundtrip.py:18: 예외 메시지에는 응답 본문 일부(`resp.text[:200]`)와 요청 URL이 … ``` -**모듈 docstring이다.** 그 파일은 위반이 아니라 위반을 *설명*하고 있었다. 주석 줄은 -걸렀지만 문자열 리터럴 안은 못 걸렀다. +**모듈 docstring이다.** 그 파일은 규약을 위반한 것이 아니라 위반 사례를 *설명*하고 있었다. +검사가 주석 줄은 걸렀지만 문자열 리터럴 안은 거르지 못했다. **왜 그냥 넘기면 안 되나.** 예외 목록을 손으로 유지하는 검사가 되면, 다음에 문서가 한 줄 -늘 때마다 초록을 되찾으려 **검사 쪽을 느슨하게 고치게 된다.** 규약 검사가 규약보다 약해지는 -방향으로만 움직인다. +늘 때마다 테스트를 다시 통과시키려고 **검사 쪽을 느슨하게 고치게 된다.** 그러면 규약 검사가 +규약보다 약해지는 방향으로만 움직인다. **대책.** `ast.parse`로 본다. `Attribute(attr="text")` 노드를 모으고 `redact_body(...)` 인자 서브트리에 든 것을 뺀다. 문자열 리터럴은 애초에 후보에 들어오지 않으므로 예외 목록이 diff --git a/docs/troubleshooting/2026-07-31-search-cut-measurement.md b/docs/troubleshooting/2026-07-31-search-cut-measurement.md index ad0fd64..ec28aca 100644 --- a/docs/troubleshooting/2026-07-31-search-cut-measurement.md +++ b/docs/troubleshooting/2026-07-31-search-cut-measurement.md @@ -2,7 +2,7 @@ `S15P11A705-213`. 구현 기록: [search-cut](../implements/2026-07-31-search-cut.md). -**셋 다 「측정이 통과했는데 결론이 틀렸을 뻔한」 종류다.** 코드가 죽지 않고 숫자가 +**셋 다 「측정이 통과했는데 결론이 틀렸을 뻔한」 종류다.** 코드가 중단되지 않고 숫자가 나왔기 때문에 알아차리기 어려웠다. --- @@ -41,12 +41,13 @@ r = 0.40 … 0.90 무관 질의 15건 중 0건이 된 것: 0/15 (모든 값 모아 두면 「덜 자르는 쪽」이 언제나 이기고, 그것은 컷을 재는 것이 아니라 컷을 끄는 쪽으로 기우는 측정이다. -같은 이유로 「0건」의 부호가 축마다 반대라는 것도 표에서 갈라야 한다 — 검증 질의의 0건은 -최악이고 무관 질의의 0건은 최선이다. 한 표에 섞으면 컷을 반대로 읽는다. +같은 이유로 「0건」의 부호가 축마다 반대라는 것도 표에서 갈라야 한다. 검증 질의의 0건은 +최악(정답을 놓침)이고 무관 질의의 0건은 최선(무관 결과를 걸러냄)이다. 한 표에 섞으면 컷을 +반대로 읽는다. -## T41. worktree 에는 `.env` 가 없다 — `get_settings()` 가 그 자리에서 죽는다 +## T41. worktree 에는 `.env` 가 없다 — `get_settings()` 가 그 자리에서 실패한다 -`git worktree` 로 격리해 측정 스크립트를 돌리면 첫 줄에서 이렇게 죽는다. +`git worktree` 로 격리해 측정 스크립트를 돌리면 첫 줄에서 이렇게 실패한다. ``` pydantic_core._pydantic_core.ValidationError: 5 validation errors for Settings @@ -71,8 +72,8 @@ DATABASE_URL="postgresql://…:15432/pinlog" .venv/Scripts/python.exe tools/… **gitignore 된 것은 worktree 에 없다.** `.env`·`.demo/`·`.venv/` 가 전부 그렇다. worktree 격리는 **커밋되는 것만** 복제한다. -`.venv` 는 메인 것을 절대경로로 부르면 된다 — `sys.path.insert(ROOT)` 가 worktree 를 -앞에 넣으므로 **코드는 worktree 것, 인터프리터는 메인 것**이 된다. 그것이 의도한 조합이다. +`.venv` 는 메인 워킹트리의 것을 절대경로로 부르면 된다. `sys.path.insert(ROOT)` 가 worktree 를 +경로 앞에 넣으므로 **코드는 worktree 것, 인터프리터는 메인 것**이 된다. 그것이 의도한 조합이다. ## T42. 임베딩은 결정적이지만 **배치 구성이 바뀌면** 소수 넷째 자리가 흔들린다 @@ -93,8 +94,8 @@ plausible 최댓값 0.5264 → 0.5258 ### 다음 사람에게 **`10⁻⁴` 규모 차이가 채택 여부를 가르는 값은 쓰지 않는다.** 이 티켓에서 안전 상한 -(`τ_abs=0.36`·`r=0.80`)에 붙이지 않고 마진을 둔 이유 중 하나가 이것이다 — 상한과 정답 +(`τ_abs=0.36`·`r=0.80`)에 붙이지 않고 마진을 둔 이유 중 하나가 이것이다. 상한과 정답 최솟값의 거리가 `0.0042` 였는데, 그것은 배치 구성만 바뀌어도 넘어갈 수 있는 폭이다. -임베딩 결과를 커밋해 둔 이유이기도 하다(`.search/matrix.json`). 다시 뜨면 미세하게 -다른 값이 나오므로, **문서에 적힌 수치를 재현하려면 그때의 행렬이 있어야 한다.** +임베딩 결과를 커밋해 둔 이유이기도 하다(`.search/matrix.json`). 다시 생성하면 미세하게 +다른 값이 나오므로, **문서에 적힌 수치를 재현하려면 그 수치를 만들 때의 행렬이 있어야 한다.** diff --git a/docs/troubleshooting/2026-07-31-tau-measurement.md b/docs/troubleshooting/2026-07-31-tau-measurement.md index 6c33f1f..3de8128 100644 --- a/docs/troubleshooting/2026-07-31-tau-measurement.md +++ b/docs/troubleshooting/2026-07-31-tau-measurement.md @@ -1,4 +1,4 @@ -# τ 측정에서 걸린 것 (T37~T39) +# τ 측정에서 만난 문제 (T37~T39) `S15P11A705-210` 후보 유사도 임계값 측정 중 겪은 것. 결과 리포트는 [구현 리포트](../implements/2026-07-31-candidate-threshold.md)에 있다. @@ -39,7 +39,7 @@ tests/test_pipeline.py::test_no_candidate_above_floor_completes_without_calling_ ### 왜 이렇게 보였나 -증상은 실재했다 — 「가지튀김이 미쳤음」에 `DRINK 술자리`가 붙는 것은 진짜다. **증상에서 +증상은 실재했다. 「가지튀김이 미쳤음」에 `DRINK 술자리`가 붙는 것은 진짜다. **증상에서 원인을 역산할 때 「임계값이 없어서」가 가장 그럴듯했고, 그 가설로 코드를 열지 않았다.** 실제 원인은 임계값의 부재가 아니라 **적합/부적합의 유사도 분포가 겹친다**는 것이다. @@ -55,12 +55,12 @@ grep -rn "similarity_floor\|SIMILARITY_FLOOR" app/ tests/ ``` 이 경우 티켓의 나머지(τ 를 근거로 정하라)는 그대로 유효했다. 전제가 틀렸다고 티켓이 -무효가 되는 것은 아니고, **틀린 전제 위에 세운 작업 계획만 무효**다 — 「조건을 넣는다」가 +무효가 되는 것은 아니고, **틀린 전제 위에 세운 작업 계획만 무효**다. 「조건을 넣는다」가 「이미 있는 값을 실데이터로 재검증한다」로 바뀐다. --- -## T38 — 스크립트에만 인코딩 방어를 넣으면 탐색용 한 줄에서 다시 밟는다 +## T38 — 스크립트에만 인코딩 방어를 넣으면 탐색용 한 줄에서 같은 문제를 다시 겪는다 **상태: 해결됨** @@ -68,13 +68,13 @@ grep -rn "similarity_floor\|SIMILARITY_FLOOR" app/ tests/ `tools/tau_grid/*.py` 는 전부 [T28](2026-07-30-seeding-quota-and-encoding.md) 대로 `sys.stdout.reconfigure(encoding="utf-8")` 를 넣어 두었다. 그런데 분포를 빠르게 보려고 -쓴 `python -c` 한 줄이 죽었다. +쓴 `python -c` 한 줄이 예외로 중단됐다. ``` UnicodeEncodeError: 'cp949' codec can't encode character '—' ``` -**출력 앞부분은 이미 찍힌 뒤였다.** 숫자 네 줄이 나오고 다섯 번째에서 죽어서, 처음엔 +**출력 앞부분은 이미 찍힌 뒤였다.** 숫자 네 줄이 나오고 다섯 번째 줄에서 중단돼서, 처음엔 계산이 끝난 줄 알고 그 네 줄을 읽었다. 찍힌 것도 한글이 깨져 있었다. ### 원인 @@ -91,7 +91,7 @@ UnicodeEncodeError: 'cp949' codec can't encode character '—' PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe -c "..." ``` -여러 번 돌릴 것이면 파일로 옮긴다 — 파일이면 방어가 코드에 들어가고 재현도 된다. +여러 번 돌릴 것이면 파일로 옮긴다. 파일이면 방어가 코드에 들어가고 재현도 된다. --- @@ -122,7 +122,7 @@ PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe -c "..." **판정(LLM) 계층을 건드리는 측정은 대조군을 같이 돌린다.** 비용이 두 배가 되지만 대조군이 없으면 「효과 있음」과 「그날 그렇게 나왔음」을 가를 수 없다. -이 티켓에서는 그 비교가 결론을 바꿨다 — τ 를 0.30→0.34 로 올려 얻는 효과가 +이 티켓에서는 그 비교가 결론을 바꿨다. τ 를 0.30→0.34 로 올려 얻는 효과가 비결정성이 만드는 변동과 **같은 크기**라는 것이 τ 상향을 기각하는 근거 하나가 됐다. 임베딩은 결정적이므로 이 대조군이 필요 없다(`emb_grid` 가 검색 순위를 2회 실행에서 diff --git a/docs/troubleshooting/2026-08-03-dead-config-key-audit.md b/docs/troubleshooting/2026-08-03-dead-config-key-audit.md index a2f9774..230972d 100644 --- a/docs/troubleshooting/2026-08-03-dead-config-key-audit.md +++ b/docs/troubleshooting/2026-08-03-dead-config-key-audit.md @@ -5,38 +5,38 @@ ## T66 — alias 매칭은 grep으로 못 잡는다 -pydantic Settings는 필드를 소문자 속성명으로 선언해도 `alias=`로 지정한 대문자 env -이름을 읽는다(`populate_by_name=True`와 무관하게 alias가 우선). 문제는 반대 방향이다 -— **`.env`나 문서에 대문자 키 이름이 리터럴로 남아 있어도, `config.py`에 그 alias를 -가진 필드가 실제로 있는지는 grep으로는 확인되지 않는다.** `PINLOG_JUDGE_MODEL`이 +pydantic Settings 는 필드를 소문자 속성명으로 선언해도 `alias=`로 지정한 대문자 env +이름을 읽는다(`populate_by_name=True`와 무관하게 alias 가 우선). 문제는 반대 방향이다. +**`.env`나 문서에 대문자 키 이름이 리터럴로 남아 있어도, `config.py`에 그 alias 를 +가진 필드가 실제로 있는지는 grep 으로는 확인되지 않는다.** `PINLOG_JUDGE_MODEL`이 `.env.example`·여러 문서·`config.py` 주석에 문자열로 등장하지만(전부 "이제 안 읽는다"는 -설명 맥락), 그 문자열 존재 자체는 "읽힌다"의 증거가 아니다. +설명 맥락), 그 문자열이 존재한다는 사실 자체는 "코드가 그 키를 읽는다"의 증거가 아니다. `-210`이 "임계값이 없다"고 적었다가 실제로는 `config.py:114`에 있었던 사고와 정반대 -모양이다 — 그쪽은 없다고 했는데 있었고, 이쪽은 있어 보이는데(그럴듯한 값) 없다. -둘 다 문자열 서치만으로 판단해 생긴 오판이다. +형태다. 그쪽은 없다고 했는데 실제로는 있었고, 이쪽은 그럴듯한 값이 있어 보이는데 실제로는 +읽히지 않는다. 둘 다 문자열 검색만으로 판단해 생긴 오판이다. **대응**: 각 후보 키에 sentinel 값을 주입해 실제로 `Settings()`를 생성하고 -`Settings.model_fields`와 필드 값을 확인했다. `os.environ`을 전부 비우면 Windows의 +`Settings.model_fields`와 필드 값을 확인했다. `os.environ`을 전부 비우면 Windows 의 `asyncio.windows_events`가 `SYSTEMROOT` 등을 못 찾아 `OSError`가 나므로, 필요한 키만 얹었다 떼는 방식으로 검증 환경을 구성했다. ## T67 — "N개 대 M개"의 차집합은 한 방향이 아닐 수 있다 -계약은 "`.env` 13키, `.env.example` 12키 — 어느 키가 example에 없는지 찾아 필요하면 +계약은 "`.env` 13키, `.env.example` 12키 — 어느 키가 example 에 없는지 찾아 필요하면 추가한다"고 단일 방향 차집합(13 - 12 = 1)을 가정했다. 실제로는 `.env`에만 있는 키가 2개(`PINLOG_JUDGE_MODEL`·`PRESET_CACHE_TTL_SEC`, 둘 다 죽은 키)였고 `.env.example`에만 -있는 키가 1개(`PINLOG_JUDGE_CHAIN`, 살아 있는 키)였다 — 공통 11개 + 2 = 13, -공통 11개 + 1 = 12로 총계는 맞지만 "하나만 다르다"는 전제와 실제 구성이 다르다. +있는 키가 1개(`PINLOG_JUDGE_CHAIN`, 살아 있는 키)였다. 공통 11개 + 2 = 13, +공통 11개 + 1 = 12로 총계는 맞지만, "하나만 다르다"는 전제와 실제 구성이 다르다. -숫자 차이(13 vs 12 = 1)만 보고 "example에 하나를 더 채워 넣으면 끝"이라고 예단하면 -틀린다 — 둘 다 셋으로 나눠(공통·`.env`만·`.env.example`만) 각각 확인해야 어느 쪽이 -죽었고 어느 쪽이 "추가할 필요 없는 정상 생략"인지 갈린다. +숫자 차이(13 vs 12 = 1)만 보고 "example 에 하나를 더 채워 넣으면 끝"이라고 예단하면 +틀린다. 두 파일의 키를 세 집합(공통·`.env`만·`.env.example`만)으로 나눠 각각 확인해야, +어느 키가 죽었고 어느 키가 "추가할 필요 없는 정상 생략"인지 갈린다. **대응**: `.env`와 `.env.example`의 키 이름 집합을 각각 뽑아 대칭차(symmetric -difference)로 비교했다. 결론은 두 방향 다 "정상"이었다 — `.env`의 2개는 죽은 키라 -`.env.example`에 추가할 필요가 없고, `.env.example`의 1개(`PINLOG_JUDGE_CHAIN`)는 +difference)로 비교했다. 결론은 두 방향 다 "정상"이었다. `.env`에만 있는 2개는 죽은 키라 +`.env.example`에 추가할 필요가 없고, `.env.example`에만 있는 1개(`PINLOG_JUDGE_CHAIN`)는 `.env`가 코드 기본값에 의존하기로 한 정상 생략이다. -> T9(H2·pgvector)·T10(flyway.schemas)와 같은 표기 규칙을 따른다 — 상세는 전수 표 -> `docs/troubleshooting/README.md`. +> T9(H2·pgvector)·T10(flyway.schemas)과 같은 표기 규칙을 따른다. 상세는 전수 표 +> `docs/troubleshooting/README.md`에 있다. diff --git a/docs/troubleshooting/2026-08-03-preset-description.md b/docs/troubleshooting/2026-08-03-preset-description.md index a901df9..04dc872 100644 --- a/docs/troubleshooting/2026-08-03-preset-description.md +++ b/docs/troubleshooting/2026-08-03-preset-description.md @@ -1,10 +1,10 @@ -# 프리셋 개정 측정 — 임베딩이 결정적이라는 전제·rank 로 밀린 손실 +# 프리셋 개정 측정 — 임베딩 API 는 같은 입력에도 다른 벡터를 반환하고, τ 격자는 rank 로 밀려난 행을 세지 않는다 `S15P11A705-228`. 구현 리포트는 [프리셋 description 개정](../implements/2026-08-03-preset-description.md)에 있다. 이 -문서는 **무엇에 걸렸나**만 적는다. +문서는 측정 중에 무엇에 걸렸는지만 적는다. -선행의 함정은 [T37~T39](2026-07-31-tau-measurement.md)(`-210`) · +선행 티켓의 문제 기록은 [T37~T39](2026-07-31-tau-measurement.md)(`-210`) · [T43~T49](2026-07-31-judge-prompt-ab.md)(`-219`) · [T57~T60](2026-07-31-judge-vote.md)(`-223`) 에 있다. @@ -12,38 +12,39 @@ ## T68 — 임베딩 API 는 **같은 배치 구성으로도** 결정적이지 않다 -**상태: 해결됨**(검증 문턱을 실측에 맞췄고, `base2` 조건으로 흔들림을 상시 관측한다) +**상태: 해결됨**(검증 문턱을 실측값에 맞췄고, `base2` 조건으로 흔들림을 상시 관측한다) ### 증상 이 티켓의 하네스는 조건마다 프리셋 27개를 다시 임베딩한다. `base` 조건은 시드 yaml -그대로이므로 **DB 에 적재된 벡터와 같아야 한다** — 같지 않으면 하네스의 임베딩 경로가 -부트스트랩과 어긋난 것이고 조건 비교 전체가 무효다. 그래서 코사인을 대조하고 `0.9999` -미만이면 멈추게 두었다. +그대로이므로 DB 에 적재된 벡터와 같아야 한다. 같지 않다면 하네스의 임베딩 경로가 +부트스트랩과 어긋난 것이고, 그 경우 조건 비교 전체가 무효다. 그래서 하네스가 두 벡터의 +코사인 유사도를 대조해 `0.9999` 미만이면 멈추게 만들어 두었다. -첫 실행이 멈췄다. +첫 실행이 그 검증에서 멈췄다. ``` base 검증 — DB 적재 벡터와의 코사인 최솟값 0.999279 (QUICK_STOP) ``` -`0.9993` 은 float32 저장 손실(`~1e-7`)보다 네 자리 크다. **텍스트가 다른가, 경로가 -다른가**가 유일하게 그럴듯한 설명으로 보인다. +`0.9993` 은 float32 저장 손실(`~1e-7`)보다 네 자리 크다. 이 시점에는 「텍스트가 +다르거나 임베딩 경로가 다르다」가 유일하게 그럴듯한 설명으로 보였다. ### 진단이 어긋난 경로 -셋을 순서대로 봤고 앞의 둘이 전부 「같다」로 나왔다. +세 가지를 순서대로 확인했고, 앞의 둘이 전부 「같다」로 나왔다. ``` ① 임베딩 입력 텍스트가 DB 와 다른가 27개 전부 한 글자까지 같다 ② 같은 텍스트를 단건으로 다시 뜨면 다른가 DB 와 코사인 1.000000 — 오히려 같다 -③ 같은 배치 27건을 두 번 떠서 서로 대조 **갈린다** +③ 같은 배치 27건을 두 번 떠서 서로 대조 갈린다 ``` -②가 결정적으로 오해를 부른다. 단건으로 뜬 `QUICK_STOP` 은 DB 와 정확히 `1.000000` -이었다. 여기서 멈추면 「배치로 부를 때만 어긋난다 → 배치 경로에 결함이 있다」로 간다. +②의 결과가 진단을 잘못된 방향으로 이끈다. 단건으로 임베딩한 `QUICK_STOP` 은 DB 와 +정확히 `1.000000` 이었다. 여기서 조사를 멈추면 「배치로 호출할 때만 어긋난다. 따라서 +배치 경로에 결함이 있다」는 잘못된 결론으로 간다. -③이 답이었다. +③이 실제 원인을 드러냈다. ``` 같은 배치 2회끼리 1회분 대 DB 적재분 @@ -53,34 +54,37 @@ WITH_KIDS 0.999981 0.999981 나머지 24개 1.000000 1.000000 ``` -**두 열의 크기가 같고, 어느 항목이 흔들리는지도 회차마다 다르다.** DB 적재분이 다른 -것이 아니라 **매번 다르다.** +두 열의 어긋남 크기가 같고, 어느 항목이 흔들리는지도 회차마다 다르다. 즉 DB 적재분이 +특별히 다른 것이 아니라, 임베딩 결과가 호출할 때마다 다르다. ### 원인 -임베딩 API 응답이 결정적이지 않다. 배치 구성·순서·텍스트가 전부 같아도 같은 값이 +임베딩 API 응답이 결정적이지 않다. 배치 구성·순서·텍스트가 전부 같아도 같은 벡터가 돌아오지 않는다. -**`T42` 가 이 전제를 반대로 적어 두었다.** +`T42` 는 이 전제를 반대로 적어 두었다. > `-191` 이 「임베딩이 결정적이라 실패 2건의 유사도가 소수 넷째 자리까지 일치한다」고 > 적은 것은 **같은 배치 구성으로 다시 돌렸을 때**의 이야기다. -`T42` 는 배치 구성이 **바뀌었을 때** 흔들리는 것을 관측하고 그 조건을 원인으로 지목했다. -관측 자체는 맞지만 조건이 필요조건이 아니다 — 배치 구성을 고정해도 흔들린다. +`T42` 는 배치 구성이 바뀌었을 때 결과가 흔들리는 것을 관측하고, 배치 구성 변화를 +원인으로 지목했다. 관측 자체는 맞지만 그 조건은 필요조건이 아니다. 배치 구성을 고정해도 +흔들린다. -### 흔들림의 크기와 그것이 무엇을 잡아먹는가 +### 흔들림의 크기와 그것이 측정에서 무엇을 위협하는가 -`base` 와 `base2`(글자 하나까지 같은 조건)의 `(Context, Preset)` 유사도 1,134쌍을 대조했다. +`base` 와 `base2`(입력 텍스트가 글자 하나까지 같은 조건)의 `(Context, Preset)` 유사도 +1,134쌍을 대조했다. ``` |Δsim| 최대 0.004413 · 중앙 0.000013 rank 43쌍에서 다르다 -후보 집합 τ=0.30·k=10 에서 **42건 전부 같다** +후보 집합 τ=0.30·k=10 에서 42건 전부 같다 ``` -마지막 줄이 이 티켓을 구했다. 흔들림이 절단선을 넘지 않으므로 조건 D·E·DE 의 후보 -변화는 전부 개정의 몫이다. **넘었다면 이 티켓의 모든 수치가 임베딩 운과 섞였을 것이다.** +마지막 줄이 이 측정의 유효성을 지켰다. 흔들림이 후보 절단선(τ·k)을 넘지 않으므로, +조건 D·E·DE 에서 관측되는 후보 변화는 전부 개정의 효과다. 만약 흔들림이 절단선을 +넘었다면 이 티켓의 모든 수치에 임베딩 운이 섞여 개정 효과와 구분할 수 없었을 것이다. ### 처방 @@ -93,15 +97,17 @@ base2 를 상시 조건으로 흔들림을 매 측정에서 다시 재고, 후 ### 다음 사람에게 -**`10⁻³` 규모 차이로 채택을 가르지 않는다.** `T42` 가 `10⁻⁴` 로 적은 것을 한 자리 -올려야 한다 — 실측 최댓값이 `0.0044` 다. +**`10⁻³` 규모의 유사도 차이로 채택을 가르지 않는다.** `T42` 가 흔들림 규모를 `10⁻⁴` +로 적었는데 한 자리 올려 읽어야 한다. 실측 최댓값이 `0.0044` 이기 때문이다. -`-210` 의 τ 격자가 `0.01` 간격이다. 그 안에서 `0.0044` 가 흔들리면 **인접한 두 τ 값의 -구분은 신뢰할 수 없다.** `-210` 이 「0.34 와 0.35 의 이득이 같다」고 적은 것은 그래서 -운이 아니라 구조다. τ 를 다시 정할 일이 있으면 격자를 `0.01` 보다 촘촘하게 두지 않는다. +`-210` 의 τ 격자는 `0.01` 간격이다. 그 간격 안에서 `0.0044` 가 흔들리면 인접한 두 τ +값 사이의 차이는 신뢰할 수 없다. `-210` 이 「0.34 와 0.35 의 이득이 같다」고 적은 것은 +그래서 우연이 아니라 구조다. τ 를 다시 정할 일이 있으면 격자를 `0.01` 보다 촘촘하게 +두지 않는다. -임베딩 결과를 커밋해 둔 `.search/matrix.json`(`-215`)의 근거도 이것이다. 다시 뜨면 -다른 값이 나오므로 **문서에 적힌 수치를 재현하려면 그때의 행렬이 있어야 한다.** +임베딩 결과를 커밋해 둔 `.search/matrix.json`(`-215`)의 근거도 이것이다. 임베딩을 다시 +생성하면 다른 값이 나오므로, 문서에 적힌 수치를 재현하려면 그 수치를 만들 때 사용한 +행렬 파일이 있어야 한다. --- @@ -111,18 +117,19 @@ base2 를 상시 조건으로 흔들림을 매 측정에서 다시 재고, 후 ### 증상 -`tools/tau_grid/score.py` 를 개정 조건(`DE`)의 행렬로 돌렸더니 앞뒤가 안 맞는다. +`tools/tau_grid/score.py` 를 개정 조건(`DE`)의 행렬로 돌렸더니 두 출력이 서로 모순됐다. ``` 라벨 분포 fit 의 최솟값 0.2554 -τ 격자 τ=0.300 에서 fit 유실 **0** +τ 격자 τ=0.300 에서 fit 유실 0 ``` -`0.2554` 는 `0.30` 아래다. τ=0.30 에서 잘려야 하는데 유실이 0 으로 잡힌다. +`0.2554` 는 `0.30` 아래다. 그 행은 τ=0.30 에서 잘려야 하므로 유실이 0 일 수 없는데, +격자는 0 으로 집계한다. ### 원인 -`score.py` 의 격자 루프가 `rank <= k` 를 먼저 건다. +`score.py` 의 격자 루프가 `rank <= k` 조건을 먼저 적용한다. ```python in_k = [x for x in c["candidates"] if x["rank"] <= k] # ← rank 11 이상은 아예 안 들어온다 @@ -131,17 +138,17 @@ for x in in_k: ... ``` -`rank > K` 로 밀려난 행은 루프에 들어오지 않으므로 **어느 칸에도 세어지지 않는다.** -`label_distribution` 은 rank 를 안 보고 `selected` 전부를 세므로 `0.2554` 를 찍는다. -두 함수가 다른 모집단을 보고 있다. +`rank > K` 로 밀려난 행은 루프에 들어오지 않으므로 어느 칸에도 세어지지 않는다. +반면 `label_distribution` 은 rank 를 보지 않고 `selected` 전부를 세므로 최솟값 +`0.2554` 를 출력한다. 두 함수가 서로 다른 모집단을 집계하고 있는 것이다. -**τ 만 조건일 때는 무해했다.** `-210` 은 임베딩이 고정이라 rank 가 안 변했고, 밀려나는 -행 자체가 없었다. 이 티켓은 임베딩을 바꾸므로 rank 가 변한다 — 같은 코드가 조건이 -달라지자 조용히 틀린 값을 낸다. +τ 만 조건일 때는 이 결함이 무해했다. `-210` 은 임베딩이 고정이라 rank 가 변하지 +않았고, 밀려나는 행 자체가 없었다. 이 티켓은 임베딩을 바꾸므로 rank 가 변한다. 같은 +코드가 조건이 달라지자 경고 없이 틀린 값을 냈다. ### 실제로 얼마나 빠졌나 -현행 판정 83행 중 **후보(rank≤10 ∧ sim≥0.30)에서 빠진 행**을 따로 셌다. +현행 판정 83행 중 후보 조건(rank≤10 ∧ sim≥0.30)에서 빠진 행을 따로 셌다. | 조건 | 빠진 행 | fit | unfit | unclear | |---|---|---|---|---| @@ -150,20 +157,22 @@ for x in in_k: | E | 2 | 0 | **2** | 0 | | DE | 5 | 2 | **3** | 0 | -`score.py` 의 τ=0.30 행은 `DE` 에서 「unfit 2 제거 · fit 유실 0」이라고 낸다. 실제로는 -**unfit 3 · fit 2** 다. 한쪽만 읽으면 교환비가 `0.00` 으로 보이는데 실제로는 `0.67` 이다. +`score.py` 의 τ=0.30 행은 `DE` 에서 「unfit 2 제거 · fit 유실 0」이라고 출력한다. +실제로는 unfit 3 · fit 2 다. `score.py` 출력만 읽으면 교환비(오분류 1건을 지우는 데 +버리는 정상 판정 수)가 `0.00` 으로 보이는데 실제로는 `0.67` 이다. ### 처방 -`score.py` 를 고치지 않았다. 그 파일은 `-210` 의 수치를 재현하는 기준점이고, 지금 -고치면 세 티켓의 표가 어느 판으로 계산된 것인지 갈린다. 대신 **밀려난 행을 따로 세어 -리포트에 나란히 낸다.** +`score.py` 는 고치지 않았다. 그 파일은 `-210` 의 수치를 재현하는 기준점이고, 지금 +고치면 세 티켓의 표가 어느 판의 코드로 계산된 것인지 알 수 없게 된다. 대신 밀려난 +행을 따로 세어 리포트에 나란히 낸다. ### 다음 사람에게 -`score.py`·`sweep.py` 를 **임베딩이 바뀐 행렬에** 댈 때는 이 값을 반드시 함께 낸다. -τ 만 바꾸는 측정이면 필요 없다 — 그때는 rank 가 고정이라 밀려나는 행이 없다. +`score.py`·`sweep.py` 를 임베딩이 바뀐 행렬에 적용할 때는 rank 로 밀려난 행의 수를 +반드시 함께 낸다. τ 만 바꾸는 측정이면 필요 없다. 그때는 rank 가 고정이라 밀려나는 +행이 없기 때문이다. -고칠 거라면 `in_k` 필터를 없애고 「rank 로 빠짐」을 τ 유실과 **다른 칸**으로 세는 -쪽이다. 같은 칸에 합치면 「τ 를 올려서 잃었다」와 「다른 프리셋이 밀어내서 잃었다」가 -구분되지 않는다. +이 결함을 고친다면, `in_k` 필터를 없애고 「rank 로 빠짐」을 τ 유실과 **다른 칸**으로 +세는 방식이어야 한다. 같은 칸에 합치면 「τ 를 올려서 잃었다」와 「다른 프리셋이 +밀어내서 잃었다」가 구분되지 않는다. diff --git a/docs/troubleshooting/2026-08-03-repeat-incidents.md b/docs/troubleshooting/2026-08-03-repeat-incidents.md index 5d0f958..276c6a0 100644 --- a/docs/troubleshooting/2026-08-03-repeat-incidents.md +++ b/docs/troubleshooting/2026-08-03-repeat-incidents.md @@ -1,15 +1,15 @@ -# 하루에 갈래마다 서너 번 — 사본에서 꺼낸 값 · 반만 덮은 자동화 · 공개 저장소 실데이터 +# 하루에 유형마다 서너 번 반복된 사고 — 사본에서 꺼낸 값 · 반만 덮는 자동화 · 공개 저장소의 실사용자 기록 -`S15P11A705-293`. 이 문서는 **개별 사고의 해결이 아니라 갈래**를 적는다. 각 건의 처리는 +`S15P11A705-293`. 이 문서는 **개별 사고의 해결이 아니라 반복 유형**을 적는다. 각 건의 처리는 자기 티켓·PR·이슈에 있고 여기에는 **무엇이 반복됐는가**만 남긴다. -세 갈래가 2026-08-03 하루에 각각 서너 번씩 났다. **갈래마다 마지막 사고는 앞선 사고의 -대응이 이미 있는 상태에서 났다.** 07-31 의 색인 사고 다섯 건이 같은 모양이었고 -(`tools/check_docs_index.py` 머리말), 그때의 결론이 여기서도 반복된다 — 차이는 -성실성이 아니라 강제 장치다. +세 유형의 사고가 2026-08-03 하루에 각각 서너 번씩 났다. **유형마다 마지막 사고는 앞선 사고의 +대응이 이미 있는 상태에서 났다.** 07-31 의 색인 사고 다섯 건이 같은 형태였고 +(`tools/check_docs_index.py` 머리말), 그때의 결론이 여기서도 반복된다. 재발을 가르는 것은 +작업자의 성실성이 아니라 강제 장치다. **이 문서 자신이 T70 의 첫 시험이다.** 측정 수치·코드 행 번호·배포 상태 값을 옮기지 -않고 좌표만 가리킨다. 값이 필요하면 가리킨 문서를 연다. +않고 정본의 위치만 가리킨다. 값이 필요하면 가리킨 문서를 연다. --- @@ -25,7 +25,8 @@ **① 철회된 단정이 그것을 인용하며 되살아난다.** 한 트러블슈팅 문서가 판정 처리량을 단정해 적었다가 다음 날 실측이 뒤집혀 **그 문서 스스로 「틀린 서술이다」로 철회했다.** -오늘 발행한 이슈가 그 형태를 되살렸다 — **근거로 그 문서를 인용하면서.** +오늘 발행한 이슈가 그 단정을 되살렸다. 그것도 **철회를 담은 바로 그 문서를 근거로 +인용하면서** 되살렸다. 철회의 대상을 정확히 적어 둔다. **폐기된 것은 값이 아니라 그 값이 상수라는 단정이다.** 값 자체는 그 시점·그 경로의 관측치로 여전히 유효하다. 둘을 뭉치면 「그 숫자를 쓰면 @@ -40,13 +41,13 @@ **같은 값의 사본이 더 있다.** 정정되지 않은 채 남은 이슈 본문이 하나 더 있고, 중앙이 다른 티켓의 계약을 쓰면서 같은 값을 명세 개정의 근거로 다시 인용했다. **하나를 고쳐도 -나머지는 자기가 낡았다고 말하지 않는다.** +나머지 사본은 자기가 낡았다고 말하지 않는다.** **② 코드 한 행을 인용하고 다음 행을 놓친다.** 측정 하네스의 머리말이 서비스 코드의 한 행을 인용하며 **「이 필드만 판정 프롬프트에 실린다」**고 적었다. 인용한 행 **바로 다음 행에 다른 필드가 있었다.** 같은 블록이 여러 필드를 한꺼번에 싣는다. -그 오독이 **조건을 가른 설계 근거**였고, 그 위에 선 작업은 **이미 병합됐다.** 같은 표가 +그 오독이 **실험 조건을 가른 설계 근거**였고, 그 위에 선 작업은 **이미 병합됐다.** 같은 표가 하네스 README · 구현 리포트 · PR 본문으로 퍼졌다. 티켓 하나를 닫는 것으로는 정리되지 않는다. @@ -61,8 +62,8 @@ > 좌표 — `.claude/DISPATCH-LOG.md` 의 해당 발급 행. 워커 산출물은 `back` 의 PR. **④ 계약에 실어 보낸 수치가 여러 번 틀린다.** 중앙이 워커 계약에 넣은 수치가 한 건에서 -여러 번 틀렸다 — 무엇이 멈춰 있던 시간, 무엇이 빠진 횟수, 대상의 개수. **전부 워커가 -원본을 열어 고쳤다.** +여러 번 틀렸다. 무엇이 멈춰 있던 시간, 무엇이 빠진 횟수, 대상의 개수가 각각 틀렸다. +**전부 워커가 원본을 열어 고쳤다.** > 좌표 — `.claude/DISPATCH-LOG.md` 의 해당 발급 행과 워커 리포트 > `docs/implements/2026-08-03-dev-deploy-gap.md`. @@ -72,7 +73,7 @@ **값을 문서에 복사했고, 값은 시점에 묶이는데 문서는 따라가지 않는다.** 그리고 **넷 다 사본을 읽은 사람이 아니라 원문을 연 다른 사람이 잡았다.** 이것이 이 -갈래의 핵심이다 — 사본을 읽는 사람은 **확인할 것이 있다는 사실 자체를 모른다.** +유형의 핵심이다. 사본을 읽는 사람은 **확인할 것이 있다는 사실 자체를 모른다.** ### 왜 재조회로 못 막는가 @@ -84,7 +85,7 @@ ### 대응 -**쓰는 쪽에 규칙을 둔다 — 값을 옮기지 않고 좌표를 가리킨다.** 「쓸 때 조심한다」가 +**쓰는 쪽에 규칙을 둔다. 값을 옮기지 않고 정본의 위치를 가리킨다.** 「쓸 때 조심한다」가 아니라 **애초에 옮기지 않는다**로 적는다. ``` @@ -101,8 +102,8 @@ ### 검사 가능성 -**기계 검사가 없다.** 색인 정합(`tools/check_docs_index.py`)처럼 셀 수 있는 것이 아니다 -— 문장이 제안 내용인지 관측값인지는 문자열로 갈리지 않는다. +**기계 검사가 없다.** 색인 정합(`tools/check_docs_index.py`)처럼 셀 수 있는 것이 아니다. +어떤 문장이 제안 내용인지 관측값인지는 문자열 패턴으로 갈리지 않기 때문이다. 사람이 묻는 수밖에 없다. @@ -119,7 +120,7 @@ 쪽이 옳게 인용할 수 있다. **계약·티켓·이슈는 문서보다 더 위험하다.** 문서는 나중에 고칠 수 있지만 계약은 그것을 -읽은 워커가 **이미 그 전제 위에서 작업을 끝낸 뒤**다. ②가 그랬다 — 오독 위에 선 작업이 +읽은 워커가 **이미 그 전제 위에서 작업을 끝낸 뒤**다. ②가 그랬다. 오독 위에 선 작업이 병합돼 있어 티켓을 닫아도 정리되지 않는다. --- @@ -131,24 +132,24 @@ ### 증상 -**자동화가 `success` 로 끝나는데 아무것도 안 바뀐다.** 또는 **한 번 지고 나면 다음 +**자동화가 `success` 로 끝나는데 아무것도 안 바뀐다.** 또는 **한 번 경합에 지고 나면 다음 기회까지 아무 일도 일어나지 않는다.** ### 무엇이 일어났는가 — 넷 **① 병합 직전 재검사가 경합에 지고, 진 뒤에 스스로 돌아오지 못한다.** 이미지 pin 자동화가 「이 PR 이 pin 하는 태그 == 지금 소스 브랜치 HEAD」를 **병합 직전에 다시 -검사한다.** 그 사이 소스 브랜치로 push 가 하나 들어오면 진다. +검사한다.** 그 사이 소스 브랜치로 push 가 하나 들어오면 검사에 진다. -**검사 자체는 옳다** — 낡은 이미지를 배포하지 않으려는 것이다. **문제는 진 뒤다.** PR 은 +**검사 자체는 옳다.** 낡은 이미지를 배포하지 않으려는 것이다. **문제는 진 뒤다.** PR 은 낡은 SHA 를 든 채 남고, 갱신 워크플로가 PR 을 다시 밀어야만 재시도된다. 자동화에 재시도가 없다. -**② 그 갱신 워크플로의 cron 이 명목 주기대로 돌지 않는다.** 회차가 **연속으로** 빠졌다. +**② 그 갱신 워크플로의 cron 이 명목 주기대로 돌지 않는다.** 실행 회차가 **연속으로** 빠졌다. 결과적으로 자동 경로가 한동안 아무것도 하지 않았고, 사람이 수동 실행해 풀었다. -①과 ②는 **같은 이슈에 절을 나눠 기록했다** — ①의 복구가 ②에 걸려 있어 따로 읽으면 -「다음 스케줄이 복구한다」로 잘못 읽힌다. +①과 ②는 **같은 이슈에 절을 나눠 기록했다.** ①의 복구가 ②에 걸려 있어서, 따로 읽으면 +「다음 스케줄이 복구한다」로 잘못 읽히기 때문이다. > 좌표 — `infra` 의 이미지 자동 갱신·자동 병합 워크플로 한 쌍과 그 이슈. @@ -162,7 +163,7 @@ > 좌표 — `infra` 의 이미지 갱신 도구와 배포 값 파일, `S15P11A705-61` 과 워커 리포트 > `docs/implements/2026-08-03-dev-deploy-gap.md`. -**④ 감시 스크립트가 세션보다 오래 살아 고아가 된다.** 세션 프로세스가 죽어도 감시 +**④ 감시 스크립트가 세션보다 오래 살아 고아가 된다.** 세션 프로세스가 종료돼도 감시 스크립트는 살아남아 계속 폴링하는데, **출력이 어디에도 닿지 않는다.** 폴링은 계속하고 이벤트는 아무도 못 받는다. 고아가 **여럿 쌓여 있었고 아무도 몰랐다.** @@ -192,12 +193,12 @@ ### 대응 **④는 heartbeat 로 고쳤다.** 스크립트가 자기 상태를 파일로 남기고, 확인 경로가 나이· -출처·중복을 사실대로 보고한다. **판정은 사람이 한다** — 확인 명령은 무엇을 지우면 되는지 -까지만 보여주고 죽이지 않는다. 파일 이름에 PID 를 넣은 것이 중복 감지 수단이다. +출처·중복을 사실대로 보고한다. **판정은 사람이 한다.** 확인 명령은 무엇을 지우면 되는지 +까지만 보여주고 직접 프로세스를 죽이지 않는다. 파일 이름에 PID 를 넣은 것이 중복 감지 수단이다. **①②는 인프라 소관이라 이슈로 기록만 했다.** 우리가 고칠 수 있는 것이 아니다. -**③은 실측으로 성격이 밝혀졌다** — 배포를 막지 않는다. **기록 정합성 문제로 남는다.** +**③은 실측으로 성격이 밝혀졌다.** 그 값은 배포를 막지 않는다. 따라서 **기록 정합성 문제로 남는다.** 검사를 어느 레포에 둘지는 인프라 소관이 걸려 단독으로 정할 수 없다. ### 남는 것 @@ -221,14 +222,14 @@ ### 무엇이 일어났는가 **① 인지하고 대응을 정했다.** 공개 저장소에 실사용자 기록이 들어 있다는 것을 확인하고 -방침을 정했다 — **이력은 두고 앞으로는 훅으로 막는다.** 그 이슈 자신이 위치를 본문에 +방침을 정했다. 방침은 「이력은 두고 앞으로는 훅으로 막는다」이다. 그 이슈 자신이 위치를 본문에 적지 않는 형태로 발행됐고, 이후 같은 성격의 기록이 그 원칙을 따른다. **② 결정 한 시간도 안 돼서 같은 종류의 데이터가 새로 커밋됐다.** 측정 산출물이었다. -**재측정 비용을 아끼려고 `.gitignore` 가 명시적으로 예외 처리해 커밋하도록 돼 있었다** — +**재측정 비용을 아끼려고 `.gitignore` 가 명시적으로 예외 처리해 커밋하도록 돼 있었다.** 무시 규칙으로 디렉터리를 덮은 뒤 특정 산출물만 되살리는 형태다. -**③ 훅이 못 막았다.** 훅은 **바깥으로 나가는 발화**를 검사한다 — 이슈·PR·코멘트를 +**③ 훅이 못 막았다.** 훅은 **바깥으로 나가는 발화**를 검사한다. 검사 대상은 이슈·PR·코멘트를 만드는 명령과 티켓을 만드는 도구 호출이다. 그 산출물은 **스크립트가 만들고 사람이 `git add` 했다.** 검사 지점을 한 번도 거치지 않았다. @@ -241,7 +242,7 @@ **마스킹·예외 처리·훅 사각이 같은 지점에서 만난다.** -### 왜 안 먹었는가 +### 왜 방어가 작동하지 않았는가 **훅이 덮는 범위와 노출이 일어나는 범위가 다르다.** @@ -271,17 +272,17 @@ **어느 것도 정해지지 않았다.** 선택지만 적는다. ``` -산출물을 마스킹본으로 교체 재판정 도구가 깨질 수 있다 — 그 산출물을 읽는 도구가 여럿이다 -산출물을 빼고 재측정 비용 감수 외부 API 를 다시 태우는 비용과 재현성 상실을 받는다 +산출물을 마스킹본으로 교체 재판정 도구가 깨질 수 있다. 그 산출물을 읽는 도구가 여럿이다 +산출물을 빼고 재측정 비용 감수 외부 API 를 다시 부르는 비용과 재현성 상실을 받는다 검사 범위를 커밋 시점으로 이동 훅이 아니라 커밋 경로에 건다. 사각을 원인 쪽에서 막는다 -마스킹 문구에서 경로 안내 제거 **이것만은 즉시 가능하다** — 다른 셋과 독립이다 +마스킹 문구에서 경로 안내 제거 **이것만은 즉시 가능하다.** 다른 셋과 독립이다 ``` ### 다음 사람에게 **「훅을 넣었다」를 방어의 완료로 읽지 않는다.** 훅이 어느 지점을 지나는 것만 보는지 -먼저 확인한다 — 편집·커밋·푸시·발화는 서로 다른 지점이고, 하나를 덮는 것이 나머지를 -덮지 않는다. 이 갈래는 `T71` 과 같은 모양이다 — **덮은 범위와 사고가 나는 범위가 어긋나 +먼저 확인한다. 편집·커밋·푸시·발화는 서로 다른 지점이고, 하나를 덮는 것이 나머지를 +덮지 않는다. 이 유형은 `T71` 과 같은 형태다. **덮은 범위와 사고가 나는 범위가 어긋나 있는데 「대응했다」는 기록만 남는다.** **예외 규칙은 방어와 반대 방향으로 작동할 수 있다.** 무시 규칙에 예외를 뚫을 때는 그 diff --git a/docs/troubleshooting/mermaid-headless-validation.md b/docs/troubleshooting/mermaid-headless-validation.md index 03222d7..f59ea38 100644 --- a/docs/troubleshooting/mermaid-headless-validation.md +++ b/docs/troubleshooting/mermaid-headless-validation.md @@ -11,14 +11,14 @@ ## 시도와 실패 -- **`@mermaid-js/parser` 단독 사용 → 실패.** `parse()`가 `Unknown diagram type: flowchart`를 던진다. 이 패키지는 신형 문법 서브셋(pie·packet·architecture 등)만 파싱하고, `flowchart`/`sequenceDiagram`은 다루지 못한다. +- **`@mermaid-js/parser` 단독 사용은 실패했다.** `parse()`가 `Unknown diagram type: flowchart`를 던진다. 이 패키지는 신형 문법 서브셋(pie·packet·architecture 등)만 파싱하고, `flowchart`/`sequenceDiagram`은 다루지 못한다. ## 해결 -전체 `mermaid@11` + `jsdom`으로 `mermaid.parse()`를 돌린다. 브라우저 전역이 없어 두 가지를 보정해야 한다. +전체 `mermaid@11` + `jsdom`으로 `mermaid.parse()`를 돌린다. 브라우저 전역 객체가 없어 두 가지를 보정해야 한다. -1. **`navigator`는 getter-only** — jsdom 위에서 `global.navigator = ...` 대입이 실패한다. `Object.defineProperty(global, 'navigator', { value: ... })`로 정의한다. -2. **CRLF** — Windows 체크아웃이라 코드펜스 추출 시 `\r\n`이 섞이면 파서가 흔들린다. 추출 후 `.replace(/\r\n/g, "\n")`로 정규화한다. +1. **`navigator`는 getter-only 다.** jsdom 위에서 `global.navigator = ...` 대입이 실패한다. `Object.defineProperty(global, 'navigator', { value: ... })`로 정의한다. +2. **CRLF 를 정규화한다.** Windows 체크아웃이라 코드펜스 추출 시 `\r\n`이 섞이면 파서가 오류를 낸다. 추출 후 `.replace(/\r\n/g, "\n")`로 정규화한다. 절차(스크래치 디렉터리에서): @@ -33,9 +33,9 @@ npm i mermaid@11 jsdom >/dev/null node validate.mjs ../path/to/architecture.md ``` -결과: `architecture.md`의 flowchart 3 + sequenceDiagram 1 = **4/4 문법 통과**. `linkStyle`, `classDef`, `alt/else`, 노드 shape 모두 유효. +결과: `architecture.md`의 flowchart 3개와 sequenceDiagram 1개, 합쳐서 **4/4 문법 통과**. `linkStyle`, `classDef`, `alt/else`, 노드 shape 모두 유효했다. ## 재사용 메모 - 이 방식은 **문법(parse) 검증**이지 시각 렌더 확인이 아니다. 레이아웃·가독성은 별도로 봐야 한다. -- 새 다이어그램 추가 시 같은 스크립트로 회귀 검증 가능. GitHub는 `mermaid` 코드펜스를 자동 렌더하므로, parse만 통과하면 PR에서 그림으로 보인다. +- 새 다이어그램 추가 시 같은 스크립트로 회귀 검증할 수 있다. GitHub 는 `mermaid` 코드펜스를 자동 렌더하므로, parse 만 통과하면 PR 에서 그림으로 보인다. diff --git a/tools/doc_rewrite/preserve_check.sh b/tools/doc_rewrite/preserve_check.sh new file mode 100644 index 0000000..18dcf06 --- /dev/null +++ b/tools/doc_rewrite/preserve_check.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# 사용: preserve_check.sh <원본> <재구성본> +# 수치·티켓·경로·URL·문서번호 토큰의 다중집합을 비교한다. +extract() { + grep -oE '[0-9]+(\.[0-9]+)?%?|S15P11A705-[0-9]+|#[0-9]+|[A-Za-z0-9_./-]+\.(md|py|yaml|yml|json|sh)|https?://[^ )>]+|\b[IPT][0-9]+\b' "$1" \ + | sort | uniq -c | sort -k2 +} +diff <(extract "$1") <(extract "$2") From 4b7ecf21444b4c858295ce2467aace06ca2a4ac4 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 14:36:29 +0900 Subject: [PATCH 14/34] =?UTF-8?q?feat(S15P11A705-337):=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=ED=9A=A8=EA=B3=BC=20=EC=8B=A4=EC=B8=A1=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EB=B8=8C=20=E2=80=94=20=EB=AC=B4=EA=B4=80=20?= =?UTF-8?q?15=EA=B1=B4=C2=B7=EC=82=AC=EB=A1=80=205=EA=B1=B4=20=EC=A0=84/?= =?UTF-8?q?=ED=9B=84=20=EB=B9=84=EA=B5=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 RewriteClient(운영 설정 체인) 호출 → 재작성문 임베딩 → 스냅샷 DB(:25432) 유사도 → 현행 컷(재작성문 기준 단어형/문장형 분기) 재구성. 재작성문 자체를 artifact 에 기록한다. 원문 기준 값은 굳힌 행렬(matrix·recall_probe)의 컷 재구성으로 얻어 GMS 호출을 재작성 20건 + 임베딩 배치 1회로 줄였다. 포트 가드(:25432)·profile 신선도 가드 포함. artifact(rewrite_probe.json)는 GMS 비용이 든 실측 결과라 커밋한다(-336 의 artifact 커밋 선례). 판정: 약어 사례 회복(부캠 8위→1위·신한 부캠 4위→3위, 반환 회복), 무관 무노출 11/15→9/15 로 채택 기준(11 이상) 미달 — 후속 분석은 리포트가 담는다. Co-Authored-By: Claude Fable 5 --- .search/rewrite_probe.json | 227 ++++++++++++++++++++++ tools/search_cut/rewrite_probe.py | 302 ++++++++++++++++++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 .search/rewrite_probe.json create mode 100644 tools/search_cut/rewrite_probe.py diff --git a/.search/rewrite_probe.json b/.search/rewrite_probe.json new file mode 100644 index 0000000..cfe8da2 --- /dev/null +++ b/.search/rewrite_probe.json @@ -0,0 +1,227 @@ +{ + "ticket": "S15P11A705-337", + "profile": "openai-text-embedding-3-small-1536-cosine-v1", + "model": "text-embedding-3-small", + "rewrite_chain": [ + "openai:gpt-4o-mini", + "gemini:gemini-2.5-flash", + "anthropic:claude-haiku-4-5-20251001" + ], + "cut": { + "tau_abs": 0.3, + "tau_abs_word": 0.24, + "word_max_chars": 5, + "ratio": 0.6, + "limit": 20 + }, + "offtopic": [ + { + "query": "자동차 엔진오일 교환 정비소", + "rewritten": "자동차 엔진 오일 교환 정비소", + "degraded": false, + "before_returned": 0, + "after_returned": 0, + "after_top1_sim": 0.135381 + }, + { + "query": "자동차 엔진오일 교환 정비소", + "rewritten": "자동차 엔진 오일 교환 정비소", + "degraded": false, + "before_returned": 0, + "after_returned": 0, + "after_top1_sim": 0.183419 + }, + { + "query": "자동차 엔진오일 교환 정비소", + "rewritten": "자동차 엔진 오일 교환 정비소", + "degraded": false, + "before_returned": 0, + "after_returned": 0, + "after_top1_sim": 0.199427 + }, + { + "query": "치과 임플란트 상담 받을 곳", + "rewritten": "치과 임플란트 상담 받을 곳", + "degraded": false, + "before_returned": 0, + "after_returned": 0, + "after_top1_sim": 0.268693 + }, + { + "query": "치과 임플란트 상담 받을 곳", + "rewritten": "치과 임플란트 상담 받을 곳", + "degraded": false, + "before_returned": 2, + "after_returned": 2, + "after_top1_sim": 0.381751 + }, + { + "query": "치과 임플란트 상담 받을 곳", + "rewritten": "치과 임플란트 상담 받을 곳", + "degraded": false, + "before_returned": 1, + "after_returned": 1, + "after_top1_sim": 0.30068 + }, + { + "query": "겨울 스키장 리프트권 파는 데", + "rewritten": "겨울 스키장 리프트권 파는 곳", + "degraded": false, + "before_returned": 0, + "after_returned": 0, + "after_top1_sim": 0.236606 + }, + { + "query": "겨울 스키장 리프트권 파는 데", + "rewritten": "겨울 스키장 리프트권 파는 곳", + "degraded": false, + "before_returned": 0, + "after_returned": 2, + "after_top1_sim": 0.319109 + }, + { + "query": "겨울 스키장 리프트권 파는 데", + "rewritten": "겨울 스키장 리프트권 파는 곳", + "degraded": false, + "before_returned": 0, + "after_returned": 3, + "after_top1_sim": 0.338516 + }, + { + "query": "노트북 액정 수리 서비스센터", + "rewritten": "노트북 액정 수리 서비스 센터", + "degraded": false, + "before_returned": 1, + "after_returned": 1, + "after_top1_sim": 0.336829 + }, + { + "query": "노트북 액정 수리 서비스센터", + "rewritten": "노트북 액정 수리 서비스 센터", + "degraded": false, + "before_returned": 0, + "after_returned": 0, + "after_top1_sim": 0.268582 + }, + { + "query": "노트북 액정 수리 서비스센터", + "rewritten": "노트북 액정 수리 서비스 센터", + "degraded": false, + "before_returned": 0, + "after_returned": 0, + "after_top1_sim": 0.252289 + }, + { + "query": "강아지 예방접종 동물병원", + "rewritten": "강아지 예방 접종 동물 병원", + "degraded": false, + "before_returned": 0, + "after_returned": 0, + "after_top1_sim": 0.235863 + }, + { + "query": "강아지 예방접종 동물병원", + "rewritten": "강아지 예방 접종 동물 병원", + "degraded": false, + "before_returned": 0, + "after_returned": 0, + "after_top1_sim": 0.254679 + }, + { + "query": "강아지 예방접종 동물병원", + "rewritten": "강아지 예방 접종 동물 병원", + "degraded": false, + "before_returned": 1, + "after_returned": 1, + "after_top1_sim": 0.335193 + } + ], + "cases": [ + { + "query": "부캠", + "rewritten": "부트캠프", + "degraded": false, + "expect": "카츠요", + "before": { + "pre_cut_rank": 8, + "returned": false, + "returned_count": 3 + }, + "after": { + "pre_cut_rank": 1, + "returned": true, + "returned_count": 6 + } + }, + { + "query": "신한 부캠", + "rewritten": "신한 부트캠프", + "degraded": false, + "expect": "카츠요", + "before": { + "pre_cut_rank": 4, + "returned": false, + "returned_count": 3 + }, + "after": { + "pre_cut_rank": 3, + "returned": true, + "returned_count": 3 + } + }, + { + "query": "신한", + "rewritten": "신한", + "degraded": false, + "expect": "카츠요", + "before": { + "pre_cut_rank": 6, + "returned": false, + "returned_count": 4 + }, + "after": { + "pre_cut_rank": 6, + "returned": false, + "returned_count": 4 + } + }, + { + "query": "그네", + "rewritten": "그네", + "degraded": false, + "expect": "동교어린이공원", + "before": { + "pre_cut_rank": 3, + "returned": true, + "returned_count": 6 + }, + "after": { + "pre_cut_rank": 3, + "returned": true, + "returned_count": 6 + } + }, + { + "query": "스팟", + "rewritten": "스팟", + "degraded": false, + "expect": "동교어린이공원", + "before": { + "pre_cut_rank": 1, + "returned": true, + "returned_count": 2 + }, + "after": { + "pre_cut_rank": 1, + "returned": true, + "returned_count": 2 + } + } + ], + "verdict": { + "silent_before": 11, + "silent_after": 9, + "baseline": 11, + "adopted": false + } +} \ No newline at end of file diff --git a/tools/search_cut/rewrite_probe.py b/tools/search_cut/rewrite_probe.py new file mode 100644 index 0000000..ab7c238 --- /dev/null +++ b/tools/search_cut/rewrite_probe.py @@ -0,0 +1,302 @@ +"""LLM 질의 재작성이 검색 결과를 어떻게 바꾸는지 실측하는 프로브 (S15P11A705-337 잔여). + +재작성 런타임(P49 §3의 LLM 질의 재작성, 커밋 39dba0d)은 구현됐지만, 실제 LLM 이 만든 +재작성문으로 검색했을 때의 효과는 재지 않았다. 이 프로브가 그 잔여를 잰다. 재는 것은 둘이다. + + ① 관련 없는 질의 15건 — 재작성을 거쳐도 검색 결과가 노출되지 않는가. + 채택 조건: 무노출 질의 수가 현행(15건 중 11건) 이상이어야 한다. + ② 실패 사례 5건(`부캠`·`신한 부캠`·`신한`·`그네`·`스팟`) — 재작성이 기대 정답의 + 컷 전 순위·컷 통과 여부를 어떻게 움직이는가. + +**재작성문 자체를 결과에 기록한다.** 무엇으로 바뀌었는지가 판정 근거의 절반이다. + +원문 기준(재작성 전)의 값은 굳힌 행렬(`matrix.json`·`recall_probe.json`)에서 컷을 +재구성해 얻는다. GMS 를 부르는 것은 재작성 LLM 호출 20건과 재작성문 임베딩 배치 1회다. + + $env:DATABASE_URL="postgresql://…:25432/…"; python tools/search_cut/rewrite_probe.py + +측정은 스냅샷 DB(:25432, `pinlog-search-upgrade-pg`)에서 한다. 시연 DB(:15432)와 e2e +DB(:5433)를 가리키면 실행을 멈춘다 — 시연 DB 는 검증 게이트 전 접근 금지이고, 다른 DB 는 +데이터가 달라 결론이 오염된다. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from app.client.embedding_client import EmbeddingClient # noqa: E402 +from app.client.retry import RetryPolicy # noqa: E402 +from app.client.rewrite_client import RewriteClient # noqa: E402 +from app.core.config import get_settings # noqa: E402 +from app.core.db import Database # noqa: E402 +from app.core.errors import PermanentError, TransientError # noqa: E402 +from app.repository import context_embedding_repo # noqa: E402 + +for _s in (sys.stdout, sys.stderr): + # T28. 콘솔이 cp949 면 장소명 한 글자에 측정이 죽는다. + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + +def log(msg: str = "") -> None: + print(msg, flush=True) + + +class GuardError(SystemExit): + pass + + +SEARCH = ROOT / ".search" + +# 검색 고도화 측정은 전부 스냅샷 DB 에서 한다(인계 §7). recall_probe.py 의 15432 가드와 +# 값이 다른 것은 대상 DB 가 다르기 때문이다 — 그쪽은 -255 시절 시연 DB 실측이고, 이 +# 트랙은 시연 DB 쓰기 접근이 게이트 전 금지라 스냅샷 사본을 쓴다. +EXPECT_PORT = "25432" + +# 컷 전 순위를 보려면 서비스 limit(20)보다 커야 한다. recall_probe.py 와 같은 값. +NO_LIMIT = 10_000 + +# 서비스 기본 limit(공용 계약 08 §6.1). 컷 재구성의 절단 기준이다. +SERVICE_LIMIT = 20 + +# 실패 사례 5건. 기대 정답은 recall_probe.json 의 expect 를 그대로 쓴다. +CASES = ("부캠", "신한 부캠", "신한", "그네", "스팟") + +# 채택 조건. 관련 없는 문장형 질의 15건 중 무노출이 이 수 미만이면 재작성을 켤 수 없다. +BASELINE_SILENT = 11 + + +def is_word_query(q: str, max_chars: int) -> bool: + """서비스의 단어형 판정(`SearchService._is_word_query`)을 다시 적는다. + + import 하지 않는 이유는 recall_probe.py 와 같다 — 구현이 명세와 달라도 둘이 함께 + 틀리면 재구성이 「일치」로 보인다. + """ + q = q.strip() + return bool(q) and not any(c.isspace() for c in q) and len(q) <= max_chars + + +def apply_cut(results: list[dict], *, query: str, settings) -> list[dict]: + """서비스의 컷(`SearchService._cut`)을 재구성한다. SQL LIMIT 이 먼저, 컷이 뒤다. + + τ_abs 는 **판정 대상 질의**(재작성 후라면 재작성문)의 단어형 여부로 갈린다 — + 서비스가 임베딩 입력과 컷 판정 입력을 같은 텍스트로 맞추기 때문이다(search_service). + """ + head = results[:SERVICE_LIMIT] + if not head: + return head + ratio = settings.search_top_ratio + if settings.search_similarity_floor <= 0 and ratio <= 0: + return head + floor = ( + settings.search_similarity_floor_word + if is_word_query(query, settings.search_word_query_max_chars) + else settings.search_similarity_floor + ) + top = head[0]["sim"] + return [r for r in head if r["sim"] >= floor and r["sim"] >= ratio * top] + + +def load_inputs(settings) -> tuple[list[dict], list[dict]]: + """굳힌 행렬에서 무관 15건·사례 5건을 꺼낸다. profile 불일치면 재지 않고 멈춘다.""" + matrix = json.loads((SEARCH / "matrix.json").read_text(encoding="utf-8")) + recall = json.loads((SEARCH / "recall_probe.json").read_text(encoding="utf-8")) + + for name, art in (("matrix.json", matrix), ("recall_probe.json", recall)): + if art["profile"] != settings.embedding_profile: + raise GuardError( + f"{name} 의 profile({art['profile']})이 현행 설정" + f"({settings.embedding_profile})과 다르다 — 행렬을 다시 뜨기 전에는 " + "이 측정이 성립하지 않는다. 재지 않고 멈춘다." + ) + + offtopic = matrix["offtopic"] + if len(offtopic) != 15: + raise GuardError(f"무관 질의가 15건이 아니다: {len(offtopic)}건") + + by_query = {q["query"]: q for q in recall["queries"]} + cases = [] + for name in CASES: + row = by_query.get(name) + if row is None: + raise GuardError(f"recall_probe.json 에 사례 질의가 없다: {name}") + cases.append({**row, "user_id": recall["user_id"]}) + return offtopic, cases + + +async def rewrite_all(queries: list[str], settings) -> dict[str, dict]: + """질의 전부를 실제 RewriteClient(운영과 같은 설정 체인)로 재작성한다. + + 실패는 원문 강등으로 기록한다 — 서비스와 같은 동작이며, 실패했다는 사실 자체가 + 측정 결과다(강등 빈도). + """ + client = RewriteClient( + gms_base_url=settings.gms_base_url, + api_key=settings.gms_api_key, + chain=settings.judge_vendors, + timeout=settings.search_llm_timeout_sec, + retry=RetryPolicy(attempts=settings.search_llm_attempts), + cache_size=settings.search_rewrite_cache_size, + ) + out: dict[str, dict] = {} + for q in queries: + try: + rewritten = await client.rewrite(q) + out[q] = {"rewritten": rewritten, "degraded": False} + except (TransientError, PermanentError) as e: + out[q] = {"rewritten": q, "degraded": True, "error": type(e).__name__} + log(f" 재작성 {q!r} → {out[q]['rewritten']!r}" + + (" (강등: 원문 유지)" if out[q]["degraded"] else "")) + return out + + +async def measure(db: Database, settings, rewrites: dict[str, dict], + offtopic: list[dict], cases: list[dict]) -> dict: + texts = [rewrites[q]["rewritten"] for q in rewrites] + client = EmbeddingClient( + base_url=settings.gms_base_url, + api_key=settings.gms_api_key, + model=settings.embedding_model, + dimension=settings.embedding_dimension, + ) + vectors = dict(zip(rewrites.keys(), await client.embed(texts))) + + async def search_rows(user_id: int, vec) -> list[dict]: + async with db.acquire() as conn: + rows = await context_embedding_repo.search( + conn, user_id, settings.embedding_profile, vec, NO_LIMIT + ) + return [ + {"record_id": r["record_id"], "context_id": r["context_id"], + "sim": round(float(r["similarity"]), 6)} + for r in rows + ] + + # ── ① 무관 질의 15건 ──────────────────────────────────────────────── + off_rows = [] + silent_before = silent_after = 0 + for item in offtopic: + q = item["query"] + before_kept = apply_cut(item["results"], query=q, settings=settings) + rw = rewrites[q] + after_all = await search_rows(item["user_id"], vectors[q]) + after_kept = apply_cut(after_all, query=rw["rewritten"], settings=settings) + silent_before += not before_kept + silent_after += not after_kept + off_rows.append({ + "query": q, + "rewritten": rw["rewritten"], + "degraded": rw["degraded"], + "before_returned": len(before_kept), + "after_returned": len(after_kept), + "after_top1_sim": after_all[0]["sim"] if after_all else None, + }) + log(f" 무관 {q!r}: 전 {len(before_kept)}건 → 후 {len(after_kept)}건") + + # ── ② 사례 5건 ───────────────────────────────────────────────────── + case_rows = [] + for item in cases: + q = item["query"] + rw = rewrites[q] + # 전(원문): 굳힌 행렬의 전량 결과에 컷을 재구성한다. + name_by_record = {r["record_id"]: r["name"] for r in item["results"]} + expect_ids = {rid for rid, n in name_by_record.items() if n == item["expect"]} + before_kept = apply_cut(item["results"], query=q, settings=settings) + before_rank = next( + (r["rank"] for r in item["results"] if r["record_id"] in expect_ids), None) + before_in = any(r["record_id"] in expect_ids for r in before_kept) + # 후(재작성문): 임베딩을 다시 떠 스냅샷 DB 를 잰다. + after_all = await search_rows(item["user_id"], vectors[q]) + after_kept = apply_cut(after_all, query=rw["rewritten"], settings=settings) + after_rank = next( + (i + 1 for i, r in enumerate(after_all) if r["record_id"] in expect_ids), + None) + after_in = any(r["record_id"] in expect_ids for r in after_kept) + case_rows.append({ + "query": q, + "rewritten": rw["rewritten"], + "degraded": rw["degraded"], + "expect": item["expect"], + "before": {"pre_cut_rank": before_rank, "returned": before_in, + "returned_count": len(before_kept)}, + "after": {"pre_cut_rank": after_rank, "returned": after_in, + "returned_count": len(after_kept)}, + }) + log(f" 사례 {q!r} → {rw['rewritten']!r}: 정답 컷 전 " + f"{before_rank}위→{after_rank}위 · 반환 {before_in}→{after_in}") + + verdict = { + "silent_before": silent_before, + "silent_after": silent_after, + "baseline": BASELINE_SILENT, + "adopted": silent_after >= BASELINE_SILENT, + } + return { + "ticket": "S15P11A705-337", + "profile": settings.embedding_profile, + "model": settings.embedding_model, + "rewrite_chain": [f"{v}:{m}" for v, m in settings.judge_vendors], + "cut": { + "tau_abs": settings.search_similarity_floor, + "tau_abs_word": settings.search_similarity_floor_word, + "word_max_chars": settings.search_word_query_max_chars, + "ratio": settings.search_top_ratio, + "limit": SERVICE_LIMIT, + }, + "offtopic": off_rows, + "cases": case_rows, + "verdict": verdict, + } + + +async def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=str(SEARCH / "rewrite_probe.json")) + args = ap.parse_args() + + settings = get_settings() + if EXPECT_PORT not in settings.database_url: + raise GuardError( + f"DATABASE_URL 이 :{EXPECT_PORT}(스냅샷 DB)를 가리키지 않는다 — " + f"{settings.database_url.rsplit('@', 1)[-1]}\n" + "검색 고도화 측정은 스냅샷 DB 에서만 한다. 재지 않고 멈춘다." + ) + + offtopic, cases = load_inputs(settings) + log(f" profile={settings.embedding_profile}") + log(f" 재작성 체인={['%s:%s' % (v, m) for v, m in settings.judge_vendors]}" + f" · 타임아웃={settings.search_llm_timeout_sec}s" + f" · 시도={settings.search_llm_attempts}\n") + + queries = [o["query"] for o in offtopic] + [c["query"] for c in cases] + rewrites = await rewrite_all(queries, settings) + log() + + db = Database(settings.database_url) + await db.connect() + try: + data = await measure(db, settings, rewrites, offtopic, cases) + finally: + await db.disconnect() + + v = data["verdict"] + log(f"\n 무관 무노출: 전 {v['silent_before']}/15 → 후 {v['silent_after']}/15 " + f"(채택 기준 {v['baseline']} 이상: {'충족' if v['adopted'] else '미달'})") + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + log(f" → {out} ({out.stat().st_size:,} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) From dddcef278d00e85aa07fd20cac9efa8409d20ce2 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 14:45:40 +0900 Subject: [PATCH 15/34] =?UTF-8?q?feat(S15P11A705-339):=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=A0=84=EC=9A=A9=20=EB=B3=91=ED=95=A9=20?= =?UTF-8?q?=ED=95=98=EB=84=A4=EC=8A=A4=20=E2=80=94=20P49=20=C2=A74=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EC=9D=98=20=EC=B1=84=ED=83=9D=EA=B0=92=20?= =?UTF-8?q?=EC=9E=AC=EC=B8=A1=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P48 구조(후보 합집합 후 병합 점수에 컷)로 잰 I53 채택값은 P49 §4 가 확정한 재정렬 전용 구조에서 의미가 다르므로 같은 artifact 위에서 다시 잰다. - fusion.rerank(): 컷 통과 집합의 순서만 조정하는 순수 함수. 후보 추가·제거가 일어나면 FusionError 로 멈춘다. 재정렬 후 2차 절단 없음 - fusion_rerank_sweep.py: BASE·binary(floor×weight 격자)·RRF(k=60) 비교. 무관 세그먼트가 BASE 와 다르면 구현 오류로 중단 — 구조적 불변을 스스로 검사 - artifact 신선도 가드: profile·preset_version·데이터셋(소유자 참조) 이 현행과 불일치하면 수치를 내지 않고 실패 (기존 load 가드 위에 추가) - 픽스처 8건: 후보 집합 불변·sim 보존·동점 결정성·강등 항등성 고정 Co-Authored-By: Claude Fable 5 --- tests/test_search_fusion.py | 72 +++++ tools/search_cut/README.md | 25 +- tools/search_cut/fusion.py | 76 +++++ tools/search_cut/fusion_rerank_sweep.py | 406 ++++++++++++++++++++++++ 4 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 tools/search_cut/fusion_rerank_sweep.py diff --git a/tests/test_search_fusion.py b/tests/test_search_fusion.py index e2a28cd..9ed2c81 100644 --- a/tests/test_search_fusion.py +++ b/tests/test_search_fusion.py @@ -321,6 +321,78 @@ def test_keyword_matrix_population_matches_search_query(): "keyword_status 는 신호 판단용으로 컬럼에 실려 있어야 한다" +# ── 9. 재정렬 전용 병합 (P49 §4, S15P11A705-339) ───────────────────────────── +# +# `fuse()` 와 병합 의미가 다르다 — 컷 통과 집합을 고정하고 **순서만** 바꾼다. +# 여기서 고정하는 계약이 런타임 계약 테스트(test_search_rerank.py)의 오프라인 판이다. + +KEPT = [ + {"record_id": 100, "sim": 0.50, "is_expected": False}, + {"record_id": 200, "sim": 0.48, "is_expected": True}, + {"record_id": 300, "sim": 0.40, "is_expected": False}, +] + + +def test_rerank_never_changes_candidate_set(): + """후보 추가·제거 없음 — 신호가 무엇이든 Record id 집합이 입력과 같다.""" + for sig in ({}, {200: 1.0}, {100: 1.0, 200: 1.0, 300: 1.0}, {999: 1.0}): + out = F.rerank(KEPT, sig, method=F.BINARY, weight=0.5) + assert {r["record_id"] for r in out} == {100, 200, 300}, sig + assert len(out) == len(KEPT), "재정렬 후 2차 절단이 있어서는 안 된다" + + +def test_rerank_binary_moves_signal_row_up_only_when_weight_covers_gap(): + """정렬 점수 = 코사인 + weight × 신호. 간격(0.02)을 못 넘는 weight 는 순서를 못 바꾼다.""" + up = F.rerank(KEPT, {200: 1.0}, method=F.BINARY, weight=0.05) + assert [r["record_id"] for r in up] == [200, 100, 300] + stay = F.rerank(KEPT, {200: 1.0}, method=F.BINARY, weight=0.01) + assert [r["record_id"] for r in stay] == [100, 200, 300] + + +def test_rerank_keeps_sim_untouched(): + """행의 `sim` 은 원래 코사인 그대로다 — 응답 similarity 계약(P48 §2.3)과 같은 이유.""" + out = F.rerank(KEPT, {200: 1.0}, method=F.BINARY, weight=0.05) + assert {r["record_id"]: r["sim"] for r in out} == {100: 0.50, 200: 0.48, 300: 0.40} + + +def test_rerank_without_signal_is_identity(): + """신호가 전부 0 이면 벡터 순서 그대로다 — 런타임 강등 경로의 성질과 같다.""" + out = F.rerank(KEPT, {}, method=F.BINARY, weight=0.5) + assert [r["record_id"] for r in out] == [100, 200, 300] + out = F.rerank(KEPT, {}, method=F.RRF) + assert [r["record_id"] for r in out] == [100, 200, 300] + + +def test_rerank_rrf_combines_vector_and_keyword_ranks(): + """RRF 는 순위 역수 합이다. 신호 있는 행이 keyword 순위를 받아 위로 온다.""" + out = F.rerank(KEPT, {300: 1.0}, method=F.RRF, rrf_k=60.0) + # 300: 1/(60+3)+1/(60+1) > 100: 1/(60+1) → 300 이 1위로 온다 + assert [r["record_id"] for r in out] == [300, 100, 200] + + +def test_rerank_ties_break_by_vector_order(): + """같은 점수면 벡터 순서를 유지한다 — 결과가 결정적이어야 한다.""" + tied = [ + {"record_id": 1, "sim": 0.50}, + {"record_id": 2, "sim": 0.50}, + ] + out = F.rerank(tied, {1: 1.0, 2: 1.0}, method=F.BINARY, weight=0.05) + assert [r["record_id"] for r in out] == [1, 2] + + +def test_rerank_empty_input_returns_empty(): + """무관 질의에서 컷 통과가 0건이면 재정렬 대상도 0건이다 — P49 §5 의 구조적 근거.""" + assert F.rerank([], {999: 1.0}, method=F.BINARY, weight=0.5) == [] + + +def test_rerank_unknown_method_raises(): + try: + F.rerank(KEPT, {}, method="nope") + raise AssertionError("알 수 없는 방식이 통과했다") + except F.FusionError: + pass + + def test_unknown_method_and_policy_raise(): cand = F.preset_candidates(QUERY_COS, PRESETS, top_k=10, floor=0.0) try: diff --git a/tools/search_cut/README.md b/tools/search_cut/README.md index 908b7e8..0906a8d 100644 --- a/tools/search_cut/README.md +++ b/tools/search_cut/README.md @@ -33,7 +33,8 @@ | `rank_score.py` | 검색 **순위** 지표 baseline(P48 0단계, I52). 컷 기준 지표로는 순위 변화가 안 보여서 따로 둔다. Hit·Recall·MRR·nDCG 를 컷 전/후 · 단어형/문장형 · 정답/무관으로 갈라 낸다. **DB 도 GMS 도 부르지 않는다** | | `fusion.py` | keyword 신호 fusion **순수 로직**(P48 1단계). DB·GMS·파일을 읽지 않고 인자만 받는다 — `tests/test_search_fusion.py` 가 픽스처로 검증한다 | | `keyword_matrix.py` | keyword 신호 artifact 생성 — 질의별 전체 활성 Preset 코사인 + Context 별 keyword·confidence·상태. **GMS 임베딩 배치 1회 + DB 읽기** | -| `fusion_sweep.py` | fusion 방식(binary·confidence·idf·RRF)×가중치×floor×RRF cutoff 격자. `keyword_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 GMS 도 부르지 않는다** | +| `fusion_sweep.py` | fusion 방식(binary·confidence·idf·RRF)×가중치×floor×RRF cutoff 격자 — **P48 구조**(후보 합집합 후 병합 점수에 컷). `keyword_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 GMS 도 부르지 않는다** | +| `fusion_rerank_sweep.py` | keyword 재정렬 전용 병합 격자 — **P49 §4 구조**(컷 통과 집합 고정·순서만 조정). BASE·binary(floor×weight)·RRF 비교. 같은 파일만 읽는다 — **DB 도 GMS 도 부르지 않는다** | | `lexical_matrix.py` | 문자열 매치 artifact — 본문에 질의가 그대로 있는지를 (질의×소유자×Record)로 굳힌다. 본문은 저장하지 않는다. **스냅샷 DB(:25432) 읽기 · GMS 0회** | | `lexical_sweep.py` | 문자열 병합 규칙 격자 — 게이트 3단×병합 3종. `lexical_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 GMS 도 부르지 않는다** | @@ -185,6 +186,28 @@ CWD 기준이고 `.env` 는 gitignore 라 worktree 에 없다 — `GMS_API_KEY` 행렬이면 `fusion_sweep.py` 의 가드가 재지 않고 멈춘다. 결과 판정 기준은 P48 §6.1, 실측 기록은 [구현 리포트 I53](../../docs/implements/2026-08-05-fusion-measurement.md). +### keyword 재정렬 전용 병합 (P49 작업 4, `S15P11A705-339`) + +```bash +.venv/Scripts/python.exe tools/search_cut/fusion_rerank_sweep.py # 파일만 읽는다 +.venv/Scripts/python.exe tools/search_cut/fusion_rerank_sweep.py \ + --json .search/fusion_rerank_sweep.json # 결정성 회차용 보존 +``` + +`fusion_sweep.py` 와 **병합 의미가 다르다** — 저쪽은 P48 구조(후보 합집합을 만들고 +컷을 병합 점수에 건다)이고, 이쪽은 P49 §4 가 확정한 구조(현행 컷이 후보를 먼저 +확정하고 keyword 신호는 그 안의 순서만 조정한다)다. I53 의 채택값은 P48 구조의 +관측이므로 이 구조의 채택값은 이 스크립트로 다시 쟀다. + +재정렬 전후의 후보 Record id 집합이 다르면 **수치를 내지 않고 멈춘다** — 무관 질의 +노출이 구조적으로 불변이라는 P49 §5 의 전제를 스크립트 스스로 검사한다. artifact 의 +profile·preset_version 이 현행과 어긋나도 멈춘다(`--expect-*` 로 현행 값을 덮어쓴다). + +결정성 회차는 `--json` 을 서로 다른 경로로 두 번 돌려 파일이 같은지로 본다 — 입력이 +파일뿐이라 같은 입력·같은 인자면 같은 출력이어야 하고, 다르면 하네스가 비결정적인 +것이다. 신규 출력은 `.search/fusion_rerank_` 접두로 만들어 기존 artifact 를 덮지 +않는다. 결과 판정은 [구현 리포트](../../docs/implements/) 의 `-339` 리포트에 있다. + ### 문자열 병합 규칙 (P49 작업 3) ```bash diff --git a/tools/search_cut/fusion.py b/tools/search_cut/fusion.py index 89489af..2e0599c 100644 --- a/tools/search_cut/fusion.py +++ b/tools/search_cut/fusion.py @@ -286,6 +286,82 @@ def fuse( return out[:limit] +# ── 4-b. 재정렬 전용 병합 (P49 §4) ─────────────────────────────────────────── + + +def rerank( + kept_rows: list[dict], + keyword_signal: dict[int, float], + *, + method: str, + weight: float = 0.0, + rrf_k: float = 60.0, +) -> list[dict]: + """벡터 컷 통과 집합의 **순서만** 바꾼다 — 후보 추가·제거 없음 (P49 §4). + + `fuse()` 와 다른 병합 의미다. `fuse()` 는 P48 구조(후보 합집합을 만든 뒤 병합 + 점수에 컷)를 구현하고, 이 함수는 P49 가 확정한 구조(코사인 컷이 후보를 먼저 + 확정하고 keyword 신호는 그 안의 순서만 조정)를 구현한다. 이 구조에서는 관련 + 없는 질의의 벡터 후보가 0건이면 재정렬 대상도 0건이므로, keyword 신호만으로 + 관련 없는 결과가 새로 노출될 수 없다(P49 §5). + + `kept_rows` 는 컷을 통과한 행을 유사도 내림차순으로 받는다. 재정렬 뒤 두 번째 + 후보 절단을 하지 않는다 — 대상이 이미 `limit` 이하라 절단할 것이 없고, 절단을 + 더하면 후보 집합 불변 계약이 깨진다. + + 정렬 규칙은 방식별로 다음과 같다. + + binary·confidence·idf 정렬 점수 = 원래 코사인 + weight × 신호. + 점수는 정렬에만 쓰고 행의 `sim` 은 그대로 둔다 + rrf 정렬 점수 = 1/(k+벡터순위) + 1/(k+keyword순위). + keyword 순위는 신호가 있는 행끼리 신호 내림차순 + + 동점은 원래 벡터 순위로 깨어 결정적으로 만든다 — 신호가 없는 행끼리는 벡터 + 순서가 그대로 유지된다. + + 반환 행의 record_id 집합이 입력과 다르면 구현 오류이므로 `FusionError` 로 + 멈춘다. 조용히 다른 집합을 돌려주는 것보다 실패가 낫다(가드 선례). + """ + if method not in METHODS: + raise FusionError(f"알 수 없는 fusion 방식: {method}") + if not kept_rows: + return [] + + vec_rank = {r["record_id"]: i for i, r in enumerate(kept_rows, 1)} + + if method == RRF: + with_signal = sorted( + (rid for rid in vec_rank if keyword_signal.get(rid, 0.0) > 0), + key=lambda rid: (-keyword_signal[rid], rid), + ) + kw_rank = {rid: i for i, rid in enumerate(with_signal, 1)} + + def score(row: dict) -> float: + rid = row["record_id"] + s = _rrf(vec_rank[rid], rrf_k) + if rid in kw_rank: + s += _rrf(kw_rank[rid], rrf_k) + return s + else: + + def score(row: dict) -> float: + return float(row["sim"]) + weight * keyword_signal.get(row["record_id"], 0.0) + + out = [] + for row in kept_rows: + annotated = dict(row) + annotated["fusion"] = score(row) + annotated["keyword_signal"] = keyword_signal.get(row["record_id"], 0.0) + out.append(annotated) + out.sort(key=lambda r: (-r["fusion"], vec_rank[r["record_id"]])) + + if {r["record_id"] for r in out} != set(vec_rank): + raise FusionError( + "재정렬이 후보 집합을 바꿨다 — 순서만 바꿔야 한다(P49 §4). 구현 오류." + ) + return out + + # ── 5. 컷 — 방식마다 다르다 (P48 §2.2) ─────────────────────────────────────── def apply_cut( diff --git a/tools/search_cut/fusion_rerank_sweep.py b/tools/search_cut/fusion_rerank_sweep.py new file mode 100644 index 0000000..d3817fd --- /dev/null +++ b/tools/search_cut/fusion_rerank_sweep.py @@ -0,0 +1,406 @@ +"""Keyword 재정렬 전용 병합 비교 — P49 §8 작업 4 (`S15P11A705-339`). + +**DB 도 GMS 도 부르지 않는다.** `keyword_matrix.py` 가 만든 artifact 와 기존 벡터 +행렬만 읽는다. `fusion_sweep.py` 와 다른 병합 의미를 잰다 — 저쪽은 P48 구조(후보 +합집합 후 병합 점수에 컷)이고, 이쪽은 P49 §4 가 확정한 구조(현행 컷이 후보를 먼저 +확정하고 keyword 신호는 그 순서만 조정)다. I53 의 채택값(floor 0.35 · binary w=0.05)은 +P48 구조의 관측이라 이 구조에서는 재측정해야 한다(P49 §9). + + python tools/search_cut/fusion_rerank_sweep.py + python tools/search_cut/fusion_rerank_sweep.py --json .search/fusion_rerank_sweep.json + +## 비교 범위 — 티켓이 고정했다 + + BASE keyword 신호 없음. 현행 컷 통과 집합 그대로 (현행 검색과 같다) + binary 맞으면 고정 보너스 — 정렬 점수 = 코사인 + weight, floor·weight 격자 + rrf 벡터 순위와 keyword 순위의 역수 합 (k=60) + +confidence·IDF 는 재개방하지 않는다 — P48 실측(I53)에서 주 채택 근거가 아니었다. + +## 이 구조에서 무관 노출은 구조적 불변이다 + +재정렬은 컷 통과 집합 안에서만 움직이므로, 관련 없는 질의에서 벡터 후보가 0건이면 +재정렬할 대상도 0건이다(P49 §5). 그래서 무관 세그먼트는 지표가 BASE 와 완전히 같은지 +**확인만** 하고, floor·weight 는 노출 방어가 아니라 순위 품질의 축으로 읽는다. + +후보 집합 불변도 같은 이유로 질의마다 검사한다 — 재정렬 전후의 Record id 집합이 +다르면 수치를 내지 않고 멈춘다. 런타임 쪽은 같은 계약을 on/off 계약 테스트가 고정한다. + +## artifact 신선도 가드 + +행렬·keyword artifact 가 현행 환경과 어긋나면 **수치를 내지 않고 실패한다** +(`fusion_sweep.load` 의 가드 + 이 스크립트의 추가 가드). + + profile 불일치 행렬 간 불일치(기존) + 현행 profile 과 불일치(추가) + preset_version 불일치 keyword artifact 의 판이 현행 판이 아니면 실측이 무효다 + 데이터셋 불일치 record_count(기존) · 소유자 집합(추가) + +현행 값의 정본은 `app/core/config.py`(profile)와 시연 DB(preset_version)다. 하네스가 +앱을 import 하지 않는 것은 기존 sweep 과 같으므로(rank_score.py 머리말) 여기 상수로 +비추고, 값이 갈리면 --expect-* 인자로 덮어쓴다 — 출력에 실제 사용값을 적는다. + +## 결정성 + +입력이 파일뿐이고 난수도 시각도 쓰지 않으므로 같은 입력·같은 인자에 같은 출력이다. +`--json` 은 입력 SHA-256 을 함께 적는다 — 두 번 돌려 파일이 같은지로 회차를 확인한다 +(T68 절차의 오프라인 축소판. 임베딩 API 비결정성 재측정은 artifact 재생성이 필요해 +이 티켓 범위 밖이다). +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import fusion as F # noqa: E402 +from fusion_sweep import build_index, load # noqa: E402 +from rank_score import ( # noqa: E402 + RATIO, + SERVICE_LIMIT, + TAU_ABS, + TAU_ABS_WORD, + GuardError, + _sha256, + aggregate, + cut, + metrics_for, + zero_rate, +) + +ROOT = Path(__file__).resolve().parents[2] +SEARCH = ROOT / ".search" + +# cp949 콘솔에서 `—` 한 글자에 죽지 않게 한다(T28·T77) — 기존 sweep 과 같은 방어다. +for _s in (sys.stdout, sys.stderr): + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + +CASES = ("신한", "부캠", "그네", "스팟") + +# 현행 환경의 거울값. profile 정본은 `app/core/config.py` 의 기본값이고, +# preset_version 정본은 시연 DB(`ai.keyword_preset.version`)다 — 27건 v1(I53 측정 조건). +CURRENT_PROFILE = "openai-text-embedding-3-small-1536-cosine-v1" +CURRENT_PRESET_VERSION = 1 + + +def log(msg: str = "") -> None: + print(msg, flush=True) + + +# ── 신선도 가드 (기존 load 가드 위에 얹는다) ───────────────────────────────── + +def check_freshness( + data: dict, kw: dict, *, expect_profile: str, expect_preset_version: int +) -> None: + """artifact 가 현행 환경의 것인지 확인한다. 어긋나면 수치를 내지 않는다.""" + if kw.get("profile") != expect_profile: + raise GuardError( + f"keyword artifact 의 profile 이 현행과 다르다: " + f"{kw.get('profile')} ≠ {expect_profile}\n" + " Profile 이 바뀌었으면 artifact 전량을 다시 떠야 한다(GMS 비용) — " + "이 티켓 범위 밖이므로 중앙에 보고한다." + ) + if kw.get("preset_version") != expect_preset_version: + raise GuardError( + f"keyword artifact 의 preset_version 이 현행과 다르다: " + f"{kw.get('preset_version')} ≠ {expect_preset_version}\n" + " Preset 이 개정됐으면 keyword_matrix.py 를 다시 돌린다(GMS 배치 1회)." + ) + stale = sorted( + p["id"] for p in kw.get("presets", []) + if p.get("version") != expect_preset_version + ) + if stale: + raise GuardError( + f"keyword artifact 안에 preset_version ≠ {expect_preset_version} 인 " + f"Preset 이 있다: {stale} — 판이 섞였다. keyword_matrix.py 를 다시 돌린다." + ) + # 데이터셋 식별 — 행렬 질의가 참조하는 소유자가 keyword artifact 에 전부 있어야 + # 한다. 없으면 그 질의의 신호가 조용히 0 이 된다(재시딩으로 user_id 가 바뀐 경우). + # record_count 는 load() 의 기존 가드가 본다. + kw_owners = {c["user_id"] for c in kw.get("contexts", [])} + referenced: set[int] = set() + for name in ("matrix", "word_grid"): + for sec in ("queries", "offtopic", "cross"): + for e in data[name].get(sec) or []: + if e.get("user_id") is not None: + referenced.add(e["user_id"]) + if data["recall_probe"].get("user_id") is not None: + referenced.add(data["recall_probe"]["user_id"]) + missing = sorted(referenced - kw_owners) + if missing: + raise GuardError( + f"행렬이 참조하는 user_id 가 keyword artifact 에 없다: {missing}\n" + " 재시딩으로 데이터셋이 바뀌었다 — keyword_matrix.py 를 다시 돌린다." + ) + + +# ── 한 조합 평가 ───────────────────────────────────────────────────────────── + +def rerank_one( + e: dict, *, presets, by_user, query_cos, + method: str | None, weight: float, floor: float, top_k: int, rrf_k: float, + cut_kw: dict, +) -> tuple[list[dict], int, bool]: + """질의 하나를 재정렬하고 (결과 행, 컷 통과 수, 신호 반영 여부)를 돌려준다. + + 후보 집합 불변을 여기서 검사한다 — 재정렬 전후 Record id 집합이 다르면 + `FusionError` 가 나고 상위에서 가드 실패로 처리된다(rerank 내부 검사). + """ + q, rows = e["query"], (e.get("results") or []) + kept = cut(rows, q, **cut_kw) + if method is None: # BASE + return kept, len(kept), False + + qc = query_cos.get(q) + if qc is None: + raise GuardError(f"keyword artifact 에 질의가 없다: `{q}` — 다시 뜬다") + cand = F.preset_candidates(qc, presets, top_k=top_k, floor=floor) + uid = e.get("user_id") + if uid is None: + raise GuardError(f"질의 `{q}` 에 user_id 가 없다 — 신호를 조인할 수 없다") + sig = F.record_signals( + by_user.get(uid, []), cand, presets, + method=method, null_policy=F.NULL_INCLUDE, + ) + reranked = F.rerank(kept, sig, method=method, weight=weight, rrf_k=rrf_k) + + kept_ids = {r["record_id"] for r in kept} + if {r["record_id"] for r in reranked} != kept_ids: + raise GuardError(f"질의 `{q}`: 재정렬이 후보 집합을 바꿨다 — 구현 오류") + signal_applied = any(r["keyword_signal"] > 0 for r in reranked) + return reranked, len(kept), signal_applied + + +def evaluate( + entries: list[dict], **kw +) -> tuple[dict, int]: + """세그먼트 하나를 재고 (지표, 신호가 반영된 질의 수)를 돌려준다.""" + per, applied = [], 0 + for e in entries: + rows = e.get("results") or [] + n_rel = sum(1 for r in rows if r.get("is_expected")) + final, _, signal_applied = rerank_one(e, **kw) + if signal_applied: + applied += 1 + per.append(metrics_for(final, n_rel)) + agg = aggregate(per) + if agg: + agg["zero_rate"] = zero_rate(per) + return agg, applied + + +def case_ranks(entries: list[dict], **kw) -> dict: + out = {} + for e in entries: + q = e["query"] + if q not in CASES: + continue + final, returned, _ = rerank_one(e, **kw) + hit = next((i for i, r in enumerate(final, 1) if r.get("is_expected")), None) + out[q] = {"rank": hit, "returned": returned} + return out + + +# ── 실행 ───────────────────────────────────────────────────────────────────── + +def parse_floats(spec: str) -> list[float]: + return [float(x) for x in spec.split(",") if x.strip()] + + +COLS = ("n", "hit@1", "hit@3", "mrr", "recall@3", "ndcg@3", "zero_rate", "returned") + + +def row_str(label: str, m: dict, extra: str = "") -> str: + if not m: + return f" {label:<18} (0건)" + cells = "".join( + (str(m[c]).rjust(9) if c == "n" else f"{m[c]:.4f}".rjust(9)) for c in COLS + ) + return f" {label:<18}{cells} {extra}" + + +def build_grid(weights: list[float]) -> list[tuple[str, str | None, float]]: + """(label, method|None, weight). BASE → binary 격자 → RRF 순서다.""" + rows: list[tuple[str, str | None, float]] = [("BASE(신호 없음)", None, 0.0)] + rows += [(f"binary w={w:g}", F.BINARY, w) for w in weights] + rows.append(("rrf", F.RRF, 0.0)) + return rows + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Keyword 재정렬 전용 병합 비교 (P49 작업 4)" + ) + ap.add_argument("--keyword-matrix", default=str(SEARCH / "keyword_matrix.json")) + ap.add_argument("--search-dir", default=str(SEARCH), + help="행렬 디렉터리. 기본은 .search/ — 픽스처 검증용 인자다") + ap.add_argument("--json") + ap.add_argument("--top-k", type=int, default=3) + ap.add_argument("--floors", default="0.25,0.30,0.32,0.35,0.40", + help="query→Preset 코사인 하한 격자 (쉼표 구분)") + ap.add_argument("--weights", default="0.02,0.05,0.10,0.20", + help="binary 가중치 격자 (쉼표 구분)") + ap.add_argument("--rrf-k", type=float, default=60.0) + ap.add_argument("--tau", type=float, default=TAU_ABS) + ap.add_argument("--tau-word", type=float, default=TAU_ABS_WORD) + ap.add_argument("--ratio", type=float, default=RATIO) + ap.add_argument("--limit", type=int, default=SERVICE_LIMIT) + ap.add_argument("--expect-profile", default=CURRENT_PROFILE) + ap.add_argument("--expect-preset-version", type=int, + default=CURRENT_PRESET_VERSION) + args = ap.parse_args() + + kw_path = Path(args.keyword_matrix) + search_dir = Path(args.search_dir) + try: + data, kw = load(kw_path, search_dir) + check_freshness( + data, kw, + expect_profile=args.expect_profile, + expect_preset_version=args.expect_preset_version, + ) + presets, by_user, query_cos = build_index(kw) + except GuardError as exc: + print(f"[가드] {exc}", file=sys.stderr) + return 1 + + floors = parse_floats(args.floors) + weights = parse_floats(args.weights) + grid = build_grid(weights) + cut_kw = dict(tau=args.tau, tau_word=args.tau_word, + ratio=args.ratio, limit=args.limit) + + segs = { + "문장형(정답)": data["matrix"]["queries"], + "단어형(정답)": data["word_grid"]["queries"], + "무관-문장형": data["matrix"]["offtopic"], + "무관-단어형": data["word_grid"]["offtopic"], + } + + log("=" * 100) + log("Keyword 재정렬 전용 병합 비교 — P49 작업 4") + log("=" * 100) + log(f" Profile {kw['profile']} (현행 일치 확인)") + log(f" Preset {kw['preset_count']}건 · version={kw['preset_version']}" + " (현행 일치 확인)") + log(" 병합 의미 컷 통과 집합 고정 · 순서만 조정 (P49 §4)") + log(f" query→Preset top_k={args.top_k} · floor 격자={args.floors}") + log(f" binary weight 격자={args.weights}") + log(f" RRF k={args.rrf_k} · 2차 절단 없음") + log(f" 컷 tau={args.tau} · tau_word={args.tau_word} · r={args.ratio}" + f" · limit={args.limit}") + log(" 호출 DB 0회 · GMS 0회") + + result: dict = { + "stage": "P49-rerank", + "params": {k: v for k, v in vars(args).items() if k != "json"}, + "inputs": { + **{k: _sha256(search_dir / f"{k}.json") for k in ("matrix", "word_grid")}, + "keyword_matrix": _sha256(kw_path), + }, + "grid": {}, + "cases": {}, + } + + # recall_probe 사례 행 준비 (fusion_sweep 과 같은 정규화) + probe_uid = data["recall_probe"].get("user_id") + probe_entries = [ + dict(e, + user_id=e.get("user_id", probe_uid), + results=[dict(r, is_expected=(r.get("name") == e.get("expect"))) + for r in (e.get("results") or [])]) + for e in data["recall_probe"]["queries"] + ] + + invariance_checked = 0 + base_metrics: dict[str, dict] = {} + + for floor in floors: + log(f"\n─── floor={floor:g} " + "─" * 80) + for seg_name, entries in segs.items(): + log(f"\n{seg_name}") + log(" " + "방식".ljust(18) + "".join(c.rjust(9) for c in COLS) + + " 신호 반영 질의") + for label, method, weight in grid: + if method is None and floor != floors[0]: + continue # BASE 는 floor 와 무관 — 첫 격자에서 한 번만 + try: + m, applied = evaluate( + entries, presets=presets, by_user=by_user, + query_cos=query_cos, method=method, weight=weight, + floor=floor, top_k=args.top_k, rrf_k=args.rrf_k, + cut_kw=cut_kw, + ) + except (GuardError, F.FusionError) as exc: + print(f"[가드] {exc}", file=sys.stderr) + return 1 + invariance_checked += len(entries) if method is not None else 0 + note = f"{applied}건" if method is not None else "(현행과 동일)" + log(row_str(label, m, note)) + key = "BASE" if method is None else f"floor={floor:g}·{label}" + if method is None: + base_metrics[seg_name] = m + result["grid"].setdefault(seg_name, {})["BASE"] = { + "metrics": m, "applied": 0, + } + else: + result["grid"].setdefault(seg_name, {})[key] = { + "metrics": m, "applied": applied, + "method": method, "weight": weight, "floor": floor, + "rrf_k": args.rrf_k if method == F.RRF else None, + } + # 무관 세그먼트 구조적 불변 확인 — BASE 와 다르면 구현 오류다. + if seg_name.startswith("무관") and m != base_metrics[seg_name]: + print(f"[가드] {seg_name} `{key}` 지표가 BASE 와 다르다 — " + "재정렬이 무관 노출을 바꿨다. 구현 오류.", file=sys.stderr) + return 1 + + log("\n사례별 (컷 후 정답 순위 · `—` 는 잘림)") + log(" " + "방식".ljust(18) + "".join(q.rjust(10) for q in CASES)) + for label, method, weight in grid: + if method is None and floor != floors[0]: + continue + try: + ranks = case_ranks( + probe_entries, presets=presets, by_user=by_user, + query_cos=query_cos, method=method, weight=weight, + floor=floor, top_k=args.top_k, rrf_k=args.rrf_k, cut_kw=cut_kw, + ) + except (GuardError, F.FusionError) as exc: + print(f"[가드] {exc}", file=sys.stderr) + return 1 + cells = "".join( + (str(ranks[q]["rank"]) if ranks.get(q, {}).get("rank") else "—").rjust(10) + for q in CASES + ) + log(f" {label:<18}{cells}") + key = "BASE" if method is None else f"floor={floor:g}·{label}" + result["cases"][key] = ranks + + log(f"\n후보 집합 불변 재정렬 {invariance_checked}회 전부 전후 Record id 집합 일치" + " (어긋나면 위에서 이미 멈췄다)") + log("\n읽는 법") + log(" · BASE 는 현행 컷 통과 집합 그대로다 — 모든 방식과 같은 자(rank_score)로 쟀다.") + log(" · 무관 세그먼트는 구조적으로 BASE 와 같아야 한다(재정렬은 후보를 못 바꾼다).") + log(" 다르면 이 스크립트가 멈춘다 — 확인용이지 조절 축이 아니다.") + log(" · floor·weight 는 순위 품질의 축이다. 인접 값의 차이가 10⁻⁴ 규모(임베딩 API") + log(" 흔들림, T68)라면 그 차이로 채택을 가르지 않는다.") + log(" · 「신호 반영 질의」 = keyword 신호가 0 이 아닌 행이 하나라도 있던 질의 수.") + + if args.json: + out = Path(args.json) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + log(f"\n저장: {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a541c2c2a821b4c217d5cd0001efbb97cfe6b898 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 14:51:41 +0900 Subject: [PATCH 16/34] =?UTF-8?q?feat(S15P11A705-339):=20keyword=20?= =?UTF-8?q?=EC=9E=AC=EC=A0=95=EB=A0=AC=20=EB=9F=B0=ED=83=80=EC=9E=84=20?= =?UTF-8?q?=E2=80=94=20=EC=BB=B7=20=ED=86=B5=EA=B3=BC=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=EB=A7=8C=20=EC=A1=B0=EC=A0=95,=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P49 §4 의 세 번째 검색 신호를 런타임에 넣는다. 컷이 후보를 확정한 뒤 keyword 신호는 그 순서만 바꾼다 — 후보 추가·제거 없음, 재정렬 후 2차 절단 없음. - context_keyword_repo.keywords_for_records(): 컷 통과 Record 의 신호 조회. keyword_status=COMPLETED 만 신호로 사용(§1-b), BLOCKED 는 PresetCache 적재 시점 제외가 후보 매치를 차단(§1-c) - SearchService._rerank_by_keyword(): 정렬 점수 = 코사인 + weight×신호. 질의-Preset 후보는 검색용 질의 임베딩 재사용(추가 모델 호출 0), 후보 규칙은 측정 하네스(fusion.preset_candidates)와 동일 — floor 먼저·동점 preset_id 순 - 실패·신호 없음·캐시 미주입이면 벡터 순서 그대로 복귀. 응답 similarity 는 원래 코사인 유지 - config: SEARCH_KEYWORD_RERANK_ENABLED(기본 false)·FLOOR 0.35·WEIGHT 0.05· TOP_K 3 — 재정렬 전용 구조 오프라인 실측의 채택값 - 계약 테스트 9건: on/off 후보 Record id 집합 불변(순서만 상이)·강등·플래그 off 동일 응답·채택값 고정·하네스와 후보 규칙 일치 Co-Authored-By: Claude Fable 5 --- app/core/config.py | 26 +++ app/main.py | 5 +- app/repository/context_keyword_repo.py | 44 +++++ app/service/search_service.py | 102 +++++++++- tests/test_search_rerank.py | 264 +++++++++++++++++++++++++ 5 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 tests/test_search_rerank.py diff --git a/app/core/config.py b/app/core/config.py index 05e94bd..03b7d7b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -191,6 +191,32 @@ class Settings(BaseSettings): # 비결정적이 된다 — 캐시가 그 성질을 막는다(P48 2단계 요구). search_rewrite_cache_size: int = Field(256, alias="SEARCH_REWRITE_CACHE_SIZE") + # Preset 키워드 재정렬 (S15P11A705-339, P49 §4). **기본 off** — 검증 게이트(P49 §7) + # 통과 전에는 어떤 환경에서도 켜지 않는다. off 면 검색은 현행과 동일하게 동작한다. + # 켜면 컷 통과 후보의 **순서만** 바뀐다 — 후보 추가·제거 없음과 similarity 원값 + # 유지는 계약 테스트(test_search_rerank.py)가 고정한다. 조회·계산이 실패해도 + # 응답은 실패하지 않고 벡터 순서로 되돌아간다. + search_keyword_rerank_enabled: bool = Field( + False, alias="SEARCH_KEYWORD_RERANK_ENABLED" + ) + # 아래 세 값은 재정렬 전용 구조의 오프라인 실측이 정했다(-339 리포트, + # tools/search_cut/fusion_rerank_sweep.py). P48 구조 실측(I53)의 같은 값과 숫자가 + # 같지만 **다른 측정의 결과다** — 구조가 달라 재측정했고 같은 값이 다시 통과했다. + # + # floor 0.35 질의-Preset 코사인 하한. 0.35~0.36 구간에서 격자 지표가 동일해 + # 임베딩 API 흔들림(10⁻⁴ 규모, T68)이 경계를 넘지 못하는 값이다 + # weight 0.05 재정렬 정렬 점수 = 원래 코사인 + weight × 신호(match=1). + # 0.10 부터 단어형 hit@3 개선이 사라지고 0.20 은 문장형 hit@1 이 + # 퇴행한다. 응답의 similarity 에는 더하지 않는다 — 정렬 전용이다 + # top_k 3 질의당 Preset 후보 수. P48 1단계 실측과 같은 값 + search_keyword_rerank_floor: float = Field( + 0.35, alias="SEARCH_KEYWORD_RERANK_FLOOR" + ) + search_keyword_rerank_weight: float = Field( + 0.05, alias="SEARCH_KEYWORD_RERANK_WEIGHT" + ) + search_keyword_rerank_top_k: int = Field(3, alias="SEARCH_KEYWORD_RERANK_TOP_K") + # PROCESSING 재선점 만료 — Spring 재스캔 만료와 동일 값 processing_expiry_sec: int = Field(600, alias="PROCESSING_EXPIRY_SEC") diff --git a/app/main.py b/app/main.py index 1f80a92..2a1f520 100644 --- a/app/main.py +++ b/app/main.py @@ -118,8 +118,11 @@ async def lifespan(app: FastAPI): app.state.embedding_client = embedding_client app.state.llm_client = llm_client app.state.preset_cache = preset_cache + # preset_cache 는 keyword 재정렬(S15P11A705-339)의 질의-Preset 후보용이다. + # SEARCH_KEYWORD_RERANK_ENABLED=false(기본)면 검색은 현행과 동일하다. app.state.search_service = SearchService( - db, embedding_client, settings, rewrite_client=rewrite_client + db, embedding_client, settings, + rewrite_client=rewrite_client, preset_cache=preset_cache, ) app.state.context_processing_service = ContextProcessingService( db, embedding_service, keyword_service diff --git a/app/repository/context_keyword_repo.py b/app/repository/context_keyword_repo.py index 01416e3..27e353b 100644 --- a/app/repository/context_keyword_repo.py +++ b/app/repository/context_keyword_repo.py @@ -12,6 +12,35 @@ _DELETE = "DELETE FROM ai.context_keyword WHERE context_id = $1" +# 검색 재정렬용 keyword 신호 조회 (S15P11A705-339, P49 §4). +# +# 대상은 컷을 통과한 후보 Record 뿐이다 — 이 조회는 후보를 만들지 않고 이미 확정된 +# 후보의 신호만 가져온다. 조건의 의미가 P48 §1-b·§1-c 그대로다. +# +# keyword_status = 'COMPLETED' 신호의 조건이지 후보의 조건이 아니다. 미완료 +# Context 의 Record 도 벡터 후보로는 그대로 남고, +# 이 조회에서 행이 안 나올 뿐이다(§1-b) +# visibility(BLOCKED 제외) 여기서 거르지 않는다 — 질의-Preset 후보를 만드는 +# PresetCache 가 적재 시점에 BLOCKED 를 제외하므로 +# (preset_cache.load), BLOCKED keyword 는 후보 집합과 +# 매치될 수 없다. PUBLIC·PRIVATE_ONLY 는 쓴다(§1-c) +# +# Record 소속 Context 는 ai.context_embedding 으로 잇는다 — core.context 는 공용 +# 계약이 접근을 금지하고, keyword 판정은 embedding 완료 뒤에만 도니 keyword 를 가진 +# Context 는 embedding 행을 반드시 갖는다. DISTINCT 는 한 Record 의 여러 Context 가 +# 같은 keyword 를 가질 때의 중복 행 제거다(신호 집계가 Record 단위 max 라 중복은 +# 결과를 바꾸지 않지만 행 수만 늘린다). +_KEYWORDS_FOR_RECORDS = """ +SELECT DISTINCT e.record_id, ck.keyword_id +FROM ai.context_embedding e +JOIN ai.context_ai_state s ON s.context_id = e.context_id +JOIN ai.context_keyword ck ON ck.context_id = e.context_id +WHERE e.user_id = $1 + AND e.record_id = ANY($2::bigint[]) + AND e.is_deleted = false + AND s.keyword_status = 'COMPLETED' +""" + _INSERT = """ INSERT INTO ai.context_keyword (context_id, keyword_id, confidence, preset_version) VALUES ($1, $2, $3, $4) @@ -47,6 +76,21 @@ async def replace( ) +async def keywords_for_records( + conn: asyncpg.Connection, + user_id: int, + record_ids: list[int], +) -> list: + """컷 통과 후보 Record 들의 (record_id, keyword_id) 신호 행 (S15P11A705-339). + + `keyword_status = COMPLETED` 인 Context 의 keyword 만 나온다. 조건의 의미와 + visibility 처리 위치는 위 `_KEYWORDS_FOR_RECORDS` 주석에 있다. + """ + if not record_ids: + return [] + return await conn.fetch(_KEYWORDS_FOR_RECORDS, user_id, record_ids) + + async def upsert_analysis( conn: asyncpg.Connection, context_id: int, diff --git a/app/service/search_service.py b/app/service/search_service.py index b50d5c4..37d5d2b 100644 --- a/app/service/search_service.py +++ b/app/service/search_service.py @@ -16,12 +16,18 @@ """ from __future__ import annotations +import numpy as np + +from app.cache.preset_cache import PresetCache from app.client.embedding_client import EmbeddingClient from app.client.rewrite_client import RewriteClient from app.core.config import Settings from app.core.db import Database from app.core.errors import PermanentError, ProfileMismatchError, TransientError -from app.repository import context_embedding_repo +from app.core.logging import get_logger +from app.repository import context_embedding_repo, context_keyword_repo + +log = get_logger("app.service.search") class SearchService: @@ -31,11 +37,13 @@ def __init__( embedding_client: EmbeddingClient, settings: Settings, rewrite_client: RewriteClient | None = None, + preset_cache: PresetCache | None = None, ) -> None: self._db = db self._embedding = embedding_client self._settings = settings self._rewrite = rewrite_client + self._preset_cache = preset_cache async def search( self, user_id: int, query: str, limit: int, embedding_profile: str @@ -63,6 +71,13 @@ async def search( rows = await context_embedding_repo.search( conn, user_id, embedding_profile, query_embedding, limit ) + # 컷이 후보를 확정한 **뒤에** keyword 재정렬이 순서만 조정한다(P49 §4). + # 재정렬은 같은 커넥션으로 context_keyword 를 읽어야 해서 컷을 acquire + # 안으로 옮겼다 — 컷은 순수 계산이라 위치가 결과를 바꾸지 않는다. + kept = self._cut(rows, query_text) + kept = await self._rerank_by_keyword( + conn, user_id, kept, query_embedding + ) return [ { @@ -70,7 +85,7 @@ async def search( "contextId": r["context_id"], "similarity": round(float(r["similarity"]), 4), } - for r in self._cut(rows, query_text) + for r in kept ] def _is_word_query(self, query: str) -> bool: @@ -136,3 +151,86 @@ def _cut(self, rows: list, query: str = "") -> list: for r in rows if float(r["similarity"]) >= floor and float(r["similarity"]) >= ratio * top ] + + def _preset_candidates(self, query_embedding: list[float]) -> set[int]: + """질의-Preset 코사인이 floor 이상인 상위 top_k Preset id (P48 1단계 규칙). + + 추가 모델 호출이 없다 — 검색용으로 이미 만든 질의 임베딩을 재사용하고, + Preset 임베딩은 기동 시 적재된 메모리 캐시에서 읽는다. `BLOCKED` Preset 은 + 캐시 적재 시점에 이미 빠져 있고(`preset_cache.load`), `PUBLIC`·`PRIVATE_ONLY` + 는 신호로 쓴다(P48 §1-c). + + floor 를 top_k 보다 먼저 걸고 동점은 preset_id 오름차순으로 깬다 — 측정 + 하네스(`tools/search_cut/fusion.preset_candidates`)와 같은 규칙이다. 잰 + 규칙과 돌리는 규칙이 다르면 채택값의 근거가 사라진다. + """ + vector = np.asarray(query_embedding, dtype=np.float32) + norm = float(np.linalg.norm(vector)) + if norm == 0.0: + return set() + query = vector / norm + + floor = self._settings.search_keyword_rerank_floor + scored = [] + for preset in self._preset_cache.snapshot().presets: + preset_norm = float(np.linalg.norm(preset.embedding)) + if preset_norm == 0.0: + continue + cos = float(preset.embedding @ query) / preset_norm + if cos >= floor: + scored.append((-cos, preset.id)) + scored.sort() + return {pid for _, pid in scored[: self._settings.search_keyword_rerank_top_k]} + + async def _rerank_by_keyword( + self, conn, user_id: int, kept: list, query_embedding: list[float] + ) -> list: + """컷 통과 후보의 **순서만** keyword 신호로 조정한다 (S15P11A705-339, P49 §4). + + 후보를 추가·제거하지 않는다 — 관련 없는 질의에서 컷 통과가 0건이면 재정렬 + 대상도 0건이므로, 이 신호만으로 관련 없는 결과가 새로 노출될 수 없다(P49 §5). + 재정렬 뒤 두 번째 절단도 없다. 이 성질은 on/off 후보 집합 불변 계약 테스트가 + 고정한다(`test_search_rerank.py`). + + 정렬 점수 = 원래 코사인 + weight × 신호(후보 Preset 과 match 면 1, 아니면 0). + binary 방식·floor 0.35·weight 0.05 는 오프라인 실측이 정했다(-339 리포트). + 점수는 정렬에만 쓰고 응답의 `similarity` 는 원래 코사인 그대로다. + + 어떤 단계가 실패해도 응답은 실패하지 않는다 — 그 단계만 생략하고 벡터 + 순서를 그대로 반환한다(P49 §5 의 실패 시 복귀 규칙). + """ + if ( + not self._settings.search_keyword_rerank_enabled + or self._preset_cache is None # 조립 실수의 방어선 — rewrite 와 같은 규칙 + or len(kept) < 2 # 0·1건은 바꿀 순서가 없다 + ): + return kept + try: + candidates = self._preset_candidates(query_embedding) + if not candidates: + return kept + signal_rows = await context_keyword_repo.keywords_for_records( + conn, user_id, [r["record_id"] for r in kept] + ) + matched = { + row["record_id"] + for row in signal_rows + if row["keyword_id"] in candidates + } + if not matched: + return kept + weight = self._settings.search_keyword_rerank_weight + # sorted 는 안정 정렬이다 — 점수가 같은 행(신호 없는 행끼리 등)은 + # 벡터 순서가 그대로 유지된다. + return sorted( + kept, + key=lambda r: -( + float(r["similarity"]) + + (weight if r["record_id"] in matched else 0.0) + ), + ) + except Exception: + log.warning( + "keyword rerank failed; falling back to vector order", exc_info=True + ) + return kept diff --git a/tests/test_search_rerank.py b/tests/test_search_rerank.py new file mode 100644 index 0000000..97d8e0f --- /dev/null +++ b/tests/test_search_rerank.py @@ -0,0 +1,264 @@ +"""검색 keyword 재정렬 — on/off 후보 불변·강등·플래그 off 계약 (S15P11A705-339). + +고정하는 계약은 다섯이다. + + ① 플래그 off(기본값)면 keyword 조회가 호출되지 않고 응답은 벡터 순서 그대로다 + — 현행 검색과 동작이 같다 + ② 같은 요청·같은 limit 에서 on/off 의 후보 Record id 집합이 같다 — 허용되는 + 차이는 순서뿐이다. 재정렬 후 두 번째 절단도 없다(반환 건수 동일). + 측정 지점은 문자열 병합 직전 = 이 API 의 반환값이다(P49 §4) + ③ 신호와 match 한 후보는 위로 오고, 응답의 `similarity` 는 원래 코사인 그대로다 + ④ 조회·계산 실패는 오류가 아니라 강등이다 — 벡터 순서로 되돌아간다 + ⑤ 신호가 없으면(후보 Preset 없음·match 없음) 벡터 순서 그대로다 + +DB 는 가짜 커넥션으로 대체한다 — 여기서 재는 것은 재정렬 경로이지 SQL 이 아니다. +같은 계약의 오프라인 판은 tests/test_search_fusion.py §9(rerank 픽스처)에 있다. +""" +from __future__ import annotations + +from contextlib import asynccontextmanager + +import numpy as np +import pytest + +from app.cache.preset_cache import Preset, PresetSnapshot +from app.core.config import Settings +from app.service.search_service import SearchService + + +def _settings(monkeypatch, **overrides) -> Settings: + from tests.test_unit import _ENV as env + for k, v in {**env, **overrides}.items(): + monkeypatch.setenv(k, v) + return Settings(_env_file=None) + + +PROFILE = "openai-text-embedding-3-small-1536-cosine-v1" + +# 질의 임베딩과 나란한 축([1,0,0,0])의 Preset 만 후보가 된다(cos=1.0 ≥ floor 0.35). +# 직교 축([0,1,0,0])은 cos=0.0 이라 후보가 아니다. +QUERY_AXIS = [1.0, 0.0, 0.0, 0.0] +OTHER_AXIS = [0.0, 1.0, 0.0, 0.0] + + +def _preset(pid: int, vec: list[float]) -> Preset: + return Preset( + id=pid, code=f"P{pid}", display_name="", category="", description="", + examples=[], visibility="PUBLIC", version=1, + embedding=np.asarray(vec, dtype=np.float32), + ) + + +class _Presets: + """PresetCache 자리에 꽂는 스냅샷 스텁. + + BLOCKED 제외는 실물 `PresetCache.load` 의 책임이고 그 계약은 + test_pipeline(시나리오 15)이 고정한다 — 여기서는 적재된 뒤의 후보 계산만 잰다. + """ + + def __init__(self, presets: list[Preset]): + self._snapshot = PresetSnapshot(presets=tuple(presets), version=1) + + def snapshot(self) -> PresetSnapshot: + return self._snapshot + + +class _FakeEmbedding: + async def embed_one(self, text: str): + return list(QUERY_AXIS) + + +class _FakeDb: + @asynccontextmanager + async def acquire(self): + yield None + + +# 컷을 전부 통과하는 행 3건 (단어형 질의 `부캠`: tau_word 0.24 · r 0.6×0.50=0.30). +# 102 가 keyword match 를 받으면 0.48+0.05=0.53 > 0.50 으로 1위에 온다. +ROWS = [ + {"record_id": 101, "context_id": 11, "similarity": 0.50}, + {"record_id": 102, "context_id": 12, "similarity": 0.48}, + {"record_id": 103, "context_id": 13, "similarity": 0.40}, +] + + +@pytest.fixture +def vector_rows(monkeypatch): + async def _rows(conn, user_id, profile, embedding, limit): + return [dict(r) for r in ROWS] + + monkeypatch.setattr( + "app.service.search_service.context_embedding_repo.search", _rows + ) + + +class _FakeKeywordRepo: + """keywords_for_records 자리에 꽂는 가짜 — 호출 수와 반환·오류를 제어한다.""" + + def __init__(self, rows=None, error: Exception | None = None): + self.rows = rows or [] + self.error = error + self.calls = 0 + + async def __call__(self, conn, user_id, record_ids): + self.calls += 1 + if self.error is not None: + raise self.error + return list(self.rows) + + +def _service(monkeypatch, settings, presets=None, keyword_rows=None, error=None): + repo = _FakeKeywordRepo(rows=keyword_rows, error=error) + monkeypatch.setattr( + "app.service.search_service.context_keyword_repo.keywords_for_records", repo + ) + cache = _Presets(presets) if presets is not None else None + service = SearchService( + _FakeDb(), _FakeEmbedding(), settings, preset_cache=cache + ) + return service, repo + + +async def _search(service): + return await service.search(1, "부캠", 20, PROFILE) + + +# match: preset 1(질의와 나란한 축) 을 record 102 가 가진다. +MATCH_102 = [{"record_id": 102, "keyword_id": 1}] +ALIGNED = [_preset(1, QUERY_AXIS), _preset(2, OTHER_AXIS)] + + +@pytest.mark.anyio +async def test_flag_off_never_reads_keywords_and_keeps_vector_order( + monkeypatch, vector_rows +): + """① 기본값(off)에서 keyword 조회 0회, 응답은 벡터 순서 그대로다.""" + settings = _settings(monkeypatch) + assert settings.search_keyword_rerank_enabled is False + service, repo = _service( + monkeypatch, settings, presets=ALIGNED, keyword_rows=MATCH_102 + ) + result = await _search(service) + assert repo.calls == 0 + assert [r["recordId"] for r in result] == [101, 102, 103] + + +@pytest.mark.anyio +async def test_candidate_set_is_invariant_between_on_and_off( + monkeypatch, vector_rows +): + """② on/off 의 후보 Record id 집합이 같다 — 차이는 순서뿐, 2차 절단 없음.""" + off_settings = _settings(monkeypatch) + service, _ = _service( + monkeypatch, off_settings, presets=ALIGNED, keyword_rows=MATCH_102 + ) + off = await _search(service) + + on_settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, repo = _service( + monkeypatch, on_settings, presets=ALIGNED, keyword_rows=MATCH_102 + ) + on = await _search(service) + + assert repo.calls == 1 + assert {r["recordId"] for r in on} == {r["recordId"] for r in off} + assert len(on) == len(off), "재정렬 후 두 번째 절단이 있어서는 안 된다" + assert [r["recordId"] for r in on] != [r["recordId"] for r in off], \ + "이 픽스처는 순서가 바뀌는 조건이다 — 안 바뀌면 재정렬이 죽은 것이다" + + +@pytest.mark.anyio +async def test_matched_record_moves_up_and_similarity_is_untouched( + monkeypatch, vector_rows +): + """③ match 후보(102)가 1위로 오고 similarity 는 원래 코사인 그대로다.""" + settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, _ = _service( + monkeypatch, settings, presets=ALIGNED, keyword_rows=MATCH_102 + ) + result = await _search(service) + assert [r["recordId"] for r in result] == [102, 101, 103] + assert {r["recordId"]: r["similarity"] for r in result} == { + 101: 0.50, 102: 0.48, 103: 0.40, + }, "정렬 점수(코사인+weight)가 응답에 새어 나가면 안 된다" + + +@pytest.mark.anyio +async def test_fetch_failure_degrades_to_vector_order(monkeypatch, vector_rows): + """④ keyword 조회 실패는 강등 — 응답이 실패하지 않고 벡터 순서를 돌려준다.""" + settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, repo = _service( + monkeypatch, settings, presets=ALIGNED, error=RuntimeError("db down") + ) + result = await _search(service) + assert repo.calls == 1 + assert [r["recordId"] for r in result] == [101, 102, 103] + + +@pytest.mark.anyio +async def test_no_candidate_above_floor_skips_keyword_read( + monkeypatch, vector_rows +): + """⑤ 후보 Preset 이 없으면(코사인 전부 floor 미만) 조회 없이 벡터 순서다.""" + settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, repo = _service( + monkeypatch, settings, + presets=[_preset(2, OTHER_AXIS)], keyword_rows=MATCH_102, + ) + result = await _search(service) + assert repo.calls == 0 + assert [r["recordId"] for r in result] == [101, 102, 103] + + +@pytest.mark.anyio +async def test_no_match_keeps_vector_order(monkeypatch, vector_rows): + """⑤ 조회는 됐는데 후보 Preset 과 match 가 없으면 벡터 순서 그대로다.""" + settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, repo = _service( + monkeypatch, settings, presets=ALIGNED, + keyword_rows=[{"record_id": 102, "keyword_id": 2}], # 후보(1)가 아닌 keyword + ) + result = await _search(service) + assert repo.calls == 1 + assert [r["recordId"] for r in result] == [101, 102, 103] + + +@pytest.mark.anyio +async def test_flag_on_without_cache_uses_vector_order(monkeypatch, vector_rows): + """캐시 미주입이면 플래그가 켜져 있어도 벡터 순서다 — 조립 실수의 방어선.""" + settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, repo = _service( + monkeypatch, settings, presets=None, keyword_rows=MATCH_102 + ) + result = await _search(service) + assert repo.calls == 0 + assert [r["recordId"] for r in result] == [101, 102, 103] + + +def test_defaults_are_the_measured_values(monkeypatch): + """채택값이 조용히 바뀌면 재정렬은 남고 -339 실측 근거만 사라진다.""" + s = _settings(monkeypatch) + assert s.search_keyword_rerank_enabled is False + assert s.search_keyword_rerank_floor == 0.35 + assert s.search_keyword_rerank_weight == 0.05 + assert s.search_keyword_rerank_top_k == 3 + + +def test_candidate_rule_matches_the_harness(monkeypatch): + """floor 를 top_k 보다 먼저 걸고 동점은 preset_id 오름차순 — 하네스와 같은 규칙. + + 잰 규칙(tools/search_cut/fusion.preset_candidates)과 돌리는 규칙이 갈리면 + 채택값의 근거가 사라진다. 축 상 동일한 Preset 셋으로 동점 처리를 고정한다. + """ + settings = _settings( + monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true", + SEARCH_KEYWORD_RERANK_TOP_K="2", + ) + service = SearchService( + None, None, settings, + preset_cache=_Presets( + [_preset(3, QUERY_AXIS), _preset(1, QUERY_AXIS), _preset(2, QUERY_AXIS)] + ), + ) + assert service._preset_candidates(QUERY_AXIS) == {1, 2} From 84e684fe878683400251392f68562f8ddec70ea0 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 15:10:20 +0900 Subject: [PATCH 17/34] =?UTF-8?q?feat(S15P11A705-337):=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EA=B8=B8=EC=9D=B4=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=E2=80=94=20strip=20=ED=9B=84=206=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B4=ED=95=98=EB=A7=8C=20=EC=9E=AC=EC=9E=91=EC=84=B1=20(I5?= =?UTF-8?q?5=20=ED=99=95=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측(I55)에서 재작성의 이득(부캠 8위→1위·신한 부캠 4위→3위 회복)은 전부 5자 이하 질의에서 났고, 손해(무관 무노출 11/15→9/15)는 긴 문장형 질의의 표현 정규화에서만 났다. 게이트 후보 8종 전수 비교에서 형태(길이) 기준만 회복·무노출을 동시 충족 — 성과 기반(결과 0건·top-1 임계)은 대역이 겹쳐 분리 불가에 회복 대상까지 놓치고, 단어형 판정 재사용은 공백 불허라 신한 부캠을 잃는다. 6 은 이득 실측 상한(5자)에 여유 1자를 더한 보수 값, 2026-08-06 사용자 확정. - SearchService._should_rewrite(): 게이트 판정. 걸린 질의는 LLM 호출 없이 원문 검색 - SEARCH_REWRITE_MAX_CHARS(기본 6): 운영 관측 후 재배포 없이 조정 - 게이트 계약 테스트 6건(호출 0회·경계 6자·strip 판정·설정 가변) — 전체 500건 통과 - rewrite_probe: 게이트 재구성 반영(걸린 질의는 재측정 없이 원문 값) 후 재실행 — 무노출 11/15 유지·회복 2건 유지로 채택 조건 충족. artifact 는 게이트 반영 최종판 Co-Authored-By: Claude Fable 5 --- .search/rewrite_probe.json | 85 ++++++++++++++++++++----------- app/core/config.py | 8 +++ app/service/search_service.py | 24 ++++++++- tests/test_search_rewrite.py | 61 +++++++++++++++++++++- tools/search_cut/rewrite_probe.py | 57 ++++++++++++++++----- 5 files changed, 187 insertions(+), 48 deletions(-) diff --git a/.search/rewrite_probe.json b/.search/rewrite_probe.json index cfe8da2..da55029 100644 --- a/.search/rewrite_probe.json +++ b/.search/rewrite_probe.json @@ -7,6 +7,9 @@ "gemini:gemini-2.5-flash", "anthropic:claude-haiku-4-5-20251001" ], + "gate": { + "max_chars": 6 + }, "cut": { "tau_abs": 0.3, "tau_abs_word": 0.24, @@ -17,123 +20,138 @@ "offtopic": [ { "query": "자동차 엔진오일 교환 정비소", - "rewritten": "자동차 엔진 오일 교환 정비소", + "rewritten": "자동차 엔진오일 교환 정비소", "degraded": false, + "gated_out": true, "before_returned": 0, "after_returned": 0, - "after_top1_sim": 0.135381 + "after_top1_sim": 0.139254 }, { "query": "자동차 엔진오일 교환 정비소", - "rewritten": "자동차 엔진 오일 교환 정비소", + "rewritten": "자동차 엔진오일 교환 정비소", "degraded": false, + "gated_out": true, "before_returned": 0, "after_returned": 0, - "after_top1_sim": 0.183419 + "after_top1_sim": 0.178002 }, { "query": "자동차 엔진오일 교환 정비소", - "rewritten": "자동차 엔진 오일 교환 정비소", + "rewritten": "자동차 엔진오일 교환 정비소", "degraded": false, + "gated_out": true, "before_returned": 0, "after_returned": 0, - "after_top1_sim": 0.199427 + "after_top1_sim": 0.19617 }, { "query": "치과 임플란트 상담 받을 곳", "rewritten": "치과 임플란트 상담 받을 곳", "degraded": false, + "gated_out": true, "before_returned": 0, "after_returned": 0, - "after_top1_sim": 0.268693 + "after_top1_sim": 0.268575 }, { "query": "치과 임플란트 상담 받을 곳", "rewritten": "치과 임플란트 상담 받을 곳", "degraded": false, + "gated_out": true, "before_returned": 2, "after_returned": 2, - "after_top1_sim": 0.381751 + "after_top1_sim": 0.381861 }, { "query": "치과 임플란트 상담 받을 곳", "rewritten": "치과 임플란트 상담 받을 곳", "degraded": false, + "gated_out": true, "before_returned": 1, "after_returned": 1, - "after_top1_sim": 0.30068 + "after_top1_sim": 0.300659 }, { "query": "겨울 스키장 리프트권 파는 데", - "rewritten": "겨울 스키장 리프트권 파는 곳", + "rewritten": "겨울 스키장 리프트권 파는 데", "degraded": false, + "gated_out": true, "before_returned": 0, "after_returned": 0, - "after_top1_sim": 0.236606 + "after_top1_sim": 0.203196 }, { "query": "겨울 스키장 리프트권 파는 데", - "rewritten": "겨울 스키장 리프트권 파는 곳", + "rewritten": "겨울 스키장 리프트권 파는 데", "degraded": false, + "gated_out": true, "before_returned": 0, - "after_returned": 2, - "after_top1_sim": 0.319109 + "after_returned": 0, + "after_top1_sim": 0.287699 }, { "query": "겨울 스키장 리프트권 파는 데", - "rewritten": "겨울 스키장 리프트권 파는 곳", + "rewritten": "겨울 스키장 리프트권 파는 데", "degraded": false, + "gated_out": true, "before_returned": 0, - "after_returned": 3, - "after_top1_sim": 0.338516 + "after_returned": 0, + "after_top1_sim": 0.287672 }, { "query": "노트북 액정 수리 서비스센터", - "rewritten": "노트북 액정 수리 서비스 센터", + "rewritten": "노트북 액정 수리 서비스센터", "degraded": false, + "gated_out": true, "before_returned": 1, "after_returned": 1, - "after_top1_sim": 0.336829 + "after_top1_sim": 0.337131 }, { "query": "노트북 액정 수리 서비스센터", - "rewritten": "노트북 액정 수리 서비스 센터", + "rewritten": "노트북 액정 수리 서비스센터", "degraded": false, + "gated_out": true, "before_returned": 0, "after_returned": 0, - "after_top1_sim": 0.268582 + "after_top1_sim": 0.276852 }, { "query": "노트북 액정 수리 서비스센터", - "rewritten": "노트북 액정 수리 서비스 센터", + "rewritten": "노트북 액정 수리 서비스센터", "degraded": false, + "gated_out": true, "before_returned": 0, "after_returned": 0, - "after_top1_sim": 0.252289 + "after_top1_sim": 0.246285 }, { "query": "강아지 예방접종 동물병원", - "rewritten": "강아지 예방 접종 동물 병원", + "rewritten": "강아지 예방접종 동물병원", "degraded": false, + "gated_out": true, "before_returned": 0, "after_returned": 0, - "after_top1_sim": 0.235863 + "after_top1_sim": 0.211259 }, { "query": "강아지 예방접종 동물병원", - "rewritten": "강아지 예방 접종 동물 병원", + "rewritten": "강아지 예방접종 동물병원", "degraded": false, + "gated_out": true, "before_returned": 0, "after_returned": 0, - "after_top1_sim": 0.254679 + "after_top1_sim": 0.253324 }, { "query": "강아지 예방접종 동물병원", - "rewritten": "강아지 예방 접종 동물 병원", + "rewritten": "강아지 예방접종 동물병원", "degraded": false, + "gated_out": true, "before_returned": 1, "after_returned": 1, - "after_top1_sim": 0.335193 + "after_top1_sim": 0.324863 } ], "cases": [ @@ -141,6 +159,7 @@ "query": "부캠", "rewritten": "부트캠프", "degraded": false, + "gated_out": false, "expect": "카츠요", "before": { "pre_cut_rank": 8, @@ -157,6 +176,7 @@ "query": "신한 부캠", "rewritten": "신한 부트캠프", "degraded": false, + "gated_out": false, "expect": "카츠요", "before": { "pre_cut_rank": 4, @@ -173,6 +193,7 @@ "query": "신한", "rewritten": "신한", "degraded": false, + "gated_out": false, "expect": "카츠요", "before": { "pre_cut_rank": 6, @@ -189,6 +210,7 @@ "query": "그네", "rewritten": "그네", "degraded": false, + "gated_out": false, "expect": "동교어린이공원", "before": { "pre_cut_rank": 3, @@ -205,6 +227,7 @@ "query": "스팟", "rewritten": "스팟", "degraded": false, + "gated_out": false, "expect": "동교어린이공원", "before": { "pre_cut_rank": 1, @@ -220,8 +243,8 @@ ], "verdict": { "silent_before": 11, - "silent_after": 9, + "silent_after": 11, "baseline": 11, - "adopted": false + "adopted": true } } \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 05e94bd..72b8a2e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -190,6 +190,14 @@ class Settings(BaseSettings): # 질의 단위 재작성 캐시 상한. 같은 질의가 회차마다 다른 재작성을 받으면 검색이 # 비결정적이 된다 — 캐시가 그 성질을 막는다(P48 2단계 요구). search_rewrite_cache_size: int = Field(256, alias="SEARCH_REWRITE_CACHE_SIZE") + # 재작성 적용 게이트 — 앞뒤 공백을 정리한 질의가 이 글자 수 이하일 때만 재작성한다. + # 실측(I55)에서 재작성의 이득은 전부 짧은 약어 질의(5자 이하)에서 났고, 손해는 긴 + # 문장형 질의에서만 났다(`파는 데`→`파는 곳` 표현 정규화가 무관 질의 무노출을 + # 11/15→9/15 로 무너뜨렸다). 성과 기반 게이트(결과 0건·top-1 유사도)는 두 집단의 + # 값 대역이 겹쳐 분리하지 못했고, 회복 대상(`부캠` — 원문도 오답 3건을 반환)까지 + # 놓친다. 6 은 이득이 실측된 상한(5자)에 여유 1자를 더한 보수 값이며, 코퍼스상 + # 5~13자 어디든 지표가 같아 경계 재조정은 운영 질의 관측 후의 일이다. + search_rewrite_max_chars: int = Field(6, alias="SEARCH_REWRITE_MAX_CHARS") # PROCESSING 재선점 만료 — Spring 재스캔 만료와 동일 값 processing_expiry_sec: int = Field(600, alias="PROCESSING_EXPIRY_SEC") diff --git a/app/service/search_service.py b/app/service/search_service.py index b50d5c4..a4a0314 100644 --- a/app/service/search_service.py +++ b/app/service/search_service.py @@ -51,7 +51,11 @@ async def search( # 임베딩된 텍스트의 유사도 대역을 따라가는 장치이므로, 임베딩 입력과 판정 입력이 # 갈리면 안 된다. query_text = query - if self._settings.search_llm_enabled and self._rewrite is not None: + if ( + self._settings.search_llm_enabled + and self._rewrite is not None + and self._should_rewrite(query) + ): try: query_text = await self._rewrite.rewrite(query) except (TransientError, PermanentError): @@ -73,6 +77,24 @@ async def search( for r in self._cut(rows, query_text) ] + def _should_rewrite(self, query: str) -> bool: + """재작성 게이트 — 앞뒤 공백을 정리한 질의가 짧을 때만 재작성한다(I55). + + 실측에서 재작성의 이득(약어 회복: `부캠` 컷 전 8위→1위·`신한 부캠` 4위→3위)은 + 전부 5자 이하 질의에서 났고, 손해는 긴 문장형 질의에서만 났다 — 의미가 같은 + 표현 정규화(`파는 데`→`파는 곳`)가 유사도를 컷 위로 올려 관련 없는 질의의 + 무노출을 11/15 에서 9/15 로 무너뜨렸다. + + 결과 부족·top-1 유사도 같은 성과 기반 게이트는 쓰지 않는다 — 회복 대상 질의도 + 원문 검색이 오답을 반환하고 있어(0건이 아니다) 그 신호로는 두 집단이 갈리지 + 않는 것이 실측됐다. 단어형 판정(`_is_word_query`)을 재사용하지도 않는다 — + 공백 불허 조건이 `신한 부캠`(공백 포함 5자)을 배제해 회복 1건을 잃는다. + + 길이는 `len()`(코드 포인트)으로 센다. 빈 질의는 재작성할 것이 없다. + """ + q = query.strip() + return bool(q) and len(q) <= self._settings.search_rewrite_max_chars + def _is_word_query(self, query: str) -> bool: """단어형인가. **공백이 없고 짧을 때만** 그렇다 — 두 조건을 함께 요구한다. diff --git a/tests/test_search_rewrite.py b/tests/test_search_rewrite.py index 57d085c..de81bdc 100644 --- a/tests/test_search_rewrite.py +++ b/tests/test_search_rewrite.py @@ -1,12 +1,14 @@ -"""검색 질의 LLM 재작성 — 강등·캐시·플래그 off 계약 (S15P11A705-337). +"""검색 질의 LLM 재작성 — 강등·캐시·플래그 off·길이 게이트 계약 (S15P11A705-337). -고정하는 계약은 넷이다. +고정하는 계약은 다섯이다. ① 플래그 off(기본값)면 재작성 클라이언트가 호출되지 않고 원문이 임베딩된다 — 현행 검색과 동작이 같다 ② 재작성 성공이면 재작성문이 임베딩되고 컷 판정도 재작성문 기준이다 ③ 재작성 실패(일시·영구)는 오류가 아니라 강등이다 — 원문으로 검색이 계속된다 ④ 같은 질의는 캐시로 같은 재작성을 받는다 — LLM 을 한 번만 부른다 + ⑤ 길이 게이트(I55) — 앞뒤 공백 정리 후 6자 이하 질의만 재작성한다. 긴 질의는 + 플래그가 켜져 있어도 LLM 호출 없이 원문으로 검색한다 DB 는 가짜 커넥션으로 대체한다 — 여기서 재는 것은 재작성 경로이지 SQL 이 아니다. """ @@ -125,6 +127,61 @@ async def test_flag_on_without_client_uses_original(monkeypatch, no_rows): assert emb.calls == ["부캠"] +@pytest.mark.anyio +async def test_long_query_skips_rewrite_even_when_enabled(monkeypatch, no_rows): + """⑤ 게이트 — 긴 질의는 플래그가 켜져 있어도 재작성하지 않는다. + + 실측(I55)에서 손해는 전부 긴 문장형 질의에서 났다(`파는 데`→`파는 곳` 표현 + 정규화가 무관 질의 무노출 11/15 를 9/15 로 무너뜨렸다). LLM 호출 자체가 없어야 + 하고(비용·지연 포함), 원문이 그대로 임베딩되어야 한다. + """ + settings = _settings(monkeypatch, SEARCH_LLM_ENABLED="true") + emb, rw = _FakeEmbedding(), _FakeRewrite(result="다른 문장") + service = SearchService(_FakeDb(), emb, settings, rewrite_client=rw) + await service.search(1, "겨울 스키장 리프트권 파는 데", 20, PROFILE) + assert rw.calls == 0 + assert emb.calls == ["겨울 스키장 리프트권 파는 데"] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("query", "rewritten"), + [ + ("부캠", "부트캠프"), # 1어절 약어 — 회복 실측 사례 + ("신한 부캠", "신한 부트캠프"), # 공백 포함 5자 — 단어형 판정 재사용이면 잃는 사례 + ("여섯글자질의", "여섯 글자 질의"), # 경계값 6자 — 게이트 포함 방향 확인 + ], +) +async def test_short_query_passes_the_gate(monkeypatch, no_rows, query, rewritten): + """⑤ 게이트 — 6자 이하 질의는 재작성 경로를 그대로 탄다.""" + settings = _settings(monkeypatch, SEARCH_LLM_ENABLED="true") + emb, rw = _FakeEmbedding(), _FakeRewrite(result=rewritten) + service = SearchService(_FakeDb(), emb, settings, rewrite_client=rw) + await service.search(1, query, 20, PROFILE) + assert rw.calls == 1 + assert emb.calls == [rewritten] + + +@pytest.mark.anyio +async def test_gate_measures_the_stripped_length(monkeypatch, no_rows): + """⑤ 게이트의 길이는 앞뒤 공백을 정리한 값이다 — 공백 패딩이 판정을 바꾸면 + 같은 질의가 입력 모양에 따라 다른 경로를 탄다.""" + settings = _settings(monkeypatch, SEARCH_LLM_ENABLED="true") + emb, rw = _FakeEmbedding(), _FakeRewrite(result="부트캠프") + service = SearchService(_FakeDb(), emb, settings, rewrite_client=rw) + await service.search(1, " 부캠 ", 20, PROFILE) + assert rw.calls == 1 + + +def test_gate_threshold_is_configurable(monkeypatch): + """⑤ 경계값은 설정(`SEARCH_REWRITE_MAX_CHARS`)이다 — 운영 질의 관측 후 재배포 + 없이 조정한다(I55 의 5~13자 동일 구간 근거).""" + settings = _settings(monkeypatch, SEARCH_REWRITE_MAX_CHARS="4") + service = SearchService(None, None, settings) + assert service._should_rewrite("부캠") is True + assert service._should_rewrite("신한 부캠") is False # 5자 — 낮춘 경계 밖 + + def test_cut_follows_rewritten_query_band(monkeypatch): """② 컷의 단어형/문장형 분기는 임베딩된 텍스트를 따라간다. diff --git a/tools/search_cut/rewrite_probe.py b/tools/search_cut/rewrite_probe.py index ab7c238..935f82c 100644 --- a/tools/search_cut/rewrite_probe.py +++ b/tools/search_cut/rewrite_probe.py @@ -84,6 +84,16 @@ def is_word_query(q: str, max_chars: int) -> bool: return bool(q) and not any(c.isspace() for c in q) and len(q) <= max_chars +def should_rewrite(q: str, max_chars: int) -> bool: + """서비스의 재작성 길이 게이트(`SearchService._should_rewrite`)를 다시 적는다. + + 게이트에 걸린 질의는 재작성·재임베딩 없이 원문 값이 곧 결과다 — 서비스도 LLM 을 + 부르지 않으므로 측정에서 GMS 를 부르면 그것이 재구성 불일치다. + """ + q = q.strip() + return bool(q) and len(q) <= max_chars + + def apply_cut(results: list[dict], *, query: str, settings) -> list[dict]: """서비스의 컷(`SearchService._cut`)을 재구성한다. SQL LIMIT 이 먼저, 컷이 뒤다. @@ -148,11 +158,16 @@ async def rewrite_all(queries: list[str], settings) -> dict[str, dict]: ) out: dict[str, dict] = {} for q in queries: + if not should_rewrite(q, settings.search_rewrite_max_chars): + out[q] = {"rewritten": q, "degraded": False, "gated_out": True} + log(f" 게이트 {q!r} (길이 초과 — 재작성 없이 원문)") + continue try: rewritten = await client.rewrite(q) - out[q] = {"rewritten": rewritten, "degraded": False} + out[q] = {"rewritten": rewritten, "degraded": False, "gated_out": False} except (TransientError, PermanentError) as e: - out[q] = {"rewritten": q, "degraded": True, "error": type(e).__name__} + out[q] = {"rewritten": q, "degraded": True, "gated_out": False, + "error": type(e).__name__} log(f" 재작성 {q!r} → {out[q]['rewritten']!r}" + (" (강등: 원문 유지)" if out[q]["degraded"] else "")) return out @@ -160,14 +175,17 @@ async def rewrite_all(queries: list[str], settings) -> dict[str, dict]: async def measure(db: Database, settings, rewrites: dict[str, dict], offtopic: list[dict], cases: list[dict]) -> dict: - texts = [rewrites[q]["rewritten"] for q in rewrites] + # 게이트를 통과한 질의만 임베딩한다 — 게이트에 걸린 질의는 서비스도 원문 그대로 + # 검색하므로 원문 기준 값(굳힌 행렬의 재구성)이 곧 재작성 후 값이다. + live = [q for q, rw in rewrites.items() if not rw["gated_out"]] client = EmbeddingClient( base_url=settings.gms_base_url, api_key=settings.gms_api_key, model=settings.embedding_model, dimension=settings.embedding_dimension, ) - vectors = dict(zip(rewrites.keys(), await client.embed(texts))) + vectors = dict(zip(live, await client.embed( + [rewrites[q]["rewritten"] for q in live]))) if live else {} async def search_rows(user_id: int, vec) -> list[dict]: async with db.acquire() as conn: @@ -187,19 +205,24 @@ async def search_rows(user_id: int, vec) -> list[dict]: q = item["query"] before_kept = apply_cut(item["results"], query=q, settings=settings) rw = rewrites[q] - after_all = await search_rows(item["user_id"], vectors[q]) - after_kept = apply_cut(after_all, query=rw["rewritten"], settings=settings) + if rw["gated_out"]: + after_kept, after_all = before_kept, item["results"] + else: + after_all = await search_rows(item["user_id"], vectors[q]) + after_kept = apply_cut(after_all, query=rw["rewritten"], settings=settings) silent_before += not before_kept silent_after += not after_kept off_rows.append({ "query": q, "rewritten": rw["rewritten"], "degraded": rw["degraded"], + "gated_out": rw["gated_out"], "before_returned": len(before_kept), "after_returned": len(after_kept), "after_top1_sim": after_all[0]["sim"] if after_all else None, }) - log(f" 무관 {q!r}: 전 {len(before_kept)}건 → 후 {len(after_kept)}건") + log(f" 무관 {q!r}: 전 {len(before_kept)}건 → 후 {len(after_kept)}건" + + (" (게이트 — 원문 유지)" if rw["gated_out"] else "")) # ── ② 사례 5건 ───────────────────────────────────────────────────── case_rows = [] @@ -213,17 +236,22 @@ async def search_rows(user_id: int, vec) -> list[dict]: before_rank = next( (r["rank"] for r in item["results"] if r["record_id"] in expect_ids), None) before_in = any(r["record_id"] in expect_ids for r in before_kept) - # 후(재작성문): 임베딩을 다시 떠 스냅샷 DB 를 잰다. - after_all = await search_rows(item["user_id"], vectors[q]) - after_kept = apply_cut(after_all, query=rw["rewritten"], settings=settings) - after_rank = next( - (i + 1 for i, r in enumerate(after_all) if r["record_id"] in expect_ids), - None) - after_in = any(r["record_id"] in expect_ids for r in after_kept) + # 후(재작성문): 임베딩을 다시 떠 스냅샷 DB 를 잰다. 게이트에 걸리면 원문 값이다. + if rw["gated_out"]: + after_rank, after_in = before_rank, before_in + else: + after_all = await search_rows(item["user_id"], vectors[q]) + after_kept = apply_cut(after_all, query=rw["rewritten"], settings=settings) + after_rank = next( + (i + 1 for i, r in enumerate(after_all) + if r["record_id"] in expect_ids), + None) + after_in = any(r["record_id"] in expect_ids for r in after_kept) case_rows.append({ "query": q, "rewritten": rw["rewritten"], "degraded": rw["degraded"], + "gated_out": rw["gated_out"], "expect": item["expect"], "before": {"pre_cut_rank": before_rank, "returned": before_in, "returned_count": len(before_kept)}, @@ -244,6 +272,7 @@ async def search_rows(user_id: int, vec) -> list[dict]: "profile": settings.embedding_profile, "model": settings.embedding_model, "rewrite_chain": [f"{v}:{m}" for v, m in settings.judge_vendors], + "gate": {"max_chars": settings.search_rewrite_max_chars}, "cut": { "tau_abs": settings.search_similarity_floor, "tau_abs_word": settings.search_similarity_floor_word, From 0fe42578bc444986912d3f6568af98777f1061d8 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 15:10:30 +0900 Subject: [PATCH 18/34] =?UTF-8?q?docs(S15P11A705-337):=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=ED=9A=A8=EA=B3=BC=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20I55(=EC=9E=A0=EC=A0=95)=20=E2=80=94=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=95=C2=B7=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EB=B9=84=EA=B5=90?= =?UTF-8?q?=C2=B7=ED=99=95=EC=A0=95=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1차(전면 적용) 실측, 원인 해석, 게이트 후보 8종 전수 비교표, 6자 확정 근거, 게이트 반영 재측정 결과, 못 한 검증(6~12자 대역 무관측·모델 1종 1회차)을 담는다. 번호는 병합 직전 origin/dev 기준 재확정. 색인 두 표 등록. Co-Authored-By: Claude Fable 5 --- docs/implements/2026-08-06-rewrite-effect.md | 100 +++++++++++++++++++ docs/implements/README.md | 2 + 2 files changed, 102 insertions(+) create mode 100644 docs/implements/2026-08-06-rewrite-effect.md diff --git a/docs/implements/2026-08-06-rewrite-effect.md b/docs/implements/2026-08-06-rewrite-effect.md new file mode 100644 index 0000000..8fc798c --- /dev/null +++ b/docs/implements/2026-08-06-rewrite-effect.md @@ -0,0 +1,100 @@ +# LLM 질의 재작성의 검색 효과 실측 — 약어 회복 확인, 무관 노출 증가와 길이 게이트 확정 + +- **티켓**: S15P11A705-337 (잔여 측정·게이트 확정) +- **날짜**: 2026-08-06 +- **하네스**: `tools/search_cut/rewrite_probe.py` — 실행 절차는 도구 도입주석 +- **기준 문서**: [P49](../proposals/P49-multi-signal-search.md) §3(LLM 질의 재작성)·§5(관련 없는 결과를 막는 방법) +- **성격**: 측정과 게이트 런타임 반영을 함께 담는다. 측정은 스냅샷 DB(:25432)에서 했다. +- **번호는 잠정 I55다.** 병합 직전 `origin/dev` 기준으로 재확정한다. + +## 한눈에 보는 결과 + +| 항목 | 확인한 내용 | 결정 또는 대응 | +|---|---|---| +| 약어 회복 | `부캠`이 `부트캠프`로 재작성되어 기대 정답이 컷 전 8위에서 1위로 올라 검색 결과에 회복됐다. `신한 부캠`도 4위에서 3위로 올라 회복됐다 | 재작성이 목표한 실패 사례 2건을 실제로 회복한다 | +| 무관 질의 안전성 | 전면 적용 시 관련 없는 질의 15건의 무노출이 11건에서 9건으로 줄어 채택 조건(11건 이상)에 미달했다 | 전면 적용 기각 | +| 원인 | 악화 2건은 모두 문장형 질의의 표현 정규화(`파는 데`→`파는 곳`)가 유사도를 컷 위로 올린 것이다 | 재작성의 이득은 짧은 약어 질의에, 손해는 긴 문장형 질의에 몰려 있다 | +| 게이트 확정 | 후보 게이트 전수 비교에서 형태(길이) 기준만 회복과 무노출을 동시에 충족했다 | **앞뒤 공백 정리 후 6자 이하만 재작성(2026-08-06 사용자 확정)**. 런타임 반영 완료 | +| 게이트 반영 재측정 | 무노출 11/15 유지·약어 회복 2건 유지 — 채택 조건 충족 | 재작성은 게이트와 함께 켤 수 있는 상태가 됐다(켜는 시점은 검증 게이트 뒤) | + +## 배경 + +LLM 질의 재작성(P49 §3)의 런타임은 구현이 끝났다. 검색 요청이 임베딩되기 전에 LLM이 질의를 한 번 다듬고, 실패하면 원문으로 검색한다. 기본값은 꺼짐이다. 그러나 목표 문장(`부트캠프`)의 검색 성공은 측정됐어도, LLM이 실제로 그 목표 문장을 만들어내는지와 관련 없는 질의에 미치는 영향은 재지 않았다(P49 §9의 미결 항목). 이 측정이 그 잔여다. + +## 측정 조건 + +- 재작성: 운영과 같은 설정 체인(OpenAI → Gemini → Anthropic 폴백, 타임아웃 5초, 시도 2회)의 실제 `RewriteClient` 호출. 재작성문을 결과에 그대로 기록했다. +- 대상: 관련 없는 문장형 질의 15건(질의 5종 × 소유자 3명)과 실패 사례 5건(`부캠`·`신한 부캠`·`신한`·`그네`·`스팟`, 기대 정답은 기존 실측의 라벨). +- 재작성 전 값은 굳힌 행렬(`matrix.json`·`recall_probe.json`)에 현행 컷을 재구성해 얻었다. 재구성한 무노출 11/15가 기존 관측과 일치해 재구성의 정확성을 교차 확인했다. +- 재작성 후 값은 재작성문을 임베딩해 스냅샷 DB의 소유자별 전체 기록 유사도를 재서 얻었다. 컷의 단어형·문장형 분기는 서비스와 같게 재작성문 기준으로 판정했다. + +## 확인한 결과 — 1차 측정 (게이트 없는 전면 적용) + +**약어 사례 2건이 회복됐다.** `부캠`은 `부트캠프`로 재작성됐고, 기대 정답(카츠요, 본문에 「신한 부트캠프」가 있는 기록)의 컷 전 순위가 8위에서 1위로 올라 검색 결과에 포함됐다. `신한 부캠`은 `신한 부트캠프`로 재작성됐고 4위에서 3위로 올라 포함됐다. 두 건 모두 재작성 전에는 컷에 걸려 결과에 없던 사례다. + +**재작성이 필요 없는 사례 3건은 변하지 않았다.** `신한`·`그네`·`스팟`은 원문 그대로 반환됐다. 프롬프트의 「고유명사·확신 없으면 원문 유지」 규칙이 작동한 것이다. `신한`의 기대 정답은 여전히 컷 전 6위로 결과에 없다. 이 사례의 회복은 재작성이 아니라 문자열 검색의 몫이며, 그 규칙은 별도 실측(I54)으로 확정되어 back 구현이 끝나 있다. + +**관련 없는 질의의 무노출이 11건에서 9건으로 줄었다.** 15건 중 재작성 후 결과가 노출된 질의가 4건에서 6건으로 늘었다. 악화 2건은 같은 질의의 소유자 차이다. `겨울 스키장 리프트권 파는 데`가 `겨울 스키장 리프트권 파는 곳`으로 재작성되면서, 소유자 2명의 검색에서 0건이던 결과가 각각 2건·3건 노출됐다. 나머지 13건은 반환 건수가 변하지 않았다. + +**강등(재작성 실패)은 0건이었다.** 20건 모두 첫 벤더(OpenAI `gpt-4o-mini`)가 응답했다. + +## 원인 — 측정 결과를 바탕으로 한 해석 + +악화 2건의 재작성은 의미를 바꾸지 않는 표현 정규화였다(`파는 데`→`파는 곳`). 그런데도 임베딩 유사도가 움직여 일부 기록이 컷 기준을 넘었다. 문장형 질의는 조사·어미의 미세한 차이에도 유사도가 흔들리는데, 무관 질의의 유사도는 컷 경계 바로 아래 대역에 몰려 있어 작은 상승으로도 노출로 바뀐다. + +반대로 재작성의 이득은 전부 짧은 질의에서 났다. 약어(`부캠`)를 푸는 것이 재작성의 목적이고, 약어는 짧다. 긴 문장형 질의는 애초에 풀 약어가 없어 이득 없이 위 손해만 남는다. + +이 패턴은 외부 연구와도 합치한다. 잘 형성되고 말뭉치와 정렬된 질의에 LLM 재작성을 적용하면 검색 품질이 유의하게 나빠지고(어휘 치환이 말뭉치 선호 표현에서 멀어지는 방향일 때), 용어 표기 불일치가 있는 질의에서만 이득이 난다는 실증이 있으며, 학습 기반 게이트는 약해서 단순 휴리스틱이 경쟁력 있다고 보고됐다(근거 절의 arXiv 2603.13301). 산업(Taobao 검색)도 재작성을 전 질의가 아니라 문제 질의 부분집합에만 선택 적용한다. + +## 게이트 후보 비교 — 같은 측정 데이터, 동일 계산 + +후보 게이트 전부를 같은 데이터로 시뮬레이션했다. 목표는 둘이다: 무관 무노출 11/15 이상 유지, 약어 회복(`부캠`·`신한 부캠`) 2건 유지. + +| 게이트 조건 | 무관 무노출 | 약어 회복 | 판정 | +|---|---|---|---| +| 게이트 없음(전면 적용) | 9/15 | 2/2 | 미달 | +| 재작성 항상 끔 | 11/15 | 0/2 | 미달 | +| 글자 4자 이하 | 11/15 | 1/2 | 미달 | +| 글자 5~13자 이하 (전 구간 동일) | 11/15 | 2/2 | 충족 | +| 어절 2 또는 3 이하 | 11/15 | 2/2 | 충족 | +| 기존 단어형 판정 재사용(공백 없음·5자) | 11/15 | 1/2 | 미달 | +| 결과 0건(또는 2건 이하) 트리거 | 9/15 | 0/2 | 미달 | +| top-1 유사도 임계(0.30~0.45) 트리거 | 9/15 | 0~1/2 | 미달 | + +**형태(길이·어절) 기준만 충족한다. 이유는 판정 입력의 분포다.** 회복 대상 사례는 전부 5자 이하·2어절 이하이고 무관 질의는 전부 13자 이상·3어절 이상이라, 길이는 두 집단을 완전히 가른다. 반면 성과 신호는 겹친다 — `부캠`·`신한 부캠`의 원문 검색은 결과 0건이 아니라 오답 3건을 반환하고 있었고(정답만 빠진 상태), 원문 top-1 유사도도 무관(0.14~0.38)과 사례(0.24~0.59)의 대역이 겹쳐 어떤 임계로도 분리되지 않는다. 결과 부족을 트리거로 쓰는 전자상거래 관행을 그대로 옮길 수 없는 이유이기도 하다 — 그쪽에서 결과 0건은 고칠 상태지만, 이 서비스에서 무관 질의의 0건은 정답이다. + +기존 단어형 판정의 재사용이 미달인 것은 공백 불허 조건이 `신한 부캠`(공백 포함 5자)을 배제해 회복 1건을 잃기 때문이다. 재작성 게이트는 단어형 판정과 목적이 달라 별도 기준이 필요하다. + +**경계값 6자의 근거.** 5~13자가 전부 충족으로 나오는 것은 이 코퍼스의 한계다 — 악화를 일으킨 질의가 16자뿐이라 13자 게이트조차 관측상 통과로 보인다. 구간 안의 선택 근거는 관측 밖 위험의 방향이다. 5자는 관측된 이득의 상한과 정확히 일치해 여유가 없고, 7자 이상은 이득 증거가 없는 대역을 여는 만큼 표현 정규화 손해의 위험만 산다. 6자는 이득의 전 관측(5자 이하)에 여유 1자를 더한 보수 값이다. 어절 기준은 글자 수 무제한의 2어절 문구를 통과시켜 6자보다 노출면이 넓다. **2026-08-06 사용자 결정으로 6자를 확정했다.** + +## 결정 및 대응 + +1. **게이트 확정: 앞뒤 공백 정리 후 6자 이하 질의만 재작성한다.** 값은 설정 `SEARCH_REWRITE_MAX_CHARS`(기본 6)로 두어 운영 질의 관측 후 재배포 없이 조정한다. +2. **런타임 반영 완료.** `SearchService._should_rewrite()`가 게이트를 판정하고, 게이트에 걸린 질의는 LLM 호출 없이 원문으로 검색한다(비용·지연도 늘지 않는다). 계약 테스트 6건을 추가했다 — 긴 질의는 플래그가 켜져 있어도 재작성 호출 0회, 경계값 6자 포함, 공백 정리 후 길이 판정, 경계값 설정 가변. +3. **게이트 반영 재측정 통과.** 무관 15건은 전부 게이트에 걸려 원문 그대로 검색되고 무노출 11/15가 유지된다. 사례 5건은 게이트를 통과해 `부캠`·`신한 부캠` 회복이 유지된다. 채택 조건을 충족한다. +4. 재작성을 실제로 켜는 결정은 이 측정이 아니라 검색 고도화 검증 게이트(P49 §7) 통과 뒤에 한다. 플래그 기본값은 여전히 꺼짐이다. + +## 검증 — 실행한 것과 못 한 것 + +**실행한 것** + +- 무관 15건·사례 5건의 재작성 전/후를 같은 컷 재구성으로 비교했다(1차: 전면 적용, 2차: 게이트 반영). +- 재작성 전 재구성 값이 기존 관측(무노출 11/15, 사례 컷 전 순위)과 일치함을 확인했다. +- 게이트 후보 8종(형태 2계열·성과 2계열·재사용·양극단)을 같은 데이터로 전수 시뮬레이션했다. +- 포트 가드(:25432 아니면 중단)와 artifact profile 신선도 가드를 도구에 넣었다. +- 게이트 계약 테스트 6건 추가, 전체 500건 통과(기준선 494 + 신규 6). ruff 통과. + +**못 한 것 — 전부 추가 검증 필요** + +- **LLM 비결정성.** 재작성은 캐시로 같은 질의에 같은 결과를 보장하지만, 캐시가 비워진 재실행에서 다른 재작성문이 나올 수 있다. 이번 측정은 각 질의 1회차 관측이다. +- **게이트 경계 대역.** 6~12자 질의의 재작성 효과는 이 코퍼스에 사례가 없어 재지 못했다. 경계값 6은 이 관측 범위 안에서의 판단이며, 운영 질의에서 재평가한다. +- **모델 간 재작성 품질 차이.** 이번 관측은 체인 1순위(`gpt-4o-mini`) 단일 모델의 것이다. 체인 자체는 판정 태스크의 6종 모델 비교 실측(가용성·속도 기준)을 상속했고, 게이트는 어떤 모델이 응답하든 적용되는 모델 무관 방어다. +- **실서버 E2E.** 이 측정은 컷 재구성 기반이다. 게이트 검증(P49 §7)의 실서버 확인이 남는다. + +## 근거 + +- 산출물: `.search/rewrite_probe.json` (게이트 반영 최종 실측, 재작성문 포함) — 1차(전면 적용) 실측은 커밋 `4b7ecf2` 판이 보존본 +- 도구: `tools/search_cut/rewrite_probe.py` · 게이트 런타임: `app/service/search_service.py` · `app/core/config.py` · 테스트: `tests/test_search_rewrite.py` +- 선행: P49 §3·§5 · `docs/implements/2026-08-05-short-query-boundary.md`(-273, 목표 문장 검색 성공 측정) · I54(문자열 검색 규칙 — `신한` 회복의 몫) · `2026-07-30-judge-vendor-fallback.md`(벤더 체인 선정 실측) +- 재작성 런타임: 커밋 `39dba0d` (클라이언트·강등 경로·질의 캐시·기본 off 플래그) +- 외부 근거: arXiv 2603.13301 「Not All Queries Need Rewriting」(잘 정렬된 질의에서 재작성 유해·학습 게이트 무력 실증) · arXiv 2311.03758 「Long-tail Query Rewriting in Taobao Search」(선택 적용 산업 사례) — 조사 요약은 세션 기록 diff --git a/docs/implements/README.md b/docs/implements/README.md index a9ff362..e1ac644 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -56,6 +56,7 @@ | I51 | [2026-08-05-short-query-boundary.md](2026-08-05-short-query-boundary.md) | 검증 | 짧은 질의의 층별 탈락과 단어형 경계 두 정의 — **`-266` 이후 남은 실패는 셋뿐이고 경계 정의로는 하나도 안 풀린다.** `신한`(③ τ_abs·6위)·`부캠`(③ τ_abs·8위)·`신한 부캠`(**④ r**·4위)이고 실서버 대조 22/22 일치로 층을 확정했다(전처리·폴백은 **존재하지 않는다** — `min_length=1` 뿐이고 0건이면 0건이 그대로 나간다). **「2자가 안 걸린다」는 부정확** — 2자 7건 중 5건은 걸리고 못 걸리는 둘은 컷 전 순위가 6·8위다. `-266` 이 「재지 않았다」고 적은 B(공백·짧다)·C(무공백·길다) 대역을 **본문 인접 어절쌍 전량**(쌍 176종 × spaced/joined × 소유자 3명 = 1,128행)으로 처음 쟀다 — 손실은 C 에 몰려 있고(16/122 → 어절 수 정의 3/122) **그런데 초점 집합 36행에서는 어느 정의에서도 누락이 0** 이라 두 표가 어긋난다(회복되는 것이 기능어 쌍이다). 새 관측 둘: **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385 · 172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 τ 탈락 — `-266` 이 「안전 방향」이라 부른 판단이 이 대역에서는 손해다). 계약이 「이것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하므로 **세 번째다** (S15P11A705-273) | | I53 | [2026-08-05-fusion-measurement.md](2026-08-05-fusion-measurement.md) | 구현 | Keyword fusion 1단계 실측 — 병합 방식(가중합 5조합·RRF cutoff 5값)×floor 4값 격자. **가중합은 정답과 관련 없는 후보를 함께 끌어올린다** — floor 0.25 에서 `신한` 복구(컷 후 4위)와 무관-문장형 무노출 11/15→9/15 감소가 같은 계산에서 나온다. RRF 는 무노출을 유지하나 문장형 hit@1 0.8333→0.5833 퇴행. **P48 §6.1 네 조건을 전부 통과한 조합은 floor 0.35 · binary w=0.05 하나** — 그 조건에서 단어형·문장형 전 지표 개선·무관 무노출 유지, 대가는 신호 반영 12/66 축소와 `신한` 재제외. `신한`·`부캠`은 이 구조로 안 풀린다(재작성 `-337`·문자열 검색의 대상). 환경 동등성은 baseline 재현 = I52 일치로 확인. 못 잰 축(floor 0.30~0.35·weight 격자·재현성 회차)은 `-339` 몫 (S15P11A705-336) | | I54 | [2026-08-06-lexical-merge-rule.md](2026-08-06-lexical-merge-rule.md) | 구현 | 문자열 검색 병합 규칙 실측 — 게이트 3단(전체/단어형 한정/어절 경계) × 병합 3종(뒤 추가/RRF/앞 고정) 격자. **무관·타인소유·문장형 세그먼트에서 문자열 매치 0건** — 어떤 조합도 관련 없는 결과 노출을 늘리지 않는다. `신한` 이 전 조합에서 회복(RRF 5위)되고 `부캠` 은 예상대로 미회복(재작성 -337 대상). 경계 게이트는 기대 정답 6건을 제외하는 손해만 관측돼 부분일치 채택 권고. **규칙 확정안: 단어형 한정·부분일치 게이트 + RRF(k=60) 병합 + 컷·응답 계약 불변 + 실패 시 벡터만** — back 연동 티켓의 판단 재료. 한계: 경계 게이트가 막으려는 유형(어절 중간 시작·부수적 언급·동형어)이 코퍼스에 없어 이득 측정 불가, 운영 코퍼스 재평가 필요 (티켓 없음 · 런타임 변경 없음 · 스냅샷 DB) | +| I55 | [2026-08-06-rewrite-effect.md](2026-08-06-rewrite-effect.md) | 구현 | LLM 질의 재작성 효과 실측·길이 게이트 확정 — 실제 RewriteClient(운영 체인)로 무관 15건·사례 5건 전/후 비교. **약어 회복 확인**(`부캠`→`부트캠프` 컷 전 8위→1위·`신한 부캠` 4위→3위, 둘 다 반환 회복)·고유명사 유지 규칙 작동(`신한`·`그네`·`스팟` 원문 불변). **전면 적용은 무관 무노출 11/15→9/15 로 채택 기준 미달** — 의미 불변 표현 정규화(`파는 데`→`파는 곳`)가 컷 경계 아래 대역을 밀어 올린다. 게이트 후보 8종 전수 시뮬레이션에서 **형태(길이) 기준만 충족** — 성과 기반(결과 0건·top-1 임계)은 두 집단 대역이 겹쳐 분리 불가에 회복 대상까지 놓치고(원문도 오답 3건 반환), 단어형 판정 재사용은 `신한 부캠` 상실. **게이트 확정: strip 후 6자 이하만 재작성**(`SEARCH_REWRITE_MAX_CHARS`, 사용자 확정) — 게이트 반영 재측정에서 무노출 11/15 유지·회복 2건 유지로 채택 조건 충족. 한계: 6~12자 대역 무관측·모델 1종 1회차 관측 (S15P11A705-337 · 스냅샷 DB) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -115,5 +116,6 @@ | I51 | 짧은 질의 층 프로브 `tools/search_cut/layer_probe.py` + 경계 행렬 `boundary_matrix.py`·`boundary_sweep.py` + `.search/boundary_grid.json`(1,128행)·`layer_probe.json` — **산출물은 하네스 셋과 판정이고 코드는 고치지 않았다.** ① 층을 코드로 확정했다 — 계약이 후보로 준 「질의 전처리」와 「후보 0건 폴백」은 **존재하지 않는다**(`schema/search.py:9` 는 `min_length=1` 뿐이고 정규화가 없어 원문이 그대로 도달하며, `_cut` 이 0건이면 0건이 그대로 응답이다). 자르는 것은 `search_service.py:111-115,122` 둘뿐이고 `LIMIT` 은 이 데이터에서 한 건도 안 자른다(후보 17·11·6건). **실서버 대조 22/22 일치** — 재구성에 `_cut`·`_is_word_query` 를 다시 적었다(구현 import 금지, `-213` 규칙). ② `-266` 이후 남은 실패는 셋뿐이고 **서로 다른 층이다** — `신한`(③ τ_abs 0.2301<0.24·6위)·`부캠`(③ 0.2105·8위)·`신한 부캠`(**④ r** 0.3187<0.3520·4위). 셋째는 τ 를 넘고 `r` 에만 걸리므로 **경계 정의를 어떻게 바꿔도 안 풀린다**(`-266` 이 `r` 을 「참여하지 않는다」로 가르지 않았다). ③ 「2자가 안 걸린다」는 부정확 — 2자 7건 중 5건은 걸리고(`우주` 0.4200 1위·`공원` 0.3672 1위) 못 걸리는 둘은 **컷 전 순위가 6·8위**다. ④ `-266` 이 「둘이 갈리는 질의가 행렬에 없다」고 적은 대역을 만들었다 — 본문 **문장 내 인접 어절쌍 전량**을 `spaced`/`joined` 짝으로 내면 기대 정답이 같아 차이가 전부 「공백 하나」에 귀속된다(쌍 176종 × 2형태 × 소유자 3명 = 1,128행, GMS 376건). 질의를 고르지 않아 `-266` 이 한계로 적은 선정 재량이 없다(남는 재량은 「두 어절 모두 2자 이상」 하나). ⑤ 손실은 **C 대역(무공백·길다)** 에 몰려 있다 — 현행 16/122·1위 손실 4 가 어절 수 정의에서 3/122·1 이 되고 A·B·D 는 안 움직인다. **그런데 내용어 초점 집합 36행에서는 여섯 정의 전부 누락 0** 이라 두 표가 어긋난다 — 회복되는 것이 기능어 쌍(`당시자주` 0.1909·`같은분위기가` 0.2170)이라 사용자 이득이 관측되지 않았다. ⑥ 새 관측 둘 — **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385·중앙값 -0.0279·172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 ④ r 이 아니라 ③ τ 에서 잘린다). 후자는 `-266` 이 「측정이 아니라 안전 방향으로의 판단」이라 적은 대역이고 **그 판단이 여기서는 손해로 나타난다**(3건 표본). ⑦ 처방 후보 여섯을 비용·부작용과 함께 냈고 **「안 바꾸는 안」을 포함**했다 — 고르는 것은 이 티켓의 일이 아니다. ⑧ 계약이 「네가 재는 것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하고 후자는 코드까지 병합돼 dev 배포 tag(`7e72826`)에 들어 있으므로 **세 번째다** | [경계 리포트](2026-08-05-short-query-boundary.md), [I45](2026-08-03-search-recall-probe.md), [I47](2026-08-03-word-query-cut.md), [tools/search_cut/](../../tools/search_cut/) | | I53 | Keyword fusion 1단계 실측 `tools/search_cut/fusion_sweep.py` 격자 — 방식(binary·conf·idf·RRF)×weight×floor(0.25/0.30/0.35/0.40)×RRF cutoff(0~0.033). **`신한` 복구와 무관 노출 증가가 같은 계산에서 나온다**(가중합은 컷을 부스트 점수에 걸어 경계 아래 정답과 무관 후보를 똑같이 끌어올린다). RRF 는 cosine eligibility gate 로 무노출을 지키지만 벡터 1위가 키워드 순위 합산에 밀려 문장형이 퇴행한다. floor 0.35 에서 무관 무노출이 baseline 복원되고 binary w=0.05 만 P48 §6.1 전 조건 통과 — Preset 후보가 floor 를 넘는 단어형 질의가 12/66 뿐이라는 대가(미반영 54건 = `-338` 설계 입력). 사례 4건 개별 보고(신한·부캠 미회복 확정). 픽스처 27종·가드 무오발동·baseline 재현 일치 | [실측 리포트](2026-08-05-fusion-measurement.md), [T73~T78](../troubleshooting/2026-08-05-multi-signal-investigation.md), [P48](../proposals/P48-search-signal-expansion.md), [P49](../proposals/P49-multi-signal-search.md) | | I54 | 문자열 병합 규칙 하네스 `tools/search_cut/lexical_matrix.py`·`lexical_sweep.py` — 본문 매치를 (질의 × 소유자 × Record) 로 굳히고(본문 미저장·매치 여부만) 게이트×병합 격자를 오프라인으로 훑는다. 스냅샷 DB(:25432) 가드. 측정 결과: 문자열 매치 94건이 전부 정답 세그먼트에만 발생, 단어형 정답 라벨이 부분일치 정의라 재현율은 구조적으로 유리(순환성 명시), 병합 방식 차이는 RRF·앞고정 > 뒤추가. 단독 추가 7건 · 빈 결과 1건 해소 | [규칙 리포트](2026-08-06-lexical-merge-rule.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md) | +| I55 | 재작성 효과 프로브 `tools/search_cut/rewrite_probe.py` — 실제 RewriteClient(운영 설정 체인) 호출 → 재작성문 임베딩 배치 1회 → 스냅샷 DB(:25432) 유사도 → 현행 컷 재구성(단어형/문장형 분기는 재작성문 기준, 서비스 미import 재구성 규칙). **재작성문 자체를 artifact 에 기록** — 무엇으로 바뀌었는지가 판정 근거의 절반. 원문 기준 값은 굳힌 행렬의 컷 재구성으로 얻어 GMS 호출 최소화, 재구성 무노출 11/15 가 기존 관측과 일치(교차 확인). 게이트(`should_rewrite` 재구성) 반영 후에는 게이트 통과 질의만 재작성·재임베딩 — 걸린 질의는 원문 값이 곧 결과(서비스와 동일). 강등 0건·전 질의 1순위 벤더 응답 관측 | [효과 리포트](2026-08-06-rewrite-effect.md), [I54](2026-08-06-lexical-merge-rule.md), [P49](../proposals/P49-multi-signal-search.md) | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. From 1171543ffad5b215434f610e2cafa1b6f6c44a15 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 15:32:41 +0900 Subject: [PATCH 19/34] =?UTF-8?q?feat(S15P11A705-339):=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=8B=A4=EC=84=9C=EB=B2=84=20on/off=20?= =?UTF-8?q?=EB=8C=80=EC=A1=B0=20=EB=8F=84=EA=B5=AC=20=E2=80=94=205?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=ED=8C=90=EC=A0=95=C2=B7=ED=9D=94=EB=93=A4?= =?UTF-8?q?=EB=A6=BC=20=EB=B6=84=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 플래그만 다른 두 서버의 실응답으로 재정렬 런타임의 계약을 판정한다. - collect: 행렬 3종의 질의 셋(93건)을 서버에 던져 응답 저장. --queries 로 재시도용 부분 수집 - judge: ① off/on 후보 Record id 집합 불변 ② on 순서 = on 유사도 + 오프라인 keyword 신호 재구성 ③ 무관 무노출이 (질의, 소유자) 단위 오프라인 기대와 일치 ④ 기대 정답 포함 무퇴행 ⑤ off 순서 = 유사도 내림차순 - 어긋난 건은 컷 경계(τ·r×top1) 또는 후보 floor 와의 거리 0.0044(T68) 이내면 흔들림으로 분류해 결함과 가른다. config 채택값과 거울값이 다르면 판정 중단 Co-Authored-By: Claude Fable 5 --- tools/search_cut/README.md | 8 + tools/search_cut/rerank_verify_live.py | 406 +++++++++++++++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 tools/search_cut/rerank_verify_live.py diff --git a/tools/search_cut/README.md b/tools/search_cut/README.md index 0906a8d..47da1e8 100644 --- a/tools/search_cut/README.md +++ b/tools/search_cut/README.md @@ -35,6 +35,7 @@ | `keyword_matrix.py` | keyword 신호 artifact 생성 — 질의별 전체 활성 Preset 코사인 + Context 별 keyword·confidence·상태. **GMS 임베딩 배치 1회 + DB 읽기** | | `fusion_sweep.py` | fusion 방식(binary·confidence·idf·RRF)×가중치×floor×RRF cutoff 격자 — **P48 구조**(후보 합집합 후 병합 점수에 컷). `keyword_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 GMS 도 부르지 않는다** | | `fusion_rerank_sweep.py` | keyword 재정렬 전용 병합 격자 — **P49 §4 구조**(컷 통과 집합 고정·순서만 조정). BASE·binary(floor×weight)·RRF 비교. 같은 파일만 읽는다 — **DB 도 GMS 도 부르지 않는다** | +| `rerank_verify_live.py` | keyword 재정렬의 **실서버 on/off 대조** — 플래그만 다른 두 서버의 실응답으로 다섯 계약(후보 불변·재정렬 정확성·무관 무노출·정답 무퇴행·off 현행 동일)을 판정한다. **GMS 임베딩 질의당·서버당 1회** | | `lexical_matrix.py` | 문자열 매치 artifact — 본문에 질의가 그대로 있는지를 (질의×소유자×Record)로 굳힌다. 본문은 저장하지 않는다. **스냅샷 DB(:25432) 읽기 · GMS 0회** | | `lexical_sweep.py` | 문자열 병합 규칙 격자 — 게이트 3단×병합 3종. `lexical_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 GMS 도 부르지 않는다** | @@ -208,6 +209,13 @@ profile·preset_version 이 현행과 어긋나도 멈춘다(`--expect-*` 로 것이다. 신규 출력은 `.search/fusion_rerank_` 접두로 만들어 기존 artifact 를 덮지 않는다. 결과 판정은 [구현 리포트](../../docs/implements/) 의 `-339` 리포트에 있다. +런타임 구현의 **실서버 검증**은 `rerank_verify_live.py` 로 한다. 스냅샷 DB(:25432)를 +상대로 이 브랜치 코드를 `SEARCH_KEYWORD_RERANK_ENABLED` 만 다르게 두 번 띄우고 +(venv 경로 명시 — T29, 로그는 리디렉션 — T30), 같은 질의 셋(93건)을 각각 수집해 +대조한다. off/on 은 별도 요청이라 임베딩이 흔들릴 수 있으므로(T68) 어긋난 건은 컷 +경계 거리 0.0044 이내면 「경계 위 흔들림」으로 분류하고 `collect --queries` 로 +재시도해 확인한다. 절차 전문은 그 스크립트 도입주석에 있다. + ### 문자열 병합 규칙 (P49 작업 3) ```bash diff --git a/tools/search_cut/rerank_verify_live.py b/tools/search_cut/rerank_verify_live.py new file mode 100644 index 0000000..a05ed77 --- /dev/null +++ b/tools/search_cut/rerank_verify_live.py @@ -0,0 +1,406 @@ +"""Keyword 재정렬의 실서버 검증 — 플래그 on/off 실응답 대조 (S15P11A705-339). + +`verify_live.py` 와 목적이 다르다 — 저쪽은 「오프라인 컷 재구성이 실서버와 같은가」를 +한 서버로 재고, 이쪽은 **같은 코드로 플래그만 다르게 띄운 두 서버**의 실응답을 대조해 +재정렬 런타임의 다섯 계약을 판정한다. + + ① 후보 집합 불변 같은 질의에서 off/on 응답의 Record id 집합이 같다 (순서만 차이) + ② 재정렬 정확성 on 응답의 순서 = on 응답의 유사도 + 오프라인 keyword 신호로 + 재구성한 순서 (binary · floor 0.35 · weight 0.05 · top_k 3) + ③ 무관 무노출 유지 무관 질의 15건의 0건 반환 집합이 off/on/오프라인 기대와 같다 + ④ 정답 무퇴행 기대 정답의 포함이 off/on 모두 유지되고 순위가 오프라인 예측과 같다 + ⑤ off = 현행 동일 off 응답의 순서가 유사도 내림차순이다 (재정렬 도입 전 동작) + +서버는 **이 브랜치 코드**로, venv 경로를 명시해 띄운다(T29) — `python -m uvicorn` 은 +시스템 Python 을 타고 조용히 죽는다. 로그는 파이프가 아니라 리디렉션으로 받는다(T30). + + DATABASE_URL=<스냅샷 :25432> SEARCH_KEYWORD_RERANK_ENABLED=false \ + .venv/Scripts/python.exe -m uvicorn app.main:app --port 8011 > .search/uvicorn_off.log 2>&1 + python tools/search_cut/rerank_verify_live.py collect --ai http://127.0.0.1:8011 \ + --out .search/rerank_live_off.json + (서버 내리고 SEARCH_KEYWORD_RERANK_ENABLED=true 로 다시 띄운 뒤) + python tools/search_cut/rerank_verify_live.py collect --ai http://127.0.0.1:8011 \ + --out .search/rerank_live_on.json + python tools/search_cut/rerank_verify_live.py judge \ + --off .search/rerank_live_off.json --on .search/rerank_live_on.json \ + --json .search/rerank_live_report.json + +GMS 임베딩이 질의당·서버당 1회 나간다(질의 97건 × 2 서버 ≈ 194회). + +## 임베딩 흔들림의 처리 (T68) + +off 와 on 은 별도 요청이라 같은 질의도 임베딩이 미세하게 다를 수 있다(|Δsim| 최대 +0.0044 실측). 그래서 집합·판정이 어긋나면 곧바로 실패로 판정하지 않고 **경계 거리**를 +함께 기록한다 — 어긋난 Record 의 유사도가 컷 경계(τ 또는 r×top1)에서 0.0044 이내면 +「경계 위 흔들림」으로 분류하고 재시도로 확인한다(`collect --queries` 로 해당 질의만 +다시 던질 수 있다). 그 밖이면 재정렬 결함이다. 재정렬 정확성(②)의 어긋남은 질의-Preset +코사인의 floor(0.35) 경계 거리로 같은 분류를 한다. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +import httpx + +ROOT = Path(__file__).resolve().parents[2] +SEARCH = ROOT / ".search" +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import fusion as F # noqa: E402 +from rank_score import cut, is_word_query # noqa: E402 + +from app.core.config import get_settings # noqa: E402 + +for _s in (sys.stdout, sys.stderr): + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + +CASES = ("신한", "부캠", "그네", "스팟") +WOBBLE = 0.0044 # T68 실측 상한 — 이 이내의 경계 거리는 흔들림으로 분류 +ROUND_TIE = 1e-4 + 1e-9 # 응답 similarity 가 4자리 반올림이라 이 이내는 동점일 수 있다 + +# 채택값 (config 기본값의 거울 — 값이 갈리면 판정이 틀리므로 judge 가 config 와 대조한다) +ADOPTED_FLOOR = 0.35 +ADOPTED_WEIGHT = 0.05 +ADOPTED_TOP_K = 3 + + +def log(msg: str = "") -> None: + print(msg, flush=True) + + +# ── 질의 셋 ────────────────────────────────────────────────────────────────── + +def build_query_set() -> list[dict]: + """행렬 3종에서 (query, user_id, tag, 기대 정답 record id 목록)을 모은다.""" + matrix = json.loads((SEARCH / "matrix.json").read_text(encoding="utf-8")) + word = json.loads((SEARCH / "word_grid.json").read_text(encoding="utf-8")) + probe = json.loads((SEARCH / "recall_probe.json").read_text(encoding="utf-8")) + + out: list[dict] = [] + for e in matrix["queries"]: + out.append({ + "query": e["query"], "user_id": e["user_id"], "tag": "문장형", + "expected": [r["record_id"] for r in e["results"] if r.get("is_expected")], + }) + for e in matrix["offtopic"]: + out.append({ + "query": e["query"], "user_id": e["user_id"], "tag": "무관", + "expected": [], + }) + for e in word["queries"]: + out.append({ + "query": e["query"], "user_id": e["user_id"], "tag": "단어형", + "expected": [r["record_id"] for r in e["results"] if r.get("is_expected")], + }) + seen = {(q["query"], q["user_id"]) for q in out} + for e in probe["queries"]: + if e["query"] not in CASES: + continue + key = (e["query"], probe["user_id"]) + if key in seen: + continue + out.append({ + "query": e["query"], "user_id": probe["user_id"], "tag": "사례", + "expected": [r["record_id"] for r in e["results"] + if r.get("name") == e.get("expect")], + }) + return out + + +# ── collect — 서버에 던져 결과를 저장한다 ──────────────────────────────────── + +async def collect(args) -> int: + settings = get_settings() + queries = build_query_set() + if args.queries: + wanted = {q.strip() for q in args.queries.split(",") if q.strip()} + queries = [q for q in queries if q["query"] in wanted] + log(f" 서버 {args.ai} · 질의 {len(queries)}건 (GMS 임베딩 질의당 1회)") + + entries = [] + async with httpx.AsyncClient(timeout=60.0) as client: + # 기동 확인 — 죽은 서버에 GMS 호출을 낭비하지 않는다. + ready = await client.get(f"{args.ai}/ready") + if ready.status_code != 200: + log(f" [중단] /ready → HTTP {ready.status_code}") + return 1 + for q in queries: + resp = await client.post( + f"{args.ai}/internal/v1/search", + headers={"X-Internal-Secret": settings.internal_shared_secret}, + json={ + "userId": q["user_id"], + "query": q["query"], + "limit": args.limit, + "embeddingProfile": settings.embedding_profile, + }, + ) + entry = dict(q) + entry["status"] = resp.status_code + entry["results"] = resp.json()["results"] if resp.status_code == 200 else [] + entries.append(entry) + if resp.status_code != 200: + log(f" [FAIL] '{q['query']}' → HTTP {resp.status_code}") + + out = Path(args.out) + out.write_text(json.dumps({ + "ai": args.ai, "limit": args.limit, + "rerank_flag": args.flag, + "entries": entries, + }, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") + n_fail = sum(1 for e in entries if e["status"] != 200) + log(f" 저장: {out} · HTTP 200 {len(entries) - n_fail}/{len(entries)}") + return 0 if n_fail == 0 else 1 + + +# ── judge — off/on 파일을 대조한다 ─────────────────────────────────────────── + +def _cut_boundary_dist(sim: float, query: str, top1: float, settings) -> float: + """유사도가 컷 경계에서 얼마나 떨어져 있나 — 흔들림 분류의 근거.""" + floor = (settings.search_similarity_floor_word + if is_word_query(query, settings.search_word_query_max_chars) + else settings.search_similarity_floor) + return min(abs(sim - floor), abs(sim - settings.search_top_ratio * top1)) + + +def _load_keyword_index(): + kw = json.loads((SEARCH / "keyword_matrix.json").read_text(encoding="utf-8")) + presets = { + p["id"]: F.Preset(id=p["id"], version=p["version"], visibility=p["visibility"]) + for p in kw["presets"] + } + by_user: dict[int, list] = {} + for c in kw["contexts"]: + by_user.setdefault(c["user_id"], []).append(F.ContextKeywords( + context_id=c["context_id"], record_id=c["record_id"], + keyword_status=c["keyword_status"], + keywords=tuple((k["keyword_id"], k["confidence"]) for k in c["keywords"]), + )) + query_cos = { + e["query"]: {c["preset_id"]: c["cos"] for c in e["cos"]} + for e in kw["query_preset"] + } + return presets, by_user, query_cos + + +def _matched_records(query: str, user_id: int, presets, by_user, query_cos) -> tuple[set, float]: + """오프라인 keyword 신호로 match 된 Record 집합과 floor 경계 거리 최솟값.""" + qc = query_cos.get(query) + if qc is None: + return set(), float("inf") + cand = F.preset_candidates(qc, presets, top_k=ADOPTED_TOP_K, floor=ADOPTED_FLOOR) + boundary = min((abs(c - ADOPTED_FLOOR) for c in qc.values()), default=float("inf")) + sig = F.record_signals(by_user.get(user_id, []), cand, presets, method=F.BINARY) + return {rid for rid, s in sig.items() if s > 0}, boundary + + +def judge(args) -> int: + settings = get_settings() + if (settings.search_keyword_rerank_floor != ADOPTED_FLOOR + or settings.search_keyword_rerank_weight != ADOPTED_WEIGHT + or settings.search_keyword_rerank_top_k != ADOPTED_TOP_K): + log(" [중단] config 채택값과 이 도구의 거울값이 다르다 — 판정 기준이 갈린다") + return 1 + + off = {(e["query"], e["user_id"]): e + for e in json.loads(Path(args.off).read_text(encoding="utf-8"))["entries"]} + on = {(e["query"], e["user_id"]): e + for e in json.loads(Path(args.on).read_text(encoding="utf-8"))["entries"]} + keys = [k for k in off if k in on] + presets, by_user, query_cos = _load_keyword_index() + + # 무관 질의의 오프라인 기대 — artifact 유사도에 현행 컷을 걸어 0건인 집합. + # **(query, user_id) 로 키를 잡는다** — 같은 질의 문구를 소유자 3명이 공유하므로 + # 질의 문구만으로 키를 잡으면 다른 소유자의 기대가 덮어써진다. + matrix = json.loads((SEARCH / "matrix.json").read_text(encoding="utf-8")) + offtopic_rows = { + (e["query"], e["user_id"]): e["results"] for e in matrix["offtopic"] + } + offtopic_zero = { + k for k, rows in offtopic_rows.items() + if len(cut(rows, k[0], + tau=settings.search_similarity_floor, + tau_word=settings.search_similarity_floor_word, + ratio=settings.search_top_ratio)) == 0 + } + + verdicts = {f"J{i}": {"pass": 0, "wobble": [], "fail": []} for i in range(1, 6)} + detail = [] + + for key in keys: + q, uid = key + eo, en = off[key], on[key] + rows_off, rows_on = eo["results"], en["results"] + ids_off = [r["recordId"] for r in rows_off] + ids_on = [r["recordId"] for r in rows_on] + sims_off = {r["recordId"]: r["similarity"] for r in rows_off} + sims_on = {r["recordId"]: r["similarity"] for r in rows_on} + d = {"query": q, "user_id": uid, "tag": eo["tag"], + "off_ids": ids_off, "on_ids": ids_on} + + # ① 후보 집합 불변 + if set(ids_off) == set(ids_on): + verdicts["J1"]["pass"] += 1 + else: + diff = set(ids_off) ^ set(ids_on) + dists = {} + for rid in diff: + src = (rows_off, sims_off) if rid in sims_off else (rows_on, sims_on) + top1 = max(r["similarity"] for r in src[0]) + dists[rid] = _cut_boundary_dist(src[1][rid], q, top1, settings) + d["set_diff"] = {str(r): round(v, 4) for r, v in dists.items()} + bucket = "wobble" if all(v <= WOBBLE for v in dists.values()) else "fail" + verdicts["J1"][bucket].append(d["query"]) + + # ② 재정렬 정확성 — on 응답 자신의 유사도 + 오프라인 신호로 재구성 + matched, cand_boundary = _matched_records(q, uid, presets, by_user, query_cos) + scored = [ + (r["recordId"], + r["similarity"] + (ADOPTED_WEIGHT if r["recordId"] in matched else 0.0)) + for r in rows_on + ] + expect_on = [rid for rid, _ in + sorted(scored, key=lambda t: -t[1])] # 안정 정렬 = 동점 시 on 순서 + if ids_on == expect_on: + verdicts["J2"]["pass"] += 1 + else: + d["on_expected"] = expect_on + d["matched"] = sorted(matched & set(ids_on)) + d["cand_floor_dist"] = round(cand_boundary, 4) + score = dict(scored) + tie_ok = all( + score[ids_on[i]] >= score[ids_on[i + 1]] - ROUND_TIE + for i in range(len(ids_on) - 1) + ) + bucket = "wobble" if (tie_ok or cand_boundary <= WOBBLE) else "fail" + verdicts["J2"][bucket].append(q) + + # ③ 무관 무노출 — 오프라인 기대(무노출 11건)와 off/on 실응답이 같아야 한다 + if eo["tag"] == "무관": + expected_zero = (q, uid) in offtopic_zero + live_zero = (len(ids_off) == 0, len(ids_on) == 0) + d["offtopic"] = {"expected_zero": expected_zero, + "off_n": len(ids_off), "on_n": len(ids_on)} + if live_zero == (expected_zero, expected_zero): + verdicts["J3"]["pass"] += 1 + else: + # 기대와 어긋남 — 경계 거리로 흔들림/결함을 가른다. 살아 있는 행이 + # 있으면 그 top 행의, 없으면 artifact top 행의 컷 경계 거리를 본다. + rows = rows_off or rows_on + if rows: + top1 = max(r["similarity"] for r in rows) + dist = _cut_boundary_dist(top1, q, top1, settings) + else: + art = offtopic_rows.get((q, uid)) or [] + top1 = art[0]["sim"] if art else 0.0 + dist = _cut_boundary_dist(top1, q, top1, settings) if art else 0.0 + d["offtopic"]["boundary_dist"] = round(dist, 4) + same_onoff = live_zero[0] == live_zero[1] + bucket = "wobble" if (same_onoff and dist <= WOBBLE) else "fail" + verdicts["J3"][bucket].append(q) + + # ④ 정답 무퇴행 (기대 정답이 있는 질의만) + if eo["expected"]: + want = set(eo["expected"]) + in_off = want & set(ids_off) + in_on = want & set(ids_on) + if in_off == in_on: + verdicts["J4"]["pass"] += 1 + else: + lost = (in_off - in_on) | (in_on - in_off) + dists = {} + for rid in lost: + sims = sims_off if rid in sims_off else sims_on + rows = rows_off if rid in sims_off else rows_on + top1 = max(r["similarity"] for r in rows) + dists[rid] = _cut_boundary_dist(sims[rid], q, top1, settings) + d["expected_diff"] = {str(r): round(v, 4) for r, v in dists.items()} + bucket = ("wobble" if dists and all(v <= WOBBLE for v in dists.values()) + else "fail") + verdicts["J4"][bucket].append(q) + d["expected_rank_off"] = [ + ids_off.index(r) + 1 for r in eo["expected"] if r in ids_off] + d["expected_rank_on"] = [ + ids_on.index(r) + 1 for r in eo["expected"] if r in ids_on] + + # ⑤ off = 유사도 내림차순 + mono = all( + rows_off[i]["similarity"] >= rows_off[i + 1]["similarity"] - ROUND_TIE + for i in range(len(rows_off) - 1) + ) + if mono: + verdicts["J5"]["pass"] += 1 + else: + verdicts["J5"]["fail"].append(q) + + detail.append(d) + + names = { + "J1": f"① 후보 집합 불변 (전 {len(keys)}건)", + "J2": f"② 재정렬 정확성 (전 {len(keys)}건)", + "J3": "③ 무관 무노출 유지 (무관 15건)", + "J4": "④ 정답 무퇴행 (기대 정답 있는 질의)", + "J5": f"⑤ off 유사도 내림차순 (전 {len(keys)}건)", + } + log("=" * 88) + log("재정렬 실서버 검증 — off/on 대조") + log("=" * 88) + all_ok = True + for j, name in names.items(): + v = verdicts[j] + ok = not v["fail"] + all_ok = all_ok and ok + line = f" [{'PASS' if ok else 'FAIL'}] {name} 통과 {v['pass']}" + if v["wobble"]: + line += f" · 경계 흔들림 {len(v['wobble'])}건 {v['wobble']}" + if v["fail"]: + line += f" · 결함 {len(v['fail'])}건 {v['fail']}" + log(line) + log("\n 경계 흔들림 = 어긋난 Record 의 컷 경계 거리(또는 후보 floor 거리)가 " + f"{WOBBLE} 이내 (T68). 재시도로 확인한다 — 결함으로 세지 않되 보고에 남긴다.") + + if args.json: + Path(args.json).write_text(json.dumps({ + "verdicts": {j: {"pass": v["pass"], "wobble": v["wobble"], + "fail": v["fail"]} for j, v in verdicts.items()}, + "adopted": {"floor": ADOPTED_FLOOR, "weight": ADOPTED_WEIGHT, + "top_k": ADOPTED_TOP_K}, + "wobble_threshold": WOBBLE, + "detail": detail, + }, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") + log(f"\n 저장: {args.json}") + return 0 if all_ok else 1 + + +def main() -> int: + ap = argparse.ArgumentParser(description="재정렬 실서버 검증 (S15P11A705-339)") + sub = ap.add_subparsers(dest="mode", required=True) + + c = sub.add_parser("collect", help="서버에 질의 셋을 던져 응답을 저장") + c.add_argument("--ai", default="http://127.0.0.1:8011") + c.add_argument("--out", required=True) + c.add_argument("--flag", default="", help="기록용 — 서버의 재정렬 플래그 상태") + c.add_argument("--limit", type=int, default=20) + c.add_argument("--queries", default="", help="쉼표 구분 — 재시도용 부분 수집") + + j = sub.add_parser("judge", help="off/on 수집 파일을 대조해 5판정") + j.add_argument("--off", required=True) + j.add_argument("--on", dest="on", required=True) + j.add_argument("--json") + + args = ap.parse_args() + if args.mode == "collect": + return asyncio.run(collect(args)) + return judge(args) + + +if __name__ == "__main__": + raise SystemExit(main()) From da58a3d0cc001c4f84478005b570515db140a402 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 15:40:07 +0900 Subject: [PATCH 20/34] =?UTF-8?q?docs(S15P11A705-339):=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=B1=84=ED=83=9D=EA=B0=92=C2=B7=EC=8B=A4?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20I55(?= =?UTF-8?q?=EC=9E=A0=EC=A0=95)=20=E2=80=94=20=EC=8B=A4=EC=84=9C=EB=B2=84?= =?UTF-8?q?=205=ED=8C=90=EC=A0=95=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 측정·런타임 구현·실서버 검증을 한 리포트로 완결한다. 사용자 승인에 따라 초안(DRAFT)을 정식화하고 색인 두 표에 I55 로 등록했다. 실검증 요지: 스냅샷 DB(:25432) 상대로 이 브랜치 코드를 플래그만 다르게 두 번 띄워 질의 93건을 대조 — 후보 집합 불변 93/93 · 재정렬 정확성 93/93 · 무관 무노출 15/15 · 정답 무퇴행 78/78 · off=현행(유사도 내림차순) 93/93 전부 통과. 재정렬이 순서를 바꾼 7건 전건 오프라인 재구성과 일치(만두 4위→1위), 신한·부캠 미회복 재확인(문자열 검색·재작성 역할 분담 검증), T68 흔들림 분류 미발동. 판정 도구 결함 1건(무관 기대의 질의 문구 키잉 → (질의, 소유자) 키로 수정) 정정 이력 포함. 번호 잠정: 병렬 브랜치(S15P11A705-337-rewrite-probe)도 잠정 I55 를 사용 중이다. 통합 병합 시 색인 검사가 중복을 잡으며, 나중에 병합되는 쪽이 I56 으로 재확정한다. Co-Authored-By: Claude Fable 5 --- docs/implements/2026-08-06-rerank-adoption.md | 122 ++++++++++++++++++ docs/implements/README.md | 2 + 2 files changed, 124 insertions(+) create mode 100644 docs/implements/2026-08-06-rerank-adoption.md diff --git a/docs/implements/2026-08-06-rerank-adoption.md b/docs/implements/2026-08-06-rerank-adoption.md new file mode 100644 index 0000000..4812af7 --- /dev/null +++ b/docs/implements/2026-08-06-rerank-adoption.md @@ -0,0 +1,122 @@ +# 키워드 재정렬 채택값 실측과 런타임 구현 — 재정렬 전용 구조의 binary w=0.05 · floor 0.35 + +- **티켓**: `S15P11A705-339` +- **번호**: **I55 (잠정)** — 병렬 브랜치(`S15P11A705-337-rewrite-probe`)도 잠정 I55 를 사용 중이다. 통합 병합 시 색인 검사가 중복을 잡으며, 나중에 병합되는 쪽이 I56 으로 재확정한다. +- **날짜**: 2026-08-06 +- **하네스**: `tools/search_cut/fusion_rerank_sweep.py`(신규) · `fusion.py` 의 `rerank()`(신규) · `rerank_verify_live.py`(실서버 대조, 신규) — 실행 절차는 그 README +- **기준 문서**: [P49](../proposals/P49-multi-signal-search.md) §4(병합 방법)·§5(무관 결과 차단)·§8(작업 4) · 선행 실측 [I53](2026-08-05-fusion-measurement.md) +- **성격**: 측정과 런타임 구현을 함께 완결한다. 측정은 기존 artifact 만 읽는 완전 오프라인이다(GMS·DB 호출 0회). 런타임 재정렬은 기본 off 플래그 뒤에 있고, 스냅샷 DB 상대의 실서버 on/off 대조까지 이 리포트가 담는다. + +## 한눈에 보는 결과 + +| 항목 | 확인한 내용 | 결정 또는 대응 | +|---|---|---| +| 채택값 | binary 방식 · floor 0.35 · weight 0.05 가 퇴행 없이 개선만 남는 유일한 격자 조합이다 | 이 값을 `app/core/config.py` 기본값으로 반영했다 | +| RRF | 문장형 정답의 1위 적중률을 0.8333 에서 0.5833~0.6667 로 떨어뜨린다 | 기각 | +| 무관 노출 | 모든 방식·모든 floor·weight 에서 무관 세그먼트 지표가 BASE 와 완전히 같았다 | 구조적 불변이 실측으로도 확인됐다. 스크립트가 다르면 중단하도록 가드를 넣었다 | +| 결정성 | 같은 artifact·같은 인자의 두 회차에서 출력 JSON 이 바이트 단위로 같았다 | 회차 대조 통과 | +| 런타임 | 재정렬이 구현됐고 on/off 후보 집합 불변이 계약 테스트로 고정됐다 | 플래그 기본 off. 기존 테스트 494건 전부 통과, 신규 17건 추가(총 511건) | +| 실서버 검증 | 스냅샷 DB 상대 on/off 실응답 대조에서 5개 판정이 질의 93건 전부 통과했다 | 재정렬이 실제 순서를 바꾼 7건이 전건 오프라인 재구성과 일치한다. 채택값을 실검증 기준으로 확정한다 | + +## 배경 + +확정된 런타임 설계(P49 §4)는 키워드 신호를 코사인 컷 이후 재정렬 전용으로 제한한다. 선행 실측(I53)의 채택값 floor 0.35 · binary weight 0.05 는 P48 구조에서 측정됐다. P48 구조는 벡터 후보와 키워드 후보의 합집합을 만들고 컷을 병합 점수에 거는 방식이라, 키워드 신호가 후보의 포함 여부를 바꿀 수 있었다. 재정렬 전용 구조에서는 키워드 신호가 순서만 바꾼다. 두 구조에서 같은 값의 의미가 다르므로 재정렬 전용 의미의 채택값을 다시 쟀다. + +측정만 하고 끝나면 채택값은 있는데 세 번째 검색 신호가 런타임에 존재하지 않는다. 그래서 이 작업은 재정렬 런타임 구현까지 포함한다. + +## 측정 조건 + +- 입력 artifact: `.search/matrix.json` · `word_grid.json` · `recall_probe.json` · `keyword_matrix.json` — 전부 기존 커밋본이다. 이번 측정에서 재생성하지 않았다. +- artifact 의 조건: Record 42건 · 소유자 3명 · Preset 27건 v1 · profile `openai-text-embedding-3-small-1536-cosine-v1` (I53 §1 과 동일). +- 컷: 현행 기본값 그대로 벡터 경로에만 적용했다(`tau_abs=0.30` · `tau_word=0.24` · `r=0.60` · `limit=20`). 재정렬은 컷 통과 집합 안에서만 순서를 바꾼다. +- 격자: floor 5값(0.25 / 0.30 / 0.32 / 0.35 / 0.40) × binary weight 4값(0.02 / 0.05 / 0.10 / 0.20) + RRF(k=60) + BASE(키워드 없음). confidence·IDF 는 티켓 범위대로 재개방하지 않았다. +- 지표: 순위 기준선 도구(`rank_score.py`)의 것을 그대로 썼다. BASE 와 다른 자로 재면 비교가 성립하지 않기 때문이다. + +## 확인한 결과 — 측정 + +**채택 조합에서 단어형 정답 지표가 전부 개선됐고 퇴행은 없었다.** floor 0.35 · binary weight 0.05 에서 단어형 정답 질의 66건의 지표가 다음과 같이 움직였다. + +| 지표 (단어형 정답 66건) | BASE | floor 0.35 · binary w=0.05 | +|---|---|---| +| hit@1 (1위에 정답) | 0.8636 | 0.8788 | +| hit@3 (3위 안에 정답) | 0.9394 | 0.9545 | +| MRR (첫 정답 순위 역수 평균) | 0.9015 | 0.9129 | +| recall@3 | 0.9205 | 0.9356 | +| nDCG@3 | 0.9007 | 0.9159 | + +hit@1 의 개선 폭 0.0152 는 66건 중 1건이 1위로 올라온 것이다. 문장형 정답 질의 12건은 hit@1 0.8333 과 hit@3 1.0 이 그대로 유지되고 MRR 이 0.9028 에서 0.9167 로, nDCG@3 이 0.9276 에서 0.9385 로 올랐다. 이 조건에서 키워드 신호가 실제로 순서에 반영된 질의는 단어형 66건 중 9건, 문장형 12건 중 8건이다. + +**무관 세그먼트는 모든 조합에서 BASE 와 완전히 같았다.** 관련 없는 문장형 질의 15건 중 11건, 단어형 45건 중 19건이 결과를 반환하지 않았고, 이 값은 방식·floor·weight 와 무관하게 변하지 않았다. 재정렬은 컷 통과 집합 안에서만 움직이므로 이것은 구조적 성질이다. 스윕 스크립트는 무관 세그먼트 지표가 BASE 와 다르면 수치를 내지 않고 중단하도록 만들었고, 이번 측정에서 그 가드는 한 번도 발동하지 않았다. + +**RRF 는 문장형 1위 적중률을 떨어뜨려 기각했다.** RRF(k=60)는 모든 floor 에서 문장형 hit@1 을 0.8333 에서 0.5833(floor 0.25~0.30) 또는 0.6667(floor 0.32~0.40)로 떨어뜨렸다. 벡터 1위가 키워드 순위와의 합산에서 밀리는 것으로, P48 구조 실측(I53)과 같은 실패 방식이 재정렬 전용 구조에서도 재현된 것이다. + +**weight 는 0.05 가 경계다.** floor 0.35 기준으로 weight 0.02 는 단어형 hit@1 개선이 없고(0.8636 유지), 0.10 은 단어형 hit@3 개선이 사라지며(0.9394 로 복귀), 0.20 은 문장형 hit@1 이 0.7500 으로 퇴행한다. 인접 weight 간 차이는 전부 질의 1건 이상의 이동(0.015 규모)이라 임베딩 흔들림(아래)으로 설명되지 않는다. + +**floor 0.35 는 임베딩 흔들림에 안전한 값이다.** 임베딩 API 는 같은 입력에도 유사도가 최대 0.0044 흔들린다(T68 실측). 질의-Preset 코사인 중 0.35 에 가장 가까운 값이 정확히 0.3500 에 1건 있어 경계 민감도를 따로 쟀다. floor 를 0.345 / 0.35 / 0.355 / 0.36 으로 옮겨도 weight 0.05 의 지표는 0.35~0.36 구간에서 완전히 같았고 0.345 는 MRR 셋째 자리만 달랐다(0.9154 대 0.9129). 경계 위의 그 1건은 지표를 바꾸지 않는다. 따라서 채택 결론은 넷째 자리 흔들림에 좌우되지 않는다. + +**사례 4건.** `그네`는 3위, `스팟`은 1위로 모든 조합에서 유지됐다. `신한`과 `부캠`은 모든 조합에서 결과에 없다. 두 질의의 정답은 컷 통과 집합에 들어오지 못하므로 순서만 바꾸는 이 신호로는 회복될 수 없다. `신한`은 문자열 검색(I54, back 구현 예정), `부캠`은 질의 재작성(-337)이 맡는다는 역할 분담이 이 구조에서는 정의상 확정된다. + +## 해석 — I53 값과의 관계 + +채택값의 숫자는 I53(P48 구조)의 채택값과 같다. 이것은 우연이 아니라 다음으로 설명된다. I53 의 floor 0.35 · binary w=0.05 는 P48 구조에서도 후보 집합을 바꾸지 않는 조합이었다(무관 무노출이 baseline 과 동일했다). 후보 집합이 같으면 P48 의 가중합 정렬과 재정렬 전용의 정렬 결과가 같아진다. 즉 I53 의 안전 조합은 사실상 재정렬만 하고 있었고, 이번 측정은 그것을 재정렬 전용 의미에서 명시적으로 재확정한 것이다. 다만 이 동일성은 결과의 관측이지 전제가 아니었다 — 두 측정은 병합 코드 경로가 다르다(`fuse()` 대 `rerank()`). + +## 결정 및 대응 + +**채택값: binary 방식 · floor 0.35 · weight 0.05 · top_k 3.** 격자에서 순위 지표 퇴행 없이 개선만 남는 조합이 이것뿐이었다. `app/core/config.py` 에 기본값으로 반영했다. + +**런타임 구현.** 다음이 이번 커밋에 들어갔다. + +1. `context_keyword_repo.keywords_for_records()` — 컷 통과 후보 Record 의 (record_id, keyword_id) 조회. `keyword_status = COMPLETED` 인 Context 의 keyword 만 신호로 나온다(P48 §1-b). BLOCKED Preset 은 PresetCache 가 적재 시점에 제외하므로 후보와 매치될 수 없고, PUBLIC·PRIVATE_ONLY 는 신호로 쓴다(§1-c). +2. `SearchService._rerank_by_keyword()` — 컷 통과 후보의 순서만 조정한다. 질의-Preset 후보는 검색용으로 이미 만든 질의 임베딩을 재사용해 추가 모델 호출이 없다. 후보 선정 규칙(floor 를 top_k 보다 먼저, 동점은 preset_id 오름차순)은 측정 하네스와 같다. +3. 플래그 `SEARCH_KEYWORD_RERANK_ENABLED` — 기본 off. off 면 검색은 현행과 동일하게 동작한다. 조회·계산이 실패하거나 신호가 없으면 벡터 순서를 그대로 반환하고 응답은 실패하지 않는다. 실패 유형을 좁히지 않고 전부 벡터 순서 복귀로 흡수한다 — 보조 신호의 어떤 실패도 주 결과를 지우면 안 된다는 요구를 그대로 옮긴 결정이다. +4. 응답의 `similarity` 는 원래 코사인을 유지한다. 정렬 점수(코사인 + weight)는 응답에 노출하지 않는다. +5. 재정렬 뒤 두 번째 후보 절단은 없다. + +**후보 집합 불변의 이중 고정.** 오프라인은 스윕 스크립트가 재정렬 3,450회(질의×조합) 전부에서 전후 Record id 집합 일치를 검사했고, 런타임은 계약 테스트가 같은 요청·같은 limit 에서 키워드 신호 on/off 의 응답 Record id 집합이 같음을 고정한다(문자열 병합 직전 지점 = 이 API 의 반환값 기준). + +## 검증 — 실행한 것과 못 한 것 + +**실행한 것** + +- 격자 실측: floor 5값 × weight 4값 + RRF + BASE 를 4개 세그먼트와 사례 4건에서 측정했다. +- 오프라인 결정성 회차: 같은 artifact·같은 인자로 두 번 돌려 출력 JSON 이 바이트 단위로 같음을 확인했다(SHA-256 일치). 화면 출력도 저장 경로 표시 한 줄 외에 같았다. +- artifact 신선도 가드 동작 확인: `--expect-preset-version 2` 와 profile 불일치 인자로 돌리면 수치를 내지 않고 종료 코드 1 로 실패하는 것을 확인했다. 실제 측정에서는 가드가 발동하지 않았다(artifact 가 현행과 일치). +- 경계 민감도: floor 0.345~0.36 구간 재측정으로 채택 결론이 넷째 자리 흔들림에 좌우되지 않음을 확인했다. +- 테스트: 기존 494건 전부 통과 유지, 신규 17건(재정렬 순수 로직 픽스처 8 + 런타임 계약 9) 추가로 총 511건 통과. ruff 통과. 커버리지 게이트(app 라인·브랜치 각 80% 이상) 통과 — 라인 94.40% · 브랜치 84.69%. + +**실행한 것 — 실서버 브랜치 검증** + +이 브랜치 코드로 FastAPI 를 실제로 띄우고 스냅샷 DB(:25432)를 상대로 재정렬 플래그 on/off 의 실응답을 대조했다. 서버는 `SEARCH_KEYWORD_RERANK_ENABLED` 만 다르게 두 번 띄웠고, 같은 질의 셋 93건(문장형 정답 12 + 무관 15 + 단어형 정답 66, 사례 4건은 단어형과 중복이라 합쳐짐)을 각각 던졌다. HTTP 200 이 두 서버 모두 93/93 이었다. 판정 도구는 `rerank_verify_live.py` 다. + +| 판정 | 결과 | +|---|---| +| ① 후보 집합 불변 — off/on 응답의 Record id 집합 동일(순서만 차이 허용) | 93/93 통과 | +| ② 재정렬 정확성 — on 순서 = on 응답 유사도 + 오프라인 keyword 신호 재구성 | 93/93 통과 | +| ③ 무관 무노출 유지 — (질의, 소유자) 단위 오프라인 기대와 off/on 일치 | 15/15 통과 (무노출 11건 유지) | +| ④ 정답 무퇴행 — 기대 정답 포함이 off/on 동일 | 78/78 통과 | +| ⑤ off = 현행 동일 — off 순서가 유사도 내림차순 | 93/93 통과 | + +재정렬이 실제로 순서를 바꾼 질의는 93건 중 7건이었고, 7건 전부 오프라인 재구성과 일치했다. 대표 사례로 단어형 질의 `만두`(소유자 80)의 기대 정답이 4위에서 1위로 올라왔고, 문장형 질의 `친구들이랑 피자에 맥주 마신 곳`의 기대 정답이 3위에서 2위로 올라왔다. 순서가 바뀐 7건에서 기대 정답의 순위 하락은 없었다. + +`신한`과 `부캠`의 진단 기대 정답(record 277)은 off/on 모두 검색 결과에 없었다. 컷 통과 집합에 들어오지 못하는 정답은 재정렬로 회복되지 않는다는 오프라인 예측이 실서버에서 재확인된 것이고, 두 질의를 문자열 검색(I54)과 질의 재작성(-337)이 맡는 역할 분담도 함께 검증됐다. + +off 와 on 은 별도 요청이라 임베딩이 흔들릴 수 있어(T68, 최대 0.0044) 판정 도구에 경계 거리 기반 흔들림 분류를 두었는데, 이번 검증에서는 한 번도 발동하지 않았다. 실응답 유사도가 artifact 값과 소수 넷째 자리까지 사실상 일치했다. + +**정정 이력 — 판정 도구 결함 1건.** 첫 판정에서 무관 무노출(③)이 4건 어긋난 것으로 나왔다. 원인은 서버가 아니라 판정 도구였다 — 무관 질의의 오프라인 기대를 질의 문구만으로 키잉해서, 같은 문구를 공유하는 소유자 3명의 기대가 마지막 소유자의 것으로 덮어써졌다. (질의, 소유자) 키로 수정한 뒤 전건 통과했다. 무관 질의 기대는 소유자 단위라는 사실을 같은 실수를 반복하지 않기 위해 기록한다. + +**못 한 것** + +- **임베딩 API 비결정성 재측정은 하지 않았다.** artifact 재생성(GMS 호출)이 필요해 티켓 범위 밖이다. 대신 경계 민감도 측정으로 그 흔들림이 채택 결론을 바꾸지 않음을 보였고, 실서버 대조에서도 흔들림 분류가 발동하지 않았다. +- **통합 검증(P49 §7)은 이 리포트 범위 밖이다.** 이번 실서버 검증은 재정렬 단독의 브랜치 검증이다. 재작성·문자열 검색을 합친 5기준 통합 검증과 시연 리허설은 작업 6 의 몫으로 남는다. +- **운영 DB 를 재지 않았다.** 시연 DB 스냅샷 시점의 artifact 와 스냅샷 DB 만 썼다. Record 42건 규모라 절대값을 일반화하지 않고 퇴행 탐지 근거로만 쓴다 — 선행 리포트들과 같은 한계다. +- Preset 이 개정되면(P51 거버넌스 루프) preset_version 가드가 실측을 막으므로 keyword artifact 재생성과 재측정이 필요하다. + +## 근거 + +- 하네스: `tools/search_cut/fusion.py`(`rerank()`) · `fusion_rerank_sweep.py` · `rerank_verify_live.py`(실서버 대조) — 실행 절차는 `tools/search_cut/README.md` +- 스윕 결과: `.search/fusion_rerank_sweep_run1.json` · `_run2.json` — artifact 에서 재구성 가능하므로 미커밋(기존 규약). 수치는 이 리포트 표가 보존본 +- 실서버 대조 결과: `.search/rerank_live_off.json` · `rerank_live_on.json` · `rerank_live_report.json`(질의별 off/on Record id·순서·기대 정답 순위·경계 거리) — 재실행으로 재구성 가능하므로 미커밋. 판정 요약은 이 리포트 표가 보존본 +- 런타임: `app/service/search_service.py` · `app/repository/context_keyword_repo.py` · `app/core/config.py` · `app/main.py` +- 테스트: `tests/test_search_fusion.py` §9 · `tests/test_search_rerank.py` +- 선행: [I53](2026-08-05-fusion-measurement.md)(P48 구조 실측) · [I54](2026-08-06-lexical-merge-rule.md)(문자열 병합 규칙 — `신한` 담당) · T68(임베딩 API 흔들림 실측) +- 커밋: `dddcef2`(하네스) · `a541c2c`(런타임) · `1171543`(실서버 검증 도구) diff --git a/docs/implements/README.md b/docs/implements/README.md index a9ff362..a014e1f 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -56,6 +56,7 @@ | I51 | [2026-08-05-short-query-boundary.md](2026-08-05-short-query-boundary.md) | 검증 | 짧은 질의의 층별 탈락과 단어형 경계 두 정의 — **`-266` 이후 남은 실패는 셋뿐이고 경계 정의로는 하나도 안 풀린다.** `신한`(③ τ_abs·6위)·`부캠`(③ τ_abs·8위)·`신한 부캠`(**④ r**·4위)이고 실서버 대조 22/22 일치로 층을 확정했다(전처리·폴백은 **존재하지 않는다** — `min_length=1` 뿐이고 0건이면 0건이 그대로 나간다). **「2자가 안 걸린다」는 부정확** — 2자 7건 중 5건은 걸리고 못 걸리는 둘은 컷 전 순위가 6·8위다. `-266` 이 「재지 않았다」고 적은 B(공백·짧다)·C(무공백·길다) 대역을 **본문 인접 어절쌍 전량**(쌍 176종 × spaced/joined × 소유자 3명 = 1,128행)으로 처음 쟀다 — 손실은 C 에 몰려 있고(16/122 → 어절 수 정의 3/122) **그런데 초점 집합 36행에서는 어느 정의에서도 누락이 0** 이라 두 표가 어긋난다(회복되는 것이 기능어 쌍이다). 새 관측 둘: **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385 · 172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 τ 탈락 — `-266` 이 「안전 방향」이라 부른 판단이 이 대역에서는 손해다). 계약이 「이것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하므로 **세 번째다** (S15P11A705-273) | | I53 | [2026-08-05-fusion-measurement.md](2026-08-05-fusion-measurement.md) | 구현 | Keyword fusion 1단계 실측 — 병합 방식(가중합 5조합·RRF cutoff 5값)×floor 4값 격자. **가중합은 정답과 관련 없는 후보를 함께 끌어올린다** — floor 0.25 에서 `신한` 복구(컷 후 4위)와 무관-문장형 무노출 11/15→9/15 감소가 같은 계산에서 나온다. RRF 는 무노출을 유지하나 문장형 hit@1 0.8333→0.5833 퇴행. **P48 §6.1 네 조건을 전부 통과한 조합은 floor 0.35 · binary w=0.05 하나** — 그 조건에서 단어형·문장형 전 지표 개선·무관 무노출 유지, 대가는 신호 반영 12/66 축소와 `신한` 재제외. `신한`·`부캠`은 이 구조로 안 풀린다(재작성 `-337`·문자열 검색의 대상). 환경 동등성은 baseline 재현 = I52 일치로 확인. 못 잰 축(floor 0.30~0.35·weight 격자·재현성 회차)은 `-339` 몫 (S15P11A705-336) | | I54 | [2026-08-06-lexical-merge-rule.md](2026-08-06-lexical-merge-rule.md) | 구현 | 문자열 검색 병합 규칙 실측 — 게이트 3단(전체/단어형 한정/어절 경계) × 병합 3종(뒤 추가/RRF/앞 고정) 격자. **무관·타인소유·문장형 세그먼트에서 문자열 매치 0건** — 어떤 조합도 관련 없는 결과 노출을 늘리지 않는다. `신한` 이 전 조합에서 회복(RRF 5위)되고 `부캠` 은 예상대로 미회복(재작성 -337 대상). 경계 게이트는 기대 정답 6건을 제외하는 손해만 관측돼 부분일치 채택 권고. **규칙 확정안: 단어형 한정·부분일치 게이트 + RRF(k=60) 병합 + 컷·응답 계약 불변 + 실패 시 벡터만** — back 연동 티켓의 판단 재료. 한계: 경계 게이트가 막으려는 유형(어절 중간 시작·부수적 언급·동형어)이 코퍼스에 없어 이득 측정 불가, 운영 코퍼스 재평가 필요 (티켓 없음 · 런타임 변경 없음 · 스냅샷 DB) | +| I55 | [2026-08-06-rerank-adoption.md](2026-08-06-rerank-adoption.md) | 구현 | 키워드 재정렬 채택값 실측·런타임 구현 — P49 §4 재정렬 전용 구조(컷 통과 집합 고정·순서만 조정)에서 BASE·binary(floor 5값×weight 4값)·RRF(k=60) 재측정. **binary·floor 0.35·w 0.05 만 퇴행 없이 개선**(단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129, 문장형 유지+MRR 개선), RRF 는 문장형 hit@1 0.8333→0.5833~0.6667 퇴행으로 기각. 무관 세그먼트는 전 조합에서 BASE 와 동일(구조적 불변 실측 확인·스크립트 가드). floor 0.35~0.36 지표 동일로 T68 흔들림에 안전. 런타임: 기본 off 플래그·`keyword_status=COMPLETED` 신호·실패 시 벡터 순서 복귀·similarity 원값 유지, on/off 후보 집합 불변 계약 테스트 9건 + 픽스처 8건(총 511 통과). **실서버 on/off 대조 5판정 93건 전부 통과** — 재정렬 발화 7건 전건 오프라인 일치(`만두` 4위→1위), `신한`·`부캠` 미회복 재확인(역할 분담 검증), 흔들림 분류 미발동. **번호 잠정** — 병렬 브랜치(`S15P11A705-337-rewrite-probe`)도 잠정 I55 사용 중, 통합 병합 시 색인 검사가 중복을 잡으며 나중에 병합되는 쪽이 I56 으로 재확정 (S15P11A705-339 · 스냅샷 DB) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -115,5 +116,6 @@ | I51 | 짧은 질의 층 프로브 `tools/search_cut/layer_probe.py` + 경계 행렬 `boundary_matrix.py`·`boundary_sweep.py` + `.search/boundary_grid.json`(1,128행)·`layer_probe.json` — **산출물은 하네스 셋과 판정이고 코드는 고치지 않았다.** ① 층을 코드로 확정했다 — 계약이 후보로 준 「질의 전처리」와 「후보 0건 폴백」은 **존재하지 않는다**(`schema/search.py:9` 는 `min_length=1` 뿐이고 정규화가 없어 원문이 그대로 도달하며, `_cut` 이 0건이면 0건이 그대로 응답이다). 자르는 것은 `search_service.py:111-115,122` 둘뿐이고 `LIMIT` 은 이 데이터에서 한 건도 안 자른다(후보 17·11·6건). **실서버 대조 22/22 일치** — 재구성에 `_cut`·`_is_word_query` 를 다시 적었다(구현 import 금지, `-213` 규칙). ② `-266` 이후 남은 실패는 셋뿐이고 **서로 다른 층이다** — `신한`(③ τ_abs 0.2301<0.24·6위)·`부캠`(③ 0.2105·8위)·`신한 부캠`(**④ r** 0.3187<0.3520·4위). 셋째는 τ 를 넘고 `r` 에만 걸리므로 **경계 정의를 어떻게 바꿔도 안 풀린다**(`-266` 이 `r` 을 「참여하지 않는다」로 가르지 않았다). ③ 「2자가 안 걸린다」는 부정확 — 2자 7건 중 5건은 걸리고(`우주` 0.4200 1위·`공원` 0.3672 1위) 못 걸리는 둘은 **컷 전 순위가 6·8위**다. ④ `-266` 이 「둘이 갈리는 질의가 행렬에 없다」고 적은 대역을 만들었다 — 본문 **문장 내 인접 어절쌍 전량**을 `spaced`/`joined` 짝으로 내면 기대 정답이 같아 차이가 전부 「공백 하나」에 귀속된다(쌍 176종 × 2형태 × 소유자 3명 = 1,128행, GMS 376건). 질의를 고르지 않아 `-266` 이 한계로 적은 선정 재량이 없다(남는 재량은 「두 어절 모두 2자 이상」 하나). ⑤ 손실은 **C 대역(무공백·길다)** 에 몰려 있다 — 현행 16/122·1위 손실 4 가 어절 수 정의에서 3/122·1 이 되고 A·B·D 는 안 움직인다. **그런데 내용어 초점 집합 36행에서는 여섯 정의 전부 누락 0** 이라 두 표가 어긋난다 — 회복되는 것이 기능어 쌍(`당시자주` 0.1909·`같은분위기가` 0.2170)이라 사용자 이득이 관측되지 않았다. ⑥ 새 관측 둘 — **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385·중앙값 -0.0279·172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 ④ r 이 아니라 ③ τ 에서 잘린다). 후자는 `-266` 이 「측정이 아니라 안전 방향으로의 판단」이라 적은 대역이고 **그 판단이 여기서는 손해로 나타난다**(3건 표본). ⑦ 처방 후보 여섯을 비용·부작용과 함께 냈고 **「안 바꾸는 안」을 포함**했다 — 고르는 것은 이 티켓의 일이 아니다. ⑧ 계약이 「네가 재는 것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하고 후자는 코드까지 병합돼 dev 배포 tag(`7e72826`)에 들어 있으므로 **세 번째다** | [경계 리포트](2026-08-05-short-query-boundary.md), [I45](2026-08-03-search-recall-probe.md), [I47](2026-08-03-word-query-cut.md), [tools/search_cut/](../../tools/search_cut/) | | I53 | Keyword fusion 1단계 실측 `tools/search_cut/fusion_sweep.py` 격자 — 방식(binary·conf·idf·RRF)×weight×floor(0.25/0.30/0.35/0.40)×RRF cutoff(0~0.033). **`신한` 복구와 무관 노출 증가가 같은 계산에서 나온다**(가중합은 컷을 부스트 점수에 걸어 경계 아래 정답과 무관 후보를 똑같이 끌어올린다). RRF 는 cosine eligibility gate 로 무노출을 지키지만 벡터 1위가 키워드 순위 합산에 밀려 문장형이 퇴행한다. floor 0.35 에서 무관 무노출이 baseline 복원되고 binary w=0.05 만 P48 §6.1 전 조건 통과 — Preset 후보가 floor 를 넘는 단어형 질의가 12/66 뿐이라는 대가(미반영 54건 = `-338` 설계 입력). 사례 4건 개별 보고(신한·부캠 미회복 확정). 픽스처 27종·가드 무오발동·baseline 재현 일치 | [실측 리포트](2026-08-05-fusion-measurement.md), [T73~T78](../troubleshooting/2026-08-05-multi-signal-investigation.md), [P48](../proposals/P48-search-signal-expansion.md), [P49](../proposals/P49-multi-signal-search.md) | | I54 | 문자열 병합 규칙 하네스 `tools/search_cut/lexical_matrix.py`·`lexical_sweep.py` — 본문 매치를 (질의 × 소유자 × Record) 로 굳히고(본문 미저장·매치 여부만) 게이트×병합 격자를 오프라인으로 훑는다. 스냅샷 DB(:25432) 가드. 측정 결과: 문자열 매치 94건이 전부 정답 세그먼트에만 발생, 단어형 정답 라벨이 부분일치 정의라 재현율은 구조적으로 유리(순환성 명시), 병합 방식 차이는 RRF·앞고정 > 뒤추가. 단독 추가 7건 · 빈 결과 1건 해소 | [규칙 리포트](2026-08-06-lexical-merge-rule.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md) | +| I55 | 키워드 재정렬 하네스·런타임 `tools/search_cut/fusion_rerank_sweep.py`(재정렬 전용 격자 + 신선도 가드 + 후보 불변 검사) · `fusion.rerank()`(순수 함수) · `rerank_verify_live.py`(실서버 on/off 5판정) · `app/service/search_service.py`(`_rerank_by_keyword` — 질의 임베딩 재사용·추가 모델 호출 0) · `app/repository/context_keyword_repo.keywords_for_records` · `SEARCH_KEYWORD_RERANK_*` 4키(기본 off·floor 0.35·w 0.05·top_k 3) — 채택값은 재정렬 전용 구조 재측정으로 확정(I53 과 숫자가 같지만 다른 측정), 결정성 회차 JSON 바이트 일치, 실서버 대조 93건 5판정 전부 통과. **번호 잠정** — 병렬 브랜치(`S15P11A705-337-rewrite-probe`)도 잠정 I55 사용 중, 나중에 병합되는 쪽이 I56 으로 재확정 | [재정렬 리포트](2026-08-06-rerank-adoption.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md), [tools/search_cut/](../../tools/search_cut/) | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. From 7092f0cf750670ba215d20a1004cf90a0421e3fe Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 17:41:29 +0900 Subject: [PATCH 21/34] =?UTF-8?q?docs(S15P11A705-339):=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=9E=A0?= =?UTF-8?q?=EC=A0=95=20=EB=B2=88=ED=98=B8=20I55=E2=86=92I56=20=EC=9E=AC?= =?UTF-8?q?=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 병렬 브랜치 둘이 각각 잠정 I55 를 등록했고 통합 병합에서 색인 검사가 중복을 잡았다 — 설계된 절차 그대로다. 나중에 병합된 -339 쪽이 I56 이 된다. 최종 확정은 dev 병합 직후에 한 번 더 대조한다. Co-Authored-By: Claude Fable 5 --- docs/implements/2026-08-06-rerank-adoption.md | 2 +- docs/implements/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/implements/2026-08-06-rerank-adoption.md b/docs/implements/2026-08-06-rerank-adoption.md index 4812af7..0e405a6 100644 --- a/docs/implements/2026-08-06-rerank-adoption.md +++ b/docs/implements/2026-08-06-rerank-adoption.md @@ -1,7 +1,7 @@ # 키워드 재정렬 채택값 실측과 런타임 구현 — 재정렬 전용 구조의 binary w=0.05 · floor 0.35 - **티켓**: `S15P11A705-339` -- **번호**: **I55 (잠정)** — 병렬 브랜치(`S15P11A705-337-rewrite-probe`)도 잠정 I55 를 사용 중이다. 통합 병합 시 색인 검사가 중복을 잡으며, 나중에 병합되는 쪽이 I56 으로 재확정한다. +- **번호**: **I56 (잠정)** — 병렬 브랜치(`S15P11A705-337-rewrite-probe`)의 잠정 I55 와 충돌해, 나중에 통합에 병합된 이쪽이 I56 으로 재확정됐다(색인 검사가 중복을 잡는 설계된 절차). 최종 확정은 dev 병합 직후다. - **날짜**: 2026-08-06 - **하네스**: `tools/search_cut/fusion_rerank_sweep.py`(신규) · `fusion.py` 의 `rerank()`(신규) · `rerank_verify_live.py`(실서버 대조, 신규) — 실행 절차는 그 README - **기준 문서**: [P49](../proposals/P49-multi-signal-search.md) §4(병합 방법)·§5(무관 결과 차단)·§8(작업 4) · 선행 실측 [I53](2026-08-05-fusion-measurement.md) diff --git a/docs/implements/README.md b/docs/implements/README.md index c656933..e807f70 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -57,7 +57,7 @@ | I53 | [2026-08-05-fusion-measurement.md](2026-08-05-fusion-measurement.md) | 구현 | Keyword fusion 1단계 실측 — 병합 방식(가중합 5조합·RRF cutoff 5값)×floor 4값 격자. **가중합은 정답과 관련 없는 후보를 함께 끌어올린다** — floor 0.25 에서 `신한` 복구(컷 후 4위)와 무관-문장형 무노출 11/15→9/15 감소가 같은 계산에서 나온다. RRF 는 무노출을 유지하나 문장형 hit@1 0.8333→0.5833 퇴행. **P48 §6.1 네 조건을 전부 통과한 조합은 floor 0.35 · binary w=0.05 하나** — 그 조건에서 단어형·문장형 전 지표 개선·무관 무노출 유지, 대가는 신호 반영 12/66 축소와 `신한` 재제외. `신한`·`부캠`은 이 구조로 안 풀린다(재작성 `-337`·문자열 검색의 대상). 환경 동등성은 baseline 재현 = I52 일치로 확인. 못 잰 축(floor 0.30~0.35·weight 격자·재현성 회차)은 `-339` 몫 (S15P11A705-336) | | I54 | [2026-08-06-lexical-merge-rule.md](2026-08-06-lexical-merge-rule.md) | 구현 | 문자열 검색 병합 규칙 실측 — 게이트 3단(전체/단어형 한정/어절 경계) × 병합 3종(뒤 추가/RRF/앞 고정) 격자. **무관·타인소유·문장형 세그먼트에서 문자열 매치 0건** — 어떤 조합도 관련 없는 결과 노출을 늘리지 않는다. `신한` 이 전 조합에서 회복(RRF 5위)되고 `부캠` 은 예상대로 미회복(재작성 -337 대상). 경계 게이트는 기대 정답 6건을 제외하는 손해만 관측돼 부분일치 채택 권고. **규칙 확정안: 단어형 한정·부분일치 게이트 + RRF(k=60) 병합 + 컷·응답 계약 불변 + 실패 시 벡터만** — back 연동 티켓의 판단 재료. 한계: 경계 게이트가 막으려는 유형(어절 중간 시작·부수적 언급·동형어)이 코퍼스에 없어 이득 측정 불가, 운영 코퍼스 재평가 필요 (티켓 없음 · 런타임 변경 없음 · 스냅샷 DB) | | I55 | [2026-08-06-rewrite-effect.md](2026-08-06-rewrite-effect.md) | 구현 | LLM 질의 재작성 효과 실측·길이 게이트 확정 — 실제 RewriteClient(운영 체인)로 무관 15건·사례 5건 전/후 비교. **약어 회복 확인**(`부캠`→`부트캠프` 컷 전 8위→1위·`신한 부캠` 4위→3위, 둘 다 반환 회복)·고유명사 유지 규칙 작동(`신한`·`그네`·`스팟` 원문 불변). **전면 적용은 무관 무노출 11/15→9/15 로 채택 기준 미달** — 의미 불변 표현 정규화(`파는 데`→`파는 곳`)가 컷 경계 아래 대역을 밀어 올린다. 게이트 후보 8종 전수 시뮬레이션에서 **형태(길이) 기준만 충족** — 성과 기반(결과 0건·top-1 임계)은 두 집단 대역이 겹쳐 분리 불가에 회복 대상까지 놓치고(원문도 오답 3건 반환), 단어형 판정 재사용은 `신한 부캠` 상실. **게이트 확정: strip 후 6자 이하만 재작성**(`SEARCH_REWRITE_MAX_CHARS`, 사용자 확정) — 게이트 반영 재측정에서 무노출 11/15 유지·회복 2건 유지로 채택 조건 충족. 한계: 6~12자 대역 무관측·모델 1종 1회차 관측 (S15P11A705-337 · 스냅샷 DB) | -| I55 | [2026-08-06-rerank-adoption.md](2026-08-06-rerank-adoption.md) | 구현 | 키워드 재정렬 채택값 실측·런타임 구현 — P49 §4 재정렬 전용 구조(컷 통과 집합 고정·순서만 조정)에서 BASE·binary(floor 5값×weight 4값)·RRF(k=60) 재측정. **binary·floor 0.35·w 0.05 만 퇴행 없이 개선**(단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129, 문장형 유지+MRR 개선), RRF 는 문장형 hit@1 0.8333→0.5833~0.6667 퇴행으로 기각. 무관 세그먼트는 전 조합에서 BASE 와 동일(구조적 불변 실측 확인·스크립트 가드). floor 0.35~0.36 지표 동일로 T68 흔들림에 안전. 런타임: 기본 off 플래그·`keyword_status=COMPLETED` 신호·실패 시 벡터 순서 복귀·similarity 원값 유지, on/off 후보 집합 불변 계약 테스트 9건 + 픽스처 8건(총 511 통과). **실서버 on/off 대조 5판정 93건 전부 통과** — 재정렬 발화 7건 전건 오프라인 일치(`만두` 4위→1위), `신한`·`부캠` 미회복 재확인(역할 분담 검증), 흔들림 분류 미발동. **번호 잠정** — 병렬 브랜치(`S15P11A705-337-rewrite-probe`)도 잠정 I55 사용 중, 통합 병합 시 색인 검사가 중복을 잡으며 나중에 병합되는 쪽이 I56 으로 재확정 (S15P11A705-339 · 스냅샷 DB) | +| I56 | [2026-08-06-rerank-adoption.md](2026-08-06-rerank-adoption.md) | 구현 | 키워드 재정렬 채택값 실측·런타임 구현 — P49 §4 재정렬 전용 구조(컷 통과 집합 고정·순서만 조정)에서 BASE·binary(floor 5값×weight 4값)·RRF(k=60) 재측정. **binary·floor 0.35·w 0.05 만 퇴행 없이 개선**(단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129, 문장형 유지+MRR 개선), RRF 는 문장형 hit@1 0.8333→0.5833~0.6667 퇴행으로 기각. 무관 세그먼트는 전 조합에서 BASE 와 동일(구조적 불변 실측 확인·스크립트 가드). floor 0.35~0.36 지표 동일로 T68 흔들림에 안전. 런타임: 기본 off 플래그·`keyword_status=COMPLETED` 신호·실패 시 벡터 순서 복귀·similarity 원값 유지, on/off 후보 집합 불변 계약 테스트 9건 + 픽스처 8건(총 511 통과). **실서버 on/off 대조 5판정 93건 전부 통과** — 재정렬 발화 7건 전건 오프라인 일치(`만두` 4위→1위), `신한`·`부캠` 미회복 재확인(역할 분담 검증), 흔들림 분류 미발동. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 색인 검사가 잡아 나중에 병합된 이쪽이 I56 으로 재확정(설계된 절차), 최종 확정은 dev 병합 직후 (S15P11A705-339 · 스냅샷 DB) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -118,6 +118,6 @@ | I53 | Keyword fusion 1단계 실측 `tools/search_cut/fusion_sweep.py` 격자 — 방식(binary·conf·idf·RRF)×weight×floor(0.25/0.30/0.35/0.40)×RRF cutoff(0~0.033). **`신한` 복구와 무관 노출 증가가 같은 계산에서 나온다**(가중합은 컷을 부스트 점수에 걸어 경계 아래 정답과 무관 후보를 똑같이 끌어올린다). RRF 는 cosine eligibility gate 로 무노출을 지키지만 벡터 1위가 키워드 순위 합산에 밀려 문장형이 퇴행한다. floor 0.35 에서 무관 무노출이 baseline 복원되고 binary w=0.05 만 P48 §6.1 전 조건 통과 — Preset 후보가 floor 를 넘는 단어형 질의가 12/66 뿐이라는 대가(미반영 54건 = `-338` 설계 입력). 사례 4건 개별 보고(신한·부캠 미회복 확정). 픽스처 27종·가드 무오발동·baseline 재현 일치 | [실측 리포트](2026-08-05-fusion-measurement.md), [T73~T78](../troubleshooting/2026-08-05-multi-signal-investigation.md), [P48](../proposals/P48-search-signal-expansion.md), [P49](../proposals/P49-multi-signal-search.md) | | I54 | 문자열 병합 규칙 하네스 `tools/search_cut/lexical_matrix.py`·`lexical_sweep.py` — 본문 매치를 (질의 × 소유자 × Record) 로 굳히고(본문 미저장·매치 여부만) 게이트×병합 격자를 오프라인으로 훑는다. 스냅샷 DB(:25432) 가드. 측정 결과: 문자열 매치 94건이 전부 정답 세그먼트에만 발생, 단어형 정답 라벨이 부분일치 정의라 재현율은 구조적으로 유리(순환성 명시), 병합 방식 차이는 RRF·앞고정 > 뒤추가. 단독 추가 7건 · 빈 결과 1건 해소 | [규칙 리포트](2026-08-06-lexical-merge-rule.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md) | | I55 | 재작성 효과 프로브 `tools/search_cut/rewrite_probe.py` — 실제 RewriteClient(운영 설정 체인) 호출 → 재작성문 임베딩 배치 1회 → 스냅샷 DB(:25432) 유사도 → 현행 컷 재구성(단어형/문장형 분기는 재작성문 기준, 서비스 미import 재구성 규칙). **재작성문 자체를 artifact 에 기록** — 무엇으로 바뀌었는지가 판정 근거의 절반. 원문 기준 값은 굳힌 행렬의 컷 재구성으로 얻어 GMS 호출 최소화, 재구성 무노출 11/15 가 기존 관측과 일치(교차 확인). 게이트(`should_rewrite` 재구성) 반영 후에는 게이트 통과 질의만 재작성·재임베딩 — 걸린 질의는 원문 값이 곧 결과(서비스와 동일). 강등 0건·전 질의 1순위 벤더 응답 관측 | [효과 리포트](2026-08-06-rewrite-effect.md), [I54](2026-08-06-lexical-merge-rule.md), [P49](../proposals/P49-multi-signal-search.md) | -| I55 | 키워드 재정렬 하네스·런타임 `tools/search_cut/fusion_rerank_sweep.py`(재정렬 전용 격자 + 신선도 가드 + 후보 불변 검사) · `fusion.rerank()`(순수 함수) · `rerank_verify_live.py`(실서버 on/off 5판정) · `app/service/search_service.py`(`_rerank_by_keyword` — 질의 임베딩 재사용·추가 모델 호출 0) · `app/repository/context_keyword_repo.keywords_for_records` · `SEARCH_KEYWORD_RERANK_*` 4키(기본 off·floor 0.35·w 0.05·top_k 3) — 채택값은 재정렬 전용 구조 재측정으로 확정(I53 과 숫자가 같지만 다른 측정), 결정성 회차 JSON 바이트 일치, 실서버 대조 93건 5판정 전부 통과. **번호 잠정** — 병렬 브랜치(`S15P11A705-337-rewrite-probe`)도 잠정 I55 사용 중, 나중에 병합되는 쪽이 I56 으로 재확정 | [재정렬 리포트](2026-08-06-rerank-adoption.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md), [tools/search_cut/](../../tools/search_cut/) | +| I56 | 키워드 재정렬 하네스·런타임 `tools/search_cut/fusion_rerank_sweep.py`(재정렬 전용 격자 + 신선도 가드 + 후보 불변 검사) · `fusion.rerank()`(순수 함수) · `rerank_verify_live.py`(실서버 on/off 5판정) · `app/service/search_service.py`(`_rerank_by_keyword` — 질의 임베딩 재사용·추가 모델 호출 0) · `app/repository/context_keyword_repo.keywords_for_records` · `SEARCH_KEYWORD_RERANK_*` 4키(기본 off·floor 0.35·w 0.05·top_k 3) — 채택값은 재정렬 전용 구조 재측정으로 확정(I53 과 숫자가 같지만 다른 측정), 결정성 회차 JSON 바이트 일치, 실서버 대조 93건 5판정 전부 통과. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 재확정(설계된 절차), 최종 확정은 dev 병합 직후 | [재정렬 리포트](2026-08-06-rerank-adoption.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md), [tools/search_cut/](../../tools/search_cut/) | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. From d5e641edc10bdde954df8514a216b4f9938751b8 Mon Sep 17 00:00:00 2001 From: colosair Date: Fri, 7 Aug 2026 10:42:54 +0900 Subject: [PATCH 22/34] =?UTF-8?q?feat:=20=EA=B2=80=EC=A6=9D=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=9F=AC=EB=84=88=EC=99=80=205=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=ED=86=B5=EA=B3=BC=20=EC=A6=9D=EA=B1=B0=20=E2=80=94?= =?UTF-8?q?=20back=20=EA=B2=BD=EC=9C=A0=20=EC=A0=84=EC=B2=B4=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20E2E=20(P49=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통합 브랜치 빌드의 back(8928ecb)+ai(7092f0c)를 함께 띄워 사용자와 같은 경로 (JWT 쿠키 인증·CSRF 회전 동반, POST /v1/search/records)로 게이트 5기준을 쟀다. 스냅샷 DB(:25432), 플래그 phase 3종(off/on/degraded=LLM 타임아웃 0.001s 강제). 판정 — 전부 통과: ① 잔존 3건 회복: 신한(문자열·Spring 경로 첫 실증)·부캠·신한 부캠 전부 결과 포함 ② 무관 무노출: 15건 중 11건 무노출 유지(현행과 동일) ③ 시연 12건 무퇴행: 퇴행 0건, 1위 10/12(현행 실측과 동일) ④ off=현행 동일: 1위 불일치 2건이 현행 실측(matrix.json)과 정확히 일치, 사례·정렬 일치 ⑤ LLM 타임아웃 복귀: 응답 무실패, 재작성 몫만 소거(부캠·신한 부캠), 문자열 회복 유지 기준 ③④의 판정 기준을 명시한다 — 「기대 정답 유지」의 기준선은 현행이며, 현행의 시연 1위 적중은 10/12가 실측 기록이다(matrix.json 문장형 hit@1 0.8333, I53 표 동일). 1위 일치가 아니라 기대 정답 포함 유지 + off 대비 순위 비악화 + 현행 실측과의 일치를 판정한다. 불일치 2건(뉴오더클럽·동교어린이공원 질의)은 트랙 이전부터의 현행 상태이고 on 에서 각각 3→2위 개선·2위 유지다. 증거 4파일(.search/gate_*.json)은 릴리스 판단 근거라 커밋한다. Co-Authored-By: Claude Fable 5 --- .search/gate_degraded.json | 668 ++++++++++++++++++++++++++++++++++++ .search/gate_off.json | 643 ++++++++++++++++++++++++++++++++++ .search/gate_on.json | 683 +++++++++++++++++++++++++++++++++++++ .search/gate_verdict.json | 31 ++ tools/e2e/run_gate.py | 361 ++++++++++++++++++++ 5 files changed, 2386 insertions(+) create mode 100644 .search/gate_degraded.json create mode 100644 .search/gate_off.json create mode 100644 .search/gate_on.json create mode 100644 .search/gate_verdict.json create mode 100644 tools/e2e/run_gate.py diff --git a/.search/gate_degraded.json b/.search/gate_degraded.json new file mode 100644 index 0000000..db2a098 --- /dev/null +++ b/.search/gate_degraded.json @@ -0,0 +1,668 @@ +{ + "phase": "degraded", + "back": "http://localhost:8082/api/core", + "results": [ + { + "kind": "demo", + "query": "비 오는 날 가려고 저장한 곳", + "member_id": 75, + "expect": "[데모] 골목 안 다방", + "status": 200, + "items": [ + { + "recordId": 252, + "similarity": 0.5019, + "place": "[데모] 골목 안 다방" + }, + { + "recordId": 255, + "similarity": 0.3491, + "place": "[데모] 언덕 위 야경 식당" + } + ] + }, + { + "kind": "demo", + "query": "혼자 조용히 작업하기 좋은 카페", + "member_id": 75, + "expect": "[데모] 창가 작업실 카페", + "status": 200, + "items": [ + { + "recordId": 253, + "similarity": 0.4969, + "place": "[데모] 창가 작업실 카페" + }, + { + "recordId": 256, + "similarity": 0.3294, + "place": "[데모] 넓은 한상 식당" + }, + { + "recordId": 257, + "similarity": 0.3164, + "place": "[데모] 강변 산책로 초입" + } + ] + }, + { + "kind": "demo", + "query": "친구들이랑 시끌벅적하게 놀 만한 곳", + "member_id": 75, + "expect": "[데모] 연남 골목 선술집", + "status": 200, + "items": [ + { + "recordId": 254, + "similarity": 0.6414, + "place": "[데모] 연남 골목 선술집" + }, + { + "recordId": 256, + "similarity": 0.4069, + "place": "[데모] 넓은 한상 식당" + } + ] + }, + { + "kind": "demo", + "query": "기념일에 야경 보면서 식사할 곳", + "member_id": 75, + "expect": "[데모] 언덕 위 야경 식당", + "status": 200, + "items": [ + { + "recordId": 255, + "similarity": 0.5266, + "place": "[데모] 언덕 위 야경 식당" + }, + { + "recordId": 257, + "similarity": 0.4488, + "place": "[데모] 강변 산책로 초입" + }, + { + "recordId": 254, + "similarity": 0.3466, + "place": "[데모] 연남 골목 선술집" + } + ] + }, + { + "kind": "demo", + "query": "돈카츠 먹으러 자주 갔던 곳", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 277, + "similarity": 0.6069, + "place": "카츠요" + }, + { + "recordId": 281, + "similarity": 0.4825, + "place": "플랜트 연남점" + }, + { + "recordId": 282, + "similarity": 0.3886, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 290, + "similarity": 0.376, + "place": "힉스커피" + }, + { + "recordId": 287, + "similarity": 0.3653, + "place": "동교어린이공원" + }, + { + "recordId": 285, + "similarity": 0.4108, + "place": "월강부산돼지국밥" + }, + { + "recordId": 284, + "similarity": 0.4054, + "place": "진우네 초밥" + }, + { + "recordId": 278, + "similarity": 0.381, + "place": "감나무집기사식당" + } + ] + }, + { + "kind": "demo", + "query": "미슐랭에 오른 라멘집", + "member_id": 81, + "expect": "사루카메", + "status": 200, + "items": [ + { + "recordId": 279, + "similarity": 0.5192, + "place": "사루카메" + }, + { + "recordId": 283, + "similarity": 0.5102, + "place": "쿠로코식당 연남점" + } + ] + }, + { + "kind": "demo", + "query": "친구들이랑 피자에 맥주 마신 곳", + "member_id": 81, + "expect": "뉴오더클럽 연남", + "status": 200, + "items": [ + { + "recordId": 277, + "similarity": 0.4512, + "place": "카츠요" + }, + { + "recordId": 280, + "similarity": 0.3644, + "place": "뉴오더클럽 연남" + }, + { + "recordId": 287, + "similarity": 0.3675, + "place": "동교어린이공원" + }, + { + "recordId": 290, + "similarity": 0.3617, + "place": "힉스커피" + }, + { + "recordId": 292, + "similarity": 0.3547, + "place": "적당" + }, + { + "recordId": 278, + "similarity": 0.3532, + "place": "감나무집기사식당" + }, + { + "recordId": 285, + "similarity": 0.3462, + "place": "월강부산돼지국밥" + }, + { + "recordId": 282, + "similarity": 0.3281, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 284, + "similarity": 0.319, + "place": "진우네 초밥" + } + ] + }, + { + "kind": "demo", + "query": "밥 먹고 산책하면서 쉬어가는 공원", + "member_id": 81, + "expect": "동교어린이공원", + "status": 200, + "items": [ + { + "recordId": 282, + "similarity": 0.5264, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 287, + "similarity": 0.4573, + "place": "동교어린이공원" + }, + { + "recordId": 291, + "similarity": 0.3996, + "place": "오케이어 맨션" + }, + { + "recordId": 283, + "similarity": 0.3256, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 285, + "similarity": 0.3337, + "place": "월강부산돼지국밥" + } + ] + }, + { + "kind": "demo", + "query": "채식 샌드위치 먹던 단골집", + "member_id": 81, + "expect": "플랜트 연남점", + "status": 200, + "items": [ + { + "recordId": 281, + "similarity": 0.5682, + "place": "플랜트 연남점" + }, + { + "recordId": 277, + "similarity": 0.4554, + "place": "카츠요" + }, + { + "recordId": 287, + "similarity": 0.3929, + "place": "동교어린이공원" + }, + { + "recordId": 290, + "similarity": 0.3905, + "place": "힉스커피" + }, + { + "recordId": 282, + "similarity": 0.412, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 278, + "similarity": 0.3952, + "place": "감나무집기사식당" + }, + { + "recordId": 285, + "similarity": 0.3832, + "place": "월강부산돼지국밥" + }, + { + "recordId": 284, + "similarity": 0.3777, + "place": "진우네 초밥" + }, + { + "recordId": 292, + "similarity": 0.3412, + "place": "적당" + } + ] + }, + { + "kind": "demo", + "query": "책 사면 꽃을 주는 서점", + "member_id": 80, + "expect": "오케이어 맨션", + "status": 200, + "items": [ + { + "recordId": 269, + "similarity": 0.8023, + "place": "오케이어 맨션" + } + ] + }, + { + "kind": "demo", + "query": "화덕에 구운 피자집", + "member_id": 80, + "expect": "주토피아 서울", + "status": 200, + "items": [ + { + "recordId": 272, + "similarity": 0.5116, + "place": "주토피아 서울" + }, + { + "recordId": 275, + "similarity": 0.397, + "place": "힉스커피" + }, + { + "recordId": 267, + "similarity": 0.3396, + "place": "애플하우스" + }, + { + "recordId": 273, + "similarity": 0.3172, + "place": "오향절면" + } + ] + }, + { + "kind": "demo", + "query": "양갱 파는 분위기 좋은 카페", + "member_id": 80, + "expect": "적당", + "status": 200, + "items": [ + { + "recordId": 270, + "similarity": 0.8188, + "place": "적당" + } + ] + }, + { + "kind": "case", + "query": "신한", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 281, + "similarity": 0.3889, + "place": "플랜트 연남점" + }, + { + "recordId": 284, + "similarity": 0.3307, + "place": "진우네 초밥" + }, + { + "recordId": 283, + "similarity": 0.3867, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 286, + "similarity": 0.241, + "place": "저스트텐동 연남본점" + }, + { + "recordId": 280, + "similarity": 0.0, + "place": "뉴오더클럽 연남" + }, + { + "recordId": 278, + "similarity": 0.0, + "place": "감나무집기사식당" + }, + { + "recordId": 277, + "similarity": 0.0, + "place": "카츠요" + } + ] + }, + { + "kind": "case", + "query": "부캠", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 283, + "similarity": 0.4102, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 281, + "similarity": 0.3304, + "place": "플랜트 연남점" + }, + { + "recordId": 284, + "similarity": 0.0, + "place": "진우네 초밥" + }, + { + "recordId": 288, + "similarity": 0.3195, + "place": "연남칼국수" + }, + { + "recordId": 278, + "similarity": 0.0, + "place": "감나무집기사식당" + } + ] + }, + { + "kind": "case", + "query": "신한 부캠", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 283, + "similarity": 0.5868, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 281, + "similarity": 0.5158, + "place": "플랜트 연남점" + }, + { + "recordId": 284, + "similarity": 0.3678, + "place": "진우네 초밥" + } + ] + }, + { + "kind": "case", + "query": "그네", + "member_id": 81, + "expect": "동교어린이공원", + "status": 200, + "items": [ + { + "recordId": 282, + "similarity": 0.2871, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 287, + "similarity": 0.2671, + "place": "동교어린이공원" + }, + { + "recordId": 290, + "similarity": 0.2676, + "place": "힉스커피" + }, + { + "recordId": 286, + "similarity": 0.266, + "place": "저스트텐동 연남본점" + }, + { + "recordId": 293, + "similarity": 0.2423, + "place": "키친갈매기" + }, + { + "recordId": 289, + "similarity": 0.2416, + "place": "모던아시안누들서비스" + } + ] + }, + { + "kind": "case", + "query": "스팟", + "member_id": 81, + "expect": "동교어린이공원", + "status": 200, + "items": [ + { + "recordId": 287, + "similarity": 0.2438, + "place": "동교어린이공원" + }, + { + "recordId": 281, + "similarity": 0.2407, + "place": "플랜트 연남점" + } + ] + }, + { + "kind": "offtopic", + "query": "자동차 엔진오일 교환 정비소", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "자동차 엔진오일 교환 정비소", + "member_id": 80, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "자동차 엔진오일 교환 정비소", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "치과 임플란트 상담 받을 곳", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "치과 임플란트 상담 받을 곳", + "member_id": 80, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 268, + "similarity": 0.3007, + "place": "키친갈매기" + } + ] + }, + { + "kind": "offtopic", + "query": "치과 임플란트 상담 받을 곳", + "member_id": 81, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 288, + "similarity": 0.3819, + "place": "연남칼국수" + }, + { + "recordId": 293, + "similarity": 0.3006, + "place": "키친갈매기" + } + ] + }, + { + "kind": "offtopic", + "query": "겨울 스키장 리프트권 파는 데", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "겨울 스키장 리프트권 파는 데", + "member_id": 80, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "겨울 스키장 리프트권 파는 데", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "노트북 액정 수리 서비스센터", + "member_id": 75, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 253, + "similarity": 0.3371, + "place": "[데모] 창가 작업실 카페" + } + ] + }, + { + "kind": "offtopic", + "query": "노트북 액정 수리 서비스센터", + "member_id": 80, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "노트북 액정 수리 서비스센터", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "강아지 예방접종 동물병원", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "강아지 예방접종 동물병원", + "member_id": 80, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 273, + "similarity": 0.3248, + "place": "오향절면" + } + ] + }, + { + "kind": "offtopic", + "query": "강아지 예방접종 동물병원", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + } + ] +} \ No newline at end of file diff --git a/.search/gate_off.json b/.search/gate_off.json new file mode 100644 index 0000000..21a7a8f --- /dev/null +++ b/.search/gate_off.json @@ -0,0 +1,643 @@ +{ + "phase": "off", + "back": "http://localhost:8082/api/core", + "results": [ + { + "kind": "demo", + "query": "비 오는 날 가려고 저장한 곳", + "member_id": 75, + "expect": "[데모] 골목 안 다방", + "status": 200, + "items": [ + { + "recordId": 252, + "similarity": 0.5019, + "place": "[데모] 골목 안 다방" + }, + { + "recordId": 255, + "similarity": 0.3492, + "place": "[데모] 언덕 위 야경 식당" + } + ] + }, + { + "kind": "demo", + "query": "혼자 조용히 작업하기 좋은 카페", + "member_id": 75, + "expect": "[데모] 창가 작업실 카페", + "status": 200, + "items": [ + { + "recordId": 253, + "similarity": 0.4969, + "place": "[데모] 창가 작업실 카페" + }, + { + "recordId": 256, + "similarity": 0.3294, + "place": "[데모] 넓은 한상 식당" + }, + { + "recordId": 257, + "similarity": 0.3164, + "place": "[데모] 강변 산책로 초입" + } + ] + }, + { + "kind": "demo", + "query": "친구들이랑 시끌벅적하게 놀 만한 곳", + "member_id": 75, + "expect": "[데모] 연남 골목 선술집", + "status": 200, + "items": [ + { + "recordId": 254, + "similarity": 0.6414, + "place": "[데모] 연남 골목 선술집" + }, + { + "recordId": 256, + "similarity": 0.4069, + "place": "[데모] 넓은 한상 식당" + } + ] + }, + { + "kind": "demo", + "query": "기념일에 야경 보면서 식사할 곳", + "member_id": 75, + "expect": "[데모] 언덕 위 야경 식당", + "status": 200, + "items": [ + { + "recordId": 255, + "similarity": 0.5266, + "place": "[데모] 언덕 위 야경 식당" + }, + { + "recordId": 257, + "similarity": 0.4488, + "place": "[데모] 강변 산책로 초입" + }, + { + "recordId": 254, + "similarity": 0.3466, + "place": "[데모] 연남 골목 선술집" + } + ] + }, + { + "kind": "demo", + "query": "돈카츠 먹으러 자주 갔던 곳", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 277, + "similarity": 0.6069, + "place": "카츠요" + }, + { + "recordId": 281, + "similarity": 0.4826, + "place": "플랜트 연남점" + }, + { + "recordId": 285, + "similarity": 0.4107, + "place": "월강부산돼지국밥" + }, + { + "recordId": 284, + "similarity": 0.4054, + "place": "진우네 초밥" + }, + { + "recordId": 282, + "similarity": 0.3886, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 278, + "similarity": 0.3809, + "place": "감나무집기사식당" + }, + { + "recordId": 290, + "similarity": 0.376, + "place": "힉스커피" + }, + { + "recordId": 287, + "similarity": 0.3654, + "place": "동교어린이공원" + } + ] + }, + { + "kind": "demo", + "query": "미슐랭에 오른 라멘집", + "member_id": 81, + "expect": "사루카메", + "status": 200, + "items": [ + { + "recordId": 279, + "similarity": 0.5191, + "place": "사루카메" + }, + { + "recordId": 283, + "similarity": 0.5104, + "place": "쿠로코식당 연남점" + } + ] + }, + { + "kind": "demo", + "query": "친구들이랑 피자에 맥주 마신 곳", + "member_id": 81, + "expect": "뉴오더클럽 연남", + "status": 200, + "items": [ + { + "recordId": 277, + "similarity": 0.4512, + "place": "카츠요" + }, + { + "recordId": 287, + "similarity": 0.3675, + "place": "동교어린이공원" + }, + { + "recordId": 280, + "similarity": 0.3644, + "place": "뉴오더클럽 연남" + }, + { + "recordId": 290, + "similarity": 0.3617, + "place": "힉스커피" + }, + { + "recordId": 292, + "similarity": 0.3547, + "place": "적당" + }, + { + "recordId": 278, + "similarity": 0.3532, + "place": "감나무집기사식당" + }, + { + "recordId": 285, + "similarity": 0.3462, + "place": "월강부산돼지국밥" + }, + { + "recordId": 282, + "similarity": 0.3281, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 284, + "similarity": 0.319, + "place": "진우네 초밥" + } + ] + }, + { + "kind": "demo", + "query": "밥 먹고 산책하면서 쉬어가는 공원", + "member_id": 81, + "expect": "동교어린이공원", + "status": 200, + "items": [ + { + "recordId": 282, + "similarity": 0.5263, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 287, + "similarity": 0.4573, + "place": "동교어린이공원" + }, + { + "recordId": 291, + "similarity": 0.3996, + "place": "오케이어 맨션" + }, + { + "recordId": 285, + "similarity": 0.3337, + "place": "월강부산돼지국밥" + }, + { + "recordId": 283, + "similarity": 0.3256, + "place": "쿠로코식당 연남점" + } + ] + }, + { + "kind": "demo", + "query": "채식 샌드위치 먹던 단골집", + "member_id": 81, + "expect": "플랜트 연남점", + "status": 200, + "items": [ + { + "recordId": 281, + "similarity": 0.5682, + "place": "플랜트 연남점" + }, + { + "recordId": 277, + "similarity": 0.4554, + "place": "카츠요" + }, + { + "recordId": 282, + "similarity": 0.412, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 278, + "similarity": 0.3952, + "place": "감나무집기사식당" + }, + { + "recordId": 287, + "similarity": 0.3928, + "place": "동교어린이공원" + }, + { + "recordId": 290, + "similarity": 0.3904, + "place": "힉스커피" + }, + { + "recordId": 285, + "similarity": 0.3831, + "place": "월강부산돼지국밥" + }, + { + "recordId": 284, + "similarity": 0.3777, + "place": "진우네 초밥" + }, + { + "recordId": 292, + "similarity": 0.3411, + "place": "적당" + } + ] + }, + { + "kind": "demo", + "query": "책 사면 꽃을 주는 서점", + "member_id": 80, + "expect": "오케이어 맨션", + "status": 200, + "items": [ + { + "recordId": 269, + "similarity": 0.8023, + "place": "오케이어 맨션" + } + ] + }, + { + "kind": "demo", + "query": "화덕에 구운 피자집", + "member_id": 80, + "expect": "주토피아 서울", + "status": 200, + "items": [ + { + "recordId": 272, + "similarity": 0.5116, + "place": "주토피아 서울" + }, + { + "recordId": 275, + "similarity": 0.397, + "place": "힉스커피" + }, + { + "recordId": 267, + "similarity": 0.3396, + "place": "애플하우스" + }, + { + "recordId": 273, + "similarity": 0.3172, + "place": "오향절면" + } + ] + }, + { + "kind": "demo", + "query": "양갱 파는 분위기 좋은 카페", + "member_id": 80, + "expect": "적당", + "status": 200, + "items": [ + { + "recordId": 270, + "similarity": 0.8188, + "place": "적당" + } + ] + }, + { + "kind": "case", + "query": "신한", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 281, + "similarity": 0.3889, + "place": "플랜트 연남점" + }, + { + "recordId": 283, + "similarity": 0.3867, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 284, + "similarity": 0.3307, + "place": "진우네 초밥" + }, + { + "recordId": 286, + "similarity": 0.241, + "place": "저스트텐동 연남본점" + } + ] + }, + { + "kind": "case", + "query": "부캠", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 283, + "similarity": 0.4102, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 281, + "similarity": 0.3304, + "place": "플랜트 연남점" + }, + { + "recordId": 288, + "similarity": 0.3195, + "place": "연남칼국수" + } + ] + }, + { + "kind": "case", + "query": "신한 부캠", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 283, + "similarity": 0.5868, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 281, + "similarity": 0.5158, + "place": "플랜트 연남점" + }, + { + "recordId": 284, + "similarity": 0.3678, + "place": "진우네 초밥" + } + ] + }, + { + "kind": "case", + "query": "그네", + "member_id": 81, + "expect": "동교어린이공원", + "status": 200, + "items": [ + { + "recordId": 282, + "similarity": 0.2871, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 290, + "similarity": 0.2676, + "place": "힉스커피" + }, + { + "recordId": 287, + "similarity": 0.2671, + "place": "동교어린이공원" + }, + { + "recordId": 286, + "similarity": 0.266, + "place": "저스트텐동 연남본점" + }, + { + "recordId": 293, + "similarity": 0.2423, + "place": "키친갈매기" + }, + { + "recordId": 289, + "similarity": 0.2416, + "place": "모던아시안누들서비스" + } + ] + }, + { + "kind": "case", + "query": "스팟", + "member_id": 81, + "expect": "동교어린이공원", + "status": 200, + "items": [ + { + "recordId": 287, + "similarity": 0.244, + "place": "동교어린이공원" + }, + { + "recordId": 281, + "similarity": 0.2407, + "place": "플랜트 연남점" + } + ] + }, + { + "kind": "offtopic", + "query": "자동차 엔진오일 교환 정비소", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "자동차 엔진오일 교환 정비소", + "member_id": 80, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "자동차 엔진오일 교환 정비소", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "치과 임플란트 상담 받을 곳", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "치과 임플란트 상담 받을 곳", + "member_id": 80, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 268, + "similarity": 0.3008, + "place": "키친갈매기" + } + ] + }, + { + "kind": "offtopic", + "query": "치과 임플란트 상담 받을 곳", + "member_id": 81, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 288, + "similarity": 0.3819, + "place": "연남칼국수" + }, + { + "recordId": 293, + "similarity": 0.3008, + "place": "키친갈매기" + } + ] + }, + { + "kind": "offtopic", + "query": "겨울 스키장 리프트권 파는 데", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "겨울 스키장 리프트권 파는 데", + "member_id": 80, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "겨울 스키장 리프트권 파는 데", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "노트북 액정 수리 서비스센터", + "member_id": 75, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 253, + "similarity": 0.3371, + "place": "[데모] 창가 작업실 카페" + } + ] + }, + { + "kind": "offtopic", + "query": "노트북 액정 수리 서비스센터", + "member_id": 80, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "노트북 액정 수리 서비스센터", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "강아지 예방접종 동물병원", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "강아지 예방접종 동물병원", + "member_id": 80, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 273, + "similarity": 0.3249, + "place": "오향절면" + } + ] + }, + { + "kind": "offtopic", + "query": "강아지 예방접종 동물병원", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + } + ] +} \ No newline at end of file diff --git a/.search/gate_on.json b/.search/gate_on.json new file mode 100644 index 0000000..d9ddb72 --- /dev/null +++ b/.search/gate_on.json @@ -0,0 +1,683 @@ +{ + "phase": "on", + "back": "http://localhost:8082/api/core", + "results": [ + { + "kind": "demo", + "query": "비 오는 날 가려고 저장한 곳", + "member_id": 75, + "expect": "[데모] 골목 안 다방", + "status": 200, + "items": [ + { + "recordId": 252, + "similarity": 0.5019, + "place": "[데모] 골목 안 다방" + }, + { + "recordId": 255, + "similarity": 0.3491, + "place": "[데모] 언덕 위 야경 식당" + } + ] + }, + { + "kind": "demo", + "query": "혼자 조용히 작업하기 좋은 카페", + "member_id": 75, + "expect": "[데모] 창가 작업실 카페", + "status": 200, + "items": [ + { + "recordId": 253, + "similarity": 0.4969, + "place": "[데모] 창가 작업실 카페" + }, + { + "recordId": 256, + "similarity": 0.3294, + "place": "[데모] 넓은 한상 식당" + }, + { + "recordId": 257, + "similarity": 0.3164, + "place": "[데모] 강변 산책로 초입" + } + ] + }, + { + "kind": "demo", + "query": "친구들이랑 시끌벅적하게 놀 만한 곳", + "member_id": 75, + "expect": "[데모] 연남 골목 선술집", + "status": 200, + "items": [ + { + "recordId": 254, + "similarity": 0.6414, + "place": "[데모] 연남 골목 선술집" + }, + { + "recordId": 256, + "similarity": 0.4069, + "place": "[데모] 넓은 한상 식당" + } + ] + }, + { + "kind": "demo", + "query": "기념일에 야경 보면서 식사할 곳", + "member_id": 75, + "expect": "[데모] 언덕 위 야경 식당", + "status": 200, + "items": [ + { + "recordId": 255, + "similarity": 0.5266, + "place": "[데모] 언덕 위 야경 식당" + }, + { + "recordId": 257, + "similarity": 0.4488, + "place": "[데모] 강변 산책로 초입" + }, + { + "recordId": 254, + "similarity": 0.3465, + "place": "[데모] 연남 골목 선술집" + } + ] + }, + { + "kind": "demo", + "query": "돈카츠 먹으러 자주 갔던 곳", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 277, + "similarity": 0.6069, + "place": "카츠요" + }, + { + "recordId": 281, + "similarity": 0.4826, + "place": "플랜트 연남점" + }, + { + "recordId": 282, + "similarity": 0.3886, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 290, + "similarity": 0.376, + "place": "힉스커피" + }, + { + "recordId": 287, + "similarity": 0.3654, + "place": "동교어린이공원" + }, + { + "recordId": 285, + "similarity": 0.4107, + "place": "월강부산돼지국밥" + }, + { + "recordId": 284, + "similarity": 0.4054, + "place": "진우네 초밥" + }, + { + "recordId": 278, + "similarity": 0.3809, + "place": "감나무집기사식당" + } + ] + }, + { + "kind": "demo", + "query": "미슐랭에 오른 라멘집", + "member_id": 81, + "expect": "사루카메", + "status": 200, + "items": [ + { + "recordId": 279, + "similarity": 0.5191, + "place": "사루카메" + }, + { + "recordId": 283, + "similarity": 0.5104, + "place": "쿠로코식당 연남점" + } + ] + }, + { + "kind": "demo", + "query": "친구들이랑 피자에 맥주 마신 곳", + "member_id": 81, + "expect": "뉴오더클럽 연남", + "status": 200, + "items": [ + { + "recordId": 277, + "similarity": 0.4512, + "place": "카츠요" + }, + { + "recordId": 280, + "similarity": 0.3644, + "place": "뉴오더클럽 연남" + }, + { + "recordId": 287, + "similarity": 0.3675, + "place": "동교어린이공원" + }, + { + "recordId": 290, + "similarity": 0.3617, + "place": "힉스커피" + }, + { + "recordId": 292, + "similarity": 0.3547, + "place": "적당" + }, + { + "recordId": 278, + "similarity": 0.3532, + "place": "감나무집기사식당" + }, + { + "recordId": 285, + "similarity": 0.3462, + "place": "월강부산돼지국밥" + }, + { + "recordId": 282, + "similarity": 0.3281, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 284, + "similarity": 0.319, + "place": "진우네 초밥" + } + ] + }, + { + "kind": "demo", + "query": "밥 먹고 산책하면서 쉬어가는 공원", + "member_id": 81, + "expect": "동교어린이공원", + "status": 200, + "items": [ + { + "recordId": 282, + "similarity": 0.5263, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 287, + "similarity": 0.4573, + "place": "동교어린이공원" + }, + { + "recordId": 291, + "similarity": 0.3996, + "place": "오케이어 맨션" + }, + { + "recordId": 283, + "similarity": 0.3256, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 285, + "similarity": 0.3337, + "place": "월강부산돼지국밥" + } + ] + }, + { + "kind": "demo", + "query": "채식 샌드위치 먹던 단골집", + "member_id": 81, + "expect": "플랜트 연남점", + "status": 200, + "items": [ + { + "recordId": 281, + "similarity": 0.5682, + "place": "플랜트 연남점" + }, + { + "recordId": 277, + "similarity": 0.4554, + "place": "카츠요" + }, + { + "recordId": 287, + "similarity": 0.3929, + "place": "동교어린이공원" + }, + { + "recordId": 290, + "similarity": 0.3905, + "place": "힉스커피" + }, + { + "recordId": 282, + "similarity": 0.412, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 278, + "similarity": 0.3952, + "place": "감나무집기사식당" + }, + { + "recordId": 285, + "similarity": 0.3832, + "place": "월강부산돼지국밥" + }, + { + "recordId": 284, + "similarity": 0.3777, + "place": "진우네 초밥" + }, + { + "recordId": 292, + "similarity": 0.3412, + "place": "적당" + } + ] + }, + { + "kind": "demo", + "query": "책 사면 꽃을 주는 서점", + "member_id": 80, + "expect": "오케이어 맨션", + "status": 200, + "items": [ + { + "recordId": 269, + "similarity": 0.8023, + "place": "오케이어 맨션" + } + ] + }, + { + "kind": "demo", + "query": "화덕에 구운 피자집", + "member_id": 80, + "expect": "주토피아 서울", + "status": 200, + "items": [ + { + "recordId": 272, + "similarity": 0.5116, + "place": "주토피아 서울" + }, + { + "recordId": 275, + "similarity": 0.397, + "place": "힉스커피" + }, + { + "recordId": 267, + "similarity": 0.3396, + "place": "애플하우스" + }, + { + "recordId": 273, + "similarity": 0.3172, + "place": "오향절면" + } + ] + }, + { + "kind": "demo", + "query": "양갱 파는 분위기 좋은 카페", + "member_id": 80, + "expect": "적당", + "status": 200, + "items": [ + { + "recordId": 270, + "similarity": 0.8188, + "place": "적당" + } + ] + }, + { + "kind": "case", + "query": "신한", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 281, + "similarity": 0.3889, + "place": "플랜트 연남점" + }, + { + "recordId": 284, + "similarity": 0.3307, + "place": "진우네 초밥" + }, + { + "recordId": 283, + "similarity": 0.3867, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 286, + "similarity": 0.241, + "place": "저스트텐동 연남본점" + }, + { + "recordId": 280, + "similarity": 0.0, + "place": "뉴오더클럽 연남" + }, + { + "recordId": 278, + "similarity": 0.0, + "place": "감나무집기사식당" + }, + { + "recordId": 277, + "similarity": 0.0, + "place": "카츠요" + } + ] + }, + { + "kind": "case", + "query": "부캠", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 281, + "similarity": 0.2875, + "place": "플랜트 연남점" + }, + { + "recordId": 283, + "similarity": 0.2613, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 277, + "similarity": 0.3255, + "place": "카츠요" + }, + { + "recordId": 284, + "similarity": 0.0, + "place": "진우네 초밥" + }, + { + "recordId": 282, + "similarity": 0.2588, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 278, + "similarity": 0.0, + "place": "감나무집기사식당" + }, + { + "recordId": 287, + "similarity": 0.2454, + "place": "동교어린이공원" + }, + { + "recordId": 290, + "similarity": 0.2436, + "place": "힉스커피" + } + ] + }, + { + "kind": "case", + "query": "신한 부캠", + "member_id": 81, + "expect": "카츠요", + "status": 200, + "items": [ + { + "recordId": 283, + "similarity": 0.4542, + "place": "쿠로코식당 연남점" + }, + { + "recordId": 281, + "similarity": 0.4162, + "place": "플랜트 연남점" + }, + { + "recordId": 277, + "similarity": 0.3793, + "place": "카츠요" + } + ] + }, + { + "kind": "case", + "query": "그네", + "member_id": 81, + "expect": "동교어린이공원", + "status": 200, + "items": [ + { + "recordId": 282, + "similarity": 0.2871, + "place": "치킨버거 이스트사이드" + }, + { + "recordId": 287, + "similarity": 0.2671, + "place": "동교어린이공원" + }, + { + "recordId": 290, + "similarity": 0.2676, + "place": "힉스커피" + }, + { + "recordId": 286, + "similarity": 0.266, + "place": "저스트텐동 연남본점" + }, + { + "recordId": 293, + "similarity": 0.2423, + "place": "키친갈매기" + }, + { + "recordId": 289, + "similarity": 0.2416, + "place": "모던아시안누들서비스" + } + ] + }, + { + "kind": "case", + "query": "스팟", + "member_id": 81, + "expect": "동교어린이공원", + "status": 200, + "items": [ + { + "recordId": 287, + "similarity": 0.2438, + "place": "동교어린이공원" + }, + { + "recordId": 281, + "similarity": 0.2407, + "place": "플랜트 연남점" + } + ] + }, + { + "kind": "offtopic", + "query": "자동차 엔진오일 교환 정비소", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "자동차 엔진오일 교환 정비소", + "member_id": 80, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "자동차 엔진오일 교환 정비소", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "치과 임플란트 상담 받을 곳", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "치과 임플란트 상담 받을 곳", + "member_id": 80, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 268, + "similarity": 0.3007, + "place": "키친갈매기" + } + ] + }, + { + "kind": "offtopic", + "query": "치과 임플란트 상담 받을 곳", + "member_id": 81, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 288, + "similarity": 0.3819, + "place": "연남칼국수" + }, + { + "recordId": 293, + "similarity": 0.3008, + "place": "키친갈매기" + } + ] + }, + { + "kind": "offtopic", + "query": "겨울 스키장 리프트권 파는 데", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "겨울 스키장 리프트권 파는 데", + "member_id": 80, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "겨울 스키장 리프트권 파는 데", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "노트북 액정 수리 서비스센터", + "member_id": 75, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 253, + "similarity": 0.3371, + "place": "[데모] 창가 작업실 카페" + } + ] + }, + { + "kind": "offtopic", + "query": "노트북 액정 수리 서비스센터", + "member_id": 80, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "노트북 액정 수리 서비스센터", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "강아지 예방접종 동물병원", + "member_id": 75, + "expect": null, + "status": 200, + "items": [] + }, + { + "kind": "offtopic", + "query": "강아지 예방접종 동물병원", + "member_id": 80, + "expect": null, + "status": 200, + "items": [ + { + "recordId": 273, + "similarity": 0.3248, + "place": "오향절면" + } + ] + }, + { + "kind": "offtopic", + "query": "강아지 예방접종 동물병원", + "member_id": 81, + "expect": null, + "status": 200, + "items": [] + } + ] +} \ No newline at end of file diff --git a/.search/gate_verdict.json b/.search/gate_verdict.json new file mode 100644 index 0000000..20542b1 --- /dev/null +++ b/.search/gate_verdict.json @@ -0,0 +1,31 @@ +{ + "1_recover": { + "신한": true, + "부캠": true, + "신한 부캠": true, + "그네": true, + "스팟": true + }, + "2_offtopic_silent": { + "silent": 11, + "total": 15, + "baseline": 11 + }, + "3_demo_no_regression": { + "regressed": [], + "top1": 10, + "total": 12 + }, + "4_off_is_current": { + "demo_top1_failed": [], + "case_mismatch": [], + "offtopic_silent": 11, + "unsorted": [] + }, + "5_degraded": { + "case_mismatch": [], + "demo_regressed": [] + }, + "failed": [], + "passed": true +} \ No newline at end of file diff --git a/tools/e2e/run_gate.py b/tools/e2e/run_gate.py new file mode 100644 index 0000000..7f7fdcc --- /dev/null +++ b/tools/e2e/run_gate.py @@ -0,0 +1,361 @@ +"""검색 고도화 검증 게이트 러너 (P49 §7) — back 경유 전체 경로 E2E. + +통합 브랜치 빌드의 back(Spring)과 ai(FastAPI)를 함께 띄운 상태에서, 사용자와 같은 +경로(`POST /v1/search/records`, JWT 인증)로 검색해 게이트 5기준을 판정한다. FastAPI 를 +직접 치는 기존 `run_search.py` 와 달리 **Spring 의 문자열 검색·병합과 Core 재검증까지** +경로에 들어온다 — 기준 ①의 `신한` 회복이 Spring 몫이라 이 경로가 아니면 잴 수 없다. + + # 수집 (서버 기동 상태에서 phase 별로) + python tools/e2e/run_gate.py collect --phase off # 전 플래그 꺼짐 + python tools/e2e/run_gate.py collect --phase on # 전 플래그 켜짐 + python tools/e2e/run_gate.py collect --phase degraded # 켜짐 + LLM 타임아웃 강제 + + # 판정 (세 phase 수집 후) + python tools/e2e/run_gate.py judge + +수집 결과는 `.search/gate_.json`, 판정은 `.search/gate_verdict.json` 에 남는다. +토큰은 back 레포 `loadtest/tools/mint-tokens.sh` 가 만든 `tokens.json` 을 쓴다(서버와 +같은 JWT 키 전제). DB 는 멤버 매핑과 장소명 조회에만 읽기로 쓰고, 포트 가드(:25432)가 +시연 DB(:15432)·e2e DB(:5433) 를 막는다. + +기준과 확인 방법 (P49 §7 표 그대로): + ① 잔존 3건(신한·부캠·신한 부캠) 회복 → phase on 에서 기대 정답 포함 + ② 무관 무노출 ≥ 11/15 → phase on 의 무관 질의 빈 결과 수 + ③ 시연 정본 12건 무퇴행 → phase on 의 1위 = demo_data.yaml 기대 + ④ 전 플래그 off = 현행 동일 → phase off 가 현행 기대와 일치 + 유사도 내림차순 + ⑤ LLM 타임아웃 시 벡터 복귀 → phase degraded 가 200 응답 + 재작성 무효과 +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +import httpx +import yaml + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from app.core.config import get_settings # noqa: E402 +from app.core.db import Database # noqa: E402 + +for _s in (sys.stdout, sys.stderr): + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + +def log(msg: str = "") -> None: + print(msg, flush=True) + + +SEARCH = ROOT / ".search" +EXPECT_PORT = "25432" +DEMO_PROVIDER = "demo-seed" + +# 잔존 실패 3건과 대조 유지 2건. 전부 jeongheon 소유 세그먼트다(선행 실측과 동일). +# expect 는 장소명이다. recover 가 True 인 3건이 기준 ① 의 대상이다. +CASES = [ + {"query": "신한", "expect": "카츠요", "recover": True, "signal": "문자열 검색(Spring)"}, + {"query": "부캠", "expect": "카츠요", "recover": True, "signal": "LLM 재작성"}, + {"query": "신한 부캠", "expect": "카츠요", "recover": True, "signal": "LLM 재작성"}, + {"query": "그네", "expect": "동교어린이공원", "recover": False, "signal": "현행 유지"}, + {"query": "스팟", "expect": "동교어린이공원", "recover": False, "signal": "현행 유지"}, +] + +# 관련 없는 문장형 질의 5종 × 소유자 3명(host·gahyeon·jeongheon) = 15건. +# 현행(재작성 전) 무노출 11/15 가 기준선이다 — matrix.json 실측과 같은 셋. +OFFTOPIC_QUERIES = [ + "자동차 엔진오일 교환 정비소", + "치과 임플란트 상담 받을 곳", + "겨울 스키장 리프트권 파는 데", + "노트북 액정 수리 서비스센터", + "강아지 예방접종 동물병원", +] +OFFTOPIC_OWNERS = ["host", "gahyeon", "jeongheon"] + +_MEMBERS = "SELECT provider_user_id, member_id FROM core.social_account WHERE provider = $1" + + +class GuardError(SystemExit): + pass + + +def load_demo_queries() -> list[dict]: + """시연 정본 질의 12건. expect(record key)를 장소명으로 푼다 — 대조는 장소명으로 한다.""" + data = yaml.safe_load( + (ROOT / "tools" / "demo_seed" / "demo_data.yaml").read_text(encoding="utf-8")) + place_by_key = {} + for member in data["members"]: + for record in member.get("records", []): + place_by_key[record["key"]] = record["place"]["name"] + queries = [] + for q in data["demo_queries"]: + queries.append({ + "query": q["query"], + "expect": place_by_key[q["expect"]], + "as": q.get("as", "host"), + }) + if len(queries) != 12: + raise GuardError(f"시연 정본 질의가 12건이 아니다: {len(queries)}건") + return queries + + +async def member_map(settings) -> dict[str, int]: + db = Database(settings.database_url) + await db.connect() + try: + async with db.acquire() as conn: + rows = await conn.fetch(_MEMBERS, DEMO_PROVIDER) + finally: + await db.disconnect() + return {r["provider_user_id"]: r["member_id"] for r in rows} + + +async def collect(args) -> int: + settings = get_settings() + if EXPECT_PORT not in settings.database_url: + raise GuardError( + f"DATABASE_URL 이 :{EXPECT_PORT}(스냅샷 DB)를 가리키지 않는다 — 재지 않고 멈춘다.") + + minted = json.loads(Path(args.tokens).read_text(encoding="utf-8")) + # mint-tokens.sh 산출물은 {baseUrl, ttl, tokens:{member_id: token}} 구조다. + tokens = minted.get("tokens", minted) + members = await member_map(settings) + demo = load_demo_queries() + + plan = [] # (kind, query, member_id, expect) + for q in demo: + plan.append(("demo", q["query"], members[q["as"]], q["expect"])) + for c in CASES: + plan.append(("case", c["query"], members["jeongheon"], c["expect"])) + for q in OFFTOPIC_QUERIES: + for owner in OFFTOPIC_OWNERS: + plan.append(("offtopic", q, members[owner], None)) + + # 인증은 Authorization 헤더가 아니라 access_token **쿠키**다. CSRF 는 예비 GET 이 + # 내려주는 XSRF-TOKEN 을 같은 요청의 쿠키+`X-XSRF-TOKEN` 헤더로 함께 실어야 통과하고, + # 서버(CsrfCookieFilter)가 매 요청 재발급하므로 응답의 회전 값으로 갱신한다 — back + # `loadtest/k6/lib/http.js` 가 확립한 프로토콜 그대로다. 쿠키를 클라이언트 항아리에 + # 맡기지 않고 요청마다 직접 싣는 이유: XSRF-TOKEN 이 Secure 쿠키라 httpx 항아리가 + # http:// 재전송에서 떨어뜨린다(로컬 검증은 평문 http 다). + xsrf: dict[int, str] = {} + + async def fetch_xsrf(client: httpx.AsyncClient, member_id: int, token: str) -> str: + resp = await client.get( + f"{args.back}/v1/collections", cookies={"access_token": token}) + value = resp.cookies.get("XSRF-TOKEN") + if not value: + raise GuardError(f"member {member_id}: XSRF-TOKEN 을 받지 못했다 (HTTP {resp.status_code})") + return value + + results = [] + async with httpx.AsyncClient(timeout=30.0) as client: + for kind, query, member_id, expect in plan: + token = tokens.get(str(member_id)) + if token is None: + raise GuardError(f"member {member_id} 의 토큰이 없다 — mint-tokens.sh 로 발급한다.") + if member_id not in xsrf: + xsrf[member_id] = await fetch_xsrf(client, member_id, token) + resp = await client.post( + f"{args.back}/v1/search/records", + json={"query": query}, + cookies={"access_token": token, "XSRF-TOKEN": xsrf[member_id]}, + headers={"X-XSRF-TOKEN": xsrf[member_id]}, + ) + rotated = resp.cookies.get("XSRF-TOKEN") + if rotated: + xsrf[member_id] = rotated + body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} + items = (body.get("data") or {}).get("items") or [] + results.append({ + "kind": kind, + "query": query, + "member_id": member_id, + "expect": expect, + "status": resp.status_code, + "items": [ + {"recordId": i["recordId"], "similarity": i["similarity"], + "place": i["place"]["name"]} + for i in items + ], + }) + mark = "" if resp.status_code == 200 else f" [HTTP {resp.status_code}]" + log(f" {kind:9s} {query!r} (m{member_id}): {len(items)}건{mark}") + + out = SEARCH / f"gate_{args.phase}.json" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps( + {"phase": args.phase, "back": args.back, "results": results}, + ensure_ascii=False, indent=2), encoding="utf-8") + log(f"\n → {out}") + return 0 + + +def _load_phase(phase: str) -> list[dict]: + return json.loads((SEARCH / f"gate_{phase}.json").read_text(encoding="utf-8"))["results"] + + +def _first_place(row: dict) -> str | None: + return row["items"][0]["place"] if row["items"] else None + + +def _names(row: dict) -> list[str]: + return [i["place"] for i in row["items"]] + + +def judge(args) -> int: + off = _load_phase("off") + on = _load_phase("on") + degraded = _load_phase("degraded") + verdict = {} + failed = [] + + def by(rows, kind): + return [r for r in rows if r["kind"] == kind] + + # 모든 phase 에서 HTTP 200 이 전제다 — 어느 기준이든 오류 응답 위에서 판정하지 않는다. + for phase_name, rows in (("off", off), ("on", on), ("degraded", degraded)): + bad = [r for r in rows if r["status"] != 200] + if bad: + failed.append(f"{phase_name}: HTTP 오류 {len(bad)}건") + + # ① 잔존 3건 회복 (on) + rec = {} + for c in CASES: + row = next(r for r in by(on, "case") if r["query"] == c["query"]) + hit = c["expect"] in _names(row) + rec[c["query"]] = hit + if c["recover"] and not hit: + failed.append(f"기준①: {c['query']!r} 미회복 ({c['signal']})") + verdict["1_recover"] = rec + + # ② 무관 무노출 ≥ 11/15 (on) + silent = sum(1 for r in by(on, "offtopic") if not r["items"]) + verdict["2_offtopic_silent"] = {"silent": silent, "total": 15, "baseline": 11} + if silent < 11: + failed.append(f"기준②: 무관 무노출 {silent}/15 < 11") + + # ③ 시연 정본 12건 무퇴행 (on) — 「기대 정답이 전부 유지된다」의 기준선은 현행이다. + # 현행의 1위 적중은 12건 중 10건이 실측 기록이다(matrix.json 문장형 hit@1 0.8333, + # I53 표와 동일). 그래서 1위 일치가 아니라 **기대 정답 포함 유지 + off 대비 순위 + # 비악화**를 판정한다. 1위 일치 수는 참고로 남긴다. + def expect_rank(row: dict) -> int | None: + names = _names(row) + return names.index(row["expect"]) + 1 if row["expect"] in names else None + + off_demo = {r["query"]: r for r in by(off, "demo")} + demo_bad = [] + for r in by(on, "demo"): + rank_on = expect_rank(r) + rank_off = expect_rank(off_demo[r["query"]]) + if rank_on is None or (rank_off is not None and rank_on > rank_off): + demo_bad.append(f"{r['query']}({rank_off}위→{rank_on}위)") + top1_on = sum(1 for r in by(on, "demo") if _first_place(r) == r["expect"]) + verdict["3_demo_no_regression"] = { + "regressed": demo_bad, "top1": top1_on, "total": 12} + if demo_bad: + failed.append(f"기준③: 시연 퇴행 {len(demo_bad)}건 {demo_bad}") + + # ④ off = 현행 동일 — 시연 1위 불일치가 **현행 실측(matrix.json)의 불일치 목록과 + # 정확히 같은지**로 대조한다(현행 동일의 실증). 사례·무관·유사도 내림차순 정렬도 + # 함께 본다. + matrix = json.loads((SEARCH / "matrix.json").read_text(encoding="utf-8")) + current_top1_bad = set() + for q in matrix["queries"]: + rows = q["results"] + expected = {r["name"] for r in rows if r.get("is_expected")} + if rows and rows[0]["name"] not in expected: + current_top1_bad.add(q["query"]) + off_demo_bad = [r["query"] for r in by(off, "demo") if _first_place(r) != r["expect"]] + if set(off_demo_bad) != current_top1_bad: + failed.append( + f"기준④: off 1위 불일치({sorted(off_demo_bad)})가 현행 실측" + f"({sorted(current_top1_bad)})과 다르다") + off_demo_bad = sorted(set(off_demo_bad) - current_top1_bad) # 현행과 같은 불일치는 정상 + off_case = {r["query"]: r for r in by(off, "case")} + off_case_bad = [] + for c in CASES: + included = c["expect"] in _names(off_case[c["query"]]) + want = not c["recover"] # 현행: 회복 대상 3건은 없어야, 유지 2건은 있어야 + if included is not want: + off_case_bad.append(c["query"]) + off_silent = sum(1 for r in by(off, "offtopic") if not r["items"]) + unsorted = [ + r["query"] for r in off + if [i["similarity"] for i in r["items"]] + != sorted((i["similarity"] for i in r["items"]), reverse=True) + ] + verdict["4_off_is_current"] = { + "demo_top1_failed": off_demo_bad, "case_mismatch": off_case_bad, + "offtopic_silent": off_silent, "unsorted": unsorted, + } + if off_demo_bad or off_case_bad or unsorted or off_silent != 11: + failed.append( + f"기준④: off 현행 불일치 — demo {off_demo_bad}, case {off_case_bad}, " + f"무관 {off_silent}/15(기대 11), 정렬 위반 {unsorted}") + + # ⑤ LLM 타임아웃 시 벡터 복귀 (degraded) — 응답이 실패하지 않고, 재작성 효과만 + # 사라진다. 재작성 몫(부캠·신한 부캠)은 off 와 같아지고, LLM 과 무관한 문자열 + # 회복(신한)과 유지 2건·시연 1위는 on 과 같아야 한다. + deg_case = {r["query"]: r for r in by(degraded, "case")} + deg_bad = [] + for c in CASES: + included = c["expect"] in _names(deg_case[c["query"]]) + if c["signal"] == "LLM 재작성": + want = False # 재작성이 죽었으므로 회복이 사라져야 한다 + elif c["signal"] == "문자열 검색(Spring)": + want = True # LLM 과 무관 — 회복이 유지돼야 한다 + else: + want = True + if included is not want: + deg_bad.append(f"{c['query']}(기대 {'포함' if want else '미포함'})") + deg_demo_bad = [] + for r in by(degraded, "demo"): + rank_deg = expect_rank(r) + rank_off = expect_rank(off_demo[r["query"]]) + if rank_deg is None or (rank_off is not None and rank_deg > rank_off): + deg_demo_bad.append(f"{r['query']}({rank_off}위→{rank_deg}위)") + verdict["5_degraded"] = {"case_mismatch": deg_bad, "demo_regressed": deg_demo_bad} + if deg_bad or deg_demo_bad: + failed.append(f"기준⑤: 강등 동작 불일치 — case {deg_bad}, demo {deg_demo_bad}") + + verdict["failed"] = failed + verdict["passed"] = not failed + out = SEARCH / "gate_verdict.json" + out.write_text(json.dumps(verdict, ensure_ascii=False, indent=2), encoding="utf-8") + + log("\n== 게이트 판정 (P49 §7) ==") + log(f" ① 잔존 3건 회복 : {'PASS' if all(rec[c['query']] for c in CASES if c['recover']) else 'FAIL'} {rec}") + log(f" ② 무관 무노출 : {'PASS' if silent >= 11 else 'FAIL'} {silent}/15 (기준 11)") + log(f" ③ 시연 12건 무퇴행 : {'PASS' if not demo_bad else 'FAIL'} 퇴행 {len(demo_bad)}건 · 1위 {top1_on}/12 (현행 10/12)") + ok4 = not (off_demo_bad or off_case_bad or unsorted) and off_silent == 11 + log(f" ④ off = 현행 동일 : {'PASS' if ok4 else 'FAIL'}") + log(f" ⑤ LLM 타임아웃 복귀 : {'PASS' if not (deg_bad or deg_demo_bad) else 'FAIL'}") + log(f"\n 종합: {'전부 통과' if not failed else 'FAIL — ' + ' / '.join(failed)}") + log(f" → {out}") + return 0 if not failed else 1 + + +def main() -> int: + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + c = sub.add_parser("collect") + c.add_argument("--phase", required=True, choices=["off", "on", "degraded"]) + c.add_argument("--back", default="http://localhost:8082/api/core") + c.add_argument("--tokens", default=str( + ROOT.parents[3] / "back" / ".claude" / "worktrees" / "integration" + / "loadtest" / "artifacts" / "tokens.json")) + j = sub.add_parser("judge") + args = ap.parse_args() + if args.cmd == "collect": + return asyncio.run(collect(args)) + return judge(args) + + +if __name__ == "__main__": + raise SystemExit(main()) From 7124ce8ba3fa7d91b479088a47735fd38cac831b Mon Sep 17 00:00:00 2001 From: colosair Date: Fri, 7 Aug 2026 16:21:52 +0900 Subject: [PATCH 23/34] =?UTF-8?q?docs:=20P52=20=EC=A0=9C=EC=95=88=20?= =?UTF-8?q?=E2=80=94=20Keyword=20taxonomy=C2=B7visibility=20=EC=9E=AC?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20(7=EC=B6=95=C2=B7=ED=99=9C=EC=84=B1=2043?= =?UTF-8?q?=EC=A2=85=C2=B7=EC=B2=98=EB=B6=84=20=EC=96=B4=ED=9C=98=C2=B7Pri?= =?UTF-8?q?vacy=20=ED=8C=90=EC=A0=95=20=EA=B3=84=EC=95=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-07 사용자 채택. 실행(시딩·재판정·재측정)은 별도 결정이며 문서는 설계까지다. - visibility 의미 재정의 + Privacy 판정 계약 2규칙(보편 이용 행위는 명시로 판정 / 속성·적합성은 동행·행동·사건으로부터의 추론 금지) - 기존 27종 전수 재평가: KEEP 15 · MOVE 10(visibility 8건 포함) · REPLACE 1 (DATE_COURSE→DATING — code-semantic 불일치 해소, 처분 어휘의 대표 적용 사례) - category 7축 재편(SITUATION 폐지, FACILITY=설비·운영 환경/SUITABILITY/OCCASION/ MEMORY 신설), meta-domain 2종은 분석용으로만 - 신규 17종(TBD 논리 ID — numeric은 back id=축 결박 해소와 함께 확정) → 활성 43종 = PUBLIC 25 · PRIVATE_ONLY 18 · BLOCKED 0(경계 정의 명시) - 43은 P51 절대 가드 안이나 40 초과 사전조건 적용 대상 — 충족·조항 개정 전 실행 가능성 비전제 - 정합 defect 식별: -292 표시명이 시연·스냅샷 DB 미반영(신선도 결함 실증) — 독립 티켓 권고 - 추천 과다 노출 원인 4건은 taxonomy와 분리, 별도 작업 단위 권고 P26은 채택 시 supersede 대상, P47·P51·공용 계약은 후속 개정 대상으로 구분 기술. Co-Authored-By: Claude Fable 5 --- .../P52-keyword-taxonomy-redesign.md | 239 ++++++++++++++++++ docs/proposals/README.md | 1 + 2 files changed, 240 insertions(+) create mode 100644 docs/proposals/P52-keyword-taxonomy-redesign.md diff --git a/docs/proposals/P52-keyword-taxonomy-redesign.md b/docs/proposals/P52-keyword-taxonomy-redesign.md new file mode 100644 index 0000000..03ae369 --- /dev/null +++ b/docs/proposals/P52-keyword-taxonomy-redesign.md @@ -0,0 +1,239 @@ +# P52. Keyword taxonomy·visibility 재설계 — 공개 장소 속성과 사적 맥락의 분리 + +- **상태**: 제안 — 2026-08-07 사용자 채택 (실행은 별도 결정) +- **날짜**: 2026-08-07 +- **관련**: P26(현행 27종 확정 — **이 제안 채택 시 supersede 대상**) · P47(라벨·축·불변 계약 — 채택 시 부분 개정 대상) · P51(프리셋 거버넌스 — 채택 시 §7 확장 전제 개정 대상) · P50(3계층 분리 — 정합) · 공용 계약 05 §8/07 ERD(채택 시 후속 개정 대상) · S15P11A705-292(표시명 명사형 통일 — 라벨 정본) +- **번호는 잠정 P52다.** 커밋 시점의 색인 기준으로 재확정한다. + +## 1. 제안 요약 + +**문제.** 현행 Keyword Preset 27종의 visibility 분포는 PUBLIC 25 · PRIVATE_ONLY 2 · BLOCKED 0이다. 3계층과 소비 경계는 구현돼 있으나, 분포가 말하는 것은 「거의 모든 키워드가 공개 정보 취급」이라는 사실이다. 레코드·컬렉션을 공개하면 PUBLIC 키워드가 함께 노출되므로, 이는 **장소에 대한 공개 정보와 사용자의 사적 기억·관계·경험을 정보모델이 구분하지 못하는 문제**다. + +**제안.** 27종 고정을 해제하고 taxonomy를 재설계한다. + +1. **visibility의 의미 재정의**(§3)와 **Privacy 판정 계약**(§3.1)을 먼저 세우고, 기존 27종 전수를 정본 정의문(description·examples)까지 대조해 재평가한다(§5). +2. **category를 7축으로 재설계**한다(§4) — 현행 SITUATION의 과부하(장소 적합성·개인 사건·기억 혼재)를 해소한다. +3. 의미 영역(coverage) 공백에 신규 Preset을 도출한다(§6·§7). 목표 개수를 먼저 정하지 않는다. +4. 소비 경계는 현행보다 엄격하게 유지한다(§8). + +**이 문서의 범위는 설계까지다.** 실행은 별도 결정이며, 조건·의존은 §9에 사실로만 기술한다. + +**이 제안이 아닌 것.** 폐기된 -338(검색 회복용 명사 보강)의 부활이 아니다. 신규 도입 사유는 「장소를 기억할 때 반복적으로 필요한 맥락인데 현행이 표현하지 못한다」 하나만 허용한다. 검색어 표현 차이는 P49 질의 재작성의 영역이다. + +## 2. 현재 문제 — 실측 근거 + +- **분포**: 활성 27종 중 PUBLIC 25(92.6%). PRIVATE_ONLY는 「동료」·「기념일」 2종뿐. +- **일관성 결함**: 동행 5종(친구·연인·가족·아이·혼자)이 PUBLIC — 장소가 아니라 사용자의 관계를 타인에게 알린다. 「동료」만 비공개인 배치는 의미 기준이 아니라 우연이다. +- **정의문과 등급의 부정합**: ACTIVITY 8종의 정본 정의는 전부 「~하는 **방문·활동·모임**」 — 사용자 행위의 기록으로 정의돼 있으면서 등급은 전부 PUBLIC이다(§5.1 전수 대조). +- **표현 공백**: 기억·경험 어휘와 물리 환경·운영 어휘가 각 0종. +- **표시명 정본 주의**: 이 문서의 라벨은 YAML 정본(-292 명사형 통일)을 따른다. 시연·스냅샷 DB에는 옛 표시명이 남아 있다 — §9의 정합 defect 참조. +- **추천 과다 노출은 별개 원인** — §10에서 분리 분석. + +## 3. visibility의 의미 재정의 (판정 기준) + +| 등급 | 의미 | 판정 질문 | +|---|---|---| +| PUBLIC | 주로 사용자가 아니라 **장소 자체**(속성·적합성·용도)를 설명하며, 타인이 봐도 사적 정보 노출 위험이 낮다 | 이 키워드가 어느 사용자의 기록에 붙었다는 사실을 타인이 봐도, 주로 그 장소의 성질에 대한 정보로 읽히는가? | +| PRIVATE_ONLY | 장소보다 **사용자의 관계·동행·행동·사건·기억**을 설명한다. 본인 회상·개인화에 가치가 크지만 타인 공개가 불필요하다 | 이 키워드가 붙었다는 사실이 그 사용자에 대한 정보(누구와·무엇을 했나·무슨 일·어떤 기억)로 읽히는가? | +| BLOCKED | 「더 사적」이 아니라 **정형 키워드로 추론·판정·축적하는 것 자체가 부적절한 민감 개념** | 이 개념을 구조화해 저장하는 것 자체가 적절한가? | + +**행위 키워드의 경계 규칙.** PRIVATE 정의가 「행동」을 포함하므로 활동 계열은 다음 기준으로 가른다: 행위가 **장소의 용도와 사실상 1:1이고 개인 특정성이 미미한 보편 이용 행위**(끼니·후식·걷기·구매·관람)는 장소 설명으로 읽혀 PUBLIC이 성립한다. **정본 정의가 장소 적합성이 아니라 사용자의 특정 행위·모임·사건을 나타내면** PRIVATE_ONLY다. 판정 근거는 표시명이 아니라 **정본 description·examples**다(§5.1). + +**category와 visibility는 독립 속성이다.** 같은 category 안에 PUBLIC과 PRIVATE_ONLY가 공존할 수 있다(예: ACTIVITY 안의 음주). + +### 3.1 Privacy 판정 계약 (신설 — 판정기 규칙으로 편입) + +PUBLIC 판정은 키워드 계열에 따라 두 규칙으로 나뉜다. §3의 경계 규칙과 정합하도록 요구 수준을 구분한 것이다. + +> **규칙 1 — PUBLIC ACTIVITY(보편 이용 행위):** 개인 특정성이 낮은 보편 이용 행위(식사·산책·쇼핑 등)가 Context에 명시되면 판정할 수 있다. 행위의 명시로 충분하며, 별도의 장소 적합성 진술을 요구하지 않는다. +> +> **규칙 2 — PUBLIC 속성·적합성(ATMOSPHERE·FACILITY·SUITABILITY):** 사용자의 동행·행동·사건만으로 대응 PUBLIC 장소 적합성 Keyword를 추론하지 않는다. 장소의 속성·적합성이 Context에서 **명시적으로 진술된 경우에만** 판정한다. + +규칙 2가 없으면 PRIVATE 어휘와 PUBLIC 어휘의 분리가 판정 단계에서 무너진다 — 동행 사실이 공개 적합성 키워드로 번역되어 노출되기 때문이다. + +| 쌍 | 판정 가능 (명시적 진술) | 판정 금지 (추론) | +|---|---|---| +| WITH_KIDS ↔ KID_FRIENDLY | "키즈존이 있어서 아이가 놀기 좋다" → KID_FRIENDLY | "아이랑 다녀왔다" → WITH_KIDS만. 동반 사실로 KID_FRIENDLY를 붙이지 않는다 | +| ALONE ↔ SOLO_FRIENDLY | "1인석이 많아 혼자 가기 편하다" → SOLO_FRIENDLY | "혼자 가서 책 읽고 옴" → ALONE만 | +| GATHERING ↔ GROUP_FRIENDLY | "단체석이 넓어서 여럿이 가기 좋다" → GROUP_FRIENDLY | "동아리 모임을 했다" → GATHERING만 | + +역방향도 같다 — 장소 적합성 진술("키즈존이 있다")만으로 동행 사실(WITH_KIDS)을 붙이지 않는다. 이 계약은 판정 프롬프트 규칙과 평가 하네스 케이스로 함께 고정해야 하며(P51 §12의 입력 변경 규율), 그 실측은 실행 단계 몫이다. + +## 4. category 재설계 — 7축 확정안 + +현행 4축의 문제는 SITUATION이 장소 적합성·개인 사건·기억을 모두 떠안는 것이다. **이 제안은 7축 재편을 확정안으로 제시한다.** + +| category | meta-domain | 의미 | 소속 (재평가·신규 반영) | +|---|---|---|---| +| ATMOSPHERE | PLACE_PROPERTY | 장소 분위기 | 조용함·아늑함·감성·활기·공간감·전망·복고·(신규)사진 명소 | +| FACILITY (신설) | PLACE_PROPERTY | **설비·운영 환경 — 장소가 갖춘 객관적 조건** (물리 설비와 운영 특성을 포함) | (신규)야외 좌석·루프탑·주차 편의·심야 영업 | +| SUITABILITY (신설) | PLACE_PROPERTY | 이용 적합성 — 「~하기(가기) 좋은」 판단 | 비 오는 날·짧은 방문·(신규)아이 동반·반려동물 동반·단체 적합·혼자 방문 적합 | +| ACTIVITY | PLACE_PROPERTY | 장소 용도로 읽히는 보편 이용 행위 | 대화·식사·디저트·학습·업무·산책·쇼핑·전시 관람 + 음주(PRIVATE — visibility 독립의 실례) | +| COMPANION | PERSONAL_CONTEXT | 동행·관계 | 친구·연인·가족·동료·아이·혼자 | +| OCCASION (신설) | PERSONAL_CONTEXT | 개인 사건·상황 | 기념일·단체 모임·축하·(신규)데이트·생일·여행·회식 | +| MEMORY (신설) | PERSONAL_CONTEXT | 기억·경험 | (신규)첫 방문·단골·재방문 의사·추억 | + +- **FACILITY와 SUITABILITY의 경계**: FACILITY는 장소가 갖춘 것(설비·운영 시간 등 객관 조건 — 심야 영업 포함), SUITABILITY는 이용자 관점의 적합성 판단(「~하기 좋은」)이다. 심야 영업은 물리 설비는 아니지만 운영 특성이라는 객관 조건이므로 FACILITY 정의를 「설비·운영 환경」으로 확장해 수용한다 — SUITABILITY로 옮기는 대안은 「심야에 가기 좋다」는 판단으로 의미가 바뀌어 기각했다(§11). +- **SITUATION은 폐지된다** — 잔류 항목이 없다. meta-domain은 분석·검증용 상위 개념으로만 유지하고 DB category로 만들지 않는다. +- 이 재편은 계약 개정 대상(05 §8.2 「초기 범주 4종」·07 ERD 주석)이며, back의 category 테스트 리터럴·표시 순회 로직·id=축 순서 결박 파급이 §9에 있다. 판정 프롬프트에 category가 노출되므로 재편은 판정 품질 재실측을 동반한다. + +## 5. B — 기존 27종 전수 재평가표 + +### 5.1 ACTIVITY 8종 — 정본 정의문 전수 대조 + +정본 description은 8종 전부 「~하는 방문/활동/모임」(행위)이다. §3 경계 규칙·§3.1 규칙 1을 적용한 결과: + +| id | code | label | 정본 정의(요지) | 판정 | +|---|---|---|---|---| +| 201 | COFFEE_CHAT | 대화 | "카페에서 대화를 나누는 방문. 티타임·수다 포함" | **PUBLIC KEEP** — 상대·관계가 특정되지 않는 보편 이용 행위(규칙 1). code(COFFEE_CHAT)와 라벨(대화)의 의미 차는 「커피챗 ⊂ 대화」의 일반화라 정보모델 왜곡이 없고, code는 내부 식별자로 외부 미노출이므로 REPLACE 비용(재판정·이력 단절)을 정당화하지 못한다 — KEEP | +| 202 | MEAL | 식사 | "끼니를 해결하는 방문. 밥·점심·저녁 약속 포함" | **PUBLIC KEEP (정비 조건)** — 보편 이용 행위. 정의문의 「약속」(사건 어휘)은 OCCASION 영역이라 제거 정비 | +| 203 | DRINK | 음주 | "음주를 곁들인 **모임**. 한잔·반주·**회식** 포함" | **PRIVATE_ONLY MOVE** — **정본 정의가 장소 적합성이 아니라 사용자의 실제 음주 방문·모임을 나타낸다**(정보모델 기준). §3 경계 규칙의 「특정 행위·모임」에 해당한다. 부차적으로 음주 사실의 노출 민감성도 이 판정을 지지한다. 장소 유형(주점) 표현은 Place metadata 소관이라 공개 대응물을 신설하지 않는다 | +| 204 | DESSERT | 디저트 | "단것을 즐기는 방문" | **PUBLIC KEEP** — 보편 이용 행위·장소 용도 1:1 | +| 205 | STUDY_WORK | 학습·업무 | "공부나 노트북 작업을 하는 방문" | **PUBLIC KEEP** — 보편 이용 행위. 예문부터 장소 적합성 진술 중심이라 정합성이 가장 강하다 | +| 206 | WALK | 산책 | "걸으며 둘러보는 활동" | **PUBLIC KEEP** — 보편 이용 행위 | +| 207 | SHOPPING | 쇼핑 | "물건을 구경하거나 사는 방문" | **PUBLIC KEEP** — 동일 | +| 208 | EXHIBITION | 전시 관람 | "전시나 공연을 보는 방문" | **PUBLIC KEEP** — 동일 | + +§3.1 규칙 1의 채택으로 v3의 「description을 장소 적합성 진술로 정비」 일괄 조건은 해소됐다 — 보편 이용 행위는 행위 명시로 판정 가능하다. 정비가 남는 것은 사건 어휘가 섞인 2건뿐이다(MEAL의 「약속」, DRINK의 「회식」 — 후자는 TEAM_DINNER 신설과 함께 OCCASION으로 분리). + +### 5.2 유지 — 장소 속성·보편 활동 (PUBLIC, 16종) + +| id | code | label | 현행 | 제안 | 처분 | 근거 | +|---|---|---|---|---|---|---| +| 301~307 | QUIET·COZY·TRENDY·LIVELY·SPACIOUS·VIEW_GOOD·RETRO | 조용함·아늑함·감성·활기·공간감·전망·복고 | ATMOSPHERE·PUBLIC | ATMOSPHERE·PUBLIC | KEEP | 장소 분위기·물리 속성 그 자체 | +| 201·202·204~208 | COFFEE_CHAT·MEAL·DESSERT·STUDY_WORK·WALK·SHOPPING·EXHIBITION | 대화·식사·디저트·학습·업무·산책·쇼핑·전시 관람 | ACTIVITY·PUBLIC | ACTIVITY·PUBLIC | KEEP | §5.1 (MEAL은 정의문 정비 조건) | +| 404 | RAINY_DAY | 비 오는 날 | SITUATION·PUBLIC | **SUITABILITY**·PUBLIC | MOVE(category) | 날씨 조건부 이용 적합성 | +| 405 | QUICK_STOP | 짧은 방문 | SITUATION·PUBLIC | **SUITABILITY**·PUBLIC | MOVE(category) | 이용 방식 적합성 | + +### 5.3 이동·대체 — 사용자의 맥락 (PRIVATE_ONLY) + +| id | code | label | 현행 | 제안 | 처분 | 근거 | +|---|---|---|---|---|---|---| +| 101 | WITH_FRIENDS | 친구 | COMPANION·PUBLIC | COMPANION·**PRIVATE_ONLY** | MOVE | 동행(사회적 관계) 정보. 장소 적합성은 GROUP_FRIENDLY(신규)가 맡는다 | +| 102 | WITH_PARTNER | 연인 | COMPANION·PUBLIC | COMPANION·**PRIVATE_ONLY** | MOVE | 연애 관계의 존재를 알리는 정보 — 동행 중 민감도 최고 | +| 103 | WITH_FAMILY | 가족 | COMPANION·PUBLIC | COMPANION·**PRIVATE_ONLY** | MOVE | 동행 사실은 개인 맥락 | +| 105 | WITH_KIDS | 아이 | COMPANION·PUBLIC | COMPANION·**PRIVATE_ONLY** | MOVE | 자녀의 존재 함의. KID_FRIENDLY와 §3.1 규칙 2로 분리 | +| 106 | ALONE | 혼자 | COMPANION·PUBLIC | COMPANION·**PRIVATE_ONLY** | MOVE | 행동 양식·생활 패턴 정보 | +| 104 | WITH_COLLEAGUES | 동료 | COMPANION·PRIVATE_ONLY | COMPANION·PRIVATE_ONLY | KEEP | 유지 — 단독 비공개이던 비일관성이 해소된다 | +| 203 | DRINK | 음주 | ACTIVITY·PUBLIC | ACTIVITY·**PRIVATE_ONLY** | MOVE | §5.1 — 정본 정의가 사용자의 음주 방문·모임을 나타낸다. category 유지(visibility 독립의 실례) | +| 401 | DATE_COURSE | 데이트 | SITUATION·PUBLIC | (비활성) | **REPLACE** | **§4 처분 어휘의 대표 적용 사례.** code(DATE_COURSE)는 「데이트 코스」라는 장소 적합성 의미인데 라벨·정의(데이트)는 개인 사건이다 — MOVE로 OCCASION·PRIVATE에 두면 code-semantic 불일치가 영구 잔존한다(code 불변 계약). 그래서 **DATE_COURSE를 DEACTIVATE하고 사건 의미의 신규 code(TBD-OCC-04 DATING·데이트·OCCASION·PRIVATE_ONLY)를 신설**한다. 기존 판정 이력은 행으로 보존되고 재판정 시 신규 code로 수렴한다 | +| 403 | GATHERING | 단체 모임 | SITUATION·PUBLIC | **OCCASION**·**PRIVATE_ONLY** | MOVE | 「모임을 했다」는 사건. code(GATHERING)도 모임 의미라 code-semantic 정합 — REPLACE 불요 | +| 406 | CELEBRATION | 축하 | SITUATION·PUBLIC | **OCCASION**·**PRIVATE_ONLY** | MOVE | 「축하할 일이 있었다」는 사건. code 정합 — REPLACE 불요 | +| 402 | ANNIVERSARY | 기념일 | SITUATION·PRIVATE_ONLY | **OCCASION**·PRIVATE_ONLY | MOVE(category) | visibility 유지, 재편 축으로 이동 | + +**처분 결과 요약**: REPLACE 1건(DATE_COURSE → 신규 DATING) · DEACTIVATE는 그 REPLACE의 구성 요소로 1건 · LOGICAL_MERGE 0건. v2~v3의 「DEACTIVATE·REPLACE 해당 없음」 결론은 code-semantic 정합 검토(이번 보정)로 **철회·정정**한다 — 라벨 개정(-292)이 code와 의미를 갈라놓은 항목이 1건 있었고, 그것이 처분 어휘가 실제로 필요한 이유다. COFFEE_CHAT은 같은 관점에서 검토했고 KEEP이다(§5.1). + +**재평가 결과 분포(기존 27종): 활성 26종 = PUBLIC 16 · PRIVATE_ONLY 10, 비활성 1종(DATE_COURSE).** + +## 6. C — Coverage gap (meta-domain 분석) + +PLACE_PROPERTY / PERSONAL_CONTEXT는 **coverage 분석용 meta-domain**이다(§4에서 7축과의 대응 확정, DB 값 아님). + +| meta-domain | 하위 영역 | 현행 | 공백 → 신규 후보 근거 (기억 구조화 관점) | +|---|---|---|---| +| PLACE_PROPERTY | atmosphere | 7종 충족 | 사진 명소 1종 보강 — 장소 회상의 반복 축 | +| PLACE_PROPERTY | physical/operational environment | **0종** | 야외 좌석·루프탑·주차 편의·심야 영업 — 「루프탑이 좋았지」·「주차 편했던 곳」·「늦게까지 하는 곳」은 반복 회상 축. 도입 사유는 검색이 아니라 표현 공백 | +| PLACE_PROPERTY | suitability | 비 오는 날·짧은 방문(이동) | 아이·반려동물·단체·혼자 적합 — **동행 사실(개인) ↔ 장소 적합성(공개) 분리가 핵심 패턴**, §3.1 규칙 2가 경계를 고정 | +| PLACE_PROPERTY | activity | 8종 충족(음주는 PRIVATE 이동) | — | +| PERSONAL_CONTEXT | companion | 6종(전부 PRIVATE) | — | +| PERSONAL_CONTEXT | occasion | 기념일·단체 모임·축하(+데이트는 REPLACE로 신규) | 생일·여행·회식 — 「언제·무슨 일의 기억인가」 축 | +| PERSONAL_CONTEXT | memory | **0종** | 첫 방문·단골·재방문 의사·추억 — 가장 자연스러운 회상 축 | +| PERSONAL_CONTEXT | relationship | WITH_* 부분 충족 | 소개팅류는 관계 추론 민감도로 보류(§11) — 도입 시 PRIVATE_ONLY 필수 | + +**BLOCKED 검토**: 의료·정신건강·법률·정치·종교 등 민감 개념은 **목록 미수록이 1차 방어**다. BLOCKED는 「배포된 code의 구조화 중단」·「발견된 민감 후보의 명시적 차단 기록」에 쓰는 상태다. **이번 안에서 0종인 이유는 신규안에 민감 추론 개념을 넣지 않았기 때문**이며, P51 발견 채널에서 민감 개념이 반복 출현하면 그때 BLOCKED 지정으로 이력을 남긴다. + +## 7. D — 새 taxonomy 초안 전체안 + +**신규 id는 numeric을 부여하지 않는다** — 논리 임시 ID(TBD-*)로 표기하며, 실제 numeric ID는 back의 「id 자릿수=축 순서」 결박 해소 방침과 함께 확정한다. 기존 id는 재번호화하지 않는다. + +### 신규 — PLACE_PROPERTY 계열 (PUBLIC 9종) + +| 임시 ID | code | label | category | visibility | description(요지) | +|---|---|---|---|---|---| +| TBD-FAC-01 | OUTDOOR_SEATING | 야외 좌석 | FACILITY | PUBLIC | 테라스·야외 자리가 있는 장소 | +| TBD-FAC-02 | ROOFTOP | 루프탑 | FACILITY | PUBLIC | 루프탑·옥상 공간이 있는 장소 | +| TBD-FAC-03 | PARKING_OK | 주차 편의 | FACILITY | PUBLIC | 주차가 편한 장소 | +| TBD-FAC-04 | LATE_NIGHT | 심야 영업 | FACILITY | PUBLIC | 늦은 시간까지 여는 장소 — 운영 환경(§4 FACILITY 정의 확장의 근거 항목) | +| TBD-ATM-01 | PHOTO_SPOT | 사진 명소 | ATMOSPHERE | PUBLIC | 사진 찍기 좋은 장소 | +| TBD-SUIT-01 | KID_FRIENDLY | 아이 동반 | SUITABILITY | PUBLIC | 아이와 가기 좋은 설비·환경이 진술된 장소 (§3.1 규칙 2) | +| TBD-SUIT-02 | PET_FRIENDLY | 반려동물 동반 | SUITABILITY | PUBLIC | 반려동물 동반 가능이 진술된 장소 | +| TBD-SUIT-03 | GROUP_FRIENDLY | 단체 적합 | SUITABILITY | PUBLIC | 여럿이 가기 좋은 환경이 진술된 장소 | +| TBD-SUIT-04 | SOLO_FRIENDLY | 혼자 방문 적합 | SUITABILITY | PUBLIC | 혼자 가기 편한 환경이 진술된 장소 | + +「대화하기 좋은」은 COFFEE_CHAT(대화)이 담당하므로 신설하지 않는다. + +### 신규 — PERSONAL_CONTEXT 계열 (PRIVATE_ONLY 8종) + +| 임시 ID | code | label | category | visibility | description(요지) | +|---|---|---|---|---|---| +| TBD-OCC-04 | DATING | 데이트 | OCCASION | PRIVATE_ONLY | 데이트로 간 곳이라는 사건 기억 — **DATE_COURSE의 REPLACE 대체 code**(§5.3) | +| TBD-OCC-01 | BIRTHDAY | 생일 | OCCASION | PRIVATE_ONLY | 생일에 간 곳이라는 사건 기억 | +| TBD-OCC-02 | ON_TRIP | 여행 | OCCASION | PRIVATE_ONLY | 여행 중 들른 곳이라는 사건 기억 | +| TBD-OCC-03 | TEAM_DINNER | 회식 | OCCASION | PRIVATE_ONLY | 회식 자리라는 사건 기억 (DRINK 정의의 회식 어휘 분리 전제 — §5.1) | +| TBD-MEM-01 | FIRST_VISIT | 첫 방문 | MEMORY | PRIVATE_ONLY | 처음 가 본 곳이라는 기억 | +| TBD-MEM-02 | REGULAR_SPOT | 단골 | MEMORY | PRIVATE_ONLY | 자주 가는 곳이라는 기억 | +| TBD-MEM-03 | WANT_REVISIT | 재방문 의사 | MEMORY | PRIVATE_ONLY | 다시 가고 싶은 곳 | +| TBD-MEM-04 | MEMORABLE | 추억 | MEMORY | PRIVATE_ONLY | 개인적으로 의미 있는 장소 | + +### 결과 요약 + +- **활성 43종** = 기존 활성 26(DATE_COURSE 비활성 제외) + 신규 17. 분포: **PUBLIC 25 · PRIVATE_ONLY 18 · BLOCKED 0.** +- **개수와 P51의 관계**: 43은 P51 §7-3의 절대 가드 범위(40~60) 안이지만, **40 초과는 §7 사전조건의 적용 대상**이다 — 후보 검색 방식 개편 실측(§7-1)·Candidate Recall@K와 Judge Accuracy 지표 분리(§7-2)가 선행돼야 하며, **사전조건 충족 또는 P52 채택에 따른 해당 조항 개정 전에는 이 안의 실행 가능성을 전제하지 않는다.** 개수는 목표가 아니라 coverage의 결과다. +- 라벨은 정본 규칙(명사형, -292)을 따른다. + +## 8. 소비 경계 원칙 — 확장 후에도 현행보다 엄격하게 + +| 소비 지점 | 사용 등급 | 현행 구현과의 관계 | +|---|---|---| +| 본인 기록·검색·회상 | PUBLIC + PRIVATE_ONLY | 현행 화이트리스트 그대로 | +| 본인 개인화(추천받는 쪽 Profile 신호) | PUBLIC + PRIVATE_ONLY (**외부 노출 금지**) | 현행 FeedKeywordRepository Profile 경로와 일치 | +| 타인의 레코드·컬렉션 표시 | PUBLIC only | 현행 화이트리스트 그대로 | +| 타인 컬렉션 추천의 콘텐츠 신호 | PUBLIC only | 현행 특징 경로와 일치 | +| BLOCKED | 판정·저장·검색·추천 전부 불사용 | 적재 제외 + 화이트리스트 밖 | + +**PRIVATE_ONLY가 타인에게 노출되거나 타인 프로파일링에 쓰이는 것을 금지한다.** §3.1이 저장(판정) 단계의 경계를, 화이트리스트(fail-closed)가 조회 단계의 경계를 맡는다. 계약 예시 SQL 결함 1건(06 §5.5)은 정정 완료(docs `f5aa439`). + +## 9. E — 영향 범위 (실행 시 변경 대상, 시점 판단 없음) + +### 정합 defect — P52와 별개로 식별 (권고: 독립 defect 티켓) + +**Defect: -292 표시명 개정이 시연·스냅샷 DB에 미반영, preset 임베딩·측정 자산이 YAML 정본과 어긋남.** 두 DB의 keyword_preset은 옛 표시명 시딩분이고, preset 임베딩은 display_name을 입력에 포함하므로 현행 keyword 측정 자산(keyword_matrix.json)·재정렬 채택값(-339)·게이트 검증은 옛 표시명 임베딩 위의 측정이다. 정본 재시딩 시 임베딩이 바뀌지만 preset_version이 항상 1이라 신선도 가드가 잡지 못한다 — **선결 결함 1(version 경로 부재)이 실제로 발생시킨 사례**다. P52 실행 여부와 무관하게 존재하는 정합 결함이므로 별도 티켓 추적을 권고한다(처리 방침·시점은 사용자 결정). + +### 실행의 전제 조건 — 선결 결함 3건 (전부 미해결) + +1. **preset_version 증가 경로 부재** — 신선도 가드 전체 불발(위 defect가 실증). 기존 티켓 S15P11A705-269. +2. **YAML 삭제 항목 미처리** — 시딩이 UPSERT만 수행. DEACTIVATE(REPLACE 포함)를 실행하려면 is_active 동기화 로직이 선행돼야 한다. +3. **재판정 수단 부재**(P47 §4) — 상태 되돌림·재스캔 수집·속도 조절 전부 없음. REPLACE(DATE_COURSE→DATING)의 「재판정 시 신규 code 수렴」도 이 수단에 의존한다. + +### 레포별 변경 대상 + +- **ai**: YAML 개정(신규 17 + visibility 8건 MOVE + category 재편 + DATE_COURSE 비활성) → 전 항목 재임베딩 시딩 → §3.1 두 규칙의 판정 프롬프트 편입 + 평가 하네스 비교(P51 §12) → K=10·floor 0.30 재검토(모집단 26→43) → -339 채택값 재측정 → artifact 전면 재생성·라벨 재라벨링·도구 하드코딩 갱신(`demo_seed/verify.py` PRIVATE code 집합 2→18 필수 갱신 포함). +- **back**: id=축 순서 결박 해소 방침(신규 numeric ID 확정의 선행 조건). 7축 재편에 따른 표시 순회 로직·category 테스트 리터럴 갱신. visibility 등급 신설 없음 — CHECK 마이그레이션 불요. category는 DB CHECK 없어 DDL 불요, ERD 주석 개정. +- **docs(계약)**: 25~30 개수 조항(43은 범위 밖 — 개정 필수) · 「초기 범주 4종」 조항 + ERD 주석(7축) · **신설**: 처분 상태 어휘(§4)·§3.1 Privacy 판정 계약(2규칙)·preset_version 승격·재판정 규칙. +- **DB·seed**: 스냅샷·시연 재시딩(위 defect 처리와 병행 시 재시딩 횟수 절감 — 사실만 기록). 시연 재시딩은 라벨 시트 전량 무효(기지 함정). +- **tests**: 개수 비결박(자체 픽스처) — taxonomy로 깨지는 테스트 없음. 갱신 대상은 도구·리터럴. +- **front**: **taxonomy 직접 하드코딩·계약 결박 없음**(display_name 배열만 소비). Preset 증가에 따른 태그 표시량·layout·payload 영향은 **실행 전 별도 검증 항목**. + +## 10. F — 추천 과다 노출 별도 분석 (taxonomy와 원인이 다름) + +**관측은 정확하며, 버그가 아니라 설계가 그렇게 돼 있다.** 원인 4건: ① 후보 자격에 관련성 조건 0(`FeedCandidateRepository` — 공개·비삭제·비어있지 않음·타인·소유자 미탈퇴뿐) ② 소규모 데이터에서 recent 100+random 20이 전체 스캔 ③ Top-N 절단 부재(`FeedRanker.arrange`가 전량 재배치, 커서가 풀 소진까지 페이징) ④ 무관 후보도 recency 항만으로 항상 양수. 명세에도 임계·기권 개념이 없다 — 코드는 명세를 정확히 구현했다. + +**개선 구조 스케치**(별도 작업 단위 권고): 전체 공개 컬렉션 → 관련성 후보 필터 → PUBLIC 신호 점수 → 최소 추천 기준 → 기준 미달 제거(기권 허용) → 추천. 「점수가 낮아도 순위에 넣는 것」과 「근거 없으면 추천하지 않는 것」의 구분이 핵심. + +**도입 시 선행 개정**: `feed-recommendation.md:29`(키워드 없는 컬렉션 노출 원칙)·`FeedApiTests:123`(같은 계약 테스트)·계약 공백 4건(eligibility 임계·적합도 정의 위임·confidence 소비·preset_version 규칙). **taxonomy 개편과 원인·해법이 달라 별도 이슈로 유지한다.** + +## 11. 채택하지 않은 대안 / 미결 + +- **물리 삭제·재번호화** — FK·code 불변 계약과 충돌, §4 처분 어휘로 대체. +- **meta-domain의 DB category화** — 분석용 상위 개념으로만 유지(§4). +- **DRINK의 PUBLIC 유지(정의 개정 방식)** — 「술 마시기 좋은 곳」으로 정의를 고치는 대안이 있으나, 그것은 사실상 다른 키워드를 만드는 것이고 현 정의(사용자의 음주 방문·모임)의 판정 이력과 단절된다. 정보모델 기준(정의가 나타내는 것)에 따라 MOVE가 정확하다고 판정. +- **DATE_COURSE의 MOVE 유지** — code-semantic 불일치가 영구 잔존해 기각. REPLACE 채택(§5.3). +- **LATE_NIGHT의 SUITABILITY 배치** — 「심야에 가기 좋다」는 적합성 판단으로 의미가 바뀜. 운영 특성은 객관 조건이므로 FACILITY 정의 확장으로 수용(§4). +- **미결**: 실행 시점·단계 구성(사용자 결정) / back id=축 순서 결박 해소 방침 / 「데이트 코스(장소 적합성)」 의미의 공개 키워드 신설 여부(수요 확인 후) / 관계 특정 어휘(소개팅류) / front 표시량·payload 검증 / 본인 화면 PRIVATE_ONLY 시각 구분(05-1 §1.4) / 정합 defect의 티켓화·처리 방침 / 추천 개선 과제의 이슈화. + +## 12. 판단 기준 충족 확인 + +- **PUBLIC** — 장소 설명 표현력: 분위기 8 + 활동 7 + 적합성 6 + 설비·운영 4 = 25종. 속성·적합성 계열은 §3.1 규칙 2(명시적 진술), 활동 계열은 규칙 1(보편 행위 명시)로 판정. +- **PRIVATE_ONLY** — 사적 회상 표현력: 동행 6 + 행위 1(음주) + 사건 7 + 기억 4 = 18종, 전부 타인 비노출. +- **BLOCKED** — 경계 정의: 0종이되 「목록 미수록이 1차 방어, BLOCKED는 차단 이력 상태」로 설명(§6). +- **추천** — 공개됐다는 이유만의 추천을 막는 구조: 원인 분리 규명 + 개선 구조·선행 개정 목록(§10). diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 3908981..a450f98 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -28,6 +28,7 @@ | [P49](P49-multi-signal-search.md) | 다신호 검색 개선 — 질의 재작성·키워드 점수·문자열 검색 추가와 검증 기준 | Proposed | AI | | [P50](P50-three-layer-place-metadata.md) | 3계층 분리와 Place metadata 결합 — 장소 사실·Context 의미·파생 결합의 실체화 (번호 잠정) | Proposed | AI(+back 협의) | | [P51](P51-keyword-preset-governance.md) | Keyword 프리셋 거버넌스 — 열린 발견, 닫힌 사용 (번호 잠정) | Proposed | AI | +| [P52](P52-keyword-taxonomy-redesign.md) | Keyword taxonomy·visibility 재설계 — 공개 장소 속성과 사적 맥락의 분리, 7축·43종·처분 어휘·Privacy 판정 계약 (번호 잠정) | Accepted | AI(+back·docs 후속 개정) | ## 제안 — 전수 (Accepted) From e2593f69392510bf93bd7e17956cca6336a5f118 Mon Sep 17 00:00:00 2001 From: ghkim1632 Date: Fri, 7 Aug 2026 17:00:55 +0900 Subject: [PATCH 24/34] =?UTF-8?q?feat(S15P11A705-366):=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=9A=A9?= =?UTF-8?q?=EB=9F=89=20=EC=A0=9C=ED=95=9C=EC=9D=84=205MB=EC=97=90=EC=84=9C?= =?UTF-8?q?=2010MB=EB=A1=9C=20=EC=83=81=ED=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring/FastAPI 양쪽에서 독립적으로 강제하던 5MB 상한을 프론트 변경에 맞춰 FastAPI 쪽도 10MB로 올린다. validate_image의 크기 경계 동작에 대한 직접 단위 테스트가 없던 기존 공백을 이번에 메운다(상한 초과 거부 + 새 상한 내 실제 이미지 통과). --- .env.example | 2 +- app/core/config.py | 2 +- tests/test_place_suggestion.py | 35 +++++++++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 3dad142..734810d 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,6 @@ PLACE_SUGGESTION_TIMEOUT_SEC=30 VISION_MAX_CONCURRENCY=1 # 개발 환경에서만 장소명과 생성 맥락을 로그로 확인한다. 운영 기본값은 false다. PLACE_SUGGESTION_LOG_RESULTS=false -IMAGE_MAX_BYTES=5242880 +IMAGE_MAX_BYTES=10485760 GMS_IMAGE_MAX_BYTES=50000 GMS_VISION_REQUEST_MAX_BYTES=90000 diff --git a/app/core/config.py b/app/core/config.py index 714991f..2ccd236 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -99,7 +99,7 @@ class Settings(BaseSettings): place_suggestion_log_results: bool = Field( False, alias="PLACE_SUGGESTION_LOG_RESULTS" ) - image_max_bytes: int = Field(5 * 1024 * 1024, alias="IMAGE_MAX_BYTES") + image_max_bytes: int = Field(10 * 1024 * 1024, alias="IMAGE_MAX_BYTES") gms_image_max_bytes: int = Field(50_000, alias="GMS_IMAGE_MAX_BYTES") gms_vision_request_max_bytes: int = Field( 90_000, alias="GMS_VISION_REQUEST_MAX_BYTES" diff --git a/tests/test_place_suggestion.py b/tests/test_place_suggestion.py index c957be9..6a5adff 100644 --- a/tests/test_place_suggestion.py +++ b/tests/test_place_suggestion.py @@ -4,6 +4,7 @@ import io import json import logging +import os import httpx import pytest @@ -13,7 +14,8 @@ from app.client.vision_client import USER_PROMPT, GmsGeminiVisionClient, compact_image from app.core.errors import PermanentError, TransientError -from app.core.image_validation import ValidatedImage +from app.core.image_validation import ValidatedImage, validate_image +from app.core.place_suggestion import ImageInputError from app.main import create_app from app.schema.place_suggestion import ( ExtractedPlace, @@ -40,6 +42,14 @@ def _upload(content: bytes | None = None) -> UploadFile: ) +def _incompressible_image_bytes(*, size: tuple[int, int]) -> bytes: + """랜덤 픽셀은 PNG로도 거의 압축되지 않아, 파일 크기를 해상도로 예측 가능하게 만든다.""" + random_pixels = os.urandom(size[0] * size[1] * 3) + buffer = io.BytesIO() + Image.frombytes("RGB", size, random_pixels).save(buffer, format="PNG") + return buffer.getvalue() + + class FakeVision: def __init__(self, result: PlaceExtractionResult, blocker: asyncio.Event | None = None): self.result = result @@ -172,6 +182,29 @@ async def test_development_result_log_contains_place_and_context(caplog): assert "context='대구랑 서울에만 있다는 화덕피자집'" in message +async def test_validate_image_rejects_content_over_max_bytes(): + max_bytes = 10 * 1024 * 1024 + oversized = _upload(b"\xff" * (max_bytes + 1)) + + with pytest.raises(ImageInputError) as excinfo: + await validate_image(oversized, max_bytes=max_bytes) + + assert excinfo.value.status_code == 413 + assert excinfo.value.code == "IMAGE_TOO_LARGE" + + +async def test_validate_image_accepts_a_real_image_within_the_new_ten_mib_limit(): + # S15P11A705-366: 5MiB -> 10MiB. 5MiB보다 크고 10MiB보다 작은 실제 이미지는 예전 상한에서는 + # 거부됐지만(RED) 새 상한에서는 통과해야 한다(GREEN). + content = _incompressible_image_bytes(size=(1600, 1536)) + assert 5 * 1024 * 1024 < len(content) < 10 * 1024 * 1024 + + result = await validate_image(_upload(content), max_bytes=10 * 1024 * 1024) + + assert result.media_type == "image/png" + assert (result.width, result.height) == (1600, 1536) + + def test_kakao_query_does_not_add_conflicting_region_to_named_branch(): candidate = ExtractedPlace( place_name="주토피아 서울", From 5159e370c73aa223546096e2b809be1c259e1faa Mon Sep 17 00:00:00 2001 From: colosair Date: Fri, 7 Aug 2026 17:18:39 +0900 Subject: [PATCH 25/34] =?UTF-8?q?fix(S15P11A705-339):=20keyword=5Fmatrix?= =?UTF-8?q?=20=ED=8F=AC=ED=8A=B8=20=EA=B0=80=EB=93=9C=EB=A5=BC=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=20DB=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0?= =?UTF-8?q?=20=ED=96=89=EB=A0=AC=EC=9D=84=20=EC=9E=AC=EC=83=9D=EC=84=B1?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포트 가드가 :15432(시연 DB)로 남아 있었다. P48 1단계 때의 값이고, 트랙이 스냅샷 DB(:25432)로 옮겨간 뒤에도 이 도구만 갱신되지 않았다. lexical_matrix 와 같은 가드로 맞춘다 — 시연 DB 를 직접 재면 측정과 시연이 같은 데이터를 공유해 부수효과가 섞인다. 행렬 재생성의 이유는 표시명 정합이다. 프리셋 표시명 명사형 통일(-292)이 YAML 정본에만 반영되고 DB 에는 옛 표시명이 남아 있었다. 표시명은 프리셋 임베딩 입력에 들어가므로 기존 행렬은 옛 임베딩 위의 값이었다. 스냅샷 DB 를 정본으로 재적재한 뒤 다시 떴다. 재적재의 부수효과 없음을 md5 로 확인했다 — Context 임베딩·판정 상태·context_keyword 83행이 전부 불변이고 프리셋 임베딩만 바뀌었다. 프리셋 27건·version 1 유지. Co-Authored-By: Claude Fable 5 --- .search/keyword_matrix.json | 4968 ++++++++++++++-------------- tools/search_cut/keyword_matrix.py | 9 +- 2 files changed, 2490 insertions(+), 2487 deletions(-) diff --git a/.search/keyword_matrix.json b/.search/keyword_matrix.json index 9591cc9..95c13dc 100644 --- a/.search/keyword_matrix.json +++ b/.search/keyword_matrix.json @@ -1,11 +1,11 @@ { "stage": "P48-1", - "generated_at": "2026-08-05T08:57:04+00:00", + "generated_at": "2026-08-07T08:14:55+00:00", "profile": "openai-text-embedding-3-small-1536-cosine-v1", "model": "text-embedding-3-small", "preset_version": 1, "source": { - "db_port": "15432", + "db_port": "25432", "gms_batch": 1, "matrices": { "matrix.json": { @@ -945,111 +945,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.212809 + "cos": 0.216347 }, { "preset_id": 102, - "cos": 0.262239 + "cos": 0.274493 }, { "preset_id": 103, - "cos": 0.196233 + "cos": 0.203181 }, { "preset_id": 104, - "cos": 0.255564 + "cos": 0.265125 }, { "preset_id": 105, - "cos": 0.145049 + "cos": 0.153667 }, { "preset_id": 106, - "cos": 0.223122 + "cos": 0.221086 }, { "preset_id": 201, - "cos": 0.221115 + "cos": 0.229493 }, { "preset_id": 202, - "cos": 0.232314 + "cos": 0.232399 }, { "preset_id": 203, - "cos": 0.299061 + "cos": 0.266144 }, { "preset_id": 204, - "cos": 0.231234 + "cos": 0.23126 }, { "preset_id": 205, - "cos": 0.230673 + "cos": 0.215678 }, { "preset_id": 206, - "cos": 0.294066 + "cos": 0.287597 }, { "preset_id": 207, - "cos": 0.222529 + "cos": 0.222591 }, { "preset_id": 208, - "cos": 0.292585 + "cos": 0.252422 }, { "preset_id": 301, - "cos": 0.181922 + "cos": 0.181593 }, { "preset_id": 302, - "cos": 0.188302 + "cos": 0.179931 }, { "preset_id": 303, - "cos": 0.228146 + "cos": 0.226573 }, { "preset_id": 304, - "cos": 0.224606 + "cos": 0.237087 }, { "preset_id": 305, - "cos": 0.230092 + "cos": 0.221805 }, { "preset_id": 306, - "cos": 0.237939 + "cos": 0.263374 }, { "preset_id": 307, - "cos": 0.260698 + "cos": 0.281126 }, { "preset_id": 401, - "cos": 0.261673 + "cos": 0.259014 }, { "preset_id": 402, - "cos": 0.339014 + "cos": 0.339021 }, { "preset_id": 403, - "cos": 0.219272 + "cos": 0.208811 }, { "preset_id": 404, - "cos": 0.506919 + "cos": 0.552268 }, { "preset_id": 405, - "cos": 0.273146 + "cos": 0.268242 }, { "preset_id": 406, - "cos": 0.218563 + "cos": 0.186068 } ] }, @@ -1058,111 +1058,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.29351 + "cos": 0.293125 }, { "preset_id": 102, - "cos": 0.214935 + "cos": 0.21818 }, { "preset_id": 103, - "cos": 0.235197 + "cos": 0.187115 }, { "preset_id": 104, - "cos": 0.306351 + "cos": 0.325184 }, { "preset_id": 105, - "cos": 0.20825 + "cos": 0.210663 }, { "preset_id": 106, - "cos": 0.58682 + "cos": 0.587532 }, { "preset_id": 201, - "cos": 0.419938 + "cos": 0.375155 }, { "preset_id": 202, - "cos": 0.268795 + "cos": 0.268936 }, { "preset_id": 203, - "cos": 0.322295 + "cos": 0.197084 }, { "preset_id": 204, - "cos": 0.267434 + "cos": 0.26747 }, { "preset_id": 205, - "cos": 0.412811 + "cos": 0.367238 }, { "preset_id": 206, - "cos": 0.296131 + "cos": 0.263567 }, { "preset_id": 207, - "cos": 0.231731 + "cos": 0.231707 }, { "preset_id": 208, - "cos": 0.272715 + "cos": 0.265891 }, { "preset_id": 301, - "cos": 0.476878 + "cos": 0.472874 }, { "preset_id": 302, - "cos": 0.315482 + "cos": 0.314546 }, { "preset_id": 303, - "cos": 0.317082 + "cos": 0.326115 }, { "preset_id": 304, - "cos": 0.245997 + "cos": 0.254471 }, { "preset_id": 305, - "cos": 0.317349 + "cos": 0.313508 }, { "preset_id": 306, - "cos": 0.373445 + "cos": 0.265031 }, { "preset_id": 307, - "cos": 0.161287 + "cos": 0.181994 }, { "preset_id": 401, - "cos": 0.344503 + "cos": 0.332785 }, { "preset_id": 402, - "cos": 0.275122 + "cos": 0.275094 }, { "preset_id": 403, - "cos": 0.281864 + "cos": 0.333053 }, { "preset_id": 404, - "cos": 0.290078 + "cos": 0.291395 }, { "preset_id": 405, - "cos": 0.344166 + "cos": 0.355332 }, { "preset_id": 406, - "cos": 0.290528 + "cos": 0.283427 } ] }, @@ -1171,111 +1171,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.555046 + "cos": 0.54479 }, { "preset_id": 102, - "cos": 0.42247 + "cos": 0.420348 }, { "preset_id": 103, - "cos": 0.348344 + "cos": 0.2538 }, { "preset_id": 104, - "cos": 0.405991 + "cos": 0.391041 }, { "preset_id": 105, - "cos": 0.257781 + "cos": 0.249511 }, { "preset_id": 106, - "cos": 0.34972 + "cos": 0.350061 }, { "preset_id": 201, - "cos": 0.35171 + "cos": 0.309576 }, { "preset_id": 202, - "cos": 0.343019 + "cos": 0.34356 }, { "preset_id": 203, - "cos": 0.395813 + "cos": 0.351111 }, { "preset_id": 204, - "cos": 0.330158 + "cos": 0.330286 }, { "preset_id": 205, - "cos": 0.297767 + "cos": 0.281664 }, { "preset_id": 206, - "cos": 0.309647 + "cos": 0.252172 }, { "preset_id": 207, - "cos": 0.206333 + "cos": 0.206567 }, { "preset_id": 208, - "cos": 0.280264 + "cos": 0.258925 }, { "preset_id": 301, - "cos": 0.329286 + "cos": 0.324052 }, { "preset_id": 302, - "cos": 0.330432 + "cos": 0.324184 }, { "preset_id": 303, - "cos": 0.336445 + "cos": 0.308636 }, { "preset_id": 304, - "cos": 0.339658 + "cos": 0.356302 }, { "preset_id": 305, - "cos": 0.308406 + "cos": 0.293539 }, { "preset_id": 306, - "cos": 0.377672 + "cos": 0.286068 }, { "preset_id": 307, - "cos": 0.196076 + "cos": 0.22672 }, { "preset_id": 401, - "cos": 0.34066 + "cos": 0.347303 }, { "preset_id": 402, - "cos": 0.298531 + "cos": 0.298685 }, { "preset_id": 403, - "cos": 0.357288 + "cos": 0.379871 }, { "preset_id": 404, - "cos": 0.293887 + "cos": 0.301318 }, { "preset_id": 405, - "cos": 0.391864 + "cos": 0.388104 }, { "preset_id": 406, - "cos": 0.256491 + "cos": 0.231303 } ] }, @@ -1284,111 +1284,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.25031 + "cos": 0.241131 }, { "preset_id": 102, - "cos": 0.32068 + "cos": 0.325745 }, { "preset_id": 103, - "cos": 0.313853 + "cos": 0.204172 }, { "preset_id": 104, - "cos": 0.236546 + "cos": 0.242843 }, { "preset_id": 105, - "cos": 0.25942 + "cos": 0.260809 }, { "preset_id": 106, - "cos": 0.234244 + "cos": 0.230503 }, { "preset_id": 201, - "cos": 0.416093 + "cos": 0.362997 }, { "preset_id": 202, - "cos": 0.418928 + "cos": 0.41892 }, { "preset_id": 203, - "cos": 0.26281 + "cos": 0.262119 }, { "preset_id": 204, - "cos": 0.42497 + "cos": 0.424575 }, { "preset_id": 205, - "cos": 0.257862 + "cos": 0.242596 }, { "preset_id": 206, - "cos": 0.339418 + "cos": 0.26667 }, { "preset_id": 207, - "cos": 0.246494 + "cos": 0.246296 }, { "preset_id": 208, - "cos": 0.29642 + "cos": 0.295394 }, { "preset_id": 301, - "cos": 0.21479 + "cos": 0.19998 }, { "preset_id": 302, - "cos": 0.233675 + "cos": 0.207312 }, { "preset_id": 303, - "cos": 0.382897 + "cos": 0.395182 }, { "preset_id": 304, - "cos": 0.237437 + "cos": 0.223607 }, { "preset_id": 305, - "cos": 0.217433 + "cos": 0.212041 }, { "preset_id": 306, - "cos": 0.563389 + "cos": 0.459812 }, { "preset_id": 307, - "cos": 0.209212 + "cos": 0.198149 }, { "preset_id": 401, - "cos": 0.312199 + "cos": 0.333241 }, { "preset_id": 402, - "cos": 0.430488 + "cos": 0.430148 }, { "preset_id": 403, - "cos": 0.247298 + "cos": 0.271027 }, { "preset_id": 404, - "cos": 0.320785 + "cos": 0.322691 }, { "preset_id": 405, - "cos": 0.377946 + "cos": 0.336747 }, { "preset_id": 406, - "cos": 0.207773 + "cos": 0.178431 } ] }, @@ -1397,111 +1397,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.328092 + "cos": 0.328553 }, { "preset_id": 102, - "cos": 0.30541 + "cos": 0.304516 }, { "preset_id": 103, - "cos": 0.327359 + "cos": 0.273349 }, { "preset_id": 104, - "cos": 0.245673 + "cos": 0.257917 }, { "preset_id": 105, - "cos": 0.204508 + "cos": 0.202597 }, { "preset_id": 106, - "cos": 0.225424 + "cos": 0.226497 }, { "preset_id": 201, - "cos": 0.421058 + "cos": 0.357822 }, { "preset_id": 202, - "cos": 0.361405 + "cos": 0.36142 }, { "preset_id": 203, - "cos": 0.362659 + "cos": 0.285103 }, { "preset_id": 204, - "cos": 0.482645 + "cos": 0.482589 }, { "preset_id": 205, - "cos": 0.292718 + "cos": 0.295562 }, { "preset_id": 206, - "cos": 0.36294 + "cos": 0.296689 }, { "preset_id": 207, - "cos": 0.237137 + "cos": 0.237142 }, { "preset_id": 208, - "cos": 0.250736 + "cos": 0.247544 }, { "preset_id": 301, - "cos": 0.168558 + "cos": 0.158416 }, { "preset_id": 302, - "cos": 0.212308 + "cos": 0.205821 }, { "preset_id": 303, - "cos": 0.226067 + "cos": 0.241219 }, { "preset_id": 304, - "cos": 0.157112 + "cos": 0.162674 }, { "preset_id": 305, - "cos": 0.248888 + "cos": 0.221285 }, { "preset_id": 306, - "cos": 0.300051 + "cos": 0.170883 }, { "preset_id": 307, - "cos": 0.181353 + "cos": 0.184895 }, { "preset_id": 401, - "cos": 0.311093 + "cos": 0.312663 }, { "preset_id": 402, - "cos": 0.269268 + "cos": 0.269272 }, { "preset_id": 403, - "cos": 0.216827 + "cos": 0.242186 }, { "preset_id": 404, - "cos": 0.248681 + "cos": 0.259632 }, { "preset_id": 405, - "cos": 0.326813 + "cos": 0.315579 }, { "preset_id": 406, - "cos": 0.237267 + "cos": 0.229756 } ] }, @@ -1510,111 +1510,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.153202 + "cos": 0.147645 }, { "preset_id": 102, - "cos": 0.191277 + "cos": 0.200773 }, { "preset_id": 103, - "cos": 0.170146 + "cos": 0.161797 }, { "preset_id": 104, - "cos": 0.174741 + "cos": 0.178977 }, { "preset_id": 105, - "cos": 0.17839 + "cos": 0.172673 }, { "preset_id": 106, - "cos": 0.16826 + "cos": 0.166798 }, { "preset_id": 201, - "cos": 0.244364 + "cos": 0.2702 }, { "preset_id": 202, - "cos": 0.164464 + "cos": 0.164413 }, { "preset_id": 203, - "cos": 0.183193 + "cos": 0.173763 }, { "preset_id": 204, - "cos": 0.264645 + "cos": 0.264576 }, { "preset_id": 205, - "cos": 0.240241 + "cos": 0.231074 }, { "preset_id": 206, - "cos": 0.282691 + "cos": 0.282215 }, { "preset_id": 207, - "cos": 0.216867 + "cos": 0.216865 }, { "preset_id": 208, - "cos": 0.291302 + "cos": 0.276539 }, { "preset_id": 301, - "cos": 0.179053 + "cos": 0.171662 }, { "preset_id": 302, - "cos": 0.106608 + "cos": 0.097791 }, { "preset_id": 303, - "cos": 0.222674 + "cos": 0.245418 }, { "preset_id": 304, - "cos": 0.179091 + "cos": 0.167105 }, { "preset_id": 305, - "cos": 0.141988 + "cos": 0.159274 }, { "preset_id": 306, - "cos": 0.240704 + "cos": 0.230689 }, { "preset_id": 307, - "cos": 0.144767 + "cos": 0.110723 }, { "preset_id": 401, - "cos": 0.174489 + "cos": 0.187097 }, { "preset_id": 402, - "cos": 0.19223 + "cos": 0.192162 }, { "preset_id": 403, - "cos": 0.240015 + "cos": 0.234246 }, { "preset_id": 404, - "cos": 0.121144 + "cos": 0.125403 }, { "preset_id": 405, - "cos": 0.213358 + "cos": 0.210323 }, { "preset_id": 406, - "cos": 0.219206 + "cos": 0.168803 } ] }, @@ -1623,111 +1623,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.524151 + "cos": 0.51747 }, { "preset_id": 102, - "cos": 0.320774 + "cos": 0.323687 }, { "preset_id": 103, - "cos": 0.352588 + "cos": 0.273229 }, { "preset_id": 104, - "cos": 0.342247 + "cos": 0.336404 }, { "preset_id": 105, - "cos": 0.185296 + "cos": 0.180981 }, { "preset_id": 106, - "cos": 0.313143 + "cos": 0.311985 }, { "preset_id": 201, - "cos": 0.376339 + "cos": 0.309029 }, { "preset_id": 202, - "cos": 0.31387 + "cos": 0.313868 }, { "preset_id": 203, - "cos": 0.484565 + "cos": 0.396676 }, { "preset_id": 204, - "cos": 0.36615 + "cos": 0.36593 }, { "preset_id": 205, - "cos": 0.239469 + "cos": 0.203988 }, { "preset_id": 206, - "cos": 0.291248 + "cos": 0.203659 }, { "preset_id": 207, - "cos": 0.186195 + "cos": 0.186131 }, { "preset_id": 208, - "cos": 0.179922 + "cos": 0.16759 }, { "preset_id": 301, - "cos": 0.214855 + "cos": 0.219268 }, { "preset_id": 302, - "cos": 0.191289 + "cos": 0.195857 }, { "preset_id": 303, - "cos": 0.221127 + "cos": 0.222813 }, { "preset_id": 304, - "cos": 0.194808 + "cos": 0.202323 }, { "preset_id": 305, - "cos": 0.238452 + "cos": 0.215513 }, { "preset_id": 306, - "cos": 0.32954 + "cos": 0.231963 }, { "preset_id": 307, - "cos": 0.119106 + "cos": 0.160147 }, { "preset_id": 401, - "cos": 0.307276 + "cos": 0.320895 }, { "preset_id": 402, - "cos": 0.241442 + "cos": 0.241377 }, { "preset_id": 403, - "cos": 0.317008 + "cos": 0.326908 }, { "preset_id": 404, - "cos": 0.238562 + "cos": 0.231352 }, { "preset_id": 405, - "cos": 0.302264 + "cos": 0.290115 }, { "preset_id": 406, - "cos": 0.222644 + "cos": 0.184828 } ] }, @@ -1736,111 +1736,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.21514 + "cos": 0.221117 }, { "preset_id": 102, - "cos": 0.196585 + "cos": 0.209816 }, { "preset_id": 103, - "cos": 0.286209 + "cos": 0.186632 }, { "preset_id": 104, - "cos": 0.131292 + "cos": 0.141049 }, { "preset_id": 105, - "cos": 0.184498 + "cos": 0.190198 }, { "preset_id": 106, - "cos": 0.280129 + "cos": 0.284493 }, { "preset_id": 201, - "cos": 0.336283 + "cos": 0.303799 }, { "preset_id": 202, - "cos": 0.314111 + "cos": 0.313674 }, { "preset_id": 203, - "cos": 0.270145 + "cos": 0.251199 }, { "preset_id": 204, - "cos": 0.358543 + "cos": 0.35705 }, { "preset_id": 205, - "cos": 0.340067 + "cos": 0.353681 }, { "preset_id": 206, - "cos": 0.523196 + "cos": 0.440776 }, { "preset_id": 207, - "cos": 0.293744 + "cos": 0.297747 }, { "preset_id": 208, - "cos": 0.258466 + "cos": 0.243638 }, { "preset_id": 301, - "cos": 0.243902 + "cos": 0.242612 }, { "preset_id": 302, - "cos": 0.241193 + "cos": 0.254254 }, { "preset_id": 303, - "cos": 0.253903 + "cos": 0.260283 }, { "preset_id": 304, - "cos": 0.252001 + "cos": 0.263718 }, { "preset_id": 305, - "cos": 0.193971 + "cos": 0.194864 }, { "preset_id": 306, - "cos": 0.378491 + "cos": 0.290981 }, { "preset_id": 307, - "cos": 0.167549 + "cos": 0.230204 }, { "preset_id": 401, - "cos": 0.198136 + "cos": 0.21168 }, { "preset_id": 402, - "cos": 0.24075 + "cos": 0.244126 }, { "preset_id": 403, - "cos": 0.142293 + "cos": 0.18543 }, { "preset_id": 404, - "cos": 0.278847 + "cos": 0.277686 }, { "preset_id": 405, - "cos": 0.335418 + "cos": 0.345922 }, { "preset_id": 406, - "cos": 0.21425 + "cos": 0.184956 } ] }, @@ -1849,111 +1849,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.257648 + "cos": 0.253997 }, { "preset_id": 102, - "cos": 0.259525 + "cos": 0.270398 }, { "preset_id": 103, - "cos": 0.30428 + "cos": 0.22301 }, { "preset_id": 104, - "cos": 0.203476 + "cos": 0.210695 }, { "preset_id": 105, - "cos": 0.171277 + "cos": 0.157626 }, { "preset_id": 106, - "cos": 0.223747 + "cos": 0.220698 }, { "preset_id": 201, - "cos": 0.365745 + "cos": 0.286088 }, { "preset_id": 202, - "cos": 0.339323 + "cos": 0.339263 }, { "preset_id": 203, - "cos": 0.254383 + "cos": 0.22575 }, { "preset_id": 204, - "cos": 0.556263 + "cos": 0.556257 }, { "preset_id": 205, - "cos": 0.277991 + "cos": 0.271558 }, { "preset_id": 206, - "cos": 0.304299 + "cos": 0.210339 }, { "preset_id": 207, - "cos": 0.227515 + "cos": 0.227418 }, { "preset_id": 208, - "cos": 0.21336 + "cos": 0.215242 }, { "preset_id": 301, - "cos": 0.173442 + "cos": 0.174898 }, { "preset_id": 302, - "cos": 0.183256 + "cos": 0.187369 }, { "preset_id": 303, - "cos": 0.277766 + "cos": 0.27232 }, { "preset_id": 304, - "cos": 0.165278 + "cos": 0.156971 }, { "preset_id": 305, - "cos": 0.238849 + "cos": 0.219659 }, { "preset_id": 306, - "cos": 0.37684 + "cos": 0.219838 }, { "preset_id": 307, - "cos": 0.132458 + "cos": 0.102721 }, { "preset_id": 401, - "cos": 0.283542 + "cos": 0.276926 }, { "preset_id": 402, - "cos": 0.231095 + "cos": 0.231178 }, { "preset_id": 403, - "cos": 0.208056 + "cos": 0.20706 }, { "preset_id": 404, - "cos": 0.201402 + "cos": 0.199976 }, { "preset_id": 405, - "cos": 0.346517 + "cos": 0.316526 }, { "preset_id": 406, - "cos": 0.195072 + "cos": 0.180579 } ] }, @@ -1962,111 +1962,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.179211 + "cos": 0.180893 }, { "preset_id": 102, - "cos": 0.181647 + "cos": 0.181741 }, { "preset_id": 103, - "cos": 0.161954 + "cos": 0.164892 }, { "preset_id": 104, - "cos": 0.107471 + "cos": 0.125485 }, { "preset_id": 105, - "cos": 0.141769 + "cos": 0.140291 }, { "preset_id": 106, - "cos": 0.219733 + "cos": 0.215845 }, { "preset_id": 201, - "cos": 0.298574 + "cos": 0.251343 }, { "preset_id": 202, - "cos": 0.193819 + "cos": 0.193945 }, { "preset_id": 203, - "cos": 0.22385 + "cos": 0.169501 }, { "preset_id": 204, - "cos": 0.264348 + "cos": 0.264376 }, { "preset_id": 205, - "cos": 0.234347 + "cos": 0.248857 }, { "preset_id": 206, - "cos": 0.386592 + "cos": 0.375534 }, { "preset_id": 207, - "cos": 0.271541 + "cos": 0.271417 }, { "preset_id": 208, - "cos": 0.263529 + "cos": 0.248615 }, { "preset_id": 301, - "cos": 0.182566 + "cos": 0.187362 }, { "preset_id": 302, - "cos": 0.175923 + "cos": 0.162787 }, { "preset_id": 303, - "cos": 0.271798 + "cos": 0.282052 }, { "preset_id": 304, - "cos": 0.127867 + "cos": 0.118624 }, { "preset_id": 305, - "cos": 0.239013 + "cos": 0.236811 }, { "preset_id": 306, - "cos": 0.365546 + "cos": 0.315072 }, { "preset_id": 307, - "cos": 0.197322 + "cos": 0.170061 }, { "preset_id": 401, - "cos": 0.229254 + "cos": 0.22331 }, { "preset_id": 402, - "cos": 0.292267 + "cos": 0.29221 }, { "preset_id": 403, - "cos": 0.231774 + "cos": 0.225797 }, { "preset_id": 404, - "cos": 0.14891 + "cos": 0.158916 }, { "preset_id": 405, - "cos": 0.207922 + "cos": 0.216186 }, { "preset_id": 406, - "cos": 0.286138 + "cos": 0.237482 } ] }, @@ -2075,111 +2075,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.230716 + "cos": 0.231891 }, { "preset_id": 102, - "cos": 0.189486 + "cos": 0.196714 }, { "preset_id": 103, - "cos": 0.286371 + "cos": 0.221286 }, { "preset_id": 104, - "cos": 0.26331 + "cos": 0.269803 }, { "preset_id": 105, - "cos": 0.143699 + "cos": 0.149139 }, { "preset_id": 106, - "cos": 0.224892 + "cos": 0.224815 }, { "preset_id": 201, - "cos": 0.255471 + "cos": 0.221227 }, { "preset_id": 202, - "cos": 0.311323 + "cos": 0.311344 }, { "preset_id": 203, - "cos": 0.277774 + "cos": 0.261593 }, { "preset_id": 204, - "cos": 0.314746 + "cos": 0.314745 }, { "preset_id": 205, - "cos": 0.148201 + "cos": 0.114109 }, { "preset_id": 206, - "cos": 0.271842 + "cos": 0.214579 }, { "preset_id": 207, - "cos": 0.135972 + "cos": 0.135899 }, { "preset_id": 208, - "cos": 0.134129 + "cos": 0.129148 }, { "preset_id": 301, - "cos": 0.163849 + "cos": 0.175364 }, { "preset_id": 302, - "cos": 0.169803 + "cos": 0.16013 }, { "preset_id": 303, - "cos": 0.211521 + "cos": 0.215896 }, { "preset_id": 304, - "cos": 0.236521 + "cos": 0.234558 }, { "preset_id": 305, - "cos": 0.218119 + "cos": 0.171834 }, { "preset_id": 306, - "cos": 0.290704 + "cos": 0.132417 }, { "preset_id": 307, - "cos": 0.143633 + "cos": 0.166031 }, { "preset_id": 401, - "cos": 0.209049 + "cos": 0.207545 }, { "preset_id": 402, - "cos": 0.229587 + "cos": 0.22964 }, { "preset_id": 403, - "cos": 0.247264 + "cos": 0.257173 }, { "preset_id": 404, - "cos": 0.151612 + "cos": 0.158812 }, { "preset_id": 405, - "cos": 0.282986 + "cos": 0.261055 }, { "preset_id": 406, - "cos": 0.248894 + "cos": 0.218524 } ] }, @@ -2188,111 +2188,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.255365 + "cos": 0.251709 }, { "preset_id": 102, - "cos": 0.246723 + "cos": 0.253595 }, { "preset_id": 103, - "cos": 0.247891 + "cos": 0.170429 }, { "preset_id": 104, - "cos": 0.196393 + "cos": 0.208631 }, { "preset_id": 105, - "cos": 0.1999 + "cos": 0.204764 }, { "preset_id": 106, - "cos": 0.254593 + "cos": 0.256724 }, { "preset_id": 201, - "cos": 0.365117 + "cos": 0.316921 }, { "preset_id": 202, - "cos": 0.194038 + "cos": 0.194113 }, { "preset_id": 203, - "cos": 0.272386 + "cos": 0.242405 }, { "preset_id": 204, - "cos": 0.247065 + "cos": 0.246963 }, { "preset_id": 205, - "cos": 0.212111 + "cos": 0.203735 }, { "preset_id": 206, - "cos": 0.222706 + "cos": 0.171214 }, { "preset_id": 207, - "cos": 0.143354 + "cos": 0.143319 }, { "preset_id": 208, - "cos": 0.223933 + "cos": 0.210512 }, { "preset_id": 301, - "cos": 0.313973 + "cos": 0.300982 }, { "preset_id": 302, - "cos": 0.294799 + "cos": 0.273211 }, { "preset_id": 303, - "cos": 0.320434 + "cos": 0.331172 }, { "preset_id": 304, - "cos": 0.2896 + "cos": 0.297486 }, { "preset_id": 305, - "cos": 0.309337 + "cos": 0.303754 }, { "preset_id": 306, - "cos": 0.413182 + "cos": 0.296551 }, { "preset_id": 307, - "cos": 0.208037 + "cos": 0.246909 }, { "preset_id": 401, - "cos": 0.347883 + "cos": 0.31894 }, { "preset_id": 402, - "cos": 0.255612 + "cos": 0.255588 }, { "preset_id": 403, - "cos": 0.265364 + "cos": 0.264155 }, { "preset_id": 404, - "cos": 0.220667 + "cos": 0.220341 }, { "preset_id": 405, - "cos": 0.193532 + "cos": 0.219469 }, { "preset_id": 406, - "cos": 0.274786 + "cos": 0.272114 } ] }, @@ -2301,111 +2301,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.117447 + "cos": 0.109271 }, { "preset_id": 102, - "cos": 0.146438 + "cos": 0.147782 }, { "preset_id": 103, - "cos": 0.123457 + "cos": 0.148983 }, { "preset_id": 104, - "cos": 0.167314 + "cos": 0.171922 }, { "preset_id": 105, - "cos": 0.123982 + "cos": 0.117384 }, { "preset_id": 106, - "cos": 0.125118 + "cos": 0.125493 }, { "preset_id": 201, - "cos": 0.120243 + "cos": 0.117531 }, { "preset_id": 202, - "cos": 0.110835 + "cos": 0.110844 }, { "preset_id": 203, - "cos": 0.122542 + "cos": 0.141612 }, { "preset_id": 204, - "cos": 0.084725 + "cos": 0.084689 }, { "preset_id": 205, - "cos": 0.141927 + "cos": 0.132815 }, { "preset_id": 206, - "cos": 0.148177 + "cos": 0.169958 }, { "preset_id": 207, - "cos": 0.121217 + "cos": 0.121038 }, { "preset_id": 208, - "cos": 0.115012 + "cos": 0.138356 }, { "preset_id": 301, - "cos": 0.128315 + "cos": 0.127061 }, { "preset_id": 302, - "cos": 0.070908 + "cos": 0.057886 }, { "preset_id": 303, - "cos": 0.048121 + "cos": 0.053713 }, { "preset_id": 304, - "cos": 0.181674 + "cos": 0.180915 }, { "preset_id": 305, - "cos": 0.074421 + "cos": 0.086454 }, { "preset_id": 306, - "cos": 0.050564 + "cos": 0.030358 }, { "preset_id": 307, - "cos": 0.090374 + "cos": 0.094212 }, { "preset_id": 401, - "cos": 0.12903 + "cos": 0.134105 }, { "preset_id": 402, - "cos": 0.103006 + "cos": 0.102983 }, { "preset_id": 403, - "cos": 0.167321 + "cos": 0.177342 }, { "preset_id": 404, - "cos": 0.166093 + "cos": 0.186853 }, { "preset_id": 405, - "cos": 0.112458 + "cos": 0.075884 }, { "preset_id": 406, - "cos": 0.196851 + "cos": 0.208812 } ] }, @@ -2414,111 +2414,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.190804 + "cos": 0.191648 }, { "preset_id": 102, - "cos": 0.272424 + "cos": 0.279689 }, { "preset_id": 103, - "cos": 0.226851 + "cos": 0.218039 }, { "preset_id": 104, - "cos": 0.292099 + "cos": 0.300672 }, { "preset_id": 105, - "cos": 0.288665 + "cos": 0.294294 }, { "preset_id": 106, - "cos": 0.18151 + "cos": 0.181659 }, { "preset_id": 201, - "cos": 0.334623 + "cos": 0.320592 }, { "preset_id": 202, - "cos": 0.327193 + "cos": 0.327355 }, { "preset_id": 203, - "cos": 0.12473 + "cos": 0.167094 }, { "preset_id": 204, - "cos": 0.224292 + "cos": 0.224294 }, { "preset_id": 205, - "cos": 0.306082 + "cos": 0.316947 }, { "preset_id": 206, - "cos": 0.181652 + "cos": 0.151718 }, { "preset_id": 207, - "cos": 0.157372 + "cos": 0.157251 }, { "preset_id": 208, - "cos": 0.259313 + "cos": 0.252759 }, { "preset_id": 301, - "cos": 0.261181 + "cos": 0.243108 }, { "preset_id": 302, - "cos": 0.120417 + "cos": 0.128212 }, { "preset_id": 303, - "cos": 0.144868 + "cos": 0.146136 }, { "preset_id": 304, - "cos": 0.1854 + "cos": 0.172218 }, { "preset_id": 305, - "cos": 0.274519 + "cos": 0.284594 }, { "preset_id": 306, - "cos": 0.218152 + "cos": 0.186563 }, { "preset_id": 307, - "cos": 0.135962 + "cos": 0.133596 }, { "preset_id": 401, - "cos": 0.185692 + "cos": 0.179292 }, { "preset_id": 402, - "cos": 0.205247 + "cos": 0.205216 }, { "preset_id": 403, - "cos": 0.278058 + "cos": 0.290993 }, { "preset_id": 404, - "cos": 0.159578 + "cos": 0.17219 }, { "preset_id": 405, - "cos": 0.316833 + "cos": 0.301863 }, { "preset_id": 406, - "cos": 0.308802 + "cos": 0.32453 } ] }, @@ -2527,111 +2527,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.10559 + "cos": 0.097523 }, { "preset_id": 102, - "cos": 0.160469 + "cos": 0.157176 }, { "preset_id": 103, - "cos": 0.084562 + "cos": 0.07315 }, { "preset_id": 104, - "cos": 0.138183 + "cos": 0.140353 }, { "preset_id": 105, - "cos": 0.179204 + "cos": 0.171163 }, { "preset_id": 106, - "cos": 0.133384 + "cos": 0.131434 }, { "preset_id": 201, - "cos": 0.133277 + "cos": 0.105751 }, { "preset_id": 202, - "cos": 0.215331 + "cos": 0.215312 }, { "preset_id": 203, - "cos": 0.222883 + "cos": 0.125512 }, { "preset_id": 204, - "cos": 0.290271 + "cos": 0.290273 }, { "preset_id": 205, - "cos": 0.287513 + "cos": 0.274927 }, { "preset_id": 206, - "cos": 0.220728 + "cos": 0.22104 }, { "preset_id": 207, - "cos": 0.288863 + "cos": 0.28877 }, { "preset_id": 208, - "cos": 0.234144 + "cos": 0.232052 }, { "preset_id": 301, - "cos": 0.076312 + "cos": 0.068437 }, { "preset_id": 302, - "cos": 0.141288 + "cos": 0.129722 }, { "preset_id": 303, - "cos": 0.196572 + "cos": 0.220847 }, { "preset_id": 304, - "cos": 0.134306 + "cos": 0.127775 }, { "preset_id": 305, - "cos": 0.15043 + "cos": 0.153561 }, { "preset_id": 306, - "cos": 0.181417 + "cos": 0.11108 }, { "preset_id": 307, - "cos": 0.215753 + "cos": 0.13935 }, { "preset_id": 401, - "cos": 0.195622 + "cos": 0.192058 }, { "preset_id": 402, - "cos": 0.199063 + "cos": 0.1991 }, { "preset_id": 403, - "cos": 0.18637 + "cos": 0.198445 }, { "preset_id": 404, - "cos": 0.104485 + "cos": 0.107127 }, { "preset_id": 405, - "cos": 0.223118 + "cos": 0.223702 }, { "preset_id": 406, - "cos": 0.205618 + "cos": 0.180589 } ] }, @@ -2640,111 +2640,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.14316 + "cos": 0.145883 }, { "preset_id": 102, - "cos": 0.130457 + "cos": 0.131279 }, { "preset_id": 103, - "cos": 0.108266 + "cos": 0.138138 }, { "preset_id": 104, - "cos": 0.191435 + "cos": 0.196053 }, { "preset_id": 105, - "cos": 0.2116 + "cos": 0.221103 }, { "preset_id": 106, - "cos": 0.13126 + "cos": 0.13179 }, { "preset_id": 201, - "cos": 0.197085 + "cos": 0.184697 }, { "preset_id": 202, - "cos": 0.203779 + "cos": 0.203805 }, { "preset_id": 203, - "cos": 0.095634 + "cos": 0.115244 }, { "preset_id": 204, - "cos": 0.174523 + "cos": 0.17452 }, { "preset_id": 205, - "cos": 0.331443 + "cos": 0.311339 }, { "preset_id": 206, - "cos": 0.152932 + "cos": 0.149951 }, { "preset_id": 207, - "cos": 0.244419 + "cos": 0.244385 }, { "preset_id": 208, - "cos": 0.214872 + "cos": 0.197305 }, { "preset_id": 301, - "cos": 0.092169 + "cos": 0.071966 }, { "preset_id": 302, - "cos": 0.106711 + "cos": 0.098911 }, { "preset_id": 303, - "cos": 0.11772 + "cos": 0.126295 }, { "preset_id": 304, - "cos": 0.116562 + "cos": 0.128332 }, { "preset_id": 305, - "cos": 0.136844 + "cos": 0.122816 }, { "preset_id": 306, - "cos": 0.099484 + "cos": 0.040618 }, { "preset_id": 307, - "cos": 0.164935 + "cos": 0.142869 }, { "preset_id": 401, - "cos": 0.104833 + "cos": 0.090864 }, { "preset_id": 402, - "cos": 0.124365 + "cos": 0.12458 }, { "preset_id": 403, - "cos": 0.138908 + "cos": 0.175549 }, { "preset_id": 404, - "cos": 0.135527 + "cos": 0.135051 }, { "preset_id": 405, - "cos": 0.223153 + "cos": 0.209514 }, { "preset_id": 406, - "cos": 0.205455 + "cos": 0.213606 } ] }, @@ -2753,111 +2753,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.155871 + "cos": 0.154637 }, { "preset_id": 102, - "cos": 0.137702 + "cos": 0.13715 }, { "preset_id": 103, - "cos": 0.189531 + "cos": 0.183383 }, { "preset_id": 104, - "cos": 0.184517 + "cos": 0.192547 }, { "preset_id": 105, - "cos": 0.318851 + "cos": 0.31617 }, { "preset_id": 106, - "cos": 0.194685 + "cos": 0.19414 }, { "preset_id": 201, - "cos": 0.116474 + "cos": 0.100036 }, { "preset_id": 202, - "cos": 0.220882 + "cos": 0.221044 }, { "preset_id": 203, - "cos": 0.078842 + "cos": 0.173094 }, { "preset_id": 204, - "cos": 0.153177 + "cos": 0.153206 }, { "preset_id": 205, - "cos": 0.225549 + "cos": 0.243171 }, { "preset_id": 206, - "cos": 0.163121 + "cos": 0.153578 }, { "preset_id": 207, - "cos": 0.205895 + "cos": 0.205903 }, { "preset_id": 208, - "cos": 0.195522 + "cos": 0.183839 }, { "preset_id": 301, - "cos": 0.201562 + "cos": 0.175118 }, { "preset_id": 302, - "cos": 0.172762 + "cos": 0.169285 }, { "preset_id": 303, - "cos": 0.141669 + "cos": 0.140926 }, { "preset_id": 304, - "cos": 0.157173 + "cos": 0.14511 }, { "preset_id": 305, - "cos": 0.159787 + "cos": 0.181654 }, { "preset_id": 306, - "cos": 0.184544 + "cos": 0.106585 }, { "preset_id": 307, - "cos": 0.0951 + "cos": 0.147686 }, { "preset_id": 401, - "cos": 0.182641 + "cos": 0.162526 }, { "preset_id": 402, - "cos": 0.188718 + "cos": 0.188751 }, { "preset_id": 403, - "cos": 0.169567 + "cos": 0.234742 }, { "preset_id": 404, - "cos": 0.132301 + "cos": 0.130135 }, { "preset_id": 405, - "cos": 0.217894 + "cos": 0.231263 }, { "preset_id": 406, - "cos": 0.269276 + "cos": 0.301017 } ] }, @@ -2866,111 +2866,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.228349 + "cos": 0.223337 }, { "preset_id": 102, - "cos": 0.188694 + "cos": 0.191696 }, { "preset_id": 103, - "cos": 0.181238 + "cos": 0.152033 }, { "preset_id": 104, - "cos": 0.193427 + "cos": 0.191685 }, { "preset_id": 105, - "cos": 0.204995 + "cos": 0.204195 }, { "preset_id": 106, - "cos": 0.193496 + "cos": 0.190941 }, { "preset_id": 201, - "cos": 0.178358 + "cos": 0.152023 }, { "preset_id": 202, - "cos": 0.163454 + "cos": 0.163464 }, { "preset_id": 203, - "cos": 0.184888 + "cos": 0.186114 }, { "preset_id": 204, - "cos": 0.183545 + "cos": 0.183517 }, { "preset_id": 205, - "cos": 0.198211 + "cos": 0.176194 }, { "preset_id": 206, - "cos": 0.168481 + "cos": 0.152051 }, { "preset_id": 207, - "cos": 0.17014 + "cos": 0.1701 }, { "preset_id": 208, - "cos": 0.203685 + "cos": 0.205027 }, { "preset_id": 301, - "cos": 0.188419 + "cos": 0.18066 }, { "preset_id": 302, - "cos": 0.185292 + "cos": 0.181633 }, { "preset_id": 303, - "cos": 0.226457 + "cos": 0.215453 }, { "preset_id": 304, - "cos": 0.17016 + "cos": 0.18123 }, { "preset_id": 305, - "cos": 0.244629 + "cos": 0.219256 }, { "preset_id": 306, - "cos": 0.188994 + "cos": 0.162553 }, { "preset_id": 307, - "cos": 0.217661 + "cos": 0.222417 }, { "preset_id": 401, - "cos": 0.188429 + "cos": 0.197392 }, { "preset_id": 402, - "cos": 0.200874 + "cos": 0.200826 }, { "preset_id": 403, - "cos": 0.193829 + "cos": 0.214849 }, { "preset_id": 404, - "cos": 0.138884 + "cos": 0.139746 }, { "preset_id": 405, - "cos": 0.177952 + "cos": 0.18804 }, { "preset_id": 406, - "cos": 0.204259 + "cos": 0.201883 } ] }, @@ -2979,111 +2979,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.221234 + "cos": 0.219468 }, { "preset_id": 102, - "cos": 0.234749 + "cos": 0.254927 }, { "preset_id": 103, - "cos": 0.178933 + "cos": 0.199397 }, { "preset_id": 104, - "cos": 0.260987 + "cos": 0.276586 }, { "preset_id": 105, - "cos": 0.183632 + "cos": 0.186462 }, { "preset_id": 106, - "cos": 0.258905 + "cos": 0.256596 }, { "preset_id": 201, - "cos": 0.184109 + "cos": 0.172602 }, { "preset_id": 202, - "cos": 0.283659 + "cos": 0.283646 }, { "preset_id": 203, - "cos": 0.219817 + "cos": 0.218783 }, { "preset_id": 204, - "cos": 0.15637 + "cos": 0.156374 }, { "preset_id": 205, - "cos": 0.261382 + "cos": 0.240559 }, { "preset_id": 206, - "cos": 0.186835 + "cos": 0.187049 }, { "preset_id": 207, - "cos": 0.24178 + "cos": 0.241716 }, { "preset_id": 208, - "cos": 0.306803 + "cos": 0.310467 }, { "preset_id": 301, - "cos": 0.256839 + "cos": 0.257357 }, { "preset_id": 302, - "cos": 0.199549 + "cos": 0.199987 }, { "preset_id": 303, - "cos": 0.229878 + "cos": 0.251014 }, { "preset_id": 304, - "cos": 0.200844 + "cos": 0.211252 }, { "preset_id": 305, - "cos": 0.227159 + "cos": 0.250858 }, { "preset_id": 306, - "cos": 0.21055 + "cos": 0.233328 }, { "preset_id": 307, - "cos": 0.218982 + "cos": 0.258429 }, { "preset_id": 401, - "cos": 0.169061 + "cos": 0.173917 }, { "preset_id": 402, - "cos": 0.349962 + "cos": 0.349915 }, { "preset_id": 403, - "cos": 0.259352 + "cos": 0.273322 }, { "preset_id": 404, - "cos": 0.161124 + "cos": 0.175065 }, { "preset_id": 405, - "cos": 0.239769 + "cos": 0.255091 }, { "preset_id": 406, - "cos": 0.260937 + "cos": 0.251449 } ] }, @@ -3092,111 +3092,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.206331 + "cos": 0.200937 }, { "preset_id": 102, - "cos": 0.321449 + "cos": 0.315386 }, { "preset_id": 103, - "cos": 0.20098 + "cos": 0.21643 }, { "preset_id": 104, - "cos": 0.249441 + "cos": 0.264505 }, { "preset_id": 105, - "cos": 0.190019 + "cos": 0.184099 }, { "preset_id": 106, - "cos": 0.180227 + "cos": 0.179309 }, { "preset_id": 201, - "cos": 0.290971 + "cos": 0.298132 }, { "preset_id": 202, - "cos": 0.217498 + "cos": 0.217617 }, { "preset_id": 203, - "cos": 0.195815 + "cos": 0.178826 }, { "preset_id": 204, - "cos": 0.264416 + "cos": 0.264433 }, { "preset_id": 205, - "cos": 0.294908 + "cos": 0.272758 }, { "preset_id": 206, - "cos": 0.263364 + "cos": 0.260651 }, { "preset_id": 207, - "cos": 0.270965 + "cos": 0.270832 }, { "preset_id": 208, - "cos": 0.309056 + "cos": 0.329772 }, { "preset_id": 301, - "cos": 0.198101 + "cos": 0.191008 }, { "preset_id": 302, - "cos": 0.147105 + "cos": 0.143294 }, { "preset_id": 303, - "cos": 0.243615 + "cos": 0.263463 }, { "preset_id": 304, - "cos": 0.203489 + "cos": 0.186937 }, { "preset_id": 305, - "cos": 0.189942 + "cos": 0.194388 }, { "preset_id": 306, - "cos": 0.242023 + "cos": 0.224533 }, { "preset_id": 307, - "cos": 0.235382 + "cos": 0.244279 }, { "preset_id": 401, - "cos": 0.190129 + "cos": 0.216706 }, { "preset_id": 402, - "cos": 0.22444 + "cos": 0.224459 }, { "preset_id": 403, - "cos": 0.234661 + "cos": 0.228406 }, { "preset_id": 404, - "cos": 0.213984 + "cos": 0.216314 }, { "preset_id": 405, - "cos": 0.256872 + "cos": 0.229104 }, { "preset_id": 406, - "cos": 0.25822 + "cos": 0.273472 } ] }, @@ -3205,111 +3205,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.147471 + "cos": 0.150947 }, { "preset_id": 102, - "cos": 0.188736 + "cos": 0.206437 }, { "preset_id": 103, - "cos": 0.122538 + "cos": 0.137167 }, { "preset_id": 104, - "cos": 0.159902 + "cos": 0.168886 }, { "preset_id": 105, - "cos": 0.122161 + "cos": 0.115094 }, { "preset_id": 106, - "cos": 0.129378 + "cos": 0.128765 }, { "preset_id": 201, - "cos": 0.115863 + "cos": 0.10538 }, { "preset_id": 202, - "cos": 0.154777 + "cos": 0.154788 }, { "preset_id": 203, - "cos": 0.226686 + "cos": 0.1491 }, { "preset_id": 204, - "cos": 0.169713 + "cos": 0.169782 }, { "preset_id": 205, - "cos": 0.222782 + "cos": 0.204195 }, { "preset_id": 206, - "cos": 0.126404 + "cos": 0.11614 }, { "preset_id": 207, - "cos": 0.189596 + "cos": 0.189595 }, { "preset_id": 208, - "cos": 0.151863 + "cos": 0.169379 }, { "preset_id": 301, - "cos": 0.071548 + "cos": 0.089568 }, { "preset_id": 302, - "cos": 0.102936 + "cos": 0.120772 }, { "preset_id": 303, - "cos": 0.138342 + "cos": 0.148858 }, { "preset_id": 304, - "cos": 0.173363 + "cos": 0.18149 }, { "preset_id": 305, - "cos": 0.133171 + "cos": 0.161445 }, { "preset_id": 306, - "cos": 0.072915 + "cos": 0.05515 }, { "preset_id": 307, - "cos": 0.141455 + "cos": 0.13576 }, { "preset_id": 401, - "cos": 0.072746 + "cos": 0.080623 }, { "preset_id": 402, - "cos": 0.106137 + "cos": 0.106119 }, { "preset_id": 403, - "cos": 0.097344 + "cos": 0.104683 }, { "preset_id": 404, - "cos": 0.058734 + "cos": 0.059953 }, { "preset_id": 405, - "cos": 0.180339 + "cos": 0.135765 }, { "preset_id": 406, - "cos": 0.134901 + "cos": 0.138959 } ] }, @@ -3318,111 +3318,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.135576 + "cos": 0.13784 }, { "preset_id": 102, - "cos": 0.196027 + "cos": 0.196628 }, { "preset_id": 103, - "cos": 0.177369 + "cos": 0.160568 }, { "preset_id": 104, - "cos": 0.12046 + "cos": 0.133964 }, { "preset_id": 105, - "cos": 0.101068 + "cos": 0.099776 }, { "preset_id": 106, - "cos": 0.180788 + "cos": 0.1762 }, { "preset_id": 201, - "cos": 0.179453 + "cos": 0.16081 }, { "preset_id": 202, - "cos": 0.136488 + "cos": 0.136515 }, { "preset_id": 203, - "cos": 0.165538 + "cos": 0.144017 }, { "preset_id": 204, - "cos": 0.220566 + "cos": 0.220506 }, { "preset_id": 205, - "cos": 0.280668 + "cos": 0.259759 }, { "preset_id": 206, - "cos": 0.531238 + "cos": 0.527591 }, { "preset_id": 207, - "cos": 0.278687 + "cos": 0.278607 }, { "preset_id": 208, - "cos": 0.212803 + "cos": 0.222815 }, { "preset_id": 301, - "cos": 0.134198 + "cos": 0.15142 }, { "preset_id": 302, - "cos": 0.10498 + "cos": 0.096035 }, { "preset_id": 303, - "cos": 0.151803 + "cos": 0.173873 }, { "preset_id": 304, - "cos": 0.161342 + "cos": 0.164657 }, { "preset_id": 305, - "cos": 0.124431 + "cos": 0.126866 }, { "preset_id": 306, - "cos": 0.170675 + "cos": 0.14591 }, { "preset_id": 307, - "cos": 0.112569 + "cos": 0.146715 }, { "preset_id": 401, - "cos": 0.107587 + "cos": 0.123028 }, { "preset_id": 402, - "cos": 0.209059 + "cos": 0.209078 }, { "preset_id": 403, - "cos": 0.156254 + "cos": 0.14807 }, { "preset_id": 404, - "cos": 0.096122 + "cos": 0.115732 }, { "preset_id": 405, - "cos": 0.200314 + "cos": 0.194799 }, { "preset_id": 406, - "cos": 0.26367 + "cos": 0.239174 } ] }, @@ -3431,111 +3431,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.209539 + "cos": 0.210469 }, { "preset_id": 102, - "cos": 0.159997 + "cos": 0.162015 }, { "preset_id": 103, - "cos": 0.126172 + "cos": 0.130734 }, { "preset_id": 104, - "cos": 0.173163 + "cos": 0.180105 }, { "preset_id": 105, - "cos": 0.176159 + "cos": 0.173883 }, { "preset_id": 106, - "cos": 0.12147 + "cos": 0.12004 }, { "preset_id": 201, - "cos": 0.246193 + "cos": 0.209924 }, { "preset_id": 202, - "cos": 0.180442 + "cos": 0.180499 }, { "preset_id": 203, - "cos": 0.186091 + "cos": 0.150729 }, { "preset_id": 204, - "cos": 0.220975 + "cos": 0.220951 }, { "preset_id": 205, - "cos": 0.182113 + "cos": 0.17148 }, { "preset_id": 206, - "cos": 0.204456 + "cos": 0.207022 }, { "preset_id": 207, - "cos": 0.172638 + "cos": 0.172613 }, { "preset_id": 208, - "cos": 0.163208 + "cos": 0.164372 }, { "preset_id": 301, - "cos": 0.160259 + "cos": 0.152255 }, { "preset_id": 302, - "cos": 0.219324 + "cos": 0.207312 }, { "preset_id": 303, - "cos": 0.222355 + "cos": 0.21765 }, { "preset_id": 304, - "cos": 0.204828 + "cos": 0.188955 }, { "preset_id": 305, - "cos": 0.225512 + "cos": 0.200702 }, { "preset_id": 306, - "cos": 0.197403 + "cos": 0.214614 }, { "preset_id": 307, - "cos": 0.226991 + "cos": 0.172073 }, { "preset_id": 401, - "cos": 0.19367 + "cos": 0.202036 }, { "preset_id": 402, - "cos": 0.18867 + "cos": 0.188615 }, { "preset_id": 403, - "cos": 0.278604 + "cos": 0.247054 }, { "preset_id": 404, - "cos": 0.121292 + "cos": 0.127164 }, { "preset_id": 405, - "cos": 0.1911 + "cos": 0.163379 }, { "preset_id": 406, - "cos": 0.232802 + "cos": 0.208041 } ] }, @@ -3544,111 +3544,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.168148 + "cos": 0.168634 }, { "preset_id": 102, - "cos": 0.128914 + "cos": 0.138163 }, { "preset_id": 103, - "cos": 0.16293 + "cos": 0.174135 }, { "preset_id": 104, - "cos": 0.179643 + "cos": 0.188017 }, { "preset_id": 105, - "cos": 0.11017 + "cos": 0.117794 }, { "preset_id": 106, - "cos": 0.178409 + "cos": 0.177647 }, { "preset_id": 201, - "cos": 0.18718 + "cos": 0.193659 }, { "preset_id": 202, - "cos": 0.188473 + "cos": 0.188479 }, { "preset_id": 203, - "cos": 0.18086 + "cos": 0.188225 }, { "preset_id": 204, - "cos": 0.201462 + "cos": 0.201426 }, { "preset_id": 205, - "cos": 0.24045 + "cos": 0.197711 }, { "preset_id": 206, - "cos": 0.248844 + "cos": 0.226173 }, { "preset_id": 207, - "cos": 0.183221 + "cos": 0.183166 }, { "preset_id": 208, - "cos": 0.176832 + "cos": 0.1868 }, { "preset_id": 301, - "cos": 0.137567 + "cos": 0.155635 }, { "preset_id": 302, - "cos": 0.180128 + "cos": 0.19283 }, { "preset_id": 303, - "cos": 0.136464 + "cos": 0.143872 }, { "preset_id": 304, - "cos": 0.186754 + "cos": 0.200202 }, { "preset_id": 305, - "cos": 0.170756 + "cos": 0.156875 }, { "preset_id": 306, - "cos": 0.108762 + "cos": 0.098312 }, { "preset_id": 307, - "cos": 0.204864 + "cos": 0.179365 }, { "preset_id": 401, - "cos": 0.100576 + "cos": 0.119692 }, { "preset_id": 402, - "cos": 0.073227 + "cos": 0.073216 }, { "preset_id": 403, - "cos": 0.228576 + "cos": 0.160484 }, { "preset_id": 404, - "cos": 0.116739 + "cos": 0.111965 }, { "preset_id": 405, - "cos": 0.189737 + "cos": 0.153274 }, { "preset_id": 406, - "cos": 0.17357 + "cos": 0.185502 } ] }, @@ -3657,111 +3657,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.137074 + "cos": 0.136592 }, { "preset_id": 102, - "cos": 0.183131 + "cos": 0.197574 }, { "preset_id": 103, - "cos": 0.112886 + "cos": 0.136943 }, { "preset_id": 104, - "cos": 0.209707 + "cos": 0.223481 }, { "preset_id": 105, - "cos": 0.145811 + "cos": 0.152267 }, { "preset_id": 106, - "cos": 0.19928 + "cos": 0.197863 }, { "preset_id": 201, - "cos": 0.099707 + "cos": 0.120849 }, { "preset_id": 202, - "cos": 0.199072 + "cos": 0.199036 }, { "preset_id": 203, - "cos": 0.123936 + "cos": 0.145783 }, { "preset_id": 204, - "cos": 0.184452 + "cos": 0.184427 }, { "preset_id": 205, - "cos": 0.283663 + "cos": 0.245778 }, { "preset_id": 206, - "cos": 0.242515 + "cos": 0.238 }, { "preset_id": 207, - "cos": 0.216865 + "cos": 0.216749 }, { "preset_id": 208, - "cos": 0.256654 + "cos": 0.256276 }, { "preset_id": 301, - "cos": 0.203886 + "cos": 0.201787 }, { "preset_id": 302, - "cos": 0.157137 + "cos": 0.14936 }, { "preset_id": 303, - "cos": 0.202082 + "cos": 0.194883 }, { "preset_id": 304, - "cos": 0.255421 + "cos": 0.260219 }, { "preset_id": 305, - "cos": 0.257008 + "cos": 0.248972 }, { "preset_id": 306, - "cos": 0.186028 + "cos": 0.192479 }, { "preset_id": 307, - "cos": 0.170642 + "cos": 0.22195 }, { "preset_id": 401, - "cos": 0.11278 + "cos": 0.11547 }, { "preset_id": 402, - "cos": 0.17891 + "cos": 0.178921 }, { "preset_id": 403, - "cos": 0.199951 + "cos": 0.18813 }, { "preset_id": 404, - "cos": 0.291482 + "cos": 0.306011 }, { "preset_id": 405, - "cos": 0.232966 + "cos": 0.201631 }, { "preset_id": 406, - "cos": 0.230445 + "cos": 0.232174 } ] }, @@ -3770,111 +3770,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.181441 + "cos": 0.185542 }, { "preset_id": 102, - "cos": 0.207703 + "cos": 0.211371 }, { "preset_id": 103, - "cos": 0.166199 + "cos": 0.208344 }, { "preset_id": 104, - "cos": 0.267963 + "cos": 0.293986 }, { "preset_id": 105, - "cos": 0.189258 + "cos": 0.187306 }, { "preset_id": 106, - "cos": 0.215493 + "cos": 0.210938 }, { "preset_id": 201, - "cos": 0.236082 + "cos": 0.22944 }, { "preset_id": 202, - "cos": 0.24337 + "cos": 0.243326 }, { "preset_id": 203, - "cos": 0.233945 + "cos": 0.166997 }, { "preset_id": 204, - "cos": 0.214625 + "cos": 0.214602 }, { "preset_id": 205, - "cos": 0.235807 + "cos": 0.225541 }, { "preset_id": 206, - "cos": 0.138045 + "cos": 0.125527 }, { "preset_id": 207, - "cos": 0.207387 + "cos": 0.207249 }, { "preset_id": 208, - "cos": 0.242106 + "cos": 0.248053 }, { "preset_id": 301, - "cos": 0.197069 + "cos": 0.192083 }, { "preset_id": 302, - "cos": 0.16361 + "cos": 0.156082 }, { "preset_id": 303, - "cos": 0.235365 + "cos": 0.231665 }, { "preset_id": 304, - "cos": 0.158195 + "cos": 0.169245 }, { "preset_id": 305, - "cos": 0.25493 + "cos": 0.271659 }, { "preset_id": 306, - "cos": 0.256602 + "cos": 0.227558 }, { "preset_id": 307, - "cos": 0.234642 + "cos": 0.187184 }, { "preset_id": 401, - "cos": 0.23524 + "cos": 0.242585 }, { "preset_id": 402, - "cos": 0.246947 + "cos": 0.246868 }, { "preset_id": 403, - "cos": 0.207462 + "cos": 0.261169 }, { "preset_id": 404, - "cos": 0.143125 + "cos": 0.160569 }, { "preset_id": 405, - "cos": 0.247278 + "cos": 0.219166 }, { "preset_id": 406, - "cos": 0.26587 + "cos": 0.256184 } ] }, @@ -3883,111 +3883,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.192969 + "cos": 0.190898 }, { "preset_id": 102, - "cos": 0.183559 + "cos": 0.201639 }, { "preset_id": 103, - "cos": 0.220376 + "cos": 0.212291 }, { "preset_id": 104, - "cos": 0.194081 + "cos": 0.205132 }, { "preset_id": 105, - "cos": 0.170907 + "cos": 0.180324 }, { "preset_id": 106, - "cos": 0.188957 + "cos": 0.190714 }, { "preset_id": 201, - "cos": 0.159035 + "cos": 0.156678 }, { "preset_id": 202, - "cos": 0.135781 + "cos": 0.135916 }, { "preset_id": 203, - "cos": 0.176655 + "cos": 0.222309 }, { "preset_id": 204, - "cos": 0.149965 + "cos": 0.14995 }, { "preset_id": 205, - "cos": 0.195663 + "cos": 0.154954 }, { "preset_id": 206, - "cos": 0.14935 + "cos": 0.138154 }, { "preset_id": 207, - "cos": 0.213324 + "cos": 0.213249 }, { "preset_id": 208, - "cos": 0.1956 + "cos": 0.220639 }, { "preset_id": 301, - "cos": 0.153354 + "cos": 0.159877 }, { "preset_id": 302, - "cos": 0.137545 + "cos": 0.135443 }, { "preset_id": 303, - "cos": 0.123654 + "cos": 0.130524 }, { "preset_id": 304, - "cos": 0.157689 + "cos": 0.182262 }, { "preset_id": 305, - "cos": 0.171596 + "cos": 0.197131 }, { "preset_id": 306, - "cos": 0.131909 + "cos": 0.133517 }, { "preset_id": 307, - "cos": 0.122157 + "cos": 0.173166 }, { "preset_id": 401, - "cos": 0.118931 + "cos": 0.101639 }, { "preset_id": 402, - "cos": 0.194965 + "cos": 0.194964 }, { "preset_id": 403, - "cos": 0.192769 + "cos": 0.219953 }, { "preset_id": 404, - "cos": 0.124262 + "cos": 0.116495 }, { "preset_id": 405, - "cos": 0.20555 + "cos": 0.184464 }, { "preset_id": 406, - "cos": 0.266388 + "cos": 0.263254 } ] }, @@ -3996,111 +3996,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.250384 + "cos": 0.249282 }, { "preset_id": 102, - "cos": 0.112905 + "cos": 0.121439 }, { "preset_id": 103, - "cos": 0.175295 + "cos": 0.190071 }, { "preset_id": 104, - "cos": 0.220649 + "cos": 0.240414 }, { "preset_id": 105, - "cos": 0.144339 + "cos": 0.144267 }, { "preset_id": 106, - "cos": 0.219509 + "cos": 0.215122 }, { "preset_id": 201, - "cos": 0.144828 + "cos": 0.147209 }, { "preset_id": 202, - "cos": 0.161743 + "cos": 0.161777 }, { "preset_id": 203, - "cos": 0.305071 + "cos": 0.349825 }, { "preset_id": 204, - "cos": 0.132221 + "cos": 0.132173 }, { "preset_id": 205, - "cos": 0.126089 + "cos": 0.100105 }, { "preset_id": 206, - "cos": 0.160856 + "cos": 0.161828 }, { "preset_id": 207, - "cos": 0.171796 + "cos": 0.171726 }, { "preset_id": 208, - "cos": 0.222938 + "cos": 0.236415 }, { "preset_id": 301, - "cos": 0.217528 + "cos": 0.214021 }, { "preset_id": 302, - "cos": 0.234384 + "cos": 0.224138 }, { "preset_id": 303, - "cos": 0.201346 + "cos": 0.199307 }, { "preset_id": 304, - "cos": 0.209402 + "cos": 0.213253 }, { "preset_id": 305, - "cos": 0.25359 + "cos": 0.272789 }, { "preset_id": 306, - "cos": 0.145416 + "cos": 0.148515 }, { "preset_id": 307, - "cos": 0.166595 + "cos": 0.19505 }, { "preset_id": 401, - "cos": 0.173671 + "cos": 0.176976 }, { "preset_id": 402, - "cos": 0.196999 + "cos": 0.196994 }, { "preset_id": 403, - "cos": 0.262909 + "cos": 0.287034 }, { "preset_id": 404, - "cos": 0.208235 + "cos": 0.203463 }, { "preset_id": 405, - "cos": 0.207234 + "cos": 0.164878 }, { "preset_id": 406, - "cos": 0.239088 + "cos": 0.226115 } ] }, @@ -4109,111 +4109,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.152437 + "cos": 0.150966 }, { "preset_id": 102, - "cos": 0.147102 + "cos": 0.150284 }, { "preset_id": 103, - "cos": 0.086094 + "cos": 0.073711 }, { "preset_id": 104, - "cos": 0.113115 + "cos": 0.121127 }, { "preset_id": 105, - "cos": 0.101504 + "cos": 0.089747 }, { "preset_id": 106, - "cos": 0.059653 + "cos": 0.058238 }, { "preset_id": 201, - "cos": 0.156578 + "cos": 0.12098 }, { "preset_id": 202, - "cos": 0.148016 + "cos": 0.148021 }, { "preset_id": 203, - "cos": 0.160862 + "cos": 0.170965 }, { "preset_id": 204, - "cos": 0.188343 + "cos": 0.188369 }, { "preset_id": 205, - "cos": 0.144965 + "cos": 0.121717 }, { "preset_id": 206, - "cos": 0.105338 + "cos": 0.099023 }, { "preset_id": 207, - "cos": 0.155651 + "cos": 0.155596 }, { "preset_id": 208, - "cos": 0.157615 + "cos": 0.161129 }, { "preset_id": 301, - "cos": 0.1172 + "cos": 0.120314 }, { "preset_id": 302, - "cos": 0.302039 + "cos": 0.318205 }, { "preset_id": 303, - "cos": 0.17034 + "cos": 0.183834 }, { "preset_id": 304, - "cos": 0.101222 + "cos": 0.114784 }, { "preset_id": 305, - "cos": 0.114864 + "cos": 0.162457 }, { "preset_id": 306, - "cos": 0.10531 + "cos": 0.03767 }, { "preset_id": 307, - "cos": 0.166254 + "cos": 0.172087 }, { "preset_id": 401, - "cos": 0.105912 + "cos": 0.113843 }, { "preset_id": 402, - "cos": 0.114073 + "cos": 0.114057 }, { "preset_id": 403, - "cos": 0.082172 + "cos": 0.086389 }, { "preset_id": 404, - "cos": 0.056609 + "cos": 0.057276 }, { "preset_id": 405, - "cos": 0.095647 + "cos": 0.05906 }, { "preset_id": 406, - "cos": 0.087209 + "cos": 0.094239 } ] }, @@ -4222,111 +4222,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.154689 + "cos": 0.15234 }, { "preset_id": 102, - "cos": 0.218253 + "cos": 0.218722 }, { "preset_id": 103, - "cos": 0.162944 + "cos": 0.181387 }, { "preset_id": 104, - "cos": 0.184218 + "cos": 0.191288 }, { "preset_id": 105, - "cos": 0.162635 + "cos": 0.173671 }, { "preset_id": 106, - "cos": 0.184909 + "cos": 0.183845 }, { "preset_id": 201, - "cos": 0.153295 + "cos": 0.161436 }, { "preset_id": 202, - "cos": 0.18878 + "cos": 0.188854 }, { "preset_id": 203, - "cos": 0.168266 + "cos": 0.136571 }, { "preset_id": 204, - "cos": 0.205166 + "cos": 0.205147 }, { "preset_id": 205, - "cos": 0.257017 + "cos": 0.222633 }, { "preset_id": 206, - "cos": 0.308092 + "cos": 0.308272 }, { "preset_id": 207, - "cos": 0.297909 + "cos": 0.297789 }, { "preset_id": 208, - "cos": 0.316394 + "cos": 0.333617 }, { "preset_id": 301, - "cos": 0.123036 + "cos": 0.150948 }, { "preset_id": 302, - "cos": 0.161592 + "cos": 0.152319 }, { "preset_id": 303, - "cos": 0.193799 + "cos": 0.197123 }, { "preset_id": 304, - "cos": 0.203471 + "cos": 0.204302 }, { "preset_id": 305, - "cos": 0.14885 + "cos": 0.186973 }, { "preset_id": 306, - "cos": 0.200783 + "cos": 0.228553 }, { "preset_id": 307, - "cos": 0.241845 + "cos": 0.217722 }, { "preset_id": 401, - "cos": 0.100139 + "cos": 0.108279 }, { "preset_id": 402, - "cos": 0.197429 + "cos": 0.197349 }, { "preset_id": 403, - "cos": 0.217419 + "cos": 0.218023 }, { "preset_id": 404, - "cos": 0.122619 + "cos": 0.125778 }, { "preset_id": 405, - "cos": 0.258729 + "cos": 0.205034 }, { "preset_id": 406, - "cos": 0.222163 + "cos": 0.216936 } ] }, @@ -4335,111 +4335,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.233098 + "cos": 0.231564 }, { "preset_id": 102, - "cos": 0.183959 + "cos": 0.200682 }, { "preset_id": 103, - "cos": 0.218371 + "cos": 0.216022 }, { "preset_id": 104, - "cos": 0.200165 + "cos": 0.223015 }, { "preset_id": 105, - "cos": 0.237265 + "cos": 0.245267 }, { "preset_id": 106, - "cos": 0.246515 + "cos": 0.24389 }, { "preset_id": 201, - "cos": 0.16673 + "cos": 0.186091 }, { "preset_id": 202, - "cos": 0.200072 + "cos": 0.199955 }, { "preset_id": 203, - "cos": 0.239211 + "cos": 0.252454 }, { "preset_id": 204, - "cos": 0.145341 + "cos": 0.145238 }, { "preset_id": 205, - "cos": 0.29465 + "cos": 0.275559 }, { "preset_id": 206, - "cos": 0.21668 + "cos": 0.213092 }, { "preset_id": 207, - "cos": 0.217554 + "cos": 0.217475 }, { "preset_id": 208, - "cos": 0.289375 + "cos": 0.308325 }, { "preset_id": 301, - "cos": 0.190917 + "cos": 0.197788 }, { "preset_id": 302, - "cos": 0.165161 + "cos": 0.179142 }, { "preset_id": 303, - "cos": 0.158936 + "cos": 0.165783 }, { "preset_id": 304, - "cos": 0.267281 + "cos": 0.27472 }, { "preset_id": 305, - "cos": 0.215 + "cos": 0.230216 }, { "preset_id": 306, - "cos": 0.199728 + "cos": 0.251237 }, { "preset_id": 307, - "cos": 0.217677 + "cos": 0.259332 }, { "preset_id": 401, - "cos": 0.154109 + "cos": 0.169558 }, { "preset_id": 402, - "cos": 0.23852 + "cos": 0.238476 }, { "preset_id": 403, - "cos": 0.193764 + "cos": 0.218701 }, { "preset_id": 404, - "cos": 0.22777 + "cos": 0.233583 }, { "preset_id": 405, - "cos": 0.27302 + "cos": 0.223982 }, { "preset_id": 406, - "cos": 0.260147 + "cos": 0.252098 } ] }, @@ -4448,111 +4448,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.256146 + "cos": 0.259213 }, { "preset_id": 102, - "cos": 0.256056 + "cos": 0.272733 }, { "preset_id": 103, - "cos": 0.232879 + "cos": 0.222717 }, { "preset_id": 104, - "cos": 0.23805 + "cos": 0.248539 }, { "preset_id": 105, - "cos": 0.173095 + "cos": 0.167153 }, { "preset_id": 106, - "cos": 0.221432 + "cos": 0.219115 }, { "preset_id": 201, - "cos": 0.232948 + "cos": 0.252168 }, { "preset_id": 202, - "cos": 0.241824 + "cos": 0.241874 }, { "preset_id": 203, - "cos": 0.178863 + "cos": 0.217202 }, { "preset_id": 204, - "cos": 0.141555 + "cos": 0.141511 }, { "preset_id": 205, - "cos": 0.183337 + "cos": 0.199495 }, { "preset_id": 206, - "cos": 0.195478 + "cos": 0.180125 }, { "preset_id": 207, - "cos": 0.186482 + "cos": 0.186416 }, { "preset_id": 208, - "cos": 0.21117 + "cos": 0.225997 }, { "preset_id": 301, - "cos": 0.294357 + "cos": 0.268309 }, { "preset_id": 302, - "cos": 0.192645 + "cos": 0.196408 }, { "preset_id": 303, - "cos": 0.192031 + "cos": 0.194283 }, { "preset_id": 304, - "cos": 0.22792 + "cos": 0.223622 }, { "preset_id": 305, - "cos": 0.222208 + "cos": 0.21041 }, { "preset_id": 306, - "cos": 0.179762 + "cos": 0.183536 }, { "preset_id": 307, - "cos": 0.149073 + "cos": 0.167644 }, { "preset_id": 401, - "cos": 0.256472 + "cos": 0.257044 }, { "preset_id": 402, - "cos": 0.208627 + "cos": 0.208573 }, { "preset_id": 403, - "cos": 0.244061 + "cos": 0.298561 }, { "preset_id": 404, - "cos": 0.147572 + "cos": 0.149465 }, { "preset_id": 405, - "cos": 0.201739 + "cos": 0.203546 }, { "preset_id": 406, - "cos": 0.292683 + "cos": 0.290398 } ] }, @@ -4561,111 +4561,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.20097 + "cos": 0.202057 }, { "preset_id": 102, - "cos": 0.159502 + "cos": 0.1711 }, { "preset_id": 103, - "cos": 0.166441 + "cos": 0.154692 }, { "preset_id": 104, - "cos": 0.148031 + "cos": 0.160777 }, { "preset_id": 105, - "cos": 0.219612 + "cos": 0.214366 }, { "preset_id": 106, - "cos": 0.214012 + "cos": 0.212557 }, { "preset_id": 201, - "cos": 0.199363 + "cos": 0.156868 }, { "preset_id": 202, - "cos": 0.145385 + "cos": 0.145383 }, { "preset_id": 203, - "cos": 0.183681 + "cos": 0.165601 }, { "preset_id": 204, - "cos": 0.149459 + "cos": 0.149414 }, { "preset_id": 205, - "cos": 0.191146 + "cos": 0.197563 }, { "preset_id": 206, - "cos": 0.198875 + "cos": 0.184762 }, { "preset_id": 207, - "cos": 0.171418 + "cos": 0.171385 }, { "preset_id": 208, - "cos": 0.17783 + "cos": 0.175248 }, { "preset_id": 301, - "cos": 0.164992 + "cos": 0.153434 }, { "preset_id": 302, - "cos": 0.136853 + "cos": 0.139436 }, { "preset_id": 303, - "cos": 0.21307 + "cos": 0.204211 }, { "preset_id": 304, - "cos": 0.237569 + "cos": 0.223499 }, { "preset_id": 305, - "cos": 0.196751 + "cos": 0.229401 }, { "preset_id": 306, - "cos": 0.303894 + "cos": 0.273616 }, { "preset_id": 307, - "cos": 0.219032 + "cos": 0.243392 }, { "preset_id": 401, - "cos": 0.173453 + "cos": 0.173619 }, { "preset_id": 402, - "cos": 0.220806 + "cos": 0.2208 }, { "preset_id": 403, - "cos": 0.194623 + "cos": 0.190753 }, { "preset_id": 404, - "cos": 0.175337 + "cos": 0.179892 }, { "preset_id": 405, - "cos": 0.189272 + "cos": 0.18348 }, { "preset_id": 406, - "cos": 0.204089 + "cos": 0.189268 } ] }, @@ -4674,111 +4674,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.198288 + "cos": 0.203146 }, { "preset_id": 102, - "cos": 0.186361 + "cos": 0.201831 }, { "preset_id": 103, - "cos": 0.159878 + "cos": 0.185396 }, { "preset_id": 104, - "cos": 0.181181 + "cos": 0.193698 }, { "preset_id": 105, - "cos": 0.15867 + "cos": 0.16323 }, { "preset_id": 106, - "cos": 0.177677 + "cos": 0.1727 }, { "preset_id": 201, - "cos": 0.176865 + "cos": 0.198975 }, { "preset_id": 202, - "cos": 0.1134 + "cos": 0.113374 }, { "preset_id": 203, - "cos": 0.182072 + "cos": 0.145776 }, { "preset_id": 204, - "cos": 0.160464 + "cos": 0.160406 }, { "preset_id": 205, - "cos": 0.212712 + "cos": 0.185888 }, { "preset_id": 206, - "cos": 0.166918 + "cos": 0.17102 }, { "preset_id": 207, - "cos": 0.219219 + "cos": 0.219167 }, { "preset_id": 208, - "cos": 0.22595 + "cos": 0.223508 }, { "preset_id": 301, - "cos": 0.179266 + "cos": 0.171639 }, { "preset_id": 302, - "cos": 0.150892 + "cos": 0.141585 }, { "preset_id": 303, - "cos": 0.172922 + "cos": 0.169541 }, { "preset_id": 304, - "cos": 0.248755 + "cos": 0.255441 }, { "preset_id": 305, - "cos": 0.227041 + "cos": 0.23341 }, { "preset_id": 306, - "cos": 0.123815 + "cos": 0.152033 }, { "preset_id": 307, - "cos": 0.150401 + "cos": 0.166212 }, { "preset_id": 401, - "cos": 0.136773 + "cos": 0.146469 }, { "preset_id": 402, - "cos": 0.140636 + "cos": 0.140586 }, { "preset_id": 403, - "cos": 0.194457 + "cos": 0.247816 }, { "preset_id": 404, - "cos": 0.15515 + "cos": 0.170354 }, { "preset_id": 405, - "cos": 0.19475 + "cos": 0.191276 }, { "preset_id": 406, - "cos": 0.212101 + "cos": 0.217695 } ] }, @@ -4787,111 +4787,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.290658 + "cos": 0.299411 }, { "preset_id": 102, - "cos": 0.212813 + "cos": 0.219268 }, { "preset_id": 103, - "cos": 0.219215 + "cos": 0.220012 }, { "preset_id": 104, - "cos": 0.219247 + "cos": 0.234417 }, { "preset_id": 105, - "cos": 0.179164 + "cos": 0.17137 }, { "preset_id": 106, - "cos": 0.272254 + "cos": 0.269154 }, { "preset_id": 201, - "cos": 0.236968 + "cos": 0.211633 }, { "preset_id": 202, - "cos": 0.163431 + "cos": 0.163433 }, { "preset_id": 203, - "cos": 0.248123 + "cos": 0.232294 }, { "preset_id": 204, - "cos": 0.185439 + "cos": 0.185399 }, { "preset_id": 205, - "cos": 0.260899 + "cos": 0.248464 }, { "preset_id": 206, - "cos": 0.258692 + "cos": 0.249305 }, { "preset_id": 207, - "cos": 0.262592 + "cos": 0.262573 }, { "preset_id": 208, - "cos": 0.180585 + "cos": 0.211264 }, { "preset_id": 301, - "cos": 0.183066 + "cos": 0.209985 }, { "preset_id": 302, - "cos": 0.191453 + "cos": 0.211144 }, { "preset_id": 303, - "cos": 0.162851 + "cos": 0.189171 }, { "preset_id": 304, - "cos": 0.214666 + "cos": 0.239268 }, { "preset_id": 305, - "cos": 0.253348 + "cos": 0.283789 }, { "preset_id": 306, - "cos": 0.184102 + "cos": 0.164723 }, { "preset_id": 307, - "cos": 0.196347 + "cos": 0.188929 }, { "preset_id": 401, - "cos": 0.145296 + "cos": 0.154018 }, { "preset_id": 402, - "cos": 0.130638 + "cos": 0.130562 }, { "preset_id": 403, - "cos": 0.183039 + "cos": 0.23015 }, { "preset_id": 404, - "cos": 0.13858 + "cos": 0.147864 }, { "preset_id": 405, - "cos": 0.282626 + "cos": 0.225084 }, { "preset_id": 406, - "cos": 0.279851 + "cos": 0.279706 } ] }, @@ -4900,111 +4900,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.245018 + "cos": 0.239567 }, { "preset_id": 102, - "cos": 0.245572 + "cos": 0.261949 }, { "preset_id": 103, - "cos": 0.266369 + "cos": 0.179072 }, { "preset_id": 104, - "cos": 0.265594 + "cos": 0.274467 }, { "preset_id": 105, - "cos": 0.15946 + "cos": 0.172434 }, { "preset_id": 106, - "cos": 0.287192 + "cos": 0.286411 }, { "preset_id": 201, - "cos": 0.20631 + "cos": 0.186577 }, { "preset_id": 202, - "cos": 0.344228 + "cos": 0.344251 }, { "preset_id": 203, - "cos": 0.237071 + "cos": 0.319307 }, { "preset_id": 204, - "cos": 0.26944 + "cos": 0.269374 }, { "preset_id": 205, - "cos": 0.261016 + "cos": 0.243117 }, { "preset_id": 206, - "cos": 0.279005 + "cos": 0.206379 }, { "preset_id": 207, - "cos": 0.182225 + "cos": 0.182194 }, { "preset_id": 208, - "cos": 0.249228 + "cos": 0.273174 }, { "preset_id": 301, - "cos": 0.17885 + "cos": 0.196823 }, { "preset_id": 302, - "cos": 0.161274 + "cos": 0.162595 }, { "preset_id": 303, - "cos": 0.178865 + "cos": 0.173046 }, { "preset_id": 304, - "cos": 0.22764 + "cos": 0.229586 }, { "preset_id": 305, - "cos": 0.193414 + "cos": 0.227888 }, { "preset_id": 306, - "cos": 0.265804 + "cos": 0.211132 }, { "preset_id": 307, - "cos": 0.153488 + "cos": 0.214067 }, { "preset_id": 401, - "cos": 0.225219 + "cos": 0.222659 }, { "preset_id": 402, - "cos": 0.278633 + "cos": 0.278636 }, { "preset_id": 403, - "cos": 0.267615 + "cos": 0.305197 }, { "preset_id": 404, - "cos": 0.197435 + "cos": 0.192756 }, { "preset_id": 405, - "cos": 0.299428 + "cos": 0.332762 }, { "preset_id": 406, - "cos": 0.239475 + "cos": 0.226915 } ] }, @@ -5013,111 +5013,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.137606 + "cos": 0.145015 }, { "preset_id": 102, - "cos": 0.161147 + "cos": 0.172527 }, { "preset_id": 103, - "cos": 0.137104 + "cos": 0.127601 }, { "preset_id": 104, - "cos": 0.170837 + "cos": 0.199067 }, { "preset_id": 105, - "cos": 0.112791 + "cos": 0.112632 }, { "preset_id": 106, - "cos": 0.142228 + "cos": 0.14011 }, { "preset_id": 201, - "cos": 0.162522 + "cos": 0.14497 }, { "preset_id": 202, - "cos": 0.139133 + "cos": 0.139188 }, { "preset_id": 203, - "cos": 0.166835 + "cos": 0.140857 }, { "preset_id": 204, - "cos": 0.168189 + "cos": 0.168202 }, { "preset_id": 205, - "cos": 0.183777 + "cos": 0.182468 }, { "preset_id": 206, - "cos": 0.186073 + "cos": 0.201068 }, { "preset_id": 207, - "cos": 0.23915 + "cos": 0.239137 }, { "preset_id": 208, - "cos": 0.289664 + "cos": 0.278453 }, { "preset_id": 301, - "cos": 0.121861 + "cos": 0.110191 }, { "preset_id": 302, - "cos": 0.142784 + "cos": 0.133471 }, { "preset_id": 303, - "cos": 0.192864 + "cos": 0.190706 }, { "preset_id": 304, - "cos": 0.14271 + "cos": 0.151801 }, { "preset_id": 305, - "cos": 0.186031 + "cos": 0.214755 }, { "preset_id": 306, - "cos": 0.249481 + "cos": 0.223083 }, { "preset_id": 307, - "cos": 0.125644 + "cos": 0.166558 }, { "preset_id": 401, - "cos": 0.19668 + "cos": 0.173935 }, { "preset_id": 402, - "cos": 0.226638 + "cos": 0.226595 }, { "preset_id": 403, - "cos": 0.167398 + "cos": 0.221025 }, { "preset_id": 404, - "cos": 0.134799 + "cos": 0.119967 }, { "preset_id": 405, - "cos": 0.1686 + "cos": 0.172157 }, { "preset_id": 406, - "cos": 0.197105 + "cos": 0.17195 } ] }, @@ -5126,111 +5126,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.250743 + "cos": 0.252045 }, { "preset_id": 102, - "cos": 0.237355 + "cos": 0.250491 }, { "preset_id": 103, - "cos": 0.30188 + "cos": 0.204669 }, { "preset_id": 104, - "cos": 0.200596 + "cos": 0.226119 }, { "preset_id": 105, - "cos": 0.147936 + "cos": 0.150703 }, { "preset_id": 106, - "cos": 0.180212 + "cos": 0.177461 }, { "preset_id": 201, - "cos": 0.304327 + "cos": 0.227644 }, { "preset_id": 202, - "cos": 0.345571 + "cos": 0.345507 }, { "preset_id": 203, - "cos": 0.2898 + "cos": 0.262699 }, { "preset_id": 204, - "cos": 0.338038 + "cos": 0.337885 }, { "preset_id": 205, - "cos": 0.205649 + "cos": 0.201959 }, { "preset_id": 206, - "cos": 0.281756 + "cos": 0.21132 }, { "preset_id": 207, - "cos": 0.198334 + "cos": 0.198288 }, { "preset_id": 208, - "cos": 0.195913 + "cos": 0.202038 }, { "preset_id": 301, - "cos": 0.230178 + "cos": 0.23038 }, { "preset_id": 302, - "cos": 0.233419 + "cos": 0.226599 }, { "preset_id": 303, - "cos": 0.244916 + "cos": 0.240088 }, { "preset_id": 304, - "cos": 0.192576 + "cos": 0.204366 }, { "preset_id": 305, - "cos": 0.218957 + "cos": 0.189912 }, { "preset_id": 306, - "cos": 0.440227 + "cos": 0.236053 }, { "preset_id": 307, - "cos": 0.172154 + "cos": 0.177799 }, { "preset_id": 401, - "cos": 0.283375 + "cos": 0.280836 }, { "preset_id": 402, - "cos": 0.2704 + "cos": 0.270321 }, { "preset_id": 403, - "cos": 0.23343 + "cos": 0.246125 }, { "preset_id": 404, - "cos": 0.182015 + "cos": 0.184546 }, { "preset_id": 405, - "cos": 0.251358 + "cos": 0.27825 }, { "preset_id": 406, - "cos": 0.207471 + "cos": 0.181326 } ] }, @@ -5239,111 +5239,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.266803 + "cos": 0.27046 }, { "preset_id": 102, - "cos": 0.211517 + "cos": 0.205047 }, { "preset_id": 103, - "cos": 0.279727 + "cos": 0.283179 }, { "preset_id": 104, - "cos": 0.215495 + "cos": 0.228948 }, { "preset_id": 105, - "cos": 0.277728 + "cos": 0.267639 }, { "preset_id": 106, - "cos": 0.201115 + "cos": 0.199965 }, { "preset_id": 201, - "cos": 0.269975 + "cos": 0.231582 }, { "preset_id": 202, - "cos": 0.244641 + "cos": 0.244789 }, { "preset_id": 203, - "cos": 0.257197 + "cos": 0.267298 }, { "preset_id": 204, - "cos": 0.2583 + "cos": 0.258274 }, { "preset_id": 205, - "cos": 0.237372 + "cos": 0.211043 }, { "preset_id": 206, - "cos": 0.235965 + "cos": 0.24099 }, { "preset_id": 207, - "cos": 0.229806 + "cos": 0.229734 }, { "preset_id": 208, - "cos": 0.222429 + "cos": 0.230347 }, { "preset_id": 301, - "cos": 0.178643 + "cos": 0.176838 }, { "preset_id": 302, - "cos": 0.193369 + "cos": 0.197886 }, { "preset_id": 303, - "cos": 0.220138 + "cos": 0.212829 }, { "preset_id": 304, - "cos": 0.202706 + "cos": 0.214274 }, { "preset_id": 305, - "cos": 0.245863 + "cos": 0.256892 }, { "preset_id": 306, - "cos": 0.174581 + "cos": 0.13491 }, { "preset_id": 307, - "cos": 0.189266 + "cos": 0.209492 }, { "preset_id": 401, - "cos": 0.183269 + "cos": 0.19217 }, { "preset_id": 402, - "cos": 0.176907 + "cos": 0.176885 }, { "preset_id": 403, - "cos": 0.256754 + "cos": 0.290731 }, { "preset_id": 404, - "cos": 0.20072 + "cos": 0.210317 }, { "preset_id": 405, - "cos": 0.279638 + "cos": 0.251731 }, { "preset_id": 406, - "cos": 0.198846 + "cos": 0.227804 } ] }, @@ -5352,111 +5352,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.206282 + "cos": 0.21149 }, { "preset_id": 102, - "cos": 0.204562 + "cos": 0.21703 }, { "preset_id": 103, - "cos": 0.227772 + "cos": 0.204003 }, { "preset_id": 104, - "cos": 0.21958 + "cos": 0.235037 }, { "preset_id": 105, - "cos": 0.15034 + "cos": 0.15482 }, { "preset_id": 106, - "cos": 0.286593 + "cos": 0.283873 }, { "preset_id": 201, - "cos": 0.208329 + "cos": 0.189617 }, { "preset_id": 202, - "cos": 0.244772 + "cos": 0.244845 }, { "preset_id": 203, - "cos": 0.180308 + "cos": 0.224445 }, { "preset_id": 204, - "cos": 0.223264 + "cos": 0.223189 }, { "preset_id": 205, - "cos": 0.226284 + "cos": 0.228298 }, { "preset_id": 206, - "cos": 0.240962 + "cos": 0.224279 }, { "preset_id": 207, - "cos": 0.177267 + "cos": 0.177242 }, { "preset_id": 208, - "cos": 0.162405 + "cos": 0.162775 }, { "preset_id": 301, - "cos": 0.195328 + "cos": 0.196267 }, { "preset_id": 302, - "cos": 0.237567 + "cos": 0.226767 }, { "preset_id": 303, - "cos": 0.211694 + "cos": 0.204832 }, { "preset_id": 304, - "cos": 0.227969 + "cos": 0.224735 }, { "preset_id": 305, - "cos": 0.254113 + "cos": 0.190916 }, { "preset_id": 306, - "cos": 0.239078 + "cos": 0.174863 }, { "preset_id": 307, - "cos": 0.230887 + "cos": 0.219057 }, { "preset_id": 401, - "cos": 0.202051 + "cos": 0.198132 }, { "preset_id": 402, - "cos": 0.230214 + "cos": 0.230215 }, { "preset_id": 403, - "cos": 0.230339 + "cos": 0.241606 }, { "preset_id": 404, - "cos": 0.181997 + "cos": 0.158886 }, { "preset_id": 405, - "cos": 0.217841 + "cos": 0.221491 }, { "preset_id": 406, - "cos": 0.308015 + "cos": 0.301729 } ] }, @@ -5465,111 +5465,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.171571 + "cos": 0.181694 }, { "preset_id": 102, - "cos": 0.179175 + "cos": 0.178331 }, { "preset_id": 103, - "cos": 0.160337 + "cos": 0.178539 }, { "preset_id": 104, - "cos": 0.193873 + "cos": 0.215472 }, { "preset_id": 105, - "cos": 0.125301 + "cos": 0.133656 }, { "preset_id": 106, - "cos": 0.159216 + "cos": 0.158557 }, { "preset_id": 201, - "cos": 0.318617 + "cos": 0.29145 }, { "preset_id": 202, - "cos": 0.173089 + "cos": 0.173045 }, { "preset_id": 203, - "cos": 0.219712 + "cos": 0.141058 }, { "preset_id": 204, - "cos": 0.21654 + "cos": 0.216467 }, { "preset_id": 205, - "cos": 0.267792 + "cos": 0.263225 }, { "preset_id": 206, - "cos": 0.193805 + "cos": 0.1672 }, { "preset_id": 207, - "cos": 0.206384 + "cos": 0.206297 }, { "preset_id": 208, - "cos": 0.206853 + "cos": 0.194124 }, { "preset_id": 301, - "cos": 0.192588 + "cos": 0.179964 }, { "preset_id": 302, - "cos": 0.169044 + "cos": 0.161106 }, { "preset_id": 303, - "cos": 0.225824 + "cos": 0.25237 }, { "preset_id": 304, - "cos": 0.123689 + "cos": 0.126251 }, { "preset_id": 305, - "cos": 0.254367 + "cos": 0.234201 }, { "preset_id": 306, - "cos": 0.236778 + "cos": 0.195267 }, { "preset_id": 307, - "cos": 0.181993 + "cos": 0.171532 }, { "preset_id": 401, - "cos": 0.222742 + "cos": 0.213428 }, { "preset_id": 402, - "cos": 0.200514 + "cos": 0.20057 }, { "preset_id": 403, - "cos": 0.153091 + "cos": 0.175142 }, { "preset_id": 404, - "cos": 0.110393 + "cos": 0.112227 }, { "preset_id": 405, - "cos": 0.234658 + "cos": 0.228663 }, { "preset_id": 406, - "cos": 0.191694 + "cos": 0.196064 } ] }, @@ -5578,111 +5578,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.366643 + "cos": 0.366554 }, { "preset_id": 102, - "cos": 0.182111 + "cos": 0.180414 }, { "preset_id": 103, - "cos": 0.287195 + "cos": 0.297896 }, { "preset_id": 104, - "cos": 0.292028 + "cos": 0.306684 }, { "preset_id": 105, - "cos": 0.233994 + "cos": 0.220118 }, { "preset_id": 106, - "cos": 0.270581 + "cos": 0.269859 }, { "preset_id": 201, - "cos": 0.292746 + "cos": 0.284085 }, { "preset_id": 202, - "cos": 0.250885 + "cos": 0.250913 }, { "preset_id": 203, - "cos": 0.297836 + "cos": 0.31439 }, { "preset_id": 204, - "cos": 0.23314 + "cos": 0.233051 }, { "preset_id": 205, - "cos": 0.224217 + "cos": 0.195046 }, { "preset_id": 206, - "cos": 0.259309 + "cos": 0.252436 }, { "preset_id": 207, - "cos": 0.267971 + "cos": 0.267958 }, { "preset_id": 208, - "cos": 0.259701 + "cos": 0.260708 }, { "preset_id": 301, - "cos": 0.251616 + "cos": 0.25994 }, { "preset_id": 302, - "cos": 0.217526 + "cos": 0.217412 }, { "preset_id": 303, - "cos": 0.199525 + "cos": 0.216322 }, { "preset_id": 304, - "cos": 0.221036 + "cos": 0.22575 }, { "preset_id": 305, - "cos": 0.273879 + "cos": 0.274411 }, { "preset_id": 306, - "cos": 0.20813 + "cos": 0.206651 }, { "preset_id": 307, - "cos": 0.158513 + "cos": 0.175921 }, { "preset_id": 401, - "cos": 0.212193 + "cos": 0.20351 }, { "preset_id": 402, - "cos": 0.177028 + "cos": 0.177021 }, { "preset_id": 403, - "cos": 0.377301 + "cos": 0.380936 }, { "preset_id": 404, - "cos": 0.165464 + "cos": 0.180152 }, { "preset_id": 405, - "cos": 0.265448 + "cos": 0.245771 }, { "preset_id": 406, - "cos": 0.279663 + "cos": 0.277904 } ] }, @@ -5691,111 +5691,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.230724 + "cos": 0.233343 }, { "preset_id": 102, - "cos": 0.144923 + "cos": 0.154717 }, { "preset_id": 103, - "cos": 0.212797 + "cos": 0.160642 }, { "preset_id": 104, - "cos": 0.221024 + "cos": 0.233766 }, { "preset_id": 105, - "cos": 0.181446 + "cos": 0.191444 }, { "preset_id": 106, - "cos": 0.165993 + "cos": 0.163895 }, { "preset_id": 201, - "cos": 0.228863 + "cos": 0.1619 }, { "preset_id": 202, - "cos": 0.244952 + "cos": 0.244905 }, { "preset_id": 203, - "cos": 0.342759 + "cos": 0.244027 }, { "preset_id": 204, - "cos": 0.286287 + "cos": 0.286242 }, { "preset_id": 205, - "cos": 0.185719 + "cos": 0.135372 }, { "preset_id": 206, - "cos": 0.140994 + "cos": 0.079705 }, { "preset_id": 207, - "cos": 0.13586 + "cos": 0.135832 }, { "preset_id": 208, - "cos": 0.040506 + "cos": 0.044715 }, { "preset_id": 301, - "cos": 0.128706 + "cos": 0.131537 }, { "preset_id": 302, - "cos": 0.154106 + "cos": 0.162909 }, { "preset_id": 303, - "cos": 0.149135 + "cos": 0.158057 }, { "preset_id": 304, - "cos": 0.198847 + "cos": 0.211936 }, { "preset_id": 305, - "cos": 0.200137 + "cos": 0.16533 }, { "preset_id": 306, - "cos": 0.146216 + "cos": 0.091754 }, { "preset_id": 307, - "cos": 0.178676 + "cos": 0.168542 }, { "preset_id": 401, - "cos": 0.145845 + "cos": 0.161367 }, { "preset_id": 402, - "cos": 0.119409 + "cos": 0.119367 }, { "preset_id": 403, - "cos": 0.181784 + "cos": 0.18191 }, { "preset_id": 404, - "cos": 0.092458 + "cos": 0.098762 }, { "preset_id": 405, - "cos": 0.223381 + "cos": 0.195258 }, { "preset_id": 406, - "cos": 0.159251 + "cos": 0.15521 } ] }, @@ -5804,111 +5804,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.177083 + "cos": 0.163976 }, { "preset_id": 102, - "cos": 0.180504 + "cos": 0.188144 }, { "preset_id": 103, - "cos": 0.14324 + "cos": 0.122715 }, { "preset_id": 104, - "cos": 0.222104 + "cos": 0.219065 }, { "preset_id": 105, - "cos": 0.196967 + "cos": 0.206823 }, { "preset_id": 106, - "cos": 0.177662 + "cos": 0.174333 }, { "preset_id": 201, - "cos": 0.235678 + "cos": 0.206679 }, { "preset_id": 202, - "cos": 0.194594 + "cos": 0.194548 }, { "preset_id": 203, - "cos": 0.243666 + "cos": 0.201188 }, { "preset_id": 204, - "cos": 0.198472 + "cos": 0.198403 }, { "preset_id": 205, - "cos": 0.183105 + "cos": 0.147418 }, { "preset_id": 206, - "cos": 0.242916 + "cos": 0.221556 }, { "preset_id": 207, - "cos": 0.159236 + "cos": 0.159218 }, { "preset_id": 208, - "cos": 0.259768 + "cos": 0.274338 }, { "preset_id": 301, - "cos": 0.192853 + "cos": 0.203925 }, { "preset_id": 302, - "cos": 0.161829 + "cos": 0.1778 }, { "preset_id": 303, - "cos": 0.224948 + "cos": 0.201112 }, { "preset_id": 304, - "cos": 0.220108 + "cos": 0.225207 }, { "preset_id": 305, - "cos": 0.205713 + "cos": 0.210726 }, { "preset_id": 306, - "cos": 0.219479 + "cos": 0.193484 }, { "preset_id": 307, - "cos": 0.20787 + "cos": 0.208306 }, { "preset_id": 401, - "cos": 0.145014 + "cos": 0.152002 }, { "preset_id": 402, - "cos": 0.195253 + "cos": 0.195196 }, { "preset_id": 403, - "cos": 0.203568 + "cos": 0.241067 }, { "preset_id": 404, - "cos": 0.162548 + "cos": 0.154888 }, { "preset_id": 405, - "cos": 0.277262 + "cos": 0.248714 }, { "preset_id": 406, - "cos": 0.191165 + "cos": 0.210684 } ] }, @@ -5917,111 +5917,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.17489 + "cos": 0.180674 }, { "preset_id": 102, - "cos": 0.219755 + "cos": 0.214085 }, { "preset_id": 103, - "cos": 0.122713 + "cos": 0.162786 }, { "preset_id": 104, - "cos": 0.217667 + "cos": 0.236779 }, { "preset_id": 105, - "cos": 0.1068 + "cos": 0.107151 }, { "preset_id": 106, - "cos": 0.13632 + "cos": 0.136932 }, { "preset_id": 201, - "cos": 0.232484 + "cos": 0.206422 }, { "preset_id": 202, - "cos": 0.145018 + "cos": 0.145074 }, { "preset_id": 203, - "cos": 0.21363 + "cos": 0.096444 }, { "preset_id": 204, - "cos": 0.233631 + "cos": 0.233695 }, { "preset_id": 205, - "cos": 0.238269 + "cos": 0.23408 }, { "preset_id": 206, - "cos": 0.218393 + "cos": 0.233232 }, { "preset_id": 207, - "cos": 0.16494 + "cos": 0.164896 }, { "preset_id": 208, - "cos": 0.158822 + "cos": 0.162887 }, { "preset_id": 301, - "cos": 0.093563 + "cos": 0.093521 }, { "preset_id": 302, - "cos": 0.132636 + "cos": 0.122401 }, { "preset_id": 303, - "cos": 0.12759 + "cos": 0.137627 }, { "preset_id": 304, - "cos": 0.093089 + "cos": 0.102008 }, { "preset_id": 305, - "cos": 0.182248 + "cos": 0.180055 }, { "preset_id": 306, - "cos": 0.102071 + "cos": 0.065085 }, { "preset_id": 307, - "cos": 0.185059 + "cos": 0.163032 }, { "preset_id": 401, - "cos": 0.171053 + "cos": 0.173595 }, { "preset_id": 402, - "cos": 0.167438 + "cos": 0.167422 }, { "preset_id": 403, - "cos": 0.147513 + "cos": 0.156171 }, { "preset_id": 404, - "cos": 0.064781 + "cos": 0.08774 }, { "preset_id": 405, - "cos": 0.153072 + "cos": 0.154549 }, { "preset_id": 406, - "cos": 0.202536 + "cos": 0.204925 } ] }, @@ -6030,111 +6030,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.125373 + "cos": 0.12335 }, { "preset_id": 102, - "cos": 0.131314 + "cos": 0.132658 }, { "preset_id": 103, - "cos": 0.122206 + "cos": 0.13564 }, { "preset_id": 104, - "cos": 0.096307 + "cos": 0.100751 }, { "preset_id": 105, - "cos": 0.139886 + "cos": 0.129485 }, { "preset_id": 106, - "cos": 0.179581 + "cos": 0.17912 }, { "preset_id": 201, - "cos": 0.162289 + "cos": 0.198898 }, { "preset_id": 202, - "cos": 0.149946 + "cos": 0.149912 }, { "preset_id": 203, - "cos": 0.184684 + "cos": 0.172884 }, { "preset_id": 204, - "cos": 0.221113 + "cos": 0.221092 }, { "preset_id": 205, - "cos": 0.187557 + "cos": 0.177291 }, { "preset_id": 206, - "cos": 0.166667 + "cos": 0.150777 }, { "preset_id": 207, - "cos": 0.181896 + "cos": 0.181837 }, { "preset_id": 208, - "cos": 0.184974 + "cos": 0.191993 }, { "preset_id": 301, - "cos": 0.12965 + "cos": 0.126963 }, { "preset_id": 302, - "cos": 0.080915 + "cos": 0.07538 }, { "preset_id": 303, - "cos": 0.161052 + "cos": 0.170296 }, { "preset_id": 304, - "cos": 0.165075 + "cos": 0.151542 }, { "preset_id": 305, - "cos": 0.113534 + "cos": 0.144035 }, { "preset_id": 306, - "cos": 0.160468 + "cos": 0.153081 }, { "preset_id": 307, - "cos": 0.125226 + "cos": 0.102597 }, { "preset_id": 401, - "cos": 0.143837 + "cos": 0.165262 }, { "preset_id": 402, - "cos": 0.16418 + "cos": 0.164178 }, { "preset_id": 403, - "cos": 0.171036 + "cos": 0.168916 }, { "preset_id": 404, - "cos": 0.094744 + "cos": 0.107874 }, { "preset_id": 405, - "cos": 0.163146 + "cos": 0.153386 }, { "preset_id": 406, - "cos": 0.169912 + "cos": 0.150165 } ] }, @@ -6143,111 +6143,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.151651 + "cos": 0.151567 }, { "preset_id": 102, - "cos": 0.135151 + "cos": 0.144217 }, { "preset_id": 103, - "cos": 0.179112 + "cos": 0.193393 }, { "preset_id": 104, - "cos": 0.16655 + "cos": 0.182082 }, { "preset_id": 105, - "cos": 0.124361 + "cos": 0.132845 }, { "preset_id": 106, - "cos": 0.239651 + "cos": 0.239514 }, { "preset_id": 201, - "cos": 0.234978 + "cos": 0.170669 }, { "preset_id": 202, - "cos": 0.251962 + "cos": 0.251955 }, { "preset_id": 203, - "cos": 0.255745 + "cos": 0.193075 }, { "preset_id": 204, - "cos": 0.255249 + "cos": 0.255209 }, { "preset_id": 205, - "cos": 0.266802 + "cos": 0.244181 }, { "preset_id": 206, - "cos": 0.301409 + "cos": 0.22284 }, { "preset_id": 207, - "cos": 0.197587 + "cos": 0.197529 }, { "preset_id": 208, - "cos": 0.202013 + "cos": 0.206264 }, { "preset_id": 301, - "cos": 0.236006 + "cos": 0.249443 }, { "preset_id": 302, - "cos": 0.151565 + "cos": 0.148712 }, { "preset_id": 303, - "cos": 0.202225 + "cos": 0.210411 }, { "preset_id": 304, - "cos": 0.272037 + "cos": 0.230937 }, { "preset_id": 305, - "cos": 0.212621 + "cos": 0.224245 }, { "preset_id": 306, - "cos": 0.282619 + "cos": 0.236628 }, { "preset_id": 307, - "cos": 0.254628 + "cos": 0.239765 }, { "preset_id": 401, - "cos": 0.140105 + "cos": 0.126817 }, { "preset_id": 402, - "cos": 0.185967 + "cos": 0.185947 }, { "preset_id": 403, - "cos": 0.177966 + "cos": 0.190909 }, { "preset_id": 404, - "cos": 0.102222 + "cos": 0.111214 }, { "preset_id": 405, - "cos": 0.231714 + "cos": 0.234875 }, { "preset_id": 406, - "cos": 0.23713 + "cos": 0.221493 } ] }, @@ -6256,111 +6256,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.187018 + "cos": 0.185998 }, { "preset_id": 102, - "cos": 0.178246 + "cos": 0.182218 }, { "preset_id": 103, - "cos": 0.21039 + "cos": 0.175151 }, { "preset_id": 104, - "cos": 0.135979 + "cos": 0.141303 }, { "preset_id": 105, - "cos": 0.132232 + "cos": 0.130715 }, { "preset_id": 106, - "cos": 0.176486 + "cos": 0.179731 }, { "preset_id": 201, - "cos": 0.181572 + "cos": 0.16376 }, { "preset_id": 202, - "cos": 0.148817 + "cos": 0.148897 }, { "preset_id": 203, - "cos": 0.152634 + "cos": 0.163523 }, { "preset_id": 204, - "cos": 0.204586 + "cos": 0.204593 }, { "preset_id": 205, - "cos": 0.273468 + "cos": 0.274114 }, { "preset_id": 206, - "cos": 0.186986 + "cos": 0.1527 }, { "preset_id": 207, - "cos": 0.185082 + "cos": 0.185 }, { "preset_id": 208, - "cos": 0.158871 + "cos": 0.171953 }, { "preset_id": 301, - "cos": 0.165253 + "cos": 0.170662 }, { "preset_id": 302, - "cos": 0.144127 + "cos": 0.142643 }, { "preset_id": 303, - "cos": 0.13244 + "cos": 0.145493 }, { "preset_id": 304, - "cos": 0.194809 + "cos": 0.17403 }, { "preset_id": 305, - "cos": 0.166549 + "cos": 0.201379 }, { "preset_id": 306, - "cos": 0.171295 + "cos": 0.117471 }, { "preset_id": 307, - "cos": 0.09682 + "cos": 0.141622 }, { "preset_id": 401, - "cos": 0.130304 + "cos": 0.129707 }, { "preset_id": 402, - "cos": 0.172428 + "cos": 0.172418 }, { "preset_id": 403, - "cos": 0.159884 + "cos": 0.186298 }, { "preset_id": 404, - "cos": 0.054925 + "cos": 0.071392 }, { "preset_id": 405, - "cos": 0.172002 + "cos": 0.171493 }, { "preset_id": 406, - "cos": 0.198496 + "cos": 0.198023 } ] }, @@ -6369,111 +6369,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.290835 + "cos": 0.295174 }, { "preset_id": 102, - "cos": 0.218921 + "cos": 0.222291 }, { "preset_id": 103, - "cos": 0.320631 + "cos": 0.325528 }, { "preset_id": 104, - "cos": 0.151653 + "cos": 0.15341 }, { "preset_id": 105, - "cos": 0.356451 + "cos": 0.363111 }, { "preset_id": 106, - "cos": 0.245945 + "cos": 0.243297 }, { "preset_id": 201, - "cos": 0.251733 + "cos": 0.229587 }, { "preset_id": 202, - "cos": 0.193428 + "cos": 0.193474 }, { "preset_id": 203, - "cos": 0.23119 + "cos": 0.183444 }, { "preset_id": 204, - "cos": 0.213473 + "cos": 0.213448 }, { "preset_id": 205, - "cos": 0.23001 + "cos": 0.211327 }, { "preset_id": 206, - "cos": 0.239592 + "cos": 0.213236 }, { "preset_id": 207, - "cos": 0.250438 + "cos": 0.250386 }, { "preset_id": 208, - "cos": 0.188607 + "cos": 0.183883 }, { "preset_id": 301, - "cos": 0.188807 + "cos": 0.185836 }, { "preset_id": 302, - "cos": 0.210896 + "cos": 0.217887 }, { "preset_id": 303, - "cos": 0.19916 + "cos": 0.190031 }, { "preset_id": 304, - "cos": 0.201254 + "cos": 0.213475 }, { "preset_id": 305, - "cos": 0.210522 + "cos": 0.21087 }, { "preset_id": 306, - "cos": 0.183807 + "cos": 0.122883 }, { "preset_id": 307, - "cos": 0.190217 + "cos": 0.225766 }, { "preset_id": 401, - "cos": 0.218107 + "cos": 0.209796 }, { "preset_id": 402, - "cos": 0.169952 + "cos": 0.16988 }, { "preset_id": 403, - "cos": 0.227738 + "cos": 0.213714 }, { "preset_id": 404, - "cos": 0.225503 + "cos": 0.213351 }, { "preset_id": 405, - "cos": 0.259492 + "cos": 0.251652 }, { "preset_id": 406, - "cos": 0.209442 + "cos": 0.218701 } ] }, @@ -6482,111 +6482,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.189681 + "cos": 0.187229 }, { "preset_id": 102, - "cos": 0.218525 + "cos": 0.224897 }, { "preset_id": 103, - "cos": 0.112167 + "cos": 0.120353 }, { "preset_id": 104, - "cos": 0.171735 + "cos": 0.177095 }, { "preset_id": 105, - "cos": 0.165599 + "cos": 0.169894 }, { "preset_id": 106, - "cos": 0.144396 + "cos": 0.144601 }, { "preset_id": 201, - "cos": 0.229263 + "cos": 0.236021 }, { "preset_id": 202, - "cos": 0.223051 + "cos": 0.223015 }, { "preset_id": 203, - "cos": 0.218986 + "cos": 0.233388 }, { "preset_id": 204, - "cos": 0.237287 + "cos": 0.237355 }, { "preset_id": 205, - "cos": 0.296582 + "cos": 0.276126 }, { "preset_id": 206, - "cos": 0.239744 + "cos": 0.227276 }, { "preset_id": 207, - "cos": 0.159611 + "cos": 0.159598 }, { "preset_id": 208, - "cos": 0.22332 + "cos": 0.225055 }, { "preset_id": 301, - "cos": 0.151577 + "cos": 0.157756 }, { "preset_id": 302, - "cos": 0.174757 + "cos": 0.178616 }, { "preset_id": 303, - "cos": 0.203239 + "cos": 0.191999 }, { "preset_id": 304, - "cos": 0.253966 + "cos": 0.252473 }, { "preset_id": 305, - "cos": 0.20443 + "cos": 0.22696 }, { "preset_id": 306, - "cos": 0.19472 + "cos": 0.186165 }, { "preset_id": 307, - "cos": 0.196359 + "cos": 0.204304 }, { "preset_id": 401, - "cos": 0.195946 + "cos": 0.204649 }, { "preset_id": 402, - "cos": 0.199266 + "cos": 0.199307 }, { "preset_id": 403, - "cos": 0.241437 + "cos": 0.241756 }, { "preset_id": 404, - "cos": 0.185944 + "cos": 0.178142 }, { "preset_id": 405, - "cos": 0.210227 + "cos": 0.219764 }, { "preset_id": 406, - "cos": 0.160669 + "cos": 0.164243 } ] }, @@ -6595,111 +6595,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.253216 + "cos": 0.257562 }, { "preset_id": 102, - "cos": 0.198418 + "cos": 0.196468 }, { "preset_id": 103, - "cos": 0.176433 + "cos": 0.225835 }, { "preset_id": 104, - "cos": 0.254799 + "cos": 0.283626 }, { "preset_id": 105, - "cos": 0.191557 + "cos": 0.190265 }, { "preset_id": 106, - "cos": 0.178612 + "cos": 0.176092 }, { "preset_id": 201, - "cos": 0.287117 + "cos": 0.298621 }, { "preset_id": 202, - "cos": 0.214458 + "cos": 0.214589 }, { "preset_id": 203, - "cos": 0.150637 + "cos": 0.108342 }, { "preset_id": 204, - "cos": 0.460136 + "cos": 0.460035 }, { "preset_id": 205, - "cos": 0.164267 + "cos": 0.144374 }, { "preset_id": 206, - "cos": 0.173678 + "cos": 0.182043 }, { "preset_id": 207, - "cos": 0.224401 + "cos": 0.224378 }, { "preset_id": 208, - "cos": 0.21586 + "cos": 0.20919 }, { "preset_id": 301, - "cos": 0.16525 + "cos": 0.15482 }, { "preset_id": 302, - "cos": 0.130415 + "cos": 0.115864 }, { "preset_id": 303, - "cos": 0.261832 + "cos": 0.257121 }, { "preset_id": 304, - "cos": 0.083548 + "cos": 0.098279 }, { "preset_id": 305, - "cos": 0.282809 + "cos": 0.244478 }, { "preset_id": 306, - "cos": 0.244442 + "cos": 0.201263 }, { "preset_id": 307, - "cos": 0.205556 + "cos": 0.134856 }, { "preset_id": 401, - "cos": 0.270631 + "cos": 0.265575 }, { "preset_id": 402, - "cos": 0.171515 + "cos": 0.171608 }, { "preset_id": 403, - "cos": 0.286087 + "cos": 0.309435 }, { "preset_id": 404, - "cos": 0.169961 + "cos": 0.176357 }, { "preset_id": 405, - "cos": 0.276535 + "cos": 0.333497 }, { "preset_id": 406, - "cos": 0.232843 + "cos": 0.231503 } ] }, @@ -6708,111 +6708,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.122891 + "cos": 0.119006 }, { "preset_id": 102, - "cos": 0.154414 + "cos": 0.15731 }, { "preset_id": 103, - "cos": 0.131772 + "cos": 0.121756 }, { "preset_id": 104, - "cos": 0.172957 + "cos": 0.182478 }, { "preset_id": 105, - "cos": 0.172064 + "cos": 0.17804 }, { "preset_id": 106, - "cos": 0.1884 + "cos": 0.188073 }, { "preset_id": 201, - "cos": 0.184561 + "cos": 0.200301 }, { "preset_id": 202, - "cos": 0.191222 + "cos": 0.191067 }, { "preset_id": 203, - "cos": 0.11586 + "cos": 0.115712 }, { "preset_id": 204, - "cos": 0.195908 + "cos": 0.195708 }, { "preset_id": 205, - "cos": 0.369481 + "cos": 0.351541 }, { "preset_id": 206, - "cos": 0.200472 + "cos": 0.182595 }, { "preset_id": 207, - "cos": 0.16188 + "cos": 0.161696 }, { "preset_id": 208, - "cos": 0.185149 + "cos": 0.171774 }, { "preset_id": 301, - "cos": 0.138104 + "cos": 0.120763 }, { "preset_id": 302, - "cos": 0.18486 + "cos": 0.180145 }, { "preset_id": 303, - "cos": 0.237042 + "cos": 0.236139 }, { "preset_id": 304, - "cos": 0.204121 + "cos": 0.202126 }, { "preset_id": 305, - "cos": 0.18526 + "cos": 0.162462 }, { "preset_id": 306, - "cos": 0.179819 + "cos": 0.158784 }, { "preset_id": 307, - "cos": 0.315723 + "cos": 0.292506 }, { "preset_id": 401, - "cos": 0.165621 + "cos": 0.171765 }, { "preset_id": 402, - "cos": 0.162915 + "cos": 0.162809 }, { "preset_id": 403, - "cos": 0.169175 + "cos": 0.183524 }, { "preset_id": 404, - "cos": 0.188932 + "cos": 0.178415 }, { "preset_id": 405, - "cos": 0.225321 + "cos": 0.223527 }, { "preset_id": 406, - "cos": 0.177633 + "cos": 0.168625 } ] }, @@ -6821,111 +6821,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.177428 + "cos": 0.17716 }, { "preset_id": 102, - "cos": 0.172111 + "cos": 0.174578 }, { "preset_id": 103, - "cos": 0.144244 + "cos": 0.182012 }, { "preset_id": 104, - "cos": 0.220919 + "cos": 0.234277 }, { "preset_id": 105, - "cos": 0.141695 + "cos": 0.134621 }, { "preset_id": 106, - "cos": 0.211015 + "cos": 0.208308 }, { "preset_id": 201, - "cos": 0.209462 + "cos": 0.143238 }, { "preset_id": 202, - "cos": 0.196356 + "cos": 0.1964 }, { "preset_id": 203, - "cos": 0.212813 + "cos": 0.111005 }, { "preset_id": 204, - "cos": 0.250391 + "cos": 0.25041 }, { "preset_id": 205, - "cos": 0.263935 + "cos": 0.286209 }, { "preset_id": 206, - "cos": 0.197496 + "cos": 0.156828 }, { "preset_id": 207, - "cos": 0.215876 + "cos": 0.215801 }, { "preset_id": 208, - "cos": 0.137658 + "cos": 0.122767 }, { "preset_id": 301, - "cos": 0.237993 + "cos": 0.23524 }, { "preset_id": 302, - "cos": 0.207883 + "cos": 0.207168 }, { "preset_id": 303, - "cos": 0.249477 + "cos": 0.251013 }, { "preset_id": 304, - "cos": 0.204396 + "cos": 0.169286 }, { "preset_id": 305, - "cos": 0.219156 + "cos": 0.220584 }, { "preset_id": 306, - "cos": 0.244378 + "cos": 0.232401 }, { "preset_id": 307, - "cos": 0.258433 + "cos": 0.173987 }, { "preset_id": 401, - "cos": 0.220784 + "cos": 0.212711 }, { "preset_id": 402, - "cos": 0.191428 + "cos": 0.191363 }, { "preset_id": 403, - "cos": 0.286771 + "cos": 0.299715 }, { "preset_id": 404, - "cos": 0.063274 + "cos": 0.07627 }, { "preset_id": 405, - "cos": 0.173312 + "cos": 0.20627 }, { "preset_id": 406, - "cos": 0.248856 + "cos": 0.217238 } ] }, @@ -6934,111 +6934,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.165614 + "cos": 0.15567 }, { "preset_id": 102, - "cos": 0.283021 + "cos": 0.290053 }, { "preset_id": 103, - "cos": 0.182748 + "cos": 0.191368 }, { "preset_id": 104, - "cos": 0.241028 + "cos": 0.245604 }, { "preset_id": 105, - "cos": 0.167474 + "cos": 0.177974 }, { "preset_id": 106, - "cos": 0.12369 + "cos": 0.119468 }, { "preset_id": 201, - "cos": 0.177581 + "cos": 0.183198 }, { "preset_id": 202, - "cos": 0.21897 + "cos": 0.218988 }, { "preset_id": 203, - "cos": 0.19488 + "cos": 0.223599 }, { "preset_id": 204, - "cos": 0.187891 + "cos": 0.187834 }, { "preset_id": 205, - "cos": 0.221828 + "cos": 0.199275 }, { "preset_id": 206, - "cos": 0.233336 + "cos": 0.228917 }, { "preset_id": 207, - "cos": 0.171101 + "cos": 0.171045 }, { "preset_id": 208, - "cos": 0.333702 + "cos": 0.33031 }, { "preset_id": 301, - "cos": 0.136907 + "cos": 0.125627 }, { "preset_id": 302, - "cos": 0.136089 + "cos": 0.116958 }, { "preset_id": 303, - "cos": 0.233349 + "cos": 0.246069 }, { "preset_id": 304, - "cos": 0.205558 + "cos": 0.211821 }, { "preset_id": 305, - "cos": 0.096578 + "cos": 0.090218 }, { "preset_id": 306, - "cos": 0.232118 + "cos": 0.257131 }, { "preset_id": 307, - "cos": 0.209435 + "cos": 0.2138 }, { "preset_id": 401, - "cos": 0.206115 + "cos": 0.214434 }, { "preset_id": 402, - "cos": 0.515742 + "cos": 0.515714 }, { "preset_id": 403, - "cos": 0.263655 + "cos": 0.281279 }, { "preset_id": 404, - "cos": 0.206433 + "cos": 0.209689 }, { "preset_id": 405, - "cos": 0.280306 + "cos": 0.237542 }, { "preset_id": 406, - "cos": 0.204111 + "cos": 0.190771 } ] }, @@ -7047,111 +7047,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.278862 + "cos": 0.280151 }, { "preset_id": 102, - "cos": 0.159199 + "cos": 0.158639 }, { "preset_id": 103, - "cos": 0.320158 + "cos": 0.358802 }, { "preset_id": 104, - "cos": 0.219516 + "cos": 0.239702 }, { "preset_id": 105, - "cos": 0.268307 + "cos": 0.272327 }, { "preset_id": 106, - "cos": 0.204333 + "cos": 0.202658 }, { "preset_id": 201, - "cos": 0.131942 + "cos": 0.142401 }, { "preset_id": 202, - "cos": 0.157415 + "cos": 0.157484 }, { "preset_id": 203, - "cos": 0.210593 + "cos": 0.222907 }, { "preset_id": 204, - "cos": 0.126364 + "cos": 0.126307 }, { "preset_id": 205, - "cos": 0.219135 + "cos": 0.198425 }, { "preset_id": 206, - "cos": 0.188061 + "cos": 0.187389 }, { "preset_id": 207, - "cos": 0.182809 + "cos": 0.182737 }, { "preset_id": 208, - "cos": 0.221958 + "cos": 0.250586 }, { "preset_id": 301, - "cos": 0.171428 + "cos": 0.173659 }, { "preset_id": 302, - "cos": 0.196099 + "cos": 0.19535 }, { "preset_id": 303, - "cos": 0.150507 + "cos": 0.149406 }, { "preset_id": 304, - "cos": 0.149793 + "cos": 0.161518 }, { "preset_id": 305, - "cos": 0.189009 + "cos": 0.184322 }, { "preset_id": 306, - "cos": 0.149866 + "cos": 0.149116 }, { "preset_id": 307, - "cos": 0.165771 + "cos": 0.21402 }, { "preset_id": 401, - "cos": 0.176701 + "cos": 0.17606 }, { "preset_id": 402, - "cos": 0.206245 + "cos": 0.20617 }, { "preset_id": 403, - "cos": 0.249236 + "cos": 0.24339 }, { "preset_id": 404, - "cos": 0.190756 + "cos": 0.190254 }, { "preset_id": 405, - "cos": 0.209187 + "cos": 0.162717 }, { "preset_id": 406, - "cos": 0.291548 + "cos": 0.28183 } ] }, @@ -7160,111 +7160,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.260531 + "cos": 0.256317 }, { "preset_id": 102, - "cos": 0.131674 + "cos": 0.134939 }, { "preset_id": 103, - "cos": 0.193217 + "cos": 0.193857 }, { "preset_id": 104, - "cos": 0.16726 + "cos": 0.175054 }, { "preset_id": 105, - "cos": 0.201566 + "cos": 0.211791 }, { "preset_id": 106, - "cos": 0.194965 + "cos": 0.195194 }, { "preset_id": 201, - "cos": 0.249791 + "cos": 0.208348 }, { "preset_id": 202, - "cos": 0.330081 + "cos": 0.33016 }, { "preset_id": 203, - "cos": 0.276169 + "cos": 0.250704 }, { "preset_id": 204, - "cos": 0.203846 + "cos": 0.203848 }, { "preset_id": 205, - "cos": 0.134705 + "cos": 0.122607 }, { "preset_id": 206, - "cos": 0.249854 + "cos": 0.207504 }, { "preset_id": 207, - "cos": 0.22595 + "cos": 0.226052 }, { "preset_id": 208, - "cos": 0.20972 + "cos": 0.2082 }, { "preset_id": 301, - "cos": 0.281206 + "cos": 0.288266 }, { "preset_id": 302, - "cos": 0.322623 + "cos": 0.317392 }, { "preset_id": 303, - "cos": 0.231302 + "cos": 0.240244 }, { "preset_id": 304, - "cos": 0.242837 + "cos": 0.265653 }, { "preset_id": 305, - "cos": 0.220439 + "cos": 0.221685 }, { "preset_id": 306, - "cos": 0.206323 + "cos": 0.171836 }, { "preset_id": 307, - "cos": 0.283763 + "cos": 0.298389 }, { "preset_id": 401, - "cos": 0.143057 + "cos": 0.152643 }, { "preset_id": 402, - "cos": 0.182055 + "cos": 0.182006 }, { "preset_id": 403, - "cos": 0.365881 + "cos": 0.334577 }, { "preset_id": 404, - "cos": 0.157 + "cos": 0.165731 }, { "preset_id": 405, - "cos": 0.219416 + "cos": 0.212303 }, { "preset_id": 406, - "cos": 0.166942 + "cos": 0.134824 } ] }, @@ -7273,111 +7273,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.213037 + "cos": 0.213044 }, { "preset_id": 102, - "cos": 0.172946 + "cos": 0.181088 }, { "preset_id": 103, - "cos": 0.212202 + "cos": 0.20596 }, { "preset_id": 104, - "cos": 0.162253 + "cos": 0.163116 }, { "preset_id": 105, - "cos": 0.187325 + "cos": 0.180439 }, { "preset_id": 106, - "cos": 0.187325 + "cos": 0.186558 }, { "preset_id": 201, - "cos": 0.186397 + "cos": 0.180011 }, { "preset_id": 202, - "cos": 0.131494 + "cos": 0.131558 }, { "preset_id": 203, - "cos": 0.181032 + "cos": 0.204699 }, { "preset_id": 204, - "cos": 0.133429 + "cos": 0.133422 }, { "preset_id": 205, - "cos": 0.238532 + "cos": 0.226944 }, { "preset_id": 206, - "cos": 0.189027 + "cos": 0.167021 }, { "preset_id": 207, - "cos": 0.164841 + "cos": 0.164767 }, { "preset_id": 208, - "cos": 0.21262 + "cos": 0.233633 }, { "preset_id": 301, - "cos": 0.148829 + "cos": 0.146091 }, { "preset_id": 302, - "cos": 0.155003 + "cos": 0.159933 }, { "preset_id": 303, - "cos": 0.149356 + "cos": 0.167396 }, { "preset_id": 304, - "cos": 0.120173 + "cos": 0.144911 }, { "preset_id": 305, - "cos": 0.154202 + "cos": 0.17712 }, { "preset_id": 306, - "cos": 0.139094 + "cos": 0.112922 }, { "preset_id": 307, - "cos": 0.172793 + "cos": 0.200003 }, { "preset_id": 401, - "cos": 0.147032 + "cos": 0.149658 }, { "preset_id": 402, - "cos": 0.220797 + "cos": 0.220784 }, { "preset_id": 403, - "cos": 0.185938 + "cos": 0.178509 }, { "preset_id": 404, - "cos": 0.158574 + "cos": 0.164879 }, { "preset_id": 405, - "cos": 0.241337 + "cos": 0.238825 }, { "preset_id": 406, - "cos": 0.212775 + "cos": 0.207636 } ] }, @@ -7386,111 +7386,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.171766 + "cos": 0.17378 }, { "preset_id": 102, - "cos": 0.165281 + "cos": 0.176436 }, { "preset_id": 103, - "cos": 0.194176 + "cos": 0.17782 }, { "preset_id": 104, - "cos": 0.206695 + "cos": 0.217943 }, { "preset_id": 105, - "cos": 0.159268 + "cos": 0.164448 }, { "preset_id": 106, - "cos": 0.201527 + "cos": 0.201093 }, { "preset_id": 201, - "cos": 0.163931 + "cos": 0.139059 }, { "preset_id": 202, - "cos": 0.123075 + "cos": 0.123158 }, { "preset_id": 203, - "cos": 0.215994 + "cos": 0.208261 }, { "preset_id": 204, - "cos": 0.202552 + "cos": 0.202598 }, { "preset_id": 205, - "cos": 0.266739 + "cos": 0.24067 }, { "preset_id": 206, - "cos": 0.234795 + "cos": 0.243376 }, { "preset_id": 207, - "cos": 0.177884 + "cos": 0.177851 }, { "preset_id": 208, - "cos": 0.274305 + "cos": 0.300241 }, { "preset_id": 301, - "cos": 0.17825 + "cos": 0.197382 }, { "preset_id": 302, - "cos": 0.152133 + "cos": 0.154203 }, { "preset_id": 303, - "cos": 0.248618 + "cos": 0.250951 }, { "preset_id": 304, - "cos": 0.219161 + "cos": 0.243286 }, { "preset_id": 305, - "cos": 0.252263 + "cos": 0.32417 }, { "preset_id": 306, - "cos": 0.115698 + "cos": 0.142692 }, { "preset_id": 307, - "cos": 0.147215 + "cos": 0.176085 }, { "preset_id": 401, - "cos": 0.092294 + "cos": 0.097533 }, { "preset_id": 402, - "cos": 0.15496 + "cos": 0.154961 }, { "preset_id": 403, - "cos": 0.234966 + "cos": 0.266655 }, { "preset_id": 404, - "cos": 0.096249 + "cos": 0.09491 }, { "preset_id": 405, - "cos": 0.323696 + "cos": 0.230863 }, { "preset_id": 406, - "cos": 0.253098 + "cos": 0.264538 } ] }, @@ -7499,111 +7499,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.252003 + "cos": 0.24748 }, { "preset_id": 102, - "cos": 0.24992 + "cos": 0.265506 }, { "preset_id": 103, - "cos": 0.281594 + "cos": 0.245784 }, { "preset_id": 104, - "cos": 0.341045 + "cos": 0.357422 }, { "preset_id": 105, - "cos": 0.246805 + "cos": 0.238824 }, { "preset_id": 106, - "cos": 0.245398 + "cos": 0.243923 }, { "preset_id": 201, - "cos": 0.216972 + "cos": 0.209766 }, { "preset_id": 202, - "cos": 0.303423 + "cos": 0.303411 }, { "preset_id": 203, - "cos": 0.245605 + "cos": 0.248626 }, { "preset_id": 204, - "cos": 0.32873 + "cos": 0.328695 }, { "preset_id": 205, - "cos": 0.280859 + "cos": 0.251737 }, { "preset_id": 206, - "cos": 0.223596 + "cos": 0.175122 }, { "preset_id": 207, - "cos": 0.220433 + "cos": 0.220359 }, { "preset_id": 208, - "cos": 0.193007 + "cos": 0.191708 }, { "preset_id": 301, - "cos": 0.238354 + "cos": 0.245834 }, { "preset_id": 302, - "cos": 0.20449 + "cos": 0.187603 }, { "preset_id": 303, - "cos": 0.268435 + "cos": 0.256439 }, { "preset_id": 304, - "cos": 0.164927 + "cos": 0.171975 }, { "preset_id": 305, - "cos": 0.222462 + "cos": 0.182587 }, { "preset_id": 306, - "cos": 0.278775 + "cos": 0.113041 }, { "preset_id": 307, - "cos": 0.112056 + "cos": 0.143289 }, { "preset_id": 401, - "cos": 0.249179 + "cos": 0.236961 }, { "preset_id": 402, - "cos": 0.296199 + "cos": 0.296218 }, { "preset_id": 403, - "cos": 0.267657 + "cos": 0.289619 }, { "preset_id": 404, - "cos": 0.210053 + "cos": 0.21605 }, { "preset_id": 405, - "cos": 0.289017 + "cos": 0.290332 }, { "preset_id": 406, - "cos": 0.221037 + "cos": 0.209995 } ] }, @@ -7612,111 +7612,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.226207 + "cos": 0.223916 }, { "preset_id": 102, - "cos": 0.268243 + "cos": 0.278761 }, { "preset_id": 103, - "cos": 0.175336 + "cos": 0.193139 }, { "preset_id": 104, - "cos": 0.269793 + "cos": 0.275792 }, { "preset_id": 105, - "cos": 0.151965 + "cos": 0.150209 }, { "preset_id": 106, - "cos": 0.192654 + "cos": 0.189828 }, { "preset_id": 201, - "cos": 0.229388 + "cos": 0.238795 }, { "preset_id": 202, - "cos": 0.188574 + "cos": 0.18868 }, { "preset_id": 203, - "cos": 0.248021 + "cos": 0.230388 }, { "preset_id": 204, - "cos": 0.292819 + "cos": 0.292768 }, { "preset_id": 205, - "cos": 0.319966 + "cos": 0.292801 }, { "preset_id": 206, - "cos": 0.236398 + "cos": 0.226472 }, { "preset_id": 207, - "cos": 0.261567 + "cos": 0.261537 }, { "preset_id": 208, - "cos": 0.273072 + "cos": 0.280927 }, { "preset_id": 301, - "cos": 0.12538 + "cos": 0.126469 }, { "preset_id": 302, - "cos": 0.125594 + "cos": 0.11781 }, { "preset_id": 303, - "cos": 0.23058 + "cos": 0.230228 }, { "preset_id": 304, - "cos": 0.19041 + "cos": 0.188195 }, { "preset_id": 305, - "cos": 0.210742 + "cos": 0.227828 }, { "preset_id": 306, - "cos": 0.228526 + "cos": 0.176407 }, { "preset_id": 307, - "cos": 0.235323 + "cos": 0.188857 }, { "preset_id": 401, - "cos": 0.221816 + "cos": 0.212426 }, { "preset_id": 402, - "cos": 0.2483 + "cos": 0.248288 }, { "preset_id": 403, - "cos": 0.311219 + "cos": 0.321782 }, { "preset_id": 404, - "cos": 0.14649 + "cos": 0.15063 }, { "preset_id": 405, - "cos": 0.287064 + "cos": 0.296016 }, { "preset_id": 406, - "cos": 0.278969 + "cos": 0.28848 } ] }, @@ -7725,111 +7725,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.246508 + "cos": 0.24609 }, { "preset_id": 102, - "cos": 0.168869 + "cos": 0.162444 }, { "preset_id": 103, - "cos": 0.248462 + "cos": 0.223596 }, { "preset_id": 104, - "cos": 0.197181 + "cos": 0.202162 }, { "preset_id": 105, - "cos": 0.223456 + "cos": 0.223196 }, { "preset_id": 106, - "cos": 0.147667 + "cos": 0.147497 }, { "preset_id": 201, - "cos": 0.275024 + "cos": 0.221383 }, { "preset_id": 202, - "cos": 0.230768 + "cos": 0.230889 }, { "preset_id": 203, - "cos": 0.234533 + "cos": 0.255355 }, { "preset_id": 204, - "cos": 0.299407 + "cos": 0.29939 }, { "preset_id": 205, - "cos": 0.217504 + "cos": 0.189754 }, { "preset_id": 206, - "cos": 0.242951 + "cos": 0.229376 }, { "preset_id": 207, - "cos": 0.221054 + "cos": 0.220978 }, { "preset_id": 208, - "cos": 0.224986 + "cos": 0.225869 }, { "preset_id": 301, - "cos": 0.15314 + "cos": 0.154885 }, { "preset_id": 302, - "cos": 0.186948 + "cos": 0.181231 }, { "preset_id": 303, - "cos": 0.178479 + "cos": 0.179913 }, { "preset_id": 304, - "cos": 0.211794 + "cos": 0.184386 }, { "preset_id": 305, - "cos": 0.21683 + "cos": 0.224579 }, { "preset_id": 306, - "cos": 0.230646 + "cos": 0.154634 }, { "preset_id": 307, - "cos": 0.157605 + "cos": 0.191324 }, { "preset_id": 401, - "cos": 0.176206 + "cos": 0.165349 }, { "preset_id": 402, - "cos": 0.171459 + "cos": 0.171431 }, { "preset_id": 403, - "cos": 0.238771 + "cos": 0.254494 }, { "preset_id": 404, - "cos": 0.15932 + "cos": 0.171246 }, { "preset_id": 405, - "cos": 0.249814 + "cos": 0.244888 }, { "preset_id": 406, - "cos": 0.184565 + "cos": 0.190954 } ] }, @@ -7838,51 +7838,51 @@ "cos": [ { "preset_id": 101, - "cos": 0.182895 + "cos": 0.172548 }, { "preset_id": 102, - "cos": 0.055403 + "cos": 0.064761 }, { "preset_id": 103, - "cos": 0.112384 + "cos": 0.115406 }, { "preset_id": 104, - "cos": 0.199898 + "cos": 0.20111 }, { "preset_id": 105, - "cos": 0.098553 + "cos": 0.095735 }, { "preset_id": 106, - "cos": 0.122058 + "cos": 0.121058 }, { "preset_id": 201, - "cos": 0.196145 + "cos": 0.246292 }, { "preset_id": 202, - "cos": 0.096121 + "cos": 0.096128 }, { "preset_id": 203, - "cos": 0.111922 + "cos": 0.130883 }, { "preset_id": 204, - "cos": 0.099013 + "cos": 0.098968 }, { "preset_id": 205, - "cos": 0.173764 + "cos": 0.148279 }, { "preset_id": 206, - "cos": 0.120942 + "cos": 0.136627 }, { "preset_id": 207, @@ -7890,59 +7890,59 @@ }, { "preset_id": 208, - "cos": 0.163789 + "cos": 0.17169 }, { "preset_id": 301, - "cos": 0.086357 + "cos": 0.10126 }, { "preset_id": 302, - "cos": 0.015947 + "cos": 0.010175 }, { "preset_id": 303, - "cos": 0.112901 + "cos": 0.098226 }, { "preset_id": 304, - "cos": 0.064256 + "cos": 0.084772 }, { "preset_id": 305, - "cos": 0.149597 + "cos": 0.192784 }, { "preset_id": 306, - "cos": 0.0724 + "cos": 0.109577 }, { "preset_id": 307, - "cos": 0.075393 + "cos": 0.072278 }, { "preset_id": 401, - "cos": 0.013906 + "cos": 0.011547 }, { "preset_id": 402, - "cos": 0.099962 + "cos": 0.099935 }, { "preset_id": 403, - "cos": 0.159137 + "cos": 0.213028 }, { "preset_id": 404, - "cos": 0.045125 + "cos": 0.039802 }, { "preset_id": 405, - "cos": 0.202393 + "cos": 0.13723 }, { "preset_id": 406, - "cos": 0.196323 + "cos": 0.204747 } ] }, @@ -7951,111 +7951,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.1646 + "cos": 0.16884 }, { "preset_id": 102, - "cos": 0.147466 + "cos": 0.151251 }, { "preset_id": 103, - "cos": 0.178073 + "cos": 0.176933 }, { "preset_id": 104, - "cos": 0.165062 + "cos": 0.187736 }, { "preset_id": 105, - "cos": 0.237236 + "cos": 0.237318 }, { "preset_id": 106, - "cos": 0.149467 + "cos": 0.150124 }, { "preset_id": 201, - "cos": 0.261039 + "cos": 0.23255 }, { "preset_id": 202, - "cos": 0.219519 + "cos": 0.219442 }, { "preset_id": 203, - "cos": 0.16418 + "cos": 0.135123 }, { "preset_id": 204, - "cos": 0.351428 + "cos": 0.35139 }, { "preset_id": 205, - "cos": 0.288713 + "cos": 0.266012 }, { "preset_id": 206, - "cos": 0.318754 + "cos": 0.300042 }, { "preset_id": 207, - "cos": 0.26592 + "cos": 0.265889 }, { "preset_id": 208, - "cos": 0.235859 + "cos": 0.246546 }, { "preset_id": 301, - "cos": 0.106452 + "cos": 0.10647 }, { "preset_id": 302, - "cos": 0.096251 + "cos": 0.081327 }, { "preset_id": 303, - "cos": 0.162664 + "cos": 0.162761 }, { "preset_id": 304, - "cos": 0.180497 + "cos": 0.168889 }, { "preset_id": 305, - "cos": 0.19608 + "cos": 0.159497 }, { "preset_id": 306, - "cos": 0.236193 + "cos": 0.1843 }, { "preset_id": 307, - "cos": 0.187525 + "cos": 0.16805 }, { "preset_id": 401, - "cos": 0.165613 + "cos": 0.164484 }, { "preset_id": 402, - "cos": 0.204042 + "cos": 0.20409 }, { "preset_id": 403, - "cos": 0.218277 + "cos": 0.205617 }, { "preset_id": 404, - "cos": 0.19501 + "cos": 0.203207 }, { "preset_id": 405, - "cos": 0.31159 + "cos": 0.28943 }, { "preset_id": 406, - "cos": 0.228544 + "cos": 0.230322 } ] }, @@ -8064,111 +8064,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.176838 + "cos": 0.172912 }, { "preset_id": 102, - "cos": 0.200907 + "cos": 0.196254 }, { "preset_id": 103, - "cos": 0.088218 + "cos": 0.122174 }, { "preset_id": 104, - "cos": 0.147571 + "cos": 0.165177 }, { "preset_id": 105, - "cos": 0.115221 + "cos": 0.109219 }, { "preset_id": 106, - "cos": 0.116611 + "cos": 0.115685 }, { "preset_id": 201, - "cos": 0.16287 + "cos": 0.171312 }, { "preset_id": 202, - "cos": 0.125751 + "cos": 0.125807 }, { "preset_id": 203, - "cos": 0.090373 + "cos": 0.025585 }, { "preset_id": 204, - "cos": 0.233885 + "cos": 0.233964 }, { "preset_id": 205, - "cos": 0.20409 + "cos": 0.212843 }, { "preset_id": 206, - "cos": 0.129709 + "cos": 0.155508 }, { "preset_id": 207, - "cos": 0.164623 + "cos": 0.164553 }, { "preset_id": 208, - "cos": 0.086239 + "cos": 0.09902 }, { "preset_id": 301, - "cos": 0.132832 + "cos": 0.138859 }, { "preset_id": 302, - "cos": 0.175771 + "cos": 0.19658 }, { "preset_id": 303, - "cos": 0.246703 + "cos": 0.235124 }, { "preset_id": 304, - "cos": 0.179104 + "cos": 0.145105 }, { "preset_id": 305, - "cos": 0.206155 + "cos": 0.219203 }, { "preset_id": 306, - "cos": 0.18704 + "cos": 0.211151 }, { "preset_id": 307, - "cos": 0.167582 + "cos": 0.114604 }, { "preset_id": 401, - "cos": 0.202852 + "cos": 0.219977 }, { "preset_id": 402, - "cos": 0.123937 + "cos": 0.123908 }, { "preset_id": 403, - "cos": 0.227671 + "cos": 0.191587 }, { "preset_id": 404, - "cos": 0.115386 + "cos": 0.112927 }, { "preset_id": 405, - "cos": 0.149944 + "cos": 0.137706 }, { "preset_id": 406, - "cos": 0.153426 + "cos": 0.15429 } ] }, @@ -8177,111 +8177,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.199552 + "cos": 0.202054 }, { "preset_id": 102, - "cos": 0.136659 + "cos": 0.149392 }, { "preset_id": 103, - "cos": 0.182989 + "cos": 0.164007 }, { "preset_id": 104, - "cos": 0.228536 + "cos": 0.236644 }, { "preset_id": 105, - "cos": 0.13684 + "cos": 0.144865 }, { "preset_id": 106, - "cos": 0.163099 + "cos": 0.162655 }, { "preset_id": 201, - "cos": 0.210584 + "cos": 0.171881 }, { "preset_id": 202, - "cos": 0.257134 + "cos": 0.257232 }, { "preset_id": 203, - "cos": 0.294619 + "cos": 0.227483 }, { "preset_id": 204, - "cos": 0.267837 + "cos": 0.267856 }, { "preset_id": 205, - "cos": 0.135262 + "cos": 0.08529 }, { "preset_id": 206, - "cos": 0.180674 + "cos": 0.145221 }, { "preset_id": 207, - "cos": 0.13003 + "cos": 0.13005 }, { "preset_id": 208, - "cos": 0.111104 + "cos": 0.100097 }, { "preset_id": 301, - "cos": 0.108305 + "cos": 0.119837 }, { "preset_id": 302, - "cos": 0.123276 + "cos": 0.121662 }, { "preset_id": 303, - "cos": 0.164734 + "cos": 0.16323 }, { "preset_id": 304, - "cos": 0.237431 + "cos": 0.236285 }, { "preset_id": 305, - "cos": 0.170366 + "cos": 0.136868 }, { "preset_id": 306, - "cos": 0.158978 + "cos": 0.067341 }, { "preset_id": 307, - "cos": 0.208054 + "cos": 0.220007 }, { "preset_id": 401, - "cos": 0.123098 + "cos": 0.122516 }, { "preset_id": 402, - "cos": 0.178473 + "cos": 0.178545 }, { "preset_id": 403, - "cos": 0.170972 + "cos": 0.164119 }, { "preset_id": 404, - "cos": 0.104278 + "cos": 0.114717 }, { "preset_id": 405, - "cos": 0.22552 + "cos": 0.186979 }, { "preset_id": 406, - "cos": 0.208253 + "cos": 0.193854 } ] }, @@ -8290,111 +8290,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.169005 + "cos": 0.164816 }, { "preset_id": 102, - "cos": 0.227269 + "cos": 0.232659 }, { "preset_id": 103, - "cos": 0.253732 + "cos": 0.246099 }, { "preset_id": 104, - "cos": 0.196521 + "cos": 0.202959 }, { "preset_id": 105, - "cos": 0.210768 + "cos": 0.209499 }, { "preset_id": 106, - "cos": 0.213172 + "cos": 0.213034 }, { "preset_id": 201, - "cos": 0.132042 + "cos": 0.148105 }, { "preset_id": 202, - "cos": 0.238824 + "cos": 0.23891 }, { "preset_id": 203, - "cos": 0.159031 + "cos": 0.197704 }, { "preset_id": 204, - "cos": 0.135124 + "cos": 0.135008 }, { "preset_id": 205, - "cos": 0.184753 + "cos": 0.166944 }, { "preset_id": 206, - "cos": 0.196529 + "cos": 0.183863 }, { "preset_id": 207, - "cos": 0.167081 + "cos": 0.167069 }, { "preset_id": 208, - "cos": 0.219528 + "cos": 0.233293 }, { "preset_id": 301, - "cos": 0.167819 + "cos": 0.160149 }, { "preset_id": 302, - "cos": 0.159254 + "cos": 0.157769 }, { "preset_id": 303, - "cos": 0.133652 + "cos": 0.132412 }, { "preset_id": 304, - "cos": 0.169786 + "cos": 0.179145 }, { "preset_id": 305, - "cos": 0.110573 + "cos": 0.096084 }, { "preset_id": 306, - "cos": 0.17639 + "cos": 0.220987 }, { "preset_id": 307, - "cos": 0.163624 + "cos": 0.224675 }, { "preset_id": 401, - "cos": 0.18171 + "cos": 0.168491 }, { "preset_id": 402, - "cos": 0.199581 + "cos": 0.199576 }, { "preset_id": 403, - "cos": 0.18037 + "cos": 0.200108 }, { "preset_id": 404, - "cos": 0.183378 + "cos": 0.192061 }, { "preset_id": 405, - "cos": 0.265722 + "cos": 0.217877 }, { "preset_id": 406, - "cos": 0.181209 + "cos": 0.184676 } ] }, @@ -8403,111 +8403,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.217985 + "cos": 0.214142 }, { "preset_id": 102, - "cos": 0.164446 + "cos": 0.170689 }, { "preset_id": 103, - "cos": 0.192873 + "cos": 0.163422 }, { "preset_id": 104, - "cos": 0.236261 + "cos": 0.248896 }, { "preset_id": 105, - "cos": 0.185412 + "cos": 0.186398 }, { "preset_id": 106, - "cos": 0.196412 + "cos": 0.19496 }, { "preset_id": 201, - "cos": 0.228696 + "cos": 0.197129 }, { "preset_id": 202, - "cos": 0.239597 + "cos": 0.239703 }, { "preset_id": 203, - "cos": 0.325941 + "cos": 0.300954 }, { "preset_id": 204, - "cos": 0.253967 + "cos": 0.254034 }, { "preset_id": 205, - "cos": 0.178144 + "cos": 0.155466 }, { "preset_id": 206, - "cos": 0.211825 + "cos": 0.172358 }, { "preset_id": 207, - "cos": 0.205369 + "cos": 0.205401 }, { "preset_id": 208, - "cos": 0.213193 + "cos": 0.206913 }, { "preset_id": 301, - "cos": 0.167 + "cos": 0.165722 }, { "preset_id": 302, - "cos": 0.117371 + "cos": 0.118987 }, { "preset_id": 303, - "cos": 0.181631 + "cos": 0.178819 }, { "preset_id": 304, - "cos": 0.172914 + "cos": 0.19258 }, { "preset_id": 305, - "cos": 0.221203 + "cos": 0.208628 }, { "preset_id": 306, - "cos": 0.210045 + "cos": 0.146677 }, { "preset_id": 307, - "cos": 0.128647 + "cos": 0.145452 }, { "preset_id": 401, - "cos": 0.159008 + "cos": 0.153548 }, { "preset_id": 402, - "cos": 0.216157 + "cos": 0.216287 }, { "preset_id": 403, - "cos": 0.280251 + "cos": 0.289334 }, { "preset_id": 404, - "cos": 0.161724 + "cos": 0.15972 }, { "preset_id": 405, - "cos": 0.26519 + "cos": 0.260883 }, { "preset_id": 406, - "cos": 0.220289 + "cos": 0.213344 } ] }, @@ -8516,111 +8516,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.204164 + "cos": 0.208233 }, { "preset_id": 102, - "cos": 0.14954 + "cos": 0.161965 }, { "preset_id": 103, - "cos": 0.155973 + "cos": 0.167258 }, { "preset_id": 104, - "cos": 0.179693 + "cos": 0.190308 }, { "preset_id": 105, - "cos": 0.20767 + "cos": 0.218839 }, { "preset_id": 106, - "cos": 0.139883 + "cos": 0.139475 }, { "preset_id": 201, - "cos": 0.17709 + "cos": 0.170471 }, { "preset_id": 202, - "cos": 0.230593 + "cos": 0.23061 }, { "preset_id": 203, - "cos": 0.19844 + "cos": 0.21652 }, { "preset_id": 204, - "cos": 0.239645 + "cos": 0.239664 }, { "preset_id": 205, - "cos": 0.164101 + "cos": 0.151784 }, { "preset_id": 206, - "cos": 0.259513 + "cos": 0.244514 }, { "preset_id": 207, - "cos": 0.227811 + "cos": 0.227638 }, { "preset_id": 208, - "cos": 0.207222 + "cos": 0.19842 }, { "preset_id": 301, - "cos": 0.137574 + "cos": 0.134078 }, { "preset_id": 302, - "cos": 0.188905 + "cos": 0.175105 }, { "preset_id": 303, - "cos": 0.215931 + "cos": 0.205588 }, { "preset_id": 304, - "cos": 0.193127 + "cos": 0.201254 }, { "preset_id": 305, - "cos": 0.209695 + "cos": 0.191602 }, { "preset_id": 306, - "cos": 0.225284 + "cos": 0.231207 }, { "preset_id": 307, - "cos": 0.228685 + "cos": 0.219009 }, { "preset_id": 401, - "cos": 0.173612 + "cos": 0.174033 }, { "preset_id": 402, - "cos": 0.226263 + "cos": 0.226125 }, { "preset_id": 403, - "cos": 0.274679 + "cos": 0.264398 }, { "preset_id": 404, - "cos": 0.168055 + "cos": 0.163148 }, { "preset_id": 405, - "cos": 0.193618 + "cos": 0.204818 }, { "preset_id": 406, - "cos": 0.245527 + "cos": 0.22026 } ] }, @@ -8629,111 +8629,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.229962 + "cos": 0.22665 }, { "preset_id": 102, - "cos": 0.184002 + "cos": 0.204718 }, { "preset_id": 103, - "cos": 0.238964 + "cos": 0.152538 }, { "preset_id": 104, - "cos": 0.239901 + "cos": 0.246204 }, { "preset_id": 105, - "cos": 0.17602 + "cos": 0.183362 }, { "preset_id": 106, - "cos": 0.266038 + "cos": 0.265802 }, { "preset_id": 201, - "cos": 0.265983 + "cos": 0.221951 }, { "preset_id": 202, - "cos": 0.322238 + "cos": 0.322124 }, { "preset_id": 203, - "cos": 0.278889 + "cos": 0.27366 }, { "preset_id": 204, - "cos": 0.344741 + "cos": 0.34451 }, { "preset_id": 205, - "cos": 0.208525 + "cos": 0.181819 }, { "preset_id": 206, - "cos": 0.213493 + "cos": 0.154199 }, { "preset_id": 207, - "cos": 0.141433 + "cos": 0.141492 }, { "preset_id": 208, - "cos": 0.119768 + "cos": 0.138786 }, { "preset_id": 301, - "cos": 0.153312 + "cos": 0.170114 }, { "preset_id": 302, - "cos": 0.14172 + "cos": 0.142959 }, { "preset_id": 303, - "cos": 0.132046 + "cos": 0.136646 }, { "preset_id": 304, - "cos": 0.209033 + "cos": 0.213713 }, { "preset_id": 305, - "cos": 0.203326 + "cos": 0.176163 }, { "preset_id": 306, - "cos": 0.235756 + "cos": 0.176024 }, { "preset_id": 307, - "cos": 0.081918 + "cos": 0.121495 }, { "preset_id": 401, - "cos": 0.211164 + "cos": 0.202926 }, { "preset_id": 402, - "cos": 0.199371 + "cos": 0.199425 }, { "preset_id": 403, - "cos": 0.220813 + "cos": 0.256895 }, { "preset_id": 404, - "cos": 0.181208 + "cos": 0.183818 }, { "preset_id": 405, - "cos": 0.328711 + "cos": 0.332236 }, { "preset_id": 406, - "cos": 0.166183 + "cos": 0.157553 } ] }, @@ -8742,111 +8742,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.176492 + "cos": 0.169392 }, { "preset_id": 102, - "cos": 0.173571 + "cos": 0.175151 }, { "preset_id": 103, - "cos": 0.134786 + "cos": 0.097121 }, { "preset_id": 104, - "cos": 0.165515 + "cos": 0.167815 }, { "preset_id": 105, - "cos": 0.197037 + "cos": 0.201025 }, { "preset_id": 106, - "cos": 0.162511 + "cos": 0.161817 }, { "preset_id": 201, - "cos": 0.189146 + "cos": 0.14805 }, { "preset_id": 202, - "cos": 0.18351 + "cos": 0.18355 }, { "preset_id": 203, - "cos": 0.149947 + "cos": 0.125044 }, { "preset_id": 204, - "cos": 0.226573 + "cos": 0.226476 }, { "preset_id": 205, - "cos": 0.147894 + "cos": 0.137513 }, { "preset_id": 206, - "cos": 0.100376 + "cos": 0.082389 }, { "preset_id": 207, - "cos": 0.231531 + "cos": 0.231397 }, { "preset_id": 208, - "cos": 0.221477 + "cos": 0.216969 }, { "preset_id": 301, - "cos": 0.203254 + "cos": 0.190357 }, { "preset_id": 302, - "cos": 0.147701 + "cos": 0.137092 }, { "preset_id": 303, - "cos": 0.421694 + "cos": 0.40133 }, { "preset_id": 304, - "cos": 0.235539 + "cos": 0.21688 }, { "preset_id": 305, - "cos": 0.315131 + "cos": 0.322283 }, { "preset_id": 306, - "cos": 0.339493 + "cos": 0.265666 }, { "preset_id": 307, - "cos": 0.180609 + "cos": 0.145317 }, { "preset_id": 401, - "cos": 0.226466 + "cos": 0.212308 }, { "preset_id": 402, - "cos": 0.187274 + "cos": 0.187145 }, { "preset_id": 403, - "cos": 0.183227 + "cos": 0.205335 }, { "preset_id": 404, - "cos": 0.146253 + "cos": 0.143272 }, { "preset_id": 405, - "cos": 0.247627 + "cos": 0.222907 }, { "preset_id": 406, - "cos": 0.154103 + "cos": 0.131635 } ] }, @@ -8855,111 +8855,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.220909 + "cos": 0.22283 }, { "preset_id": 102, - "cos": 0.172612 + "cos": 0.175552 }, { "preset_id": 103, - "cos": 0.169787 + "cos": 0.128263 }, { "preset_id": 104, - "cos": 0.147563 + "cos": 0.143457 }, { "preset_id": 105, - "cos": 0.322042 + "cos": 0.330147 }, { "preset_id": 106, - "cos": 0.125758 + "cos": 0.129547 }, { "preset_id": 201, - "cos": 0.162406 + "cos": 0.096196 }, { "preset_id": 202, - "cos": 0.1814 + "cos": 0.180816 }, { "preset_id": 203, - "cos": 0.176061 + "cos": 0.146791 }, { "preset_id": 204, - "cos": 0.30386 + "cos": 0.302506 }, { "preset_id": 205, - "cos": 0.197206 + "cos": 0.166727 }, { "preset_id": 206, - "cos": 0.162016 + "cos": 0.130598 }, { "preset_id": 207, - "cos": 0.222455 + "cos": 0.219618 }, { "preset_id": 208, - "cos": 0.108874 + "cos": 0.111315 }, { "preset_id": 301, - "cos": 0.099172 + "cos": 0.090126 }, { "preset_id": 302, - "cos": 0.100273 + "cos": 0.10779 }, { "preset_id": 303, - "cos": 0.193213 + "cos": 0.188861 }, { "preset_id": 304, - "cos": 0.149813 + "cos": 0.140152 }, { "preset_id": 305, - "cos": 0.148206 + "cos": 0.131312 }, { "preset_id": 306, - "cos": 0.171263 + "cos": 0.127632 }, { "preset_id": 307, - "cos": 0.173652 + "cos": 0.144486 }, { "preset_id": 401, - "cos": 0.233973 + "cos": 0.223161 }, { "preset_id": 402, - "cos": 0.115312 + "cos": 0.112059 }, { "preset_id": 403, - "cos": 0.151127 + "cos": 0.136295 }, { "preset_id": 404, - "cos": 0.095359 + "cos": 0.103096 }, { "preset_id": 405, - "cos": 0.248831 + "cos": 0.248481 }, { "preset_id": 406, - "cos": 0.159514 + "cos": 0.140771 } ] }, @@ -8968,111 +8968,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.199035 + "cos": 0.191946 }, { "preset_id": 102, - "cos": 0.176198 + "cos": 0.177396 }, { "preset_id": 103, - "cos": 0.202326 + "cos": 0.163308 }, { "preset_id": 104, - "cos": 0.286512 + "cos": 0.288578 }, { "preset_id": 105, - "cos": 0.203613 + "cos": 0.195381 }, { "preset_id": 106, - "cos": 0.163334 + "cos": 0.163778 }, { "preset_id": 201, - "cos": 0.201264 + "cos": 0.164575 }, { "preset_id": 202, - "cos": 0.24846 + "cos": 0.248613 }, { "preset_id": 203, - "cos": 0.139586 + "cos": 0.211574 }, { "preset_id": 204, - "cos": 0.204478 + "cos": 0.204609 }, { "preset_id": 205, - "cos": 0.268714 + "cos": 0.255246 }, { "preset_id": 206, - "cos": 0.219244 + "cos": 0.186245 }, { "preset_id": 207, - "cos": 0.185301 + "cos": 0.185296 }, { "preset_id": 208, - "cos": 0.208757 + "cos": 0.230806 }, { "preset_id": 301, - "cos": 0.142499 + "cos": 0.146348 }, { "preset_id": 302, - "cos": 0.073014 + "cos": 0.077191 }, { "preset_id": 303, - "cos": 0.139047 + "cos": 0.138558 }, { "preset_id": 304, - "cos": 0.157278 + "cos": 0.149032 }, { "preset_id": 305, - "cos": 0.20213 + "cos": 0.231394 }, { "preset_id": 306, - "cos": 0.2046 + "cos": 0.21032 }, { "preset_id": 307, - "cos": 0.086711 + "cos": 0.135956 }, { "preset_id": 401, - "cos": 0.142645 + "cos": 0.145654 }, { "preset_id": 402, - "cos": 0.253034 + "cos": 0.253109 }, { "preset_id": 403, - "cos": 0.210856 + "cos": 0.256214 }, { "preset_id": 404, - "cos": 0.146558 + "cos": 0.149249 }, { "preset_id": 405, - "cos": 0.252947 + "cos": 0.270535 }, { "preset_id": 406, - "cos": 0.266999 + "cos": 0.286467 } ] }, @@ -9081,111 +9081,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.207335 + "cos": 0.20798 }, { "preset_id": 102, - "cos": 0.114581 + "cos": 0.116954 }, { "preset_id": 103, - "cos": 0.182039 + "cos": 0.173974 }, { "preset_id": 104, - "cos": 0.19419 + "cos": 0.202339 }, { "preset_id": 105, - "cos": 0.17052 + "cos": 0.190128 }, { "preset_id": 106, - "cos": 0.141384 + "cos": 0.139412 }, { "preset_id": 201, - "cos": 0.12073 + "cos": 0.082391 }, { "preset_id": 202, - "cos": 0.127709 + "cos": 0.127896 }, { "preset_id": 203, - "cos": 0.221386 + "cos": 0.21259 }, { "preset_id": 204, - "cos": 0.152453 + "cos": 0.152554 }, { "preset_id": 205, - "cos": 0.171257 + "cos": 0.147476 }, { "preset_id": 206, - "cos": 0.09957 + "cos": 0.086531 }, { "preset_id": 207, - "cos": 0.142601 + "cos": 0.142528 }, { "preset_id": 208, - "cos": 0.156307 + "cos": 0.162918 }, { "preset_id": 301, - "cos": 0.141507 + "cos": 0.152234 }, { "preset_id": 302, - "cos": 0.082635 + "cos": 0.083671 }, { "preset_id": 303, - "cos": 0.170484 + "cos": 0.14908 }, { "preset_id": 304, - "cos": 0.175863 + "cos": 0.188123 }, { "preset_id": 305, - "cos": 0.284053 + "cos": 0.250436 }, { "preset_id": 306, - "cos": 0.145772 + "cos": 0.137151 }, { "preset_id": 307, - "cos": 0.143298 + "cos": 0.114749 }, { "preset_id": 401, - "cos": 0.030859 + "cos": 0.042516 }, { "preset_id": 402, - "cos": 0.133791 + "cos": 0.133888 }, { "preset_id": 403, - "cos": 0.189408 + "cos": 0.18605 }, { "preset_id": 404, - "cos": 0.102704 + "cos": 0.112325 }, { "preset_id": 405, - "cos": 0.227743 + "cos": 0.205346 }, { "preset_id": 406, - "cos": 0.240637 + "cos": 0.238147 } ] }, @@ -9194,111 +9194,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.078695 + "cos": 0.080742 }, { "preset_id": 102, - "cos": 0.089751 + "cos": 0.094151 }, { "preset_id": 103, - "cos": 0.166198 + "cos": 0.135598 }, { "preset_id": 104, - "cos": 0.138683 + "cos": 0.157904 }, { "preset_id": 105, - "cos": 0.147261 + "cos": 0.15287 }, { "preset_id": 106, - "cos": 0.152022 + "cos": 0.151008 }, { "preset_id": 201, - "cos": 0.141612 + "cos": 0.115112 }, { "preset_id": 202, - "cos": 0.188288 + "cos": 0.1884 }, { "preset_id": 203, - "cos": 0.120048 + "cos": 0.143053 }, { "preset_id": 204, - "cos": 0.178634 + "cos": 0.178652 }, { "preset_id": 205, - "cos": 0.241718 + "cos": 0.243833 }, { "preset_id": 206, - "cos": 0.152094 + "cos": 0.15555 }, { "preset_id": 207, - "cos": 0.182062 + "cos": 0.182019 }, { "preset_id": 208, - "cos": 0.178117 + "cos": 0.185463 }, { "preset_id": 301, - "cos": 0.122087 + "cos": 0.123567 }, { "preset_id": 302, - "cos": 0.094218 + "cos": 0.106855 }, { "preset_id": 303, - "cos": 0.11896 + "cos": 0.128601 }, { "preset_id": 304, - "cos": 0.110962 + "cos": 0.100629 }, { "preset_id": 305, - "cos": 0.093872 + "cos": 0.094741 }, { "preset_id": 306, - "cos": 0.202086 + "cos": 0.17136 }, { "preset_id": 307, - "cos": 0.094854 + "cos": 0.137118 }, { "preset_id": 401, - "cos": 0.07124 + "cos": 0.074976 }, { "preset_id": 402, - "cos": 0.17847 + "cos": 0.178466 }, { "preset_id": 403, - "cos": 0.156457 + "cos": 0.201519 }, { "preset_id": 404, - "cos": 0.127944 + "cos": 0.135552 }, { "preset_id": 405, - "cos": 0.189754 + "cos": 0.183493 }, { "preset_id": 406, - "cos": 0.215727 + "cos": 0.248138 } ] }, @@ -9307,111 +9307,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.081997 + "cos": 0.075923 }, { "preset_id": 102, - "cos": 0.109158 + "cos": 0.10784 }, { "preset_id": 103, - "cos": 0.118451 + "cos": 0.138304 }, { "preset_id": 104, - "cos": 0.131343 + "cos": 0.142052 }, { "preset_id": 105, - "cos": 0.169212 + "cos": 0.177408 }, { "preset_id": 106, - "cos": 0.116211 + "cos": 0.114795 }, { "preset_id": 201, - "cos": 0.059769 + "cos": 0.056026 }, { "preset_id": 202, - "cos": 0.149513 + "cos": 0.149491 }, { "preset_id": 203, - "cos": 0.11074 + "cos": 0.149383 }, { "preset_id": 204, - "cos": 0.134859 + "cos": 0.134847 }, { "preset_id": 205, - "cos": 0.187709 + "cos": 0.18799 }, { "preset_id": 206, - "cos": 0.226079 + "cos": 0.226052 }, { "preset_id": 207, - "cos": 0.186486 + "cos": 0.186349 }, { "preset_id": 208, - "cos": 0.20241 + "cos": 0.211423 }, { "preset_id": 301, - "cos": 0.141932 + "cos": 0.148723 }, { "preset_id": 302, - "cos": 0.115416 + "cos": 0.109103 }, { "preset_id": 303, - "cos": 0.168116 + "cos": 0.166029 }, { "preset_id": 304, - "cos": 0.156026 + "cos": 0.153479 }, { "preset_id": 305, - "cos": 0.120954 + "cos": 0.141474 }, { "preset_id": 306, - "cos": 0.139177 + "cos": 0.149749 }, { "preset_id": 307, - "cos": 0.148682 + "cos": 0.171909 }, { "preset_id": 401, - "cos": 0.113103 + "cos": 0.117541 }, { "preset_id": 402, - "cos": 0.19702 + "cos": 0.196997 }, { "preset_id": 403, - "cos": 0.187798 + "cos": 0.177346 }, { "preset_id": 404, - "cos": 0.131991 + "cos": 0.155166 }, { "preset_id": 405, - "cos": 0.162285 + "cos": 0.139904 }, { "preset_id": 406, - "cos": 0.262323 + "cos": 0.280372 } ] }, @@ -9420,111 +9420,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.188399 + "cos": 0.18323 }, { "preset_id": 102, - "cos": 0.167368 + "cos": 0.172101 }, { "preset_id": 103, - "cos": 0.185848 + "cos": 0.199831 }, { "preset_id": 104, - "cos": 0.20678 + "cos": 0.21901 }, { "preset_id": 105, - "cos": 0.162324 + "cos": 0.154177 }, { "preset_id": 106, - "cos": 0.19931 + "cos": 0.197905 }, { "preset_id": 201, - "cos": 0.157309 + "cos": 0.155605 }, { "preset_id": 202, - "cos": 0.156126 + "cos": 0.156163 }, { "preset_id": 203, - "cos": 0.144757 + "cos": 0.125489 }, { "preset_id": 204, - "cos": 0.145562 + "cos": 0.145501 }, { "preset_id": 205, - "cos": 0.226625 + "cos": 0.201268 }, { "preset_id": 206, - "cos": 0.176638 + "cos": 0.153056 }, { "preset_id": 207, - "cos": 0.40721 + "cos": 0.407093 }, { "preset_id": 208, - "cos": 0.192197 + "cos": 0.201527 }, { "preset_id": 301, - "cos": 0.118775 + "cos": 0.134548 }, { "preset_id": 302, - "cos": 0.125834 + "cos": 0.108054 }, { "preset_id": 303, - "cos": 0.165445 + "cos": 0.18279 }, { "preset_id": 304, - "cos": 0.168979 + "cos": 0.187576 }, { "preset_id": 305, - "cos": 0.176994 + "cos": 0.169232 }, { "preset_id": 306, - "cos": 0.135811 + "cos": 0.069045 }, { "preset_id": 307, - "cos": 0.098006 + "cos": 0.081806 }, { "preset_id": 401, - "cos": 0.104251 + "cos": 0.109035 }, { "preset_id": 402, - "cos": 0.173544 + "cos": 0.173482 }, { "preset_id": 403, - "cos": 0.175009 + "cos": 0.190398 }, { "preset_id": 404, - "cos": 0.112108 + "cos": 0.113852 }, { "preset_id": 405, - "cos": 0.236264 + "cos": 0.226276 }, { "preset_id": 406, - "cos": 0.225642 + "cos": 0.221316 } ] }, @@ -9533,111 +9533,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.168167 + "cos": 0.172942 }, { "preset_id": 102, - "cos": 0.176605 + "cos": 0.178796 }, { "preset_id": 103, - "cos": 0.143688 + "cos": 0.164872 }, { "preset_id": 104, - "cos": 0.208394 + "cos": 0.233188 }, { "preset_id": 105, - "cos": 0.210041 + "cos": 0.18769 }, { "preset_id": 106, - "cos": 0.200283 + "cos": 0.200297 }, { "preset_id": 201, - "cos": 0.227409 + "cos": 0.18396 }, { "preset_id": 202, - "cos": 0.19816 + "cos": 0.198231 }, { "preset_id": 203, - "cos": 0.259178 + "cos": 0.1724 }, { "preset_id": 204, - "cos": 0.324089 + "cos": 0.324126 }, { "preset_id": 205, - "cos": 0.245738 + "cos": 0.22475 }, { "preset_id": 206, - "cos": 0.186348 + "cos": 0.149823 }, { "preset_id": 207, - "cos": 0.306694 + "cos": 0.306619 }, { "preset_id": 208, - "cos": 0.214333 + "cos": 0.214856 }, { "preset_id": 301, - "cos": 0.175423 + "cos": 0.165641 }, { "preset_id": 302, - "cos": 0.144483 + "cos": 0.143216 }, { "preset_id": 303, - "cos": 0.227786 + "cos": 0.249722 }, { "preset_id": 304, - "cos": 0.121702 + "cos": 0.119743 }, { "preset_id": 305, - "cos": 0.206186 + "cos": 0.208636 }, { "preset_id": 306, - "cos": 0.276267 + "cos": 0.156257 }, { "preset_id": 307, - "cos": 0.194034 + "cos": 0.171314 }, { "preset_id": 401, - "cos": 0.219357 + "cos": 0.216883 }, { "preset_id": 402, - "cos": 0.18975 + "cos": 0.189693 }, { "preset_id": 403, - "cos": 0.197388 + "cos": 0.220107 }, { "preset_id": 404, - "cos": 0.077157 + "cos": 0.088347 }, { "preset_id": 405, - "cos": 0.252181 + "cos": 0.252626 }, { "preset_id": 406, - "cos": 0.222691 + "cos": 0.203592 } ] }, @@ -9646,111 +9646,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.161807 + "cos": 0.158597 }, { "preset_id": 102, - "cos": 0.161361 + "cos": 0.175051 }, { "preset_id": 103, - "cos": 0.153532 + "cos": 0.137743 }, { "preset_id": 104, - "cos": 0.227239 + "cos": 0.244043 }, { "preset_id": 105, - "cos": 0.242318 + "cos": 0.248933 }, { "preset_id": 106, - "cos": 0.202308 + "cos": 0.201488 }, { "preset_id": 201, - "cos": 0.163105 + "cos": 0.116488 }, { "preset_id": 202, - "cos": 0.263435 + "cos": 0.263394 }, { "preset_id": 203, - "cos": 0.238852 + "cos": 0.269946 }, { "preset_id": 204, - "cos": 0.196593 + "cos": 0.196506 }, { "preset_id": 205, - "cos": 0.263374 + "cos": 0.246213 }, { "preset_id": 206, - "cos": 0.234497 + "cos": 0.216678 }, { "preset_id": 207, - "cos": 0.221927 + "cos": 0.221829 }, { "preset_id": 208, - "cos": 0.264592 + "cos": 0.284033 }, { "preset_id": 301, - "cos": 0.229081 + "cos": 0.233674 }, { "preset_id": 302, - "cos": 0.209958 + "cos": 0.192386 }, { "preset_id": 303, - "cos": 0.200943 + "cos": 0.232678 }, { "preset_id": 304, - "cos": 0.261093 + "cos": 0.241649 }, { "preset_id": 305, - "cos": 0.223214 + "cos": 0.24503 }, { "preset_id": 306, - "cos": 0.236436 + "cos": 0.195247 }, { "preset_id": 307, - "cos": 0.189561 + "cos": 0.256088 }, { "preset_id": 401, - "cos": 0.165491 + "cos": 0.16006 }, { "preset_id": 402, - "cos": 0.197432 + "cos": 0.197498 }, { "preset_id": 403, - "cos": 0.263898 + "cos": 0.264859 }, { "preset_id": 404, - "cos": 0.260295 + "cos": 0.279642 }, { "preset_id": 405, - "cos": 0.193023 + "cos": 0.193701 }, { "preset_id": 406, - "cos": 0.23725 + "cos": 0.182405 } ] }, @@ -9759,111 +9759,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.150528 + "cos": 0.144548 }, { "preset_id": 102, - "cos": 0.122324 + "cos": 0.128598 }, { "preset_id": 103, - "cos": 0.128998 + "cos": 0.14036 }, { "preset_id": 104, - "cos": 0.152394 + "cos": 0.163201 }, { "preset_id": 105, - "cos": 0.154361 + "cos": 0.151911 }, { "preset_id": 106, - "cos": 0.114436 + "cos": 0.111963 }, { "preset_id": 201, - "cos": 0.136677 + "cos": 0.116009 }, { "preset_id": 202, - "cos": 0.160935 + "cos": 0.160984 }, { "preset_id": 203, - "cos": 0.14209 + "cos": 0.072628 }, { "preset_id": 204, - "cos": 0.197156 + "cos": 0.197152 }, { "preset_id": 205, - "cos": 0.132583 + "cos": 0.117898 }, { "preset_id": 206, - "cos": 0.152034 + "cos": 0.162377 }, { "preset_id": 207, - "cos": 0.211222 + "cos": 0.211168 }, { "preset_id": 208, - "cos": 0.159334 + "cos": 0.167079 }, { "preset_id": 301, - "cos": 0.132599 + "cos": 0.128361 }, { "preset_id": 302, - "cos": 0.138162 + "cos": 0.128989 }, { "preset_id": 303, - "cos": 0.212808 + "cos": 0.204751 }, { "preset_id": 304, - "cos": 0.167192 + "cos": 0.152915 }, { "preset_id": 305, - "cos": 0.202401 + "cos": 0.193423 }, { "preset_id": 306, - "cos": 0.18502 + "cos": 0.152565 }, { "preset_id": 307, - "cos": 0.286381 + "cos": 0.178411 }, { "preset_id": 401, - "cos": 0.178729 + "cos": 0.177248 }, { "preset_id": 402, - "cos": 0.146321 + "cos": 0.146335 }, { "preset_id": 403, - "cos": 0.195831 + "cos": 0.221899 }, { "preset_id": 404, - "cos": 0.075108 + "cos": 0.079466 }, { "preset_id": 405, - "cos": 0.161061 + "cos": 0.171467 }, { "preset_id": 406, - "cos": 0.223821 + "cos": 0.192694 } ] }, @@ -9872,111 +9872,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.176761 + "cos": 0.186274 }, { "preset_id": 102, - "cos": 0.144988 + "cos": 0.158627 }, { "preset_id": 103, - "cos": 0.141686 + "cos": 0.192311 }, { "preset_id": 104, - "cos": 0.216333 + "cos": 0.239822 }, { "preset_id": 105, - "cos": 0.138451 + "cos": 0.133052 }, { "preset_id": 106, - "cos": 0.228505 + "cos": 0.224739 }, { "preset_id": 201, - "cos": 0.208103 + "cos": 0.164604 }, { "preset_id": 202, - "cos": 0.1681 + "cos": 0.168124 }, { "preset_id": 203, - "cos": 0.188507 + "cos": 0.155349 }, { "preset_id": 204, - "cos": 0.271286 + "cos": 0.271205 }, { "preset_id": 205, - "cos": 0.240474 + "cos": 0.221231 }, { "preset_id": 206, - "cos": 0.204992 + "cos": 0.182031 }, { "preset_id": 207, - "cos": 0.252048 + "cos": 0.251819 }, { "preset_id": 208, - "cos": 0.233554 + "cos": 0.239144 }, { "preset_id": 301, - "cos": 0.197642 + "cos": 0.190592 }, { "preset_id": 302, - "cos": 0.095179 + "cos": 0.092348 }, { "preset_id": 303, - "cos": 0.207263 + "cos": 0.217674 }, { "preset_id": 304, - "cos": 0.198365 + "cos": 0.166914 }, { "preset_id": 305, - "cos": 0.265209 + "cos": 0.261288 }, { "preset_id": 306, - "cos": 0.255372 + "cos": 0.200563 }, { "preset_id": 307, - "cos": 0.190413 + "cos": 0.160171 }, { "preset_id": 401, - "cos": 0.241364 + "cos": 0.235776 }, { "preset_id": 402, - "cos": 0.169935 + "cos": 0.169875 }, { "preset_id": 403, - "cos": 0.241871 + "cos": 0.267862 }, { "preset_id": 404, - "cos": 0.12775 + "cos": 0.130926 }, { "preset_id": 405, - "cos": 0.234961 + "cos": 0.263809 }, { "preset_id": 406, - "cos": 0.210486 + "cos": 0.190878 } ] }, @@ -9985,111 +9985,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.157995 + "cos": 0.159368 }, { "preset_id": 102, - "cos": 0.115835 + "cos": 0.126074 }, { "preset_id": 103, - "cos": 0.169191 + "cos": 0.185548 }, { "preset_id": 104, - "cos": 0.202518 + "cos": 0.225207 }, { "preset_id": 105, - "cos": 0.225162 + "cos": 0.224269 }, { "preset_id": 106, - "cos": 0.21738 + "cos": 0.214846 }, { "preset_id": 201, - "cos": 0.195368 + "cos": 0.162307 }, { "preset_id": 202, - "cos": 0.141717 + "cos": 0.141583 }, { "preset_id": 203, - "cos": 0.23998 + "cos": 0.241166 }, { "preset_id": 204, - "cos": 0.190895 + "cos": 0.190731 }, { "preset_id": 205, - "cos": 0.1998 + "cos": 0.162881 }, { "preset_id": 206, - "cos": 0.169017 + "cos": 0.136646 }, { "preset_id": 207, - "cos": 0.191175 + "cos": 0.191105 }, { "preset_id": 208, - "cos": 0.222396 + "cos": 0.207537 }, { "preset_id": 301, - "cos": 0.202351 + "cos": 0.194363 }, { "preset_id": 302, - "cos": 0.179481 + "cos": 0.164604 }, { "preset_id": 303, - "cos": 0.241476 + "cos": 0.227169 }, { "preset_id": 304, - "cos": 0.170514 + "cos": 0.165798 }, { "preset_id": 305, - "cos": 0.196275 + "cos": 0.227807 }, { "preset_id": 306, - "cos": 0.230252 + "cos": 0.129664 }, { "preset_id": 307, - "cos": 0.16462 + "cos": 0.159172 }, { "preset_id": 401, - "cos": 0.219509 + "cos": 0.184381 }, { "preset_id": 402, - "cos": 0.198427 + "cos": 0.198424 }, { "preset_id": 403, - "cos": 0.202265 + "cos": 0.262017 }, { "preset_id": 404, - "cos": 0.22959 + "cos": 0.236201 }, { "preset_id": 405, - "cos": 0.140329 + "cos": 0.145002 }, { "preset_id": 406, - "cos": 0.140036 + "cos": 0.11695 } ] }, @@ -10098,111 +10098,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.10633 + "cos": 0.104428 }, { "preset_id": 102, - "cos": 0.178943 + "cos": 0.181738 }, { "preset_id": 103, - "cos": 0.130539 + "cos": 0.145887 }, { "preset_id": 104, - "cos": 0.163161 + "cos": 0.177618 }, { "preset_id": 105, - "cos": 0.199202 + "cos": 0.204383 }, { "preset_id": 106, - "cos": 0.179873 + "cos": 0.177558 }, { "preset_id": 201, - "cos": 0.273211 + "cos": 0.268924 }, { "preset_id": 202, - "cos": 0.20002 + "cos": 0.200099 }, { "preset_id": 203, - "cos": 0.115652 + "cos": 0.060641 }, { "preset_id": 204, - "cos": 0.185604 + "cos": 0.18557 }, { "preset_id": 205, - "cos": 0.203857 + "cos": 0.196578 }, { "preset_id": 206, - "cos": 0.188614 + "cos": 0.191577 }, { "preset_id": 207, - "cos": 0.239726 + "cos": 0.239589 }, { "preset_id": 208, - "cos": 0.288735 + "cos": 0.290162 }, { "preset_id": 301, - "cos": 0.178681 + "cos": 0.158448 }, { "preset_id": 302, - "cos": 0.135361 + "cos": 0.117886 }, { "preset_id": 303, - "cos": 0.261527 + "cos": 0.26877 }, { "preset_id": 304, - "cos": 0.233895 + "cos": 0.207492 }, { "preset_id": 305, - "cos": 0.210479 + "cos": 0.250076 }, { "preset_id": 306, - "cos": 0.453859 + "cos": 0.443418 }, { "preset_id": 307, - "cos": 0.218555 + "cos": 0.193753 }, { "preset_id": 401, - "cos": 0.252917 + "cos": 0.243309 }, { "preset_id": 402, - "cos": 0.256 + "cos": 0.255955 }, { "preset_id": 403, - "cos": 0.181013 + "cos": 0.209823 }, { "preset_id": 404, - "cos": 0.201832 + "cos": 0.208371 }, { "preset_id": 405, - "cos": 0.248753 + "cos": 0.230648 }, { "preset_id": 406, - "cos": 0.21017 + "cos": 0.192387 } ] }, @@ -10211,111 +10211,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.155328 + "cos": 0.149362 }, { "preset_id": 102, - "cos": 0.175027 + "cos": 0.192229 }, { "preset_id": 103, - "cos": 0.161681 + "cos": 0.158086 }, { "preset_id": 104, - "cos": 0.150439 + "cos": 0.149986 }, { "preset_id": 105, - "cos": 0.078104 + "cos": 0.085363 }, { "preset_id": 106, - "cos": 0.158893 + "cos": 0.158764 }, { "preset_id": 201, - "cos": 0.142656 + "cos": 0.161437 }, { "preset_id": 202, - "cos": 0.1136 + "cos": 0.113906 }, { "preset_id": 203, - "cos": 0.13007 + "cos": 0.171185 }, { "preset_id": 204, - "cos": 0.132917 + "cos": 0.132282 }, { "preset_id": 205, - "cos": 0.126053 + "cos": 0.105228 }, { "preset_id": 206, - "cos": 0.152012 + "cos": 0.165524 }, { "preset_id": 207, - "cos": 0.054747 + "cos": 0.053479 }, { "preset_id": 208, - "cos": 0.092494 + "cos": 0.10194 }, { "preset_id": 301, - "cos": 0.106625 + "cos": 0.122824 }, { "preset_id": 302, - "cos": 0.123543 + "cos": 0.114422 }, { "preset_id": 303, - "cos": 0.130409 + "cos": 0.126145 }, { "preset_id": 304, - "cos": 0.210935 + "cos": 0.241594 }, { "preset_id": 305, - "cos": 0.142756 + "cos": 0.146872 }, { "preset_id": 306, - "cos": 0.077571 + "cos": 0.069495 }, { "preset_id": 307, - "cos": 0.147666 + "cos": 0.123246 }, { "preset_id": 401, - "cos": 0.107548 + "cos": 0.113059 }, { "preset_id": 402, - "cos": 0.112263 + "cos": 0.110987 }, { "preset_id": 403, - "cos": 0.142415 + "cos": 0.120782 }, { "preset_id": 404, - "cos": 0.183157 + "cos": 0.172394 }, { "preset_id": 405, - "cos": 0.12898 + "cos": 0.111215 }, { "preset_id": 406, - "cos": 0.152319 + "cos": 0.170915 } ] }, @@ -10324,111 +10324,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.211009 + "cos": 0.204731 }, { "preset_id": 102, - "cos": 0.201667 + "cos": 0.21271 }, { "preset_id": 103, - "cos": 0.166126 + "cos": 0.17583 }, { "preset_id": 104, - "cos": 0.213964 + "cos": 0.212611 }, { "preset_id": 105, - "cos": 0.197038 + "cos": 0.206475 }, { "preset_id": 106, - "cos": 0.143975 + "cos": 0.143638 }, { "preset_id": 201, - "cos": 0.315121 + "cos": 0.333747 }, { "preset_id": 202, - "cos": 0.246551 + "cos": 0.246491 }, { "preset_id": 203, - "cos": 0.187605 + "cos": 0.183056 }, { "preset_id": 204, - "cos": 0.240056 + "cos": 0.239944 }, { "preset_id": 205, - "cos": 0.253626 + "cos": 0.240327 }, { "preset_id": 206, - "cos": 0.193488 + "cos": 0.190584 }, { "preset_id": 207, - "cos": 0.136941 + "cos": 0.13691 }, { "preset_id": 208, - "cos": 0.248574 + "cos": 0.227551 }, { "preset_id": 301, - "cos": 0.207137 + "cos": 0.209475 }, { "preset_id": 302, - "cos": 0.169416 + "cos": 0.176346 }, { "preset_id": 303, - "cos": 0.251629 + "cos": 0.232998 }, { "preset_id": 304, - "cos": 0.235005 + "cos": 0.246839 }, { "preset_id": 305, - "cos": 0.239384 + "cos": 0.238366 }, { "preset_id": 306, - "cos": 0.196234 + "cos": 0.166743 }, { "preset_id": 307, - "cos": 0.272363 + "cos": 0.191046 }, { "preset_id": 401, - "cos": 0.167085 + "cos": 0.150368 }, { "preset_id": 402, - "cos": 0.152204 + "cos": 0.152216 }, { "preset_id": 403, - "cos": 0.225718 + "cos": 0.222455 }, { "preset_id": 404, - "cos": 0.171122 + "cos": 0.178839 }, { "preset_id": 405, - "cos": 0.270598 + "cos": 0.24229 }, { "preset_id": 406, - "cos": 0.166876 + "cos": 0.154213 } ] }, @@ -10437,111 +10437,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.101694 + "cos": 0.103768 }, { "preset_id": 102, - "cos": 0.144359 + "cos": 0.158183 }, { "preset_id": 103, - "cos": 0.188433 + "cos": 0.213321 }, { "preset_id": 104, - "cos": 0.161121 + "cos": 0.17605 }, { "preset_id": 105, - "cos": 0.226186 + "cos": 0.233752 }, { "preset_id": 106, - "cos": 0.234226 + "cos": 0.229764 }, { "preset_id": 201, - "cos": 0.107111 + "cos": 0.07154 }, { "preset_id": 202, - "cos": 0.189904 + "cos": 0.190057 }, { "preset_id": 203, - "cos": 0.121199 + "cos": 0.189891 }, { "preset_id": 204, - "cos": 0.129905 + "cos": 0.129843 }, { "preset_id": 205, - "cos": 0.283032 + "cos": 0.279597 }, { "preset_id": 206, - "cos": 0.173501 + "cos": 0.173782 }, { "preset_id": 207, - "cos": 0.229485 + "cos": 0.22938 }, { "preset_id": 208, - "cos": 0.195372 + "cos": 0.206654 }, { "preset_id": 301, - "cos": 0.142954 + "cos": 0.133055 }, { "preset_id": 302, - "cos": 0.114519 + "cos": 0.118429 }, { "preset_id": 303, - "cos": 0.12105 + "cos": 0.126002 }, { "preset_id": 304, - "cos": 0.144868 + "cos": 0.127313 }, { "preset_id": 305, - "cos": 0.167764 + "cos": 0.192631 }, { "preset_id": 306, - "cos": 0.184881 + "cos": 0.143001 }, { "preset_id": 307, - "cos": 0.14303 + "cos": 0.171813 }, { "preset_id": 401, - "cos": 0.114822 + "cos": 0.113116 }, { "preset_id": 402, - "cos": 0.151575 + "cos": 0.151512 }, { "preset_id": 403, - "cos": 0.137898 + "cos": 0.179711 }, { "preset_id": 404, - "cos": 0.082221 + "cos": 0.079144 }, { "preset_id": 405, - "cos": 0.232853 + "cos": 0.222088 }, { "preset_id": 406, - "cos": 0.257852 + "cos": 0.288983 } ] }, @@ -10550,91 +10550,91 @@ "cos": [ { "preset_id": 101, - "cos": 0.207174 + "cos": 0.204506 }, { "preset_id": 102, - "cos": 0.155084 + "cos": 0.148166 }, { "preset_id": 103, - "cos": 0.179608 + "cos": 0.201301 }, { "preset_id": 104, - "cos": 0.292542 + "cos": 0.31174 }, { "preset_id": 105, - "cos": 0.29282 + "cos": 0.282668 }, { "preset_id": 106, - "cos": 0.17935 + "cos": 0.17954 }, { "preset_id": 201, - "cos": 0.146168 + "cos": 0.145789 }, { "preset_id": 202, - "cos": 0.212186 + "cos": 0.212281 }, { "preset_id": 203, - "cos": 0.117954 + "cos": 0.204003 }, { "preset_id": 204, - "cos": 0.175615 + "cos": 0.17563 }, { "preset_id": 205, - "cos": 0.16258 + "cos": 0.167218 }, { "preset_id": 206, - "cos": 0.207 + "cos": 0.190738 }, { "preset_id": 207, - "cos": 0.221214 + "cos": 0.221202 }, { "preset_id": 208, - "cos": 0.214278 + "cos": 0.193524 }, { "preset_id": 301, - "cos": 0.222927 + "cos": 0.205947 }, { "preset_id": 302, - "cos": 0.152241 + "cos": 0.142933 }, { "preset_id": 303, - "cos": 0.182919 + "cos": 0.180442 }, { "preset_id": 304, - "cos": 0.198853 + "cos": 0.201795 }, { "preset_id": 305, - "cos": 0.18703 + "cos": 0.186494 }, { "preset_id": 306, - "cos": 0.19181 + "cos": 0.138665 }, { "preset_id": 307, - "cos": 0.093162 + "cos": 0.147022 }, { "preset_id": 401, - "cos": 0.223062 + "cos": 0.20854 }, { "preset_id": 402, @@ -10642,19 +10642,19 @@ }, { "preset_id": 403, - "cos": 0.256338 + "cos": 0.31811 }, { "preset_id": 404, - "cos": 0.204937 + "cos": 0.205724 }, { "preset_id": 405, - "cos": 0.192087 + "cos": 0.186637 }, { "preset_id": 406, - "cos": 0.205407 + "cos": 0.229264 } ] }, @@ -10663,111 +10663,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.475195 + "cos": 0.474751 }, { "preset_id": 102, - "cos": 0.328264 + "cos": 0.332616 }, { "preset_id": 103, - "cos": 0.291837 + "cos": 0.268085 }, { "preset_id": 104, - "cos": 0.320942 + "cos": 0.317046 }, { "preset_id": 105, - "cos": 0.198666 + "cos": 0.195265 }, { "preset_id": 106, - "cos": 0.217298 + "cos": 0.217405 }, { "preset_id": 201, - "cos": 0.237003 + "cos": 0.211855 }, { "preset_id": 202, - "cos": 0.191462 + "cos": 0.191532 }, { "preset_id": 203, - "cos": 0.220122 + "cos": 0.219089 }, { "preset_id": 204, - "cos": 0.209809 + "cos": 0.209805 }, { "preset_id": 205, - "cos": 0.282261 + "cos": 0.270038 }, { "preset_id": 206, - "cos": 0.202869 + "cos": 0.159678 }, { "preset_id": 207, - "cos": 0.182638 + "cos": 0.182541 }, { "preset_id": 208, - "cos": 0.231607 + "cos": 0.228619 }, { "preset_id": 301, - "cos": 0.202595 + "cos": 0.209817 }, { "preset_id": 302, - "cos": 0.162704 + "cos": 0.16555 }, { "preset_id": 303, - "cos": 0.158092 + "cos": 0.185565 }, { "preset_id": 304, - "cos": 0.179733 + "cos": 0.177374 }, { "preset_id": 305, - "cos": 0.167581 + "cos": 0.149237 }, { "preset_id": 306, - "cos": 0.210971 + "cos": 0.19449 }, { "preset_id": 307, - "cos": 0.124753 + "cos": 0.164773 }, { "preset_id": 401, - "cos": 0.214432 + "cos": 0.232896 }, { "preset_id": 402, - "cos": 0.238569 + "cos": 0.238579 }, { "preset_id": 403, - "cos": 0.236485 + "cos": 0.264464 }, { "preset_id": 404, - "cos": 0.185619 + "cos": 0.199632 }, { "preset_id": 405, - "cos": 0.214384 + "cos": 0.243611 }, { "preset_id": 406, - "cos": 0.285974 + "cos": 0.27613 } ] }, @@ -10776,111 +10776,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.193745 + "cos": 0.192492 }, { "preset_id": 102, - "cos": 0.170108 + "cos": 0.168892 }, { "preset_id": 103, - "cos": 0.149315 + "cos": 0.103986 }, { "preset_id": 104, - "cos": 0.149105 + "cos": 0.149782 }, { "preset_id": 105, - "cos": 0.186241 + "cos": 0.178178 }, { "preset_id": 106, - "cos": 0.150918 + "cos": 0.149866 }, { "preset_id": 201, - "cos": 0.164634 + "cos": 0.123748 }, { "preset_id": 202, - "cos": 0.131199 + "cos": 0.131219 }, { "preset_id": 203, - "cos": 0.13295 + "cos": 0.142204 }, { "preset_id": 204, - "cos": 0.15348 + "cos": 0.15342 }, { "preset_id": 205, - "cos": 0.169275 + "cos": 0.171384 }, { "preset_id": 206, - "cos": 0.205116 + "cos": 0.201791 }, { "preset_id": 207, - "cos": 0.220885 + "cos": 0.220911 }, { "preset_id": 208, - "cos": 0.239656 + "cos": 0.215964 }, { "preset_id": 301, - "cos": 0.188033 + "cos": 0.173307 }, { "preset_id": 302, - "cos": 0.19733 + "cos": 0.187024 }, { "preset_id": 303, - "cos": 0.286798 + "cos": 0.260803 }, { "preset_id": 304, - "cos": 0.174456 + "cos": 0.179215 }, { "preset_id": 305, - "cos": 0.207215 + "cos": 0.197695 }, { "preset_id": 306, - "cos": 0.285843 + "cos": 0.238458 }, { "preset_id": 307, - "cos": 0.175273 + "cos": 0.207983 }, { "preset_id": 401, - "cos": 0.189535 + "cos": 0.183028 }, { "preset_id": 402, - "cos": 0.227571 + "cos": 0.227514 }, { "preset_id": 403, - "cos": 0.169743 + "cos": 0.213496 }, { "preset_id": 404, - "cos": 0.152136 + "cos": 0.152703 }, { "preset_id": 405, - "cos": 0.146344 + "cos": 0.188011 }, { "preset_id": 406, - "cos": 0.188592 + "cos": 0.168753 } ] }, @@ -10889,111 +10889,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.167607 + "cos": 0.16159 }, { "preset_id": 102, - "cos": 0.228247 + "cos": 0.23995 }, { "preset_id": 103, - "cos": 0.138761 + "cos": 0.175876 }, { "preset_id": 104, - "cos": 0.230434 + "cos": 0.232872 }, { "preset_id": 105, - "cos": 0.136971 + "cos": 0.140096 }, { "preset_id": 106, - "cos": 0.163259 + "cos": 0.160728 }, { "preset_id": 201, - "cos": 0.156752 + "cos": 0.162414 }, { "preset_id": 202, - "cos": 0.223087 + "cos": 0.22311 }, { "preset_id": 203, - "cos": 0.185543 + "cos": 0.194494 }, { "preset_id": 204, - "cos": 0.228701 + "cos": 0.228647 }, { "preset_id": 205, - "cos": 0.272915 + "cos": 0.241329 }, { "preset_id": 206, - "cos": 0.214388 + "cos": 0.202856 }, { "preset_id": 207, - "cos": 0.191461 + "cos": 0.191426 }, { "preset_id": 208, - "cos": 0.279727 + "cos": 0.281349 }, { "preset_id": 301, - "cos": 0.105745 + "cos": 0.107261 }, { "preset_id": 302, - "cos": 0.105601 + "cos": 0.107712 }, { "preset_id": 303, - "cos": 0.174089 + "cos": 0.175845 }, { "preset_id": 304, - "cos": 0.180368 + "cos": 0.177483 }, { "preset_id": 305, - "cos": 0.131921 + "cos": 0.17001 }, { "preset_id": 306, - "cos": 0.179068 + "cos": 0.162637 }, { "preset_id": 307, - "cos": 0.273443 + "cos": 0.24114 }, { "preset_id": 401, - "cos": 0.11812 + "cos": 0.100055 }, { "preset_id": 402, - "cos": 0.224805 + "cos": 0.224822 }, { "preset_id": 403, - "cos": 0.220724 + "cos": 0.228817 }, { "preset_id": 404, - "cos": 0.067813 + "cos": 0.076419 }, { "preset_id": 405, - "cos": 0.216305 + "cos": 0.221184 }, { "preset_id": 406, - "cos": 0.192919 + "cos": 0.187939 } ] }, @@ -11002,111 +11002,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.149513 + "cos": 0.153933 }, { "preset_id": 102, - "cos": 0.25728 + "cos": 0.265762 }, { "preset_id": 103, - "cos": 0.153528 + "cos": 0.172385 }, { "preset_id": 104, - "cos": 0.165853 + "cos": 0.174984 }, { "preset_id": 105, - "cos": 0.14518 + "cos": 0.152741 }, { "preset_id": 106, - "cos": 0.228403 + "cos": 0.225972 }, { "preset_id": 201, - "cos": 0.16843 + "cos": 0.121794 }, { "preset_id": 202, - "cos": 0.264906 + "cos": 0.264909 }, { "preset_id": 203, - "cos": 0.251679 + "cos": 0.197166 }, { "preset_id": 204, - "cos": 0.217403 + "cos": 0.217413 }, { "preset_id": 205, - "cos": 0.309081 + "cos": 0.287219 }, { "preset_id": 206, - "cos": 0.273382 + "cos": 0.265671 }, { "preset_id": 207, - "cos": 0.241632 + "cos": 0.241567 }, { "preset_id": 208, - "cos": 0.248925 + "cos": 0.253884 }, { "preset_id": 301, - "cos": 0.178009 + "cos": 0.180125 }, { "preset_id": 302, - "cos": 0.183583 + "cos": 0.189527 }, { "preset_id": 303, - "cos": 0.194846 + "cos": 0.204836 }, { "preset_id": 304, - "cos": 0.241799 + "cos": 0.21785 }, { "preset_id": 305, - "cos": 0.14846 + "cos": 0.187173 }, { "preset_id": 306, - "cos": 0.209955 + "cos": 0.198152 }, { "preset_id": 307, - "cos": 0.232988 + "cos": 0.268871 }, { "preset_id": 401, - "cos": 0.120059 + "cos": 0.118797 }, { "preset_id": 402, - "cos": 0.257353 + "cos": 0.257311 }, { "preset_id": 403, - "cos": 0.180012 + "cos": 0.171986 }, { "preset_id": 404, - "cos": 0.11588 + "cos": 0.127447 }, { "preset_id": 405, - "cos": 0.22934 + "cos": 0.210816 }, { "preset_id": 406, - "cos": 0.180714 + "cos": 0.172347 } ] }, @@ -11115,111 +11115,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.162441 + "cos": 0.153645 }, { "preset_id": 102, - "cos": 0.1477 + "cos": 0.153506 }, { "preset_id": 103, - "cos": 0.113842 + "cos": 0.100103 }, { "preset_id": 104, - "cos": 0.21767 + "cos": 0.213373 }, { "preset_id": 105, - "cos": 0.203777 + "cos": 0.21135 }, { "preset_id": 106, - "cos": 0.140094 + "cos": 0.13791 }, { "preset_id": 201, - "cos": 0.205817 + "cos": 0.178703 }, { "preset_id": 202, - "cos": 0.181606 + "cos": 0.181574 }, { "preset_id": 203, - "cos": 0.261129 + "cos": 0.173062 }, { "preset_id": 204, - "cos": 0.199181 + "cos": 0.199101 }, { "preset_id": 205, - "cos": 0.175361 + "cos": 0.150329 }, { "preset_id": 206, - "cos": 0.270689 + "cos": 0.250242 }, { "preset_id": 207, - "cos": 0.151434 + "cos": 0.151461 }, { "preset_id": 208, - "cos": 0.239631 + "cos": 0.241112 }, { "preset_id": 301, - "cos": 0.163569 + "cos": 0.170383 }, { "preset_id": 302, - "cos": 0.127344 + "cos": 0.1393 }, { "preset_id": 303, - "cos": 0.227602 + "cos": 0.194119 }, { "preset_id": 304, - "cos": 0.185255 + "cos": 0.194672 }, { "preset_id": 305, - "cos": 0.175562 + "cos": 0.179479 }, { "preset_id": 306, - "cos": 0.217454 + "cos": 0.172663 }, { "preset_id": 307, - "cos": 0.200958 + "cos": 0.192826 }, { "preset_id": 401, - "cos": 0.113216 + "cos": 0.115959 }, { "preset_id": 402, - "cos": 0.168064 + "cos": 0.168033 }, { "preset_id": 403, - "cos": 0.160321 + "cos": 0.188464 }, { "preset_id": 404, - "cos": 0.135083 + "cos": 0.125335 }, { "preset_id": 405, - "cos": 0.239474 + "cos": 0.222293 }, { "preset_id": 406, - "cos": 0.181035 + "cos": 0.198217 } ] }, @@ -11228,111 +11228,111 @@ "cos": [ { "preset_id": 101, - "cos": 0.405896 + "cos": 0.394865 }, { "preset_id": 102, - "cos": 0.377996 + "cos": 0.382544 }, { "preset_id": 103, - "cos": 0.350849 + "cos": 0.302001 }, { "preset_id": 104, - "cos": 0.320143 + "cos": 0.310294 }, { "preset_id": 105, - "cos": 0.210123 + "cos": 0.198449 }, { "preset_id": 106, - "cos": 0.232411 + "cos": 0.233243 }, { "preset_id": 201, - "cos": 0.355783 + "cos": 0.312759 }, { "preset_id": 202, - "cos": 0.358129 + "cos": 0.358194 }, { "preset_id": 203, - "cos": 0.333091 + "cos": 0.27462 }, { "preset_id": 204, - "cos": 0.442065 + "cos": 0.442011 }, { "preset_id": 205, - "cos": 0.345734 + "cos": 0.33046 }, { "preset_id": 206, - "cos": 0.294084 + "cos": 0.20868 }, { "preset_id": 207, - "cos": 0.216811 + "cos": 0.216785 }, { "preset_id": 208, - "cos": 0.271592 + "cos": 0.265545 }, { "preset_id": 301, - "cos": 0.193589 + "cos": 0.183542 }, { "preset_id": 302, - "cos": 0.180381 + "cos": 0.176162 }, { "preset_id": 303, - "cos": 0.193642 + "cos": 0.218163 }, { "preset_id": 304, - "cos": 0.158019 + "cos": 0.155351 }, { "preset_id": 305, - "cos": 0.159706 + "cos": 0.147811 }, { "preset_id": 306, - "cos": 0.263341 + "cos": 0.12769 }, { "preset_id": 307, - "cos": 0.190735 + "cos": 0.20459 }, { "preset_id": 401, - "cos": 0.258814 + "cos": 0.267607 }, { "preset_id": 402, - "cos": 0.265215 + "cos": 0.265272 }, { "preset_id": 403, - "cos": 0.229716 + "cos": 0.237789 }, { "preset_id": 404, - "cos": 0.192945 + "cos": 0.215847 }, { "preset_id": 405, - "cos": 0.311727 + "cos": 0.319565 }, { "preset_id": 406, - "cos": 0.198289 + "cos": 0.189573 } ] } diff --git a/tools/search_cut/keyword_matrix.py b/tools/search_cut/keyword_matrix.py index 7d16c23..9186c38 100644 --- a/tools/search_cut/keyword_matrix.py +++ b/tools/search_cut/keyword_matrix.py @@ -57,9 +57,12 @@ SEARCH = ROOT / ".search" -# 시연 정본은 15432 다(T33). `matrix.py`·`word_matrix.py`·`recall_probe.py` 와 같은 가드 — -# 데이터가 없는 DB 를 재면 「keyword 신호가 아무 데도 없다」가 결론으로 나온다. -EXPECT_PORT = "15432" +# 검색 고도화 측정 정본은 스냅샷 DB 다(P49 §6) — `lexical_matrix.py` 와 같은 가드다. +# 이 상수는 P48 1단계 때 시연 DB(:15432)를 재던 값(T33)으로 남아 있었고, 트랙이 스냅샷으로 +# 옮겨간 뒤에도 이 도구만 갱신되지 않았다. 시연 DB 를 직접 재면 측정과 시연이 같은 데이터를 +# 공유해 부수효과가 섞인다. 데이터가 없는 DB 를 재면 「keyword 신호가 아무 데도 없다」가 +# 결론으로 나오는 것은 그대로 막는다. +EXPECT_PORT = "25432" MATRICES = ("matrix.json", "word_grid.json", "recall_probe.json") From 3f7325a2594afc64232877581f897496ed3243db Mon Sep 17 00:00:00 2001 From: colosair Date: Fri, 7 Aug 2026 17:24:18 +0900 Subject: [PATCH 26/34] =?UTF-8?q?test:=20=ED=91=9C=EC=8B=9C=EB=AA=85=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EB=B0=98=EC=98=81=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=AC=EA=B2=80=EC=A6=9D=20=E2=80=94=205?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=A0=84=EB=B6=80=20=EC=9E=AC=ED=86=B5?= =?UTF-8?q?=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프리셋 표시명을 정본으로 재적재한 뒤(5159e37) 게이트를 3 phase 전량 다시 돌렸다. 부분 재실행은 성립하지 않는다 — judge 가 off·on·degraded 세 결과를 함께 읽으므로 한 phase 만 갱신하면 옛 결과와 새 결과가 섞인 판정이 조용히 나온다. 판정: ① 잔존 3건(신한·부캠·신한 부캠) 회복 ② 무관 무노출 11/15 ③ 시연 12건 퇴행 0 (1위 10/12, 현행과 동일) ④ 전 플래그 off 가 현행과 동일 ⑤ LLM 타임아웃 시 벡터 복귀. ④를 다시 실증한 것이 이번 재검증의 핵심이다 — 스냅샷 DB 에 쓰기가 들어간 뒤이므로 「끈 상태가 현행과 같다」가 여전히 성립하는지가 이 변경의 방어선이다. Co-Authored-By: Claude Fable 5 --- .search/gate_degraded.json | 62 +++++++++++++++--------------- .search/gate_off.json | 42 ++++++++++---------- .search/gate_on.json | 78 +++++++++++++++++++------------------- 3 files changed, 91 insertions(+), 91 deletions(-) diff --git a/.search/gate_degraded.json b/.search/gate_degraded.json index db2a098..4ca3622 100644 --- a/.search/gate_degraded.json +++ b/.search/gate_degraded.json @@ -105,21 +105,6 @@ "similarity": 0.4825, "place": "플랜트 연남점" }, - { - "recordId": 282, - "similarity": 0.3886, - "place": "치킨버거 이스트사이드" - }, - { - "recordId": 290, - "similarity": 0.376, - "place": "힉스커피" - }, - { - "recordId": 287, - "similarity": 0.3653, - "place": "동교어린이공원" - }, { "recordId": 285, "similarity": 0.4108, @@ -130,10 +115,25 @@ "similarity": 0.4054, "place": "진우네 초밥" }, + { + "recordId": 282, + "similarity": 0.3886, + "place": "치킨버거 이스트사이드" + }, { "recordId": 278, "similarity": 0.381, "place": "감나무집기사식당" + }, + { + "recordId": 290, + "similarity": 0.376, + "place": "힉스커피" + }, + { + "recordId": 287, + "similarity": 0.3653, + "place": "동교어린이공원" } ] }, @@ -146,12 +146,12 @@ "items": [ { "recordId": 279, - "similarity": 0.5192, + "similarity": 0.5191, "place": "사루카메" }, { "recordId": 283, - "similarity": 0.5102, + "similarity": 0.5104, "place": "쿠로코식당 연남점" } ] @@ -168,24 +168,24 @@ "similarity": 0.4512, "place": "카츠요" }, - { - "recordId": 280, - "similarity": 0.3644, - "place": "뉴오더클럽 연남" - }, { "recordId": 287, "similarity": 0.3675, "place": "동교어린이공원" }, + { + "recordId": 280, + "similarity": 0.3642, + "place": "뉴오더클럽 연남" + }, { "recordId": 290, - "similarity": 0.3617, + "similarity": 0.3616, "place": "힉스커피" }, { "recordId": 292, - "similarity": 0.3547, + "similarity": 0.3546, "place": "적당" }, { @@ -195,17 +195,17 @@ }, { "recordId": 285, - "similarity": 0.3462, + "similarity": 0.3464, "place": "월강부산돼지국밥" }, { "recordId": 282, - "similarity": 0.3281, + "similarity": 0.3282, "place": "치킨버거 이스트사이드" }, { "recordId": 284, - "similarity": 0.319, + "similarity": 0.3191, "place": "진우네 초밥" } ] @@ -321,7 +321,7 @@ "items": [ { "recordId": 272, - "similarity": 0.5116, + "similarity": 0.5115, "place": "주토피아 서울" }, { @@ -408,12 +408,12 @@ "items": [ { "recordId": 283, - "similarity": 0.4102, + "similarity": 0.4103, "place": "쿠로코식당 연남점" }, { "recordId": 281, - "similarity": 0.3304, + "similarity": 0.3305, "place": "플랜트 연남점" }, { @@ -556,7 +556,7 @@ "items": [ { "recordId": 268, - "similarity": 0.3007, + "similarity": 0.3008, "place": "키친갈매기" } ] diff --git a/.search/gate_off.json b/.search/gate_off.json index 21a7a8f..0d6ce51 100644 --- a/.search/gate_off.json +++ b/.search/gate_off.json @@ -16,7 +16,7 @@ }, { "recordId": 255, - "similarity": 0.3492, + "similarity": 0.3491, "place": "[데모] 언덕 위 야경 식당" } ] @@ -30,12 +30,12 @@ "items": [ { "recordId": 253, - "similarity": 0.4969, + "similarity": 0.4968, "place": "[데모] 창가 작업실 카페" }, { "recordId": 256, - "similarity": 0.3294, + "similarity": 0.3295, "place": "[데모] 넓은 한상 식당" }, { @@ -175,17 +175,17 @@ }, { "recordId": 280, - "similarity": 0.3644, + "similarity": 0.3642, "place": "뉴오더클럽 연남" }, { "recordId": 290, - "similarity": 0.3617, + "similarity": 0.3616, "place": "힉스커피" }, { "recordId": 292, - "similarity": 0.3547, + "similarity": 0.3546, "place": "적당" }, { @@ -195,17 +195,17 @@ }, { "recordId": 285, - "similarity": 0.3462, + "similarity": 0.3464, "place": "월강부산돼지국밥" }, { "recordId": 282, - "similarity": 0.3281, + "similarity": 0.3282, "place": "치킨버거 이스트사이드" }, { "recordId": 284, - "similarity": 0.319, + "similarity": 0.3191, "place": "진우네 초밥" } ] @@ -273,17 +273,17 @@ }, { "recordId": 287, - "similarity": 0.3928, + "similarity": 0.3929, "place": "동교어린이공원" }, { "recordId": 290, - "similarity": 0.3904, + "similarity": 0.3905, "place": "힉스커피" }, { "recordId": 285, - "similarity": 0.3831, + "similarity": 0.3832, "place": "월강부산돼지국밥" }, { @@ -293,7 +293,7 @@ }, { "recordId": 292, - "similarity": 0.3411, + "similarity": 0.3412, "place": "적당" } ] @@ -321,7 +321,7 @@ "items": [ { "recordId": 272, - "similarity": 0.5116, + "similarity": 0.5115, "place": "주토피아 서울" }, { @@ -417,7 +417,7 @@ "items": [ { "recordId": 283, - "similarity": 0.5868, + "similarity": 0.5867, "place": "쿠로코식당 연남점" }, { @@ -427,7 +427,7 @@ }, { "recordId": 284, - "similarity": 0.3678, + "similarity": 0.3679, "place": "진우네 초밥" } ] @@ -446,7 +446,7 @@ }, { "recordId": 290, - "similarity": 0.2676, + "similarity": 0.2677, "place": "힉스커피" }, { @@ -456,7 +456,7 @@ }, { "recordId": 286, - "similarity": 0.266, + "similarity": 0.2659, "place": "저스트텐동 연남본점" }, { @@ -480,7 +480,7 @@ "items": [ { "recordId": 287, - "similarity": 0.244, + "similarity": 0.2438, "place": "동교어린이공원" }, { @@ -531,7 +531,7 @@ "items": [ { "recordId": 268, - "similarity": 0.3008, + "similarity": 0.3007, "place": "키친갈매기" } ] @@ -550,7 +550,7 @@ }, { "recordId": 293, - "similarity": 0.3008, + "similarity": 0.3006, "place": "키친갈매기" } ] diff --git a/.search/gate_on.json b/.search/gate_on.json index d9ddb72..efa5189 100644 --- a/.search/gate_on.json +++ b/.search/gate_on.json @@ -16,7 +16,7 @@ }, { "recordId": 255, - "similarity": 0.3491, + "similarity": 0.3492, "place": "[데모] 언덕 위 야경 식당" } ] @@ -102,14 +102,29 @@ }, { "recordId": 281, - "similarity": 0.4826, + "similarity": 0.4825, "place": "플랜트 연남점" }, + { + "recordId": 285, + "similarity": 0.4108, + "place": "월강부산돼지국밥" + }, + { + "recordId": 284, + "similarity": 0.4054, + "place": "진우네 초밥" + }, { "recordId": 282, "similarity": 0.3886, "place": "치킨버거 이스트사이드" }, + { + "recordId": 278, + "similarity": 0.381, + "place": "감나무집기사식당" + }, { "recordId": 290, "similarity": 0.376, @@ -117,23 +132,8 @@ }, { "recordId": 287, - "similarity": 0.3654, + "similarity": 0.3653, "place": "동교어린이공원" - }, - { - "recordId": 285, - "similarity": 0.4107, - "place": "월강부산돼지국밥" - }, - { - "recordId": 284, - "similarity": 0.4054, - "place": "진우네 초밥" - }, - { - "recordId": 278, - "similarity": 0.3809, - "place": "감나무집기사식당" } ] }, @@ -168,24 +168,24 @@ "similarity": 0.4512, "place": "카츠요" }, - { - "recordId": 280, - "similarity": 0.3644, - "place": "뉴오더클럽 연남" - }, { "recordId": 287, "similarity": 0.3675, "place": "동교어린이공원" }, + { + "recordId": 280, + "similarity": 0.3642, + "place": "뉴오더클럽 연남" + }, { "recordId": 290, - "similarity": 0.3617, + "similarity": 0.3616, "place": "힉스커피" }, { "recordId": 292, - "similarity": 0.3547, + "similarity": 0.3546, "place": "적당" }, { @@ -195,17 +195,17 @@ }, { "recordId": 285, - "similarity": 0.3462, + "similarity": 0.3464, "place": "월강부산돼지국밥" }, { "recordId": 282, - "similarity": 0.3281, + "similarity": 0.3282, "place": "치킨버거 이스트사이드" }, { "recordId": 284, - "similarity": 0.319, + "similarity": 0.3191, "place": "진우네 초밥" } ] @@ -219,7 +219,7 @@ "items": [ { "recordId": 282, - "similarity": 0.5263, + "similarity": 0.5264, "place": "치킨버거 이스트사이드" }, { @@ -413,7 +413,7 @@ }, { "recordId": 283, - "similarity": 0.2613, + "similarity": 0.2614, "place": "쿠로코식당 연남점" }, { @@ -428,7 +428,7 @@ }, { "recordId": 282, - "similarity": 0.2588, + "similarity": 0.2589, "place": "치킨버거 이스트사이드" }, { @@ -438,12 +438,12 @@ }, { "recordId": 287, - "similarity": 0.2454, + "similarity": 0.2455, "place": "동교어린이공원" }, { "recordId": 290, - "similarity": 0.2436, + "similarity": 0.2435, "place": "힉스커피" } ] @@ -491,12 +491,12 @@ }, { "recordId": 290, - "similarity": 0.2676, + "similarity": 0.2677, "place": "힉스커피" }, { "recordId": 286, - "similarity": 0.266, + "similarity": 0.2659, "place": "저스트텐동 연남본점" }, { @@ -571,7 +571,7 @@ "items": [ { "recordId": 268, - "similarity": 0.3007, + "similarity": 0.3008, "place": "키친갈매기" } ] @@ -590,7 +590,7 @@ }, { "recordId": 293, - "similarity": 0.3008, + "similarity": 0.3006, "place": "키친갈매기" } ] @@ -628,7 +628,7 @@ "items": [ { "recordId": 253, - "similarity": 0.3371, + "similarity": 0.3372, "place": "[데모] 창가 작업실 카페" } ] @@ -666,7 +666,7 @@ "items": [ { "recordId": 273, - "similarity": 0.3248, + "similarity": 0.3249, "place": "오향절면" } ] From 86ed6788f54ee471a571b16b0b9f901dd2fd8c26 Mon Sep 17 00:00:00 2001 From: colosair Date: Fri, 7 Aug 2026 17:34:18 +0900 Subject: [PATCH 27/34] =?UTF-8?q?docs(S15P11A705-339):=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=AA=85=20=EC=A0=95=ED=95=A9=20=ED=9A=8C=EB=B3=B5?= =?UTF-8?q?=EA=B3=BC=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EC=9E=AC=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20I57(=EC=9E=A0?= =?UTF-8?q?=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프리셋 표시명 개정(-292)이 두 DB에 미반영이던 결함을 스냅샷 재적재로 고치고, 그 조작이 무효화한 측정을 전부 다시 뜬 기록이다. 조작 범위를 md5 4종으로 대조해 판정이 불변임을 고정했고, 새 임베딩 격자에서 채택값이 유지됨을 확인했다. 기록해야 할 변화가 하나 있다 — 선행 리포트(I56)가 적은 문장형 개선이 이번 측정에서는 사라지고 floor 0.40 구간으로 옮겨갔다. 재정렬의 이득이 단어형에 집중된다는 뜻이다. 채택 조건(퇴행 없이 개선)은 여전히 만족하므로 값은 그대로 둔다. 한계도 남긴다: 프리셋 개정 번호가 1로 고정돼 신선도 검사가 이 무효화를 자동으로 잡지 못했고, 기준선 0.35 인접 쌍 10건 중 9건이 임베딩 흔들림 폭 안에 있다. Co-Authored-By: Claude Fable 5 --- .../2026-08-07-preset-label-realign-gate.md | 117 ++++++++++++++++++ docs/implements/README.md | 2 + 2 files changed, 119 insertions(+) create mode 100644 docs/implements/2026-08-07-preset-label-realign-gate.md diff --git a/docs/implements/2026-08-07-preset-label-realign-gate.md b/docs/implements/2026-08-07-preset-label-realign-gate.md new file mode 100644 index 0000000..28f2a89 --- /dev/null +++ b/docs/implements/2026-08-07-preset-label-realign-gate.md @@ -0,0 +1,117 @@ +# 프리셋 표시명 정합 회복과 검증 게이트 재검증 — 채택값 유지 확인 + +- **티켓**: 검색 고도화 트랙 공통 (P49 §8 작업 6) + 표시명 정합 결함 처리 +- **날짜**: 2026-08-07 +- **하네스**: `tools/e2e/run_gate.py`(게이트) · `tools/search_cut/keyword_matrix.py`·`fusion_rerank_sweep.py`(재측정) +- **기준 문서**: [P49](../proposals/P49-multi-signal-search.md) §7(검증 기준)·§8(작업 6) · 선행 [I56](2026-08-06-rerank-adoption.md)(재정렬 채택값) +- **성격**: 정합 결함을 고치고, 그 조작이 무효화한 측정을 다시 재고, 게이트를 다시 통과시킨 기록이다. 이 판정이 3레포 동시 릴리스의 근거다. +- **번호는 잠정 I57이다.** 릴리스 병합 직후 재확정한다. + +## 한눈에 보는 결과 + +| 항목 | 확인한 내용 | 결정 | +|---|---|---| +| 정합 결함 | 프리셋 표시명 명사형 통일(-292)이 YAML 정본에만 반영되고 두 DB에는 옛 표시명이 남아 있었다 | 스냅샷 DB를 정본으로 재적재해 해소 | +| 재적재의 부수효과 | Context 임베딩·판정 상태·`context_keyword` 83행의 md5가 전부 불변, 프리셋 임베딩만 변경 | 판정 결과는 건드리지 않았다 | +| 채택값 | 새 임베딩 기준 격자에서도 binary·floor 0.35·weight 0.05가 퇴행 없이 단어형을 개선한다 | **채택값 유지**(코드 변경 없음) | +| 변화 | 문장형 정답의 개선(평균 역순위 0.9028→0.9167)이 사라지고 floor 0.40 구간으로 옮겨갔다 | 재정렬의 이득이 단어형에 집중된다는 뜻. 채택 조건은 여전히 만족 | +| 게이트 | 5기준 전부 재통과 | 3레포 릴리스 가능 | + +## 배경 + +검색 고도화 트랙은 2026-08-06에 게이트 5기준을 통과했다. 그 직후 조사에서 정합 결함이 드러났다. 프리셋 표시명을 명사형으로 통일한 개정(-292, 「커피챗」→「대화」·「뷰맛집」→「전망」 등 21건)이 `data/keyword_preset.yaml` 정본에만 반영되고, 시연 DB와 그 사본인 스냅샷 DB에는 옛 표시명이 시딩된 채 남아 있었다. + +이것이 측정 문제인 이유는 프리셋 임베딩의 입력이 표시명·정의문·예문의 결합이기 때문이다. 표시명이 다르면 임베딩이 다르고, 임베딩이 다르면 질의와 프리셋의 코사인이 달라진다. 즉 **키워드 재정렬의 채택값과 게이트 증거가 모두 옛 표시명 임베딩 위에서 측정된 것**이었다. 프리셋 개정 번호(`preset_version`)가 항상 1로 고정돼 있어 측정 도구의 신선도 검사가 이 어긋남을 잡지 못했다. + +사용자 결정은 「지금 갱신하고 다시 잰다」였다. 표시명을 고치면 시연 화면이 정본과 일치하지만 같은 조작이 측정 자산을 무효화하므로, 화면 정합과 측정 정합을 한 번에 처리한 것이다. + +## 수행한 조작과 그 범위 + +`python -m app.bootstrap.load_presets`를 스냅샷 DB(:25432)에 한 번 실행했다. 이 명령은 프리셋 27건의 표시명과 임베딩을 갱신하고, 그 밖의 테이블은 건드리지 않는다. + +조작 전후로 네 가지 값의 md5를 떠서 범위를 확인했다. + +| 대상 | 조작 전 | 조작 후 | 판정 | +|---|---|---|---| +| 프리셋 임베딩 27건 | `f59475e2…` | `b371c29e…` | 변경 — 의도한 것 | +| Context 임베딩 42건 | `dbcf5b0f…` | `dbcf5b0f…` | 불변 | +| 판정 상태 42건 | `9296cbc8…` | `9296cbc8…` | 불변 | +| `context_keyword` 83행 | `ce041626…` | `ce041626…` | 불변 | + +기존 기록의 판정 결과가 그대로 남았다는 뜻이다. 프리셋 식별자와 코드는 -292에서 바뀌지 않았으므로, 표시명만 고쳐도 「어느 기록에 어떤 키워드가 붙었는가」는 달라지지 않는다. 표시명 갱신 뒤 새로 판정되는 기록은 다른 선택을 할 수 있으나, 이번 조작에서는 새 판정이 없다. + +부수 정정 하나를 함께 했다. `keyword_matrix.py`의 대상 DB 검사가 `:15432`(시연 DB)로 남아 있었다. 검색 고도화의 측정 정본은 스냅샷 DB이고 다른 도구들은 이미 `:25432`를 검사하는데 이 도구만 갱신되지 않은 상태였다. 같은 값으로 맞췄다. + +## 확인한 결과 — 재측정 + +### 채택값은 유지된다 + +새 임베딩으로 행렬을 다시 뜨고(질의 92건 × 프리셋 27건) 격자를 다시 훑었다. 기존 채택값(binary 방식·floor 0.35·weight 0.05·상위 3개)의 성적은 다음과 같다. + +| 지표 (단어형 정답 66건) | 신호 없음 | floor 0.35 · w 0.05 | +|---|---|---| +| 1위 적중률 | 0.8636 | 0.8788 | +| 3위 내 적중률 | 0.9394 | 0.9545 | +| 평균 역순위 | 0.9015 | 0.9121 | +| 3위 내 재현율 | 0.9205 | 0.9356 | +| 3위 내 순위 품질 | 0.9007 | 0.9159 | + +문장형 정답 12건은 모든 지표가 신호 없음과 동일하다. 관련 없는 질의 세그먼트도 신호 없음과 완전히 같다(재정렬이 후보를 바꾸지 않는 구조적 성질). + +즉 **퇴행 없이 개선만 남는다**는 채택 조건을 그대로 만족한다. 코드·설정·테스트를 바꾸지 않았다. + +### 문장형의 개선이 사라졌다 — 이번 측정의 진짜 변화 + +선행 리포트(I56)는 같은 채택값에서 문장형 정답의 평균 역순위가 0.9028에서 0.9167로 오른다고 기록했다. **새 측정에서는 그 개선이 없다.** 대신 floor를 0.40으로 올린 구간에서 같은 개선이 나타난다. + +해석은 이렇다. 표시명이 바뀌면서 질의와 프리셋의 코사인 분포가 이동했고, 문장형 질의에서 개선을 만들던 프리셋이 0.35 기준선 아래로 내려갔다. 반대로 단어형의 개선은 유지된다. 결과적으로 **재정렬의 이득이 단어형 질의에 집중되는 그림**이 됐다. + +floor를 0.40으로 올려 문장형 개선을 되찾는 선택도 가능하지만 채택하지 않았다. 그 구간에서는 단어형 개선이 전부 사라져(모든 지표가 신호 없음과 동일) 이득의 총량이 줄기 때문이다. + +### 경계 안정성 + +채택값이 기준선 근처에서 흔들리는지 따로 쟀다. floor를 0.345·0.35·0.355·0.36으로 옮겨도 단어형의 1위 적중률과 3위 내 적중률은 완전히 같고 평균 역순위만 0.9121에서 0.9146 사이에서 움직인다. 문장형은 전 구간에서 신호 없음과 동일하다. + +다만 **기준선 근처가 붐빈다**는 사실은 기록해 둔다. 질의와 프리셋의 코사인 중 0.35에서 0.005 이내인 쌍이 10건이고, 그중 9건은 임베딩 API의 회차 간 흔들림 폭(0.0044) 안에 있다. 가장 가까운 것은 0.350061(차이 0.000061)이다. 지표가 안정적인 것은 그 쌍들이 최종 순위를 바꾸지 않기 때문이지 기준선이 여유로워서가 아니다. **프리셋을 다시 고치면 이 대역이 다시 움직인다.** + +순위 결합(RRF) 방식은 이번에도 기각했다. 문장형 1위 적중률이 0.8333에서 0.75로 떨어져 선행 판정과 같은 실패 방식을 보였다. + +### 결정성 + +같은 자료·같은 인자로 두 번 돌려 출력이 완전히 같음을 확인했다(SHA-256 `a423abfd…` 일치). + +## 확인한 결과 — 게이트 재검증 + +통합 브랜치 빌드의 back과 ai를 함께 띄워 사용자와 같은 경로로 세 조합(전부 끔·전부 켬·재작성 강제 타임아웃)을 각각 32질의씩 측정했다. **부분 재실행은 하지 않았다** — 판정 도구가 세 결과를 함께 읽으므로 한 조합만 갱신하면 옛 결과와 새 결과가 섞인 판정이 조용히 나온다. + +| 기준 (P49 §7) | 결과 | +|---|---| +| ① 잔존 3건(`신한`·`부캠`·`신한 부캠`) 회복 | 통과 — 전부 결과에 포함 | +| ② 관련 없는 질의 무노출 | 통과 — 15건 중 11건 무노출, 노출 4건의 분포도 동일 | +| ③ 시연 정본 12건 무퇴행 | 통과 — 퇴행 0건, 1위 적중 10/12로 현행과 동일 | +| ④ 전 플래그 끔 = 현행 동일 | 통과 | +| ⑤ 재작성 타임아웃 시 벡터 복귀 | 통과 — 전 질의 정상 응답, 재작성 몫만 소거 | + +**④를 다시 실증한 것이 이번 재검증의 핵심이다.** 스냅샷 DB에 쓰기가 들어간 뒤이므로 「끈 상태가 현행과 같다」가 여전히 성립하는지가 이 변경의 방어선이다. + +## 검증 — 실행한 것과 못 한 것 + +**실행한 것** + +- 조작 범위를 md5 네 종으로 조작 전후 대조. +- 행렬 재생성 → 격자 재실행(결정성 2회) → 경계 감도(0.345~0.36) → 게이트 3조합 전량. +- 회귀: pytest 517건 통과, 정적 검사·문서 색인 정합 통과. + +**못 한 것 — 한계로 남긴다** + +- **신선도 검사가 이 무효화를 자동으로 잡지 못한다.** 프리셋 개정 번호가 1로 고정돼 있어 도구의 검사를 통과한다. 이번에는 사람이 순서를 지켜 막았고, 판본의 증거는 행렬의 생성 시각과 위 md5뿐이다. 근본 해결(개정 번호 증가 경로)은 별건이다. +- **시연 DB는 아직 옛 표시명이다.** 화면 정합은 릴리스 뒤에 같은 조작으로 맞춘다. +- **판정 프롬프트 변화는 재지 않았다.** 표시명은 판정 입력에도 들어가므로 새 기록의 판정이 달라질 수 있다. 기존 기록의 판정은 불변이고, 새 판정의 품질 측정은 이번 범위 밖이다. + +## 근거 + +- 도구: `tools/e2e/run_gate.py` · `tools/search_cut/keyword_matrix.py` · `fusion_rerank_sweep.py` +- 증거: `.search/gate_{off,on,degraded,verdict}.json` · `.search/keyword_matrix.json`(재생성분) +- 커밋: `5159e37`(포트 가드 정정·행렬 재생성) · `3f7325a`(게이트 재검증 증거) +- 빌드: ai `search-upgrade` · back `search-upgrade`(dev 동기화 반영분) · docs `search-upgrade` +- 선행: [I56](2026-08-06-rerank-adoption.md)(재정렬 채택값 — 이 리포트가 같은 값을 새 조건에서 재확인) · I55(재작성 효과) · I54(문자열 병합 규칙) diff --git a/docs/implements/README.md b/docs/implements/README.md index e807f70..5411acf 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -58,6 +58,7 @@ | I54 | [2026-08-06-lexical-merge-rule.md](2026-08-06-lexical-merge-rule.md) | 구현 | 문자열 검색 병합 규칙 실측 — 게이트 3단(전체/단어형 한정/어절 경계) × 병합 3종(뒤 추가/RRF/앞 고정) 격자. **무관·타인소유·문장형 세그먼트에서 문자열 매치 0건** — 어떤 조합도 관련 없는 결과 노출을 늘리지 않는다. `신한` 이 전 조합에서 회복(RRF 5위)되고 `부캠` 은 예상대로 미회복(재작성 -337 대상). 경계 게이트는 기대 정답 6건을 제외하는 손해만 관측돼 부분일치 채택 권고. **규칙 확정안: 단어형 한정·부분일치 게이트 + RRF(k=60) 병합 + 컷·응답 계약 불변 + 실패 시 벡터만** — back 연동 티켓의 판단 재료. 한계: 경계 게이트가 막으려는 유형(어절 중간 시작·부수적 언급·동형어)이 코퍼스에 없어 이득 측정 불가, 운영 코퍼스 재평가 필요 (티켓 없음 · 런타임 변경 없음 · 스냅샷 DB) | | I55 | [2026-08-06-rewrite-effect.md](2026-08-06-rewrite-effect.md) | 구현 | LLM 질의 재작성 효과 실측·길이 게이트 확정 — 실제 RewriteClient(운영 체인)로 무관 15건·사례 5건 전/후 비교. **약어 회복 확인**(`부캠`→`부트캠프` 컷 전 8위→1위·`신한 부캠` 4위→3위, 둘 다 반환 회복)·고유명사 유지 규칙 작동(`신한`·`그네`·`스팟` 원문 불변). **전면 적용은 무관 무노출 11/15→9/15 로 채택 기준 미달** — 의미 불변 표현 정규화(`파는 데`→`파는 곳`)가 컷 경계 아래 대역을 밀어 올린다. 게이트 후보 8종 전수 시뮬레이션에서 **형태(길이) 기준만 충족** — 성과 기반(결과 0건·top-1 임계)은 두 집단 대역이 겹쳐 분리 불가에 회복 대상까지 놓치고(원문도 오답 3건 반환), 단어형 판정 재사용은 `신한 부캠` 상실. **게이트 확정: strip 후 6자 이하만 재작성**(`SEARCH_REWRITE_MAX_CHARS`, 사용자 확정) — 게이트 반영 재측정에서 무노출 11/15 유지·회복 2건 유지로 채택 조건 충족. 한계: 6~12자 대역 무관측·모델 1종 1회차 관측 (S15P11A705-337 · 스냅샷 DB) | | I56 | [2026-08-06-rerank-adoption.md](2026-08-06-rerank-adoption.md) | 구현 | 키워드 재정렬 채택값 실측·런타임 구현 — P49 §4 재정렬 전용 구조(컷 통과 집합 고정·순서만 조정)에서 BASE·binary(floor 5값×weight 4값)·RRF(k=60) 재측정. **binary·floor 0.35·w 0.05 만 퇴행 없이 개선**(단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129, 문장형 유지+MRR 개선), RRF 는 문장형 hit@1 0.8333→0.5833~0.6667 퇴행으로 기각. 무관 세그먼트는 전 조합에서 BASE 와 동일(구조적 불변 실측 확인·스크립트 가드). floor 0.35~0.36 지표 동일로 T68 흔들림에 안전. 런타임: 기본 off 플래그·`keyword_status=COMPLETED` 신호·실패 시 벡터 순서 복귀·similarity 원값 유지, on/off 후보 집합 불변 계약 테스트 9건 + 픽스처 8건(총 511 통과). **실서버 on/off 대조 5판정 93건 전부 통과** — 재정렬 발화 7건 전건 오프라인 일치(`만두` 4위→1위), `신한`·`부캠` 미회복 재확인(역할 분담 검증), 흔들림 분류 미발동. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 색인 검사가 잡아 나중에 병합된 이쪽이 I56 으로 재확정(설계된 절차), 최종 확정은 dev 병합 직후 (S15P11A705-339 · 스냅샷 DB) | +| I57 | [2026-08-07-preset-label-realign-gate.md](2026-08-07-preset-label-realign-gate.md) | 검증 | 프리셋 표시명 정합 회복과 게이트 재검증 — 표시명 명사형 통일(-292)이 YAML 정본에만 반영되고 두 DB에는 옛 표시명이 남아 **프리셋 임베딩·측정 자산이 정본과 어긋나 있던 결함**을 스냅샷 재적재로 해소. 조작 범위를 md5 4종으로 대조해 Context 임베딩·판정 상태·context_keyword 83행 불변 확인(판정 불변). 새 임베딩 격자에서 **채택값(binary·floor 0.35·w 0.05·top_k 3) 유지** — 퇴행 없이 단어형 개선(1위 0.8636→0.8788·3위내 0.9394→0.9545·MRR 0.9015→0.9121). **변화: 문장형 개선(MRR 0.9028→0.9167)이 사라지고 floor 0.40 구간으로 이동** — 재정렬의 이득이 단어형에 집중. 경계 감도 0.345~0.36 안정하나 기준선 0.35 인접 쌍 10건 중 9건이 임베딩 흔들림 폭 이내라는 한계 기록. 결정성 2회 일치. **게이트 5기준 전량 재통과**(3 phase 재실행 — judge가 세 결과를 함께 읽어 부분 재실행 불성립). keyword_matrix 포트 가드를 시연 DB에서 스냅샷으로 정정 동반 (검색 고도화 트랙 공통 · 스냅샷 DB) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -119,5 +120,6 @@ | I54 | 문자열 병합 규칙 하네스 `tools/search_cut/lexical_matrix.py`·`lexical_sweep.py` — 본문 매치를 (질의 × 소유자 × Record) 로 굳히고(본문 미저장·매치 여부만) 게이트×병합 격자를 오프라인으로 훑는다. 스냅샷 DB(:25432) 가드. 측정 결과: 문자열 매치 94건이 전부 정답 세그먼트에만 발생, 단어형 정답 라벨이 부분일치 정의라 재현율은 구조적으로 유리(순환성 명시), 병합 방식 차이는 RRF·앞고정 > 뒤추가. 단독 추가 7건 · 빈 결과 1건 해소 | [규칙 리포트](2026-08-06-lexical-merge-rule.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md) | | I55 | 재작성 효과 프로브 `tools/search_cut/rewrite_probe.py` — 실제 RewriteClient(운영 설정 체인) 호출 → 재작성문 임베딩 배치 1회 → 스냅샷 DB(:25432) 유사도 → 현행 컷 재구성(단어형/문장형 분기는 재작성문 기준, 서비스 미import 재구성 규칙). **재작성문 자체를 artifact 에 기록** — 무엇으로 바뀌었는지가 판정 근거의 절반. 원문 기준 값은 굳힌 행렬의 컷 재구성으로 얻어 GMS 호출 최소화, 재구성 무노출 11/15 가 기존 관측과 일치(교차 확인). 게이트(`should_rewrite` 재구성) 반영 후에는 게이트 통과 질의만 재작성·재임베딩 — 걸린 질의는 원문 값이 곧 결과(서비스와 동일). 강등 0건·전 질의 1순위 벤더 응답 관측 | [효과 리포트](2026-08-06-rewrite-effect.md), [I54](2026-08-06-lexical-merge-rule.md), [P49](../proposals/P49-multi-signal-search.md) | | I56 | 키워드 재정렬 하네스·런타임 `tools/search_cut/fusion_rerank_sweep.py`(재정렬 전용 격자 + 신선도 가드 + 후보 불변 검사) · `fusion.rerank()`(순수 함수) · `rerank_verify_live.py`(실서버 on/off 5판정) · `app/service/search_service.py`(`_rerank_by_keyword` — 질의 임베딩 재사용·추가 모델 호출 0) · `app/repository/context_keyword_repo.keywords_for_records` · `SEARCH_KEYWORD_RERANK_*` 4키(기본 off·floor 0.35·w 0.05·top_k 3) — 채택값은 재정렬 전용 구조 재측정으로 확정(I53 과 숫자가 같지만 다른 측정), 결정성 회차 JSON 바이트 일치, 실서버 대조 93건 5판정 전부 통과. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 재확정(설계된 절차), 최종 확정은 dev 병합 직후 | [재정렬 리포트](2026-08-06-rerank-adoption.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md), [tools/search_cut/](../../tools/search_cut/) | +| I57 | 표시명 정합 회복·재측정·게이트 재검증 — `app/bootstrap/load_presets` 재적재로 프리셋 표시명(-292 명사형)을 정본에 맞추고, 그 조작이 무효화한 측정을 전부 다시 뜬다. 조작 범위는 md5 4종 대조로 고정(프리셋 임베딩만 변경 · Context 임베딩·판정 상태·context_keyword 83행 불변). `keyword_matrix` 재생성 → `fusion_rerank_sweep` 격자 재실행(결정성 2회 일치) → 게이트 3 phase 전량. **채택값 유지**(퇴행 없이 단어형 개선) · **문장형 개선 소멸**(floor 0.40 구간으로 이동) · 기준선 인접 쌍 9건이 임베딩 흔들림 폭 이내라는 한계. `keyword_matrix.py` 포트 가드를 시연 DB(:15432)에서 스냅샷(:25432)으로 정정 | [재검증 리포트](2026-08-07-preset-label-realign-gate.md), [I56](2026-08-06-rerank-adoption.md), [P49](../proposals/P49-multi-signal-search.md) | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. From 761eb760df9a83f10df10ece2faa746593d42c3c Mon Sep 17 00:00:00 2001 From: ghkim1632 Date: Fri, 7 Aug 2026 18:29:31 +0900 Subject: [PATCH 28/34] =?UTF-8?q?feat(S15P11A705-401):=20=EA=B2=B0?= =?UTF-8?q?=ED=95=A9=20=EC=8B=A0=EB=A2=B0=EB=8F=84=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=84=EA=B3=84=EA=B0=92=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=9E=AC=EC=B8=A1=EC=A0=95=20=E2=80=94=20?= =?UTF-8?q?0.35=20=EC=B1=84=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md §4.3이 제안한 게이트의 threshold 후보 0.35는 SEARCH_KEYWORD_RERANK_FLOOR에서 가져온 값이라 이 용도(S1 단독· 저유사도 결과 숨김)로는 검증된 적이 없었다. gate_sweep.py로 기존 행렬(문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건)에 게이트 규칙을 적용해 0.35에서 정답 손실 0을 유지하며 무관 노출이 크게 줄어드는 것을 확인했다. DB·GMS 호출 없이 기존 커밋된 행렬만 읽는다. --- docs/WORKLOG.md | 1 + .../2026-08-07-gate-threshold-remeasure.md | 52 +++ tools/search_cut/gate_sweep.py | 364 ++++++++++++++++++ 3 files changed, 417 insertions(+) create mode 100644 docs/implements/2026-08-07-gate-threshold-remeasure.md create mode 100644 tools/search_cut/gate_sweep.py diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 2e36d60..c03a515 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -77,3 +77,4 @@ | 2026-08-03 | 하루에 유형마다 서너 번씩 난 **반복 사고 세 유형**을 트러블슈팅으로 남겼다(`S15P11A705-293`, T70~T72). **개별 사고의 해결이 아니라 반복 유형을 적는 문서다** — 각 건의 처리는 자기 티켓·PR·이슈에 있고 여기에는 「무엇이 반복됐는가」만 남는다. **유형마다 마지막 사고는 앞선 사고의 대응이 이미 있는 상태에서 났다** — 07-31 색인 사고 다섯 건과 같은 모양이고, 차이는 성실성이 아니라 강제 장치다. ① **사본에서 꺼낸 값**(T70) — 철회된 단정이 그 문서를 근거로 인용하며 되살아나고, 코드 한 행을 인용하며 **바로 다음 행**을 놓친 오독이 **이미 병합된 작업의 설계 근거**가 되고, 원장에 적어 둔 정정이 계약에서 원래 문구로 되돌아오고, 계약에 실은 수치가 여러 번 틀렸다. **넷 다 사본이 아니라 원문을 연 다른 사람이 잡았다** — 「쓰기 직전 재조회」 규칙은 이미 있었는데도 났다. 사본은 자기가 낡았다고 말하지 않으므로 대응을 읽는 쪽이 아니라 **쓰는 쪽**에 뒀다(`R-18` 「값 대신 출처」). 철회하는 쪽도 대상을 좁힌다 — **폐기된 것은 값이 아니라 값의 항상성이다.** ② **반만 덮는 자동화**(T71) — 「성공했다」와 「실제로 반영됐다」가 다르고 실패가 조용하다. **성공 판정을 네 층으로 가른다**(프로세스 생존 ≠ 실행 성공 ≠ 실제 변경 발생 ≠ 최종 전달 성공). 감시 고아는 heartbeat 로 고쳤고(판정은 사람이 한다), 배포 자동화 둘은 인프라 소관이라 이슈로 기록만 했다. **「다음 스케줄이 복구한다」는 전제 자체가 약하다.** ③ **공개 저장소 실사용자 기록**(T72) — 훅이 **바깥으로 나가는 발화**만 검사해 스크립트가 만들고 사람이 `git add` 한 산출물은 검사 지점을 한 번도 안 거쳤고, `.gitignore` 예외가 「커밋하라」고 **적극적으로 지시**하고 있었다. **대응 미정** — 선택지 넷 중 마스킹 문구의 경로 안내 제거만 즉시 가능하다. 위치·규모는 적지 않는다(위치 비공개 선례). **이 문서 자신이 T70 의 첫 시험이라 수치·코드 행 번호를 옮기지 않고 좌표만 가리킨다** | [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | | 2026-08-03 | 표시명 개정(`S15P11A705-292`)이 코드에서 운영 화면까지 간 **배포 체인을 기록했다**(I50). **체인 여섯 단계의 소관을 갈랐다** — 우리(`ai#102` dev · `ai#104` 릴리스) · 자동화(`image-publish` 는 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**) · 인프라 소관(`infra#185`) · 클러스터 sync · 화면. **핵심은 미결 판정 하나를 실측으로 닫은 것이다** — 프리셋 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 부트스트랩 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 이미지 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 「값이 같으면 재실행되지 않는다」로 판정했고 독립 검증이 논거를 반증했으나 **클러스터 관측 경로가 없어 「확인 불가」로 끝나 있었다** — 배포가 답을 냈다. **그래도 표기가 낡은 것은 기록 정합성 문제로 남는다**(배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다). **릴리스가 두 번 막혔고 둘 다 다음에 그대로 다시 만난다** — base 검사는 `main` base 를 `release/*`·`hotfix/*` 로 한정하므로 `dev` 를 그대로 열면 CI 가 막고(`ai#103` 이 그렇게 닫혔다), strict 상태 검사는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(병합 커밋 `032e391f`). 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 변경 없이 끝났고, 갱신을 만든 것은 수동 실행이라 **`success` 두 번의 뜻이 다르다.** 확인 수단이 화면뿐이라 「아직인가 실패인가」가 갈리지 않아 한 번 오판했다가 재확인에서 뒤집혔다. **값은 옮기지 않고 좌표만 적었다**(`T70` 적용) | [배포 체인 리포트](implements/2026-08-03-preset-display-name-deploy-chain.md), [I48](implements/2026-08-03-dev-deploy-gap.md), [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | | 2026-08-03 | 외부 리뷰 문서를 **P47 제안 문서로 다시 썼다** — 프리셋의 표시 라벨·축 정의·스키마 개정안(`S15P11A705-228`·`-269`·`-270`·`-271`·`-292`, `ai#90` 후속). **proposals 최초의 `Proposed`** 다 — §7 이후가 미실행이고 §11 실측이 없다. **다만 §6(표시 라벨)은 문서를 쓰는 사이에 반영이 끝났다** — `-292` 가 `display_name` 을 명사·명사구로 통일해 `ai#102`(`dev`)·`ai#104`(`main`)로 나갔고, 그래서 그 절만 제안이 아니라 **기록**으로 다시 썼다(제목에 「반영 완료」 · 표의 「권장 표시명」을 「반영값」으로 · 정본은 문서가 아니라 `data/keyword_preset.yaml` 임을 명시). **초안과 실제가 갈린 셋을 확정으로 남겼다** — `ALONE` 은 초안의 `1인` 을 기각하고 `혼자` 를 유지했고(축의 나머지가 관계어인데 수량 표현만 이질적이다), `TRENDY` 는 `examples` 가 유행이 아니라 미감을 가리키므로 `감성` 으로 확정했으며, `RETRO` 는 **초안에 없던 결정**으로 `복고` 가 됐다(같은 축의 어휘 층위 통일 · `description` 과 정합). **선결 조건이 통째로 바뀌었다** — 「프리셋을 고치면 기존 판정을 전부 재처리한다」로 정해지면서 「판을 구분할 경로」(행 `version` 을 올릴 경로가 없다는 사실, `-269`)가 선결에서 §3-F 로 내려갔고, 그 자리에 **「재처리할 수단이 없다」**가 들어갔다: 완료 State 를 되돌리는 코드가 없고 · 재스캔이 잡는 상태에 완료가 없으며 · 한 번에 밀면 게이트웨이 한도에 걸려 재시도 예산이 소진되면 실패로 굳는다(부재의 범위는 `ai#100`). **「정할 것」이 아니라 「만들 것」이라 §13 0단계가 구현 항목이 됐고**, 같은 이유로 §10.3 세트 release 는 운영 용도가 빠지고 평가 재현만 남아 우선순위가 내려갔다. **원본의 다섯 곳을 정정했다**: ① Feed 표시 정렬을 절째로 빼고 `back` P46 을 가리켰다(원본 제안이 그 결정이 **명시적으로 기각한** 방향이었다), ② 「CI 에서 검증되지 않는 것」 목록을 없애고 방향과 「테스트 코드가 정본」만 남겼다(이미 있는 테스트를 없다고 적고 있었다), ③ 배포 서술을 hook 메커니즘 존재라는 구조 사실로 줄였다(나머지는 `infra` 소관), ④ `version` 을 「세트 release 와 분리」가 아니라 판 식별 문제로 재배치, ⑤ 표시명 오염을 「임베딩 입력」이 아니라 **임베딩·판정 프롬프트 양쪽**으로 고쳤다 — 그래서 이 변경은 임베딩 실험이 아니라 프롬프트 실험 대상이다. **구조 사실 일곱을 보강했다**(개정에 기존 판정 재처리가 따라붙는다 · 후보 슬롯 경쟁으로 프리셋 단위 개선이 전역에서 상쇄된다 · 저빈도 프리셋 처분이 라벨 개정보다 앞선다 · 표시명 개정이 백엔드 조회의 정렬과 중복 흡수에 파급된다 · **`is_active` 를 끌 경로가 없어 tombstone 은 구멍 메우기다** · 공용 계약 개정 단계 · 임베딩 비결정성 때문에 1회 측정으로 채택을 가르지 않는다). **값을 쓰지 않았다** — 측정 수치·현행 상수·행 번호·배포 상태를 전부 출처 참조로 바꿨고 남은 숫자는 §6.2 의 반영값뿐인데 그것도 YAML 이 정본임을 표 앞에 못박았다. 문서가 값을 안고 있으면 값이 바뀔 때 문서만 낡는다. **`§6` 이 실측 없이 먼저 나간 것은 §14 에 감수 항목으로 남겼다** — 표시 계층만 손댄 범위였기 때문이고, 대가로 라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 §11 에 남는다 | [P47](proposals/P47-keyword-preset-label-axis.md), [근거 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md) | +| 2026-08-07 | 결합 신뢰도 게이트(중앙 조정 세션 인계 문서 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) 의 threshold 후보 0.35 를 오프라인으로 재측정했다(`S15P11A705-401`). 0.35 는 원래 `SEARCH_KEYWORD_RERANK_FLOOR`(질의-Preset 코사인 판정값)에서 가져온 값이라 이 용도(S1 단독·저유사도 결과를 숨기는 게이트)로는 검증된 적이 없었다. `gate_sweep.py` 로 문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건에 게이트 규칙을 적용해 보니 threshold 0.35 에서 정답 손실 0 을 유지하면서 무관 노출이 크게 줄었고(단어무관 23/45·타인소유 55/96 신규 침묵), 손실 0 구간의 최적값(0.36)과도 사실상 같았다. **최초 실행에서는 프로브 6 건이 손실로 나왔는데, 원인은 `recall_probe.json` 이 질의별이 아니라 최상위에 `user_id` 를 두는 형식을 놓친 것이었다** — 고치자 손실이 threshold 0.38 까지 0 건으로 나왔다. 0.35 채택을 권고한다 | [리포트](implements/2026-08-07-gate-threshold-remeasure.md) | diff --git a/docs/implements/2026-08-07-gate-threshold-remeasure.md b/docs/implements/2026-08-07-gate-threshold-remeasure.md new file mode 100644 index 0000000..850b270 --- /dev/null +++ b/docs/implements/2026-08-07-gate-threshold-remeasure.md @@ -0,0 +1,52 @@ +# 결합 신뢰도 게이트 임계값 오프라인 재측정 + +- **티켓**: S15P11A705-401 +- **날짜**: 2026-08-07 +- **하네스**: `tools/search_cut/gate_sweep.py` — 실행 절차·데이터 원본은 그 파일 docstring +- **기준 문서**: `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4 · [P49](../proposals/P49-multi-signal-search.md) §9(0.35의 원출처) · 선행 실측 [I57](2026-08-06-rerank-adoption.md) +- **성격**: 재기만 한다. 앱 런타임 코드를 바꾸지 않는다. 기존에 커밋된 행렬만 읽었다(DB·GMS 호출 0회). + +## 한눈에 보는 결과 + +| 항목 | 확인한 내용 | 판단 | +|---|---|---| +| 0.35 재사용 안전성 | 문장형 정답 12건·단어형 정답 66건·진단프로브 22건 어디에서도 threshold=0.35에서 정답 손실이 0건이다 | 이 데이터에서는 **안전하다** — 재사용해도 된다 | +| 0.35의 최적성 | 정답 손실 0을 유지하는 구간(0.25~0.37)에서 최적값은 0.36이고, 0.35는 그보다 침묵 2건(타인소유 1·단어무관 1) 적을 뿐이다 | 0.35는 최적에 근접한다 — 별도 값을 채택할 실익이 없다 | +| 무관 노출 감소 | 0.35에서 문장무관 3/15·단어무관 23/45·타인소유 55/96이 추가로 침묵한다(현재 0건 대비) | 게이트가 실제로 노출을 크게 줄인다 | +| 재사용 판단 근거 | P48→P49 구조 전환 때 우연히 같은 숫자가 재확정됐던 선례(I57)와 달리, 이번엔 **구조가 또 달라** 실제로 다시 쟀다 | "값이 같다고 검증을 생략하지 않는다"는 이 프로젝트 관행을 그대로 따랐다 | + +## 배경 + +`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.3은 새 게이트(S1 단독·유사도 None: + print(msg, flush=True) + + +def head(title: str) -> None: + log("\n" + "=" * 100) + log(title) + log("=" * 100) + + +# 채택 재정렬 파라미터 — S3 신호를 재현하는 데 쓴다. 정본은 `app/core/config.py`, +# 실측 근거는 `docs/implements/2026-08-06-rerank-adoption.md`(I57). +RERANK_METHOD = F.BINARY +RERANK_FLOOR = 0.35 +RERANK_TOP_K = 3 + +# 채택 문자열 게이트 — S2 신호를 재현하는 데 쓴다(I54: 단어형 한정 · 부분일치). +LEXICAL_GATE = "G1" + +# 이 티켓이 훑는 임계값 후보. 0.35 를 격자 한가운데 두고 촘촘히(0.01) 잡는다 — +# `word_sweep.py` 가 τ_abs 를 잡은 것과 같은 간격 선택 이유다(채택 후보가 데이터점 +# 하나에 붙을 수 있다). +THRESHOLDS = [0.0] + [round(0.16 + 0.01 * i, 3) for i in range(25)] + + +# ------------------------------------------------------------------------- 적재 + + +def load(search_dir: Path = SEARCH): + paths = { + "matrix": search_dir / "matrix.json", + "word_grid": search_dir / "word_grid.json", + "recall_probe": search_dir / "recall_probe.json", + "lexical": search_dir / "lexical_matrix.json", + "keyword": search_dir / "keyword_matrix.json", + } + for k, p in paths.items(): + if not p.exists(): + raise GuardError( + f"{p.relative_to(ROOT)} 가 없다. README(tools/search_cut/README.md)의 " + "해당 matrix 스크립트를 먼저 돌려라." + ) + data = {k: json.loads(p.read_text(encoding="utf-8")) for k, p in paths.items()} + + profiles = {k: d.get("profile") for k, d in data.items()} + if len(set(profiles.values())) != 1 or None in profiles.values(): + raise GuardError(f"Profile 이 어긋난다 — 한 표에 놓을 수 없다: {profiles}") + + lex = {e["query"]: e["matches"] for e in data["lexical"]["queries"]} + + kw = data["keyword"] + presets = { + p["id"]: F.Preset(id=p["id"], version=p["version"], visibility=p["visibility"]) + for p in kw["presets"] + } + contexts = [ + F.ContextKeywords( + context_id=c["context_id"], + record_id=c["record_id"], + keyword_status=c["keyword_status"], + keywords=tuple((k["keyword_id"], k["confidence"]) for k in c["keywords"]), + ) + for c in kw["contexts"] + ] + by_user: dict[int, list] = {} + for c, raw in zip(contexts, kw["contexts"]): + by_user.setdefault(raw["user_id"], []).append(c) + query_cos = { + e["query"]: {c["preset_id"]: c["cos"] for c in e["cos"]} + for e in kw["query_preset"] + } + + # recall_probe 는 `is_expected` 가 없다 — `expect`(장소명)로 합성한다(rank_score.py + # `_probe_segment` 와 같은 규칙). + for e in data["recall_probe"]["queries"]: + want = e.get("expect") + for r in e.get("results") or []: + r["is_expected"] = r.get("name") == want + + return data, lex, presets, by_user, query_cos + + +# --------------------------------------------------------------------------- 신호 + + +def lexical_ids(lex: dict, query: str, uid, gate: str = LEXICAL_GATE) -> set[int]: + """S2 — 채택 문자열 게이트(G1)를 통과한 매치 Record id.""" + if gate in ("G1", "G2") and not is_word_query(query): + return set() + entries = (lex.get(query) or {}).get(str(uid)) or [] + if gate == "G2": + entries = [e for e in entries if e["boundary"]] + return {e["record_id"] for e in entries} + + +def keyword_ids(query_cos: dict, presets: dict, by_user: dict, query: str, uid) -> set[int]: + """S3 — 채택 재정렬 파라미터로 신호가 있는(> 0) Record id.""" + qc = query_cos.get(query) + if qc is None or uid not in by_user: + return set() + cand = F.preset_candidates(qc, presets, top_k=RERANK_TOP_K, floor=RERANK_FLOOR) + sig = F.record_signals(by_user.get(uid, []), cand, presets, method=RERANK_METHOD) + return {rid for rid, s in sig.items() if s > 0} + + +def gate( + rows: list[dict], query: str, uid, threshold: float, *, + lex: dict, query_cos: dict, presets: dict, by_user: dict, limit: int = SERVICE_LIMIT, +) -> list[dict]: + """S1/S2/S3 를 세어 게이트를 적용한다(OFFTOPIC 문서 §4.3). + + S2·S3 가 하나도 없고 S1 유사도가 `threshold` 미만이면 뺀다. `threshold=0.0` 은 + 게이트가 없는 현재 동작과 같다(모든 것이 통과). + """ + kept = cut(rows, query, limit=limit) + if not kept: + return kept + lex_ids = lexical_ids(lex, query, uid) + kw_ids = keyword_ids(query_cos, presets, by_user, query, uid) + out = [] + for r in kept: + rid = r["record_id"] + if rid not in lex_ids and rid not in kw_ids and float(r["sim"]) < threshold: + continue + out.append(r) + return out + + +# --------------------------------------------------------------------------- 평가 + + +def eval_answerable(entries: list[dict], threshold: float, ctx: dict, default_uid=None) -> dict: + """기대 정답이 있는 질의 집합. 게이트가 정답까지 지웠는지를 센다.""" + miss_all = miss_partial = lost_top1 = 0 + lost: list[dict] = [] + for e in entries: + q, uid = e["query"], e.get("user_id", default_uid) + rows = e.get("results") or [] + base = cut(rows, q, limit=SERVICE_LIMIT) + want = [r for r in base if r.get("is_expected")] + if not want: + continue + gated = gate(rows, q, uid, threshold, **ctx) + gated_ids = {r["record_id"] for r in gated} + top1 = next((r for r in want if r.get("rank") == 1), None) + if top1 and top1["record_id"] not in gated_ids: + lost_top1 += 1 + alive = [r for r in want if r["record_id"] in gated_ids] + if not alive: + miss_all += 1 + lost.append({"query": q, "sim": max(r["sim"] for r in want)}) + elif len(alive) < len(want): + miss_partial += 1 + return {"n": len(entries), "miss_all": miss_all, "miss_partial": miss_partial, + "lost_top1": lost_top1, "lost": lost} + + +def eval_control(entries: list[dict], threshold: float, ctx: dict, default_uid=None) -> dict: + """정답이 없는 질의 집합. 게이트를 더 걸수록 침묵(0건)이 늘어야 개선이다.""" + before_silenced = after_silenced = 0 + for e in entries: + q, uid = e["query"], e.get("user_id", default_uid) + rows = e.get("results") or [] + if not cut(rows, q, limit=SERVICE_LIMIT): + before_silenced += 1 + if not gate(rows, q, uid, threshold, **ctx): + after_silenced += 1 + n = len(entries) + return {"n": n, "before": before_silenced, "after": after_silenced, + "gained": after_silenced - before_silenced} + + +def table(title: str, rows: list[dict]) -> None: + head(title) + log(f" {'threshold':>9} │ {'문장정답손실':>10} {'단어정답손실':>10} {'프로브손실':>8} " + f"{'1위손실(문/단)':>14} │ {'무관-문장 신규침묵':>16} {'무관-단어 신규침묵':>16} " + f"{'타인소유 신규침묵':>16}") + log(" " + "-" * 120) + for r in rows: + sa, wa, pa = r["sentence_answer"], r["word_answer"], r["probe"] + so, wo, co = r["sentence_offtopic"], r["word_offtopic"], r["cross"] + top1 = f"{sa['lost_top1']}/{wa['lost_top1']}" + log(f" {r['threshold']:>9.3f} │ {sa['miss_all']:>6}/{sa['n']:<3} " + f"{wa['miss_all']:>6}/{wa['n']:<3} {pa['miss_all']:>4}/{pa['n']:<3} " + f"{top1:>14} │ {so['gained']:>10}/{so['n']:<3} {wo['gained']:>10}/{wo['n']:<3} " + f"{co['gained']:>10}/{co['n']:<3}") + + +def main() -> int: + ap = argparse.ArgumentParser(description="결합 신뢰도 게이트 임계값 재측정 (S15P11A705-401)") + ap.add_argument("--threshold", default="", help="비우면 0.0 및 0.16~0.40 (0.01 간격)") + ap.add_argument("--out", default=str(SEARCH / "gate_sweep.json")) + args = ap.parse_args() + + try: + data, lex, presets, by_user, query_cos = load() + except GuardError as exc: + print(f"[가드] {exc}", file=sys.stderr) + return 1 + + ctx = {"lex": lex, "query_cos": query_cos, "presets": presets, "by_user": by_user} + + thresholds = ([float(x) for x in args.threshold.split(",") if x.strip()] or THRESHOLDS) + + head("입력") + log(f" 문장형 정답 {data['matrix']['query_count']}건 · 무관 " + f"{data['matrix']['offtopic_count']}건") + log(f" 단어형 정답 {data['word_grid']['word_count']}건 · 무관 " + f"{data['word_grid']['offtopic_count']}건 · 타인소유 {data['word_grid']['cross_count']}건") + log(f" 진단프로브 {len(data['recall_probe']['queries'])}건") + log(f" S2 게이트 {LEXICAL_GATE}(단어형 한정 · 부분일치, I54 채택값)") + log(f" S3 파라미터 {RERANK_METHOD} · floor={RERANK_FLOOR} · top_k={RERANK_TOP_K} " + "(I57 채택값, app/core/config.py)") + log(" 호출 DB 0회 · GMS 0회") + + probe_uid = data["recall_probe"].get("user_id") + + rows = [] + for t in thresholds: + rows.append({ + "threshold": t, + "sentence_answer": eval_answerable(data["matrix"]["queries"], t, ctx), + "word_answer": eval_answerable(data["word_grid"]["queries"], t, ctx), + "probe": eval_answerable(data["recall_probe"]["queries"], t, ctx, + default_uid=probe_uid), + "sentence_offtopic": eval_control(data["matrix"]["offtopic"], t, ctx), + "word_offtopic": eval_control(data["word_grid"]["offtopic"], t, ctx), + "cross": eval_control(data["word_grid"]["cross"], t, ctx), + }) + + table("threshold 격자 — 정답 손실 대 무관 신규 침묵", rows) + + head("현재 후보값 0.35 에서 잃는 정답") + at_035 = next((r for r in rows if abs(r["threshold"] - 0.35) < 1e-9), None) + if at_035 is None: + log(" 격자에 0.35 가 없다 — --threshold 로 추가해서 다시 돌려라.") + else: + for label, seg in (("문장형", at_035["sentence_answer"]), ("단어형", at_035["word_answer"]), + ("프로브", at_035["probe"])): + if seg["lost"]: + log(f" {label}: " + "; ".join( + f"「{d['query']}」(sim={d['sim']:.4f})" for d in seg["lost"])) + else: + log(f" {label}: 손실 없음") + + # 정답 손실이 전혀 없는(문장형·단어형·프로브 miss_all=0, lost_top1=0) 임계값만 + # 후보로 남긴다 — word_sweep.py 의 「safe」와 같은 원칙이다. 그중 무관/타인소유 + # 신규 침묵이 가장 큰 값을 위로 올린다. + safe = [ + r for r in rows + if r["sentence_answer"]["miss_all"] == 0 and r["sentence_answer"]["lost_top1"] == 0 + and r["word_answer"]["miss_all"] == 0 and r["word_answer"]["lost_top1"] == 0 + and r["probe"]["miss_all"] == 0 + ] + head(f"정답 손실 0 인 threshold: {len(safe)}/{len(rows)}") + if safe: + ranked = sorted( + safe, + key=lambda r: -(r["sentence_offtopic"]["gained"] + r["word_offtopic"]["gained"] + + r["cross"]["gained"]), + ) + table("정답 손실 0 후보 — 무관/타인소유 신규 침묵 많은 순", ranked) + best = ranked[0] + log(f"\n 이 데이터에서 정답 손실 없이 가장 많이 침묵시키는 threshold: " + f"{best['threshold']}") + def _silenced(r: dict) -> int: + return (r["sentence_offtopic"]["gained"] + r["word_offtopic"]["gained"] + + r["cross"]["gained"]) + + if abs(best["threshold"] - 0.35) > 1e-9: + gap = (_silenced(best) - _silenced(at_035)) if at_035 else "?" + log(f" → 0.35 와 다르다. 0.35 를 그대로 채택하면 이 데이터 기준으로 " + f"{gap}건만큼 침묵 기회를 덜 쓰는 것이다" + "(안전한 방향이지만 최적은 아니다).") + else: + log(" → 0.35 가 이 데이터에서도 최적이다. 재사용을 채택해도 된다.") + else: + log(" 정답 손실이 0 인 threshold 가 격자에 없다 — 이 게이트 설계 자체를") + log(" 재검토해야 한다(모든 임계값이 어떤 정답을 희생시킨다).") + + log("\n주의") + log(" · Record 42건 · 소유자 3명 규모다. 절대 채택값이 아니라 **방향과 상대 비교**로") + log(" 읽는다(rank_score.py 의 같은 경고와 원칙이 같다).") + log(" · 유사도 값 자체의 회차 간 흔들림은 재측정하지 않았다 — 기존 실측(T68,") + log(" |Δsim| 최대 0.0044)을 그대로 인용한다. 이 스크립트는 그 흔들림 폭보다") + log(" 좁은 임계값 비교(0.01 간격)에 대해서는 인접 값 결론을 보류해야 한다.") + log(" · S2·S3 는 이 스크립트가 채택 파라미터로 재구성한 값이다 — 실서버 게이트") + log(" 구현(S15P11A705-400) 이후에는 실서버 대조로 한 번 더 검증해야 한다.") + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps( + {"ticket": "S15P11A705-401", "lexical_gate": LEXICAL_GATE, + "rerank_params": {"method": RERANK_METHOD, "floor": RERANK_FLOOR, + "top_k": RERANK_TOP_K}, + "grid": rows}, ensure_ascii=False, indent=2), encoding="utf-8") + log(f"\n → {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 0bfa94aeb6285e1cd5ddc361d46c173c91f3c40d Mon Sep 17 00:00:00 2001 From: ghkim1632 Date: Fri, 7 Aug 2026 18:35:50 +0900 Subject: [PATCH 29/34] =?UTF-8?q?feat(S15P11A705-399):=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=9D=91=EB=8B=B5=EC=97=90=20keywordMatched=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _rerank_by_keyword가 이미 계산하는 Preset 매치 여부를 정렬에만 쓰고 버리던 것을 (list, matched_ids) 튜플로 바꿔 응답까지 싣는다. similarity는 원래 코사인 그대로 유지해 정렬 점수 비노출 계약을 지킨다. 재정렬이 생략되는 모든 경로에서는 빈 집합이라 keywordMatched가 자연히 False다. 결합 신뢰도 게이트(S15P11A705-400)가 쓸 S3 신호를 준비하는 작업이다. --- app/schema/search.py | 5 ++ app/service/search_service.py | 24 +++++--- docs/WORKLOG.md | 1 + ...2026-08-07-search-keyword-matched-field.md | 24 ++++++++ tests/test_api.py | 2 + tests/test_search_rerank.py | 58 +++++++++++++++++++ 6 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 docs/implements/2026-08-07-search-keyword-matched-field.md diff --git a/app/schema/search.py b/app/schema/search.py index 371bd72..20d1c88 100644 --- a/app/schema/search.py +++ b/app/schema/search.py @@ -18,6 +18,11 @@ class SearchResultItem(BaseModel): recordId: int contextId: int similarity: float + # 재정렬(S15P11A705-339)이 이미 계산하는 키워드 매치 여부를 버리지 않고 싣는다 + # (S15P11A705-399). 결과를 보여줄지 정하는 데는 쓰지 않는다 — 이 필드는 신호를 + # 실어 보내는 것이고, 그 신호로 결과 유무를 정하는 것은 별도 게이트(S15P11A705-400) + # 의 몫이다. + keywordMatched: bool class SearchResponse(BaseModel): diff --git a/app/service/search_service.py b/app/service/search_service.py index dccce96..5b390c1 100644 --- a/app/service/search_service.py +++ b/app/service/search_service.py @@ -79,7 +79,7 @@ async def search( # 재정렬은 같은 커넥션으로 context_keyword 를 읽어야 해서 컷을 acquire # 안으로 옮겼다 — 컷은 순수 계산이라 위치가 결과를 바꾸지 않는다. kept = self._cut(rows, query_text) - kept = await self._rerank_by_keyword( + kept, keyword_matched = await self._rerank_by_keyword( conn, user_id, kept, query_embedding ) @@ -88,6 +88,11 @@ async def search( "recordId": r["record_id"], "contextId": r["context_id"], "similarity": round(float(r["similarity"]), 4), + # 재정렬이 이미 계산하는 match 여부를 버리지 않고 싣는다 + # (S15P11A705-399, OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md §4.2 S3). + # 재정렬이 계산되지 않은 모든 경로(off·오류·후보 없음)에서 + # `keyword_matched` 는 빈 집합이라 여기서 자연히 False 다. + "keywordMatched": r["record_id"] in keyword_matched, } for r in kept ] @@ -206,7 +211,7 @@ def _preset_candidates(self, query_embedding: list[float]) -> set[int]: async def _rerank_by_keyword( self, conn, user_id: int, kept: list, query_embedding: list[float] - ) -> list: + ) -> tuple[list, set[int]]: """컷 통과 후보의 **순서만** keyword 신호로 조정한다 (S15P11A705-339, P49 §4). 후보를 추가·제거하지 않는다 — 관련 없는 질의에서 컷 통과가 0건이면 재정렬 @@ -220,17 +225,21 @@ async def _rerank_by_keyword( 어떤 단계가 실패해도 응답은 실패하지 않는다 — 그 단계만 생략하고 벡터 순서를 그대로 반환한다(P49 §5 의 실패 시 복귀 규칙). + + 두 번째 반환값은 실제로 match 한 Record id 집합이다(S15P11A705-399) — 순서를 + 정하는 데만 쓰고 버리던 신호를 호출부가 응답에 실을 수 있게 넘긴다. 재정렬이 + 생략된 모든 경로(off·오류·후보 없음·match 없음)에서는 빈 집합을 돌려준다. """ if ( not self._settings.search_keyword_rerank_enabled or self._preset_cache is None # 조립 실수의 방어선 — rewrite 와 같은 규칙 or len(kept) < 2 # 0·1건은 바꿀 순서가 없다 ): - return kept + return kept, set() try: candidates = self._preset_candidates(query_embedding) if not candidates: - return kept + return kept, set() signal_rows = await context_keyword_repo.keywords_for_records( conn, user_id, [r["record_id"] for r in kept] ) @@ -240,19 +249,20 @@ async def _rerank_by_keyword( if row["keyword_id"] in candidates } if not matched: - return kept + return kept, set() weight = self._settings.search_keyword_rerank_weight # sorted 는 안정 정렬이다 — 점수가 같은 행(신호 없는 행끼리 등)은 # 벡터 순서가 그대로 유지된다. - return sorted( + reranked = sorted( kept, key=lambda r: -( float(r["similarity"]) + (weight if r["record_id"] in matched else 0.0) ), ) + return reranked, matched except Exception: log.warning( "keyword rerank failed; falling back to vector order", exc_info=True ) - return kept + return kept, set() diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 2e36d60..8ed083d 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -77,3 +77,4 @@ | 2026-08-03 | 하루에 유형마다 서너 번씩 난 **반복 사고 세 유형**을 트러블슈팅으로 남겼다(`S15P11A705-293`, T70~T72). **개별 사고의 해결이 아니라 반복 유형을 적는 문서다** — 각 건의 처리는 자기 티켓·PR·이슈에 있고 여기에는 「무엇이 반복됐는가」만 남는다. **유형마다 마지막 사고는 앞선 사고의 대응이 이미 있는 상태에서 났다** — 07-31 색인 사고 다섯 건과 같은 모양이고, 차이는 성실성이 아니라 강제 장치다. ① **사본에서 꺼낸 값**(T70) — 철회된 단정이 그 문서를 근거로 인용하며 되살아나고, 코드 한 행을 인용하며 **바로 다음 행**을 놓친 오독이 **이미 병합된 작업의 설계 근거**가 되고, 원장에 적어 둔 정정이 계약에서 원래 문구로 되돌아오고, 계약에 실은 수치가 여러 번 틀렸다. **넷 다 사본이 아니라 원문을 연 다른 사람이 잡았다** — 「쓰기 직전 재조회」 규칙은 이미 있었는데도 났다. 사본은 자기가 낡았다고 말하지 않으므로 대응을 읽는 쪽이 아니라 **쓰는 쪽**에 뒀다(`R-18` 「값 대신 출처」). 철회하는 쪽도 대상을 좁힌다 — **폐기된 것은 값이 아니라 값의 항상성이다.** ② **반만 덮는 자동화**(T71) — 「성공했다」와 「실제로 반영됐다」가 다르고 실패가 조용하다. **성공 판정을 네 층으로 가른다**(프로세스 생존 ≠ 실행 성공 ≠ 실제 변경 발생 ≠ 최종 전달 성공). 감시 고아는 heartbeat 로 고쳤고(판정은 사람이 한다), 배포 자동화 둘은 인프라 소관이라 이슈로 기록만 했다. **「다음 스케줄이 복구한다」는 전제 자체가 약하다.** ③ **공개 저장소 실사용자 기록**(T72) — 훅이 **바깥으로 나가는 발화**만 검사해 스크립트가 만들고 사람이 `git add` 한 산출물은 검사 지점을 한 번도 안 거쳤고, `.gitignore` 예외가 「커밋하라」고 **적극적으로 지시**하고 있었다. **대응 미정** — 선택지 넷 중 마스킹 문구의 경로 안내 제거만 즉시 가능하다. 위치·규모는 적지 않는다(위치 비공개 선례). **이 문서 자신이 T70 의 첫 시험이라 수치·코드 행 번호를 옮기지 않고 좌표만 가리킨다** | [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | | 2026-08-03 | 표시명 개정(`S15P11A705-292`)이 코드에서 운영 화면까지 간 **배포 체인을 기록했다**(I50). **체인 여섯 단계의 소관을 갈랐다** — 우리(`ai#102` dev · `ai#104` 릴리스) · 자동화(`image-publish` 는 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**) · 인프라 소관(`infra#185`) · 클러스터 sync · 화면. **핵심은 미결 판정 하나를 실측으로 닫은 것이다** — 프리셋 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 부트스트랩 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 이미지 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 「값이 같으면 재실행되지 않는다」로 판정했고 독립 검증이 논거를 반증했으나 **클러스터 관측 경로가 없어 「확인 불가」로 끝나 있었다** — 배포가 답을 냈다. **그래도 표기가 낡은 것은 기록 정합성 문제로 남는다**(배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다). **릴리스가 두 번 막혔고 둘 다 다음에 그대로 다시 만난다** — base 검사는 `main` base 를 `release/*`·`hotfix/*` 로 한정하므로 `dev` 를 그대로 열면 CI 가 막고(`ai#103` 이 그렇게 닫혔다), strict 상태 검사는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(병합 커밋 `032e391f`). 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 변경 없이 끝났고, 갱신을 만든 것은 수동 실행이라 **`success` 두 번의 뜻이 다르다.** 확인 수단이 화면뿐이라 「아직인가 실패인가」가 갈리지 않아 한 번 오판했다가 재확인에서 뒤집혔다. **값은 옮기지 않고 좌표만 적었다**(`T70` 적용) | [배포 체인 리포트](implements/2026-08-03-preset-display-name-deploy-chain.md), [I48](implements/2026-08-03-dev-deploy-gap.md), [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | | 2026-08-03 | 외부 리뷰 문서를 **P47 제안 문서로 다시 썼다** — 프리셋의 표시 라벨·축 정의·스키마 개정안(`S15P11A705-228`·`-269`·`-270`·`-271`·`-292`, `ai#90` 후속). **proposals 최초의 `Proposed`** 다 — §7 이후가 미실행이고 §11 실측이 없다. **다만 §6(표시 라벨)은 문서를 쓰는 사이에 반영이 끝났다** — `-292` 가 `display_name` 을 명사·명사구로 통일해 `ai#102`(`dev`)·`ai#104`(`main`)로 나갔고, 그래서 그 절만 제안이 아니라 **기록**으로 다시 썼다(제목에 「반영 완료」 · 표의 「권장 표시명」을 「반영값」으로 · 정본은 문서가 아니라 `data/keyword_preset.yaml` 임을 명시). **초안과 실제가 갈린 셋을 확정으로 남겼다** — `ALONE` 은 초안의 `1인` 을 기각하고 `혼자` 를 유지했고(축의 나머지가 관계어인데 수량 표현만 이질적이다), `TRENDY` 는 `examples` 가 유행이 아니라 미감을 가리키므로 `감성` 으로 확정했으며, `RETRO` 는 **초안에 없던 결정**으로 `복고` 가 됐다(같은 축의 어휘 층위 통일 · `description` 과 정합). **선결 조건이 통째로 바뀌었다** — 「프리셋을 고치면 기존 판정을 전부 재처리한다」로 정해지면서 「판을 구분할 경로」(행 `version` 을 올릴 경로가 없다는 사실, `-269`)가 선결에서 §3-F 로 내려갔고, 그 자리에 **「재처리할 수단이 없다」**가 들어갔다: 완료 State 를 되돌리는 코드가 없고 · 재스캔이 잡는 상태에 완료가 없으며 · 한 번에 밀면 게이트웨이 한도에 걸려 재시도 예산이 소진되면 실패로 굳는다(부재의 범위는 `ai#100`). **「정할 것」이 아니라 「만들 것」이라 §13 0단계가 구현 항목이 됐고**, 같은 이유로 §10.3 세트 release 는 운영 용도가 빠지고 평가 재현만 남아 우선순위가 내려갔다. **원본의 다섯 곳을 정정했다**: ① Feed 표시 정렬을 절째로 빼고 `back` P46 을 가리켰다(원본 제안이 그 결정이 **명시적으로 기각한** 방향이었다), ② 「CI 에서 검증되지 않는 것」 목록을 없애고 방향과 「테스트 코드가 정본」만 남겼다(이미 있는 테스트를 없다고 적고 있었다), ③ 배포 서술을 hook 메커니즘 존재라는 구조 사실로 줄였다(나머지는 `infra` 소관), ④ `version` 을 「세트 release 와 분리」가 아니라 판 식별 문제로 재배치, ⑤ 표시명 오염을 「임베딩 입력」이 아니라 **임베딩·판정 프롬프트 양쪽**으로 고쳤다 — 그래서 이 변경은 임베딩 실험이 아니라 프롬프트 실험 대상이다. **구조 사실 일곱을 보강했다**(개정에 기존 판정 재처리가 따라붙는다 · 후보 슬롯 경쟁으로 프리셋 단위 개선이 전역에서 상쇄된다 · 저빈도 프리셋 처분이 라벨 개정보다 앞선다 · 표시명 개정이 백엔드 조회의 정렬과 중복 흡수에 파급된다 · **`is_active` 를 끌 경로가 없어 tombstone 은 구멍 메우기다** · 공용 계약 개정 단계 · 임베딩 비결정성 때문에 1회 측정으로 채택을 가르지 않는다). **값을 쓰지 않았다** — 측정 수치·현행 상수·행 번호·배포 상태를 전부 출처 참조로 바꿨고 남은 숫자는 §6.2 의 반영값뿐인데 그것도 YAML 이 정본임을 표 앞에 못박았다. 문서가 값을 안고 있으면 값이 바뀔 때 문서만 낡는다. **`§6` 이 실측 없이 먼저 나간 것은 §14 에 감수 항목으로 남겼다** — 표시 계층만 손댄 범위였기 때문이고, 대가로 라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 §11 에 남는다 | [P47](proposals/P47-keyword-preset-label-axis.md), [근거 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md) | +| 2026-08-07 | 검색 응답에 `keywordMatched` 필드를 추가했다(`S15P11A705-399`). 키워드 재정렬(`_rerank_by_keyword`, P49 §4)이 이미 계산하지만 정렬에만 쓰고 버리던 match 여부를 응답까지 살렸다 — 재정렬을 `tuple[list, set[int]]`로 바꿔 match 한 Record id 를 함께 반환하고, 재정렬이 생략되는 모든 경로에서는 빈 집합이라 자연히 `False` 다. `similarity` 는 원래 코사인 그대로 두어 정렬 점수가 새어 나가지 않는다는 기존 계약을 지켰다. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호를 준비하는 작업이다 | [리포트](implements/2026-08-07-search-keyword-matched-field.md) | diff --git a/docs/implements/2026-08-07-search-keyword-matched-field.md b/docs/implements/2026-08-07-search-keyword-matched-field.md new file mode 100644 index 0000000..5373716 --- /dev/null +++ b/docs/implements/2026-08-07-search-keyword-matched-field.md @@ -0,0 +1,24 @@ +# 검색 응답에 키워드 매치 여부 필드 추가 + +- **티켓**: S15P11A705-399 +- **날짜**: 2026-08-07 +- **성격**: 응답 스키마 확장. 재정렬(P49 §4, `S15P11A705-339`)의 순서·계약은 바꾸지 않는다. + +## 배경 + +`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4.2가 지적한 것 — 키워드 재정렬(`_rerank_by_keyword`)은 컷 통과 후보와 Preset 후보가 실제로 match 하는지 이미 계산하지만, 그 결과는 정렬 키로만 쓰이고 버려진다. 결합 신뢰도 게이트(§4, `S15P11A705-400`)가 S3(키워드 매치) 신호를 쓰려면 이 값이 응답까지 살아 있어야 한다. + +## 변경 + +- `_rerank_by_keyword`가 재정렬된 목록만이 아니라 실제로 match 한 Record id 집합도 함께 돌려준다(`tuple[list, set[int]]`). 재정렬이 생략되는 모든 경로(플래그 off·캐시 없음·후보 Preset 없음·조회 실패·match 없음)에서는 빈 집합을 돌려주므로 그 경로들에서 새 필드는 자연히 `False`다. +- `SearchService.search()`가 이 집합을 받아 응답 각 행에 `keywordMatched: bool`을 싣는다. +- `SearchResultItem` 스키마에 `keywordMatched: bool` 필드를 추가했다(기본값 없음 — 모든 경로가 명시적으로 채운다). + +`similarity`는 여전히 원래 코사인 값 그대로다 — 이 필드는 정렬 점수를 새어 보내는 것이 아니라 이미 계산된 match 여부 하나만 싣는다. + +## 검증 + +- 신규 단위 테스트 4건(`tests/test_search_rerank.py`) — match된 Record만 `True`, 그리고 flag off·조회 실패·후보 Preset 없음 세 경로에서 전부 `False`. +- 기존 재정렬 계약 테스트(RED/GREEN 5개)는 수정 없이 그대로 통과 — `_rerank_by_keyword`의 반환 형태가 튜플로 바뀌었지만 `search()` 내부에서만 소비하므로 외부 계약(응답의 `recordId`·`similarity` 순서·값)은 그대로다. +- 통합 테스트(`tests/test_api.py::test_search_returns_context_id`, Testcontainers)에 `keywordMatched is False` 단정을 추가했다 — 재정렬 기본 off 상태에서 실제 API 응답까지 필드가 배선됐는지 확인한다. +- `ruff check .` · `compileall app tools` · `pytest --cov` 전체 통과, 커버리지 게이트 line 94.64%·branch 85.00%(둘 다 ≥80%). diff --git a/tests/test_api.py b/tests/test_api.py index bfc76de..584891c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -141,6 +141,8 @@ async def test_search_returns_context_id(api, conn, settings): assert r.status_code == 200 item = r.json()["results"][0] assert item["recordId"] == 50 and item["contextId"] == 5 and "similarity" in item + # 재정렬 기본 off — 조회를 안 했으니 매치도 없다(S15P11A705-399). + assert item["keywordMatched"] is False # ── 질의 길이별 τ_abs 배선 (S15P11A705-266) ────────────── diff --git a/tests/test_search_rerank.py b/tests/test_search_rerank.py index 97d8e0f..a504515 100644 --- a/tests/test_search_rerank.py +++ b/tests/test_search_rerank.py @@ -245,6 +245,64 @@ def test_defaults_are_the_measured_values(monkeypatch): assert s.search_keyword_rerank_top_k == 3 + +# ── keywordMatched 필드 (S15P11A705-399) ────────────────────────────────── +# +# 재정렬이 이미 계산하는 match 여부를 응답에 싣는다. **재정렬 자신의 계약(위 5개)과는 +# 별개다** — 여기서 재는 것은 "필드 값이 실제 match 를 반영하는가"이지 순서가 아니다. + + +@pytest.mark.anyio +async def test_keyword_matched_is_true_only_for_the_matched_record( + monkeypatch, vector_rows +): + settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, _ = _service( + monkeypatch, settings, presets=ALIGNED, keyword_rows=MATCH_102 + ) + result = await _search(service) + assert {r["recordId"]: r["keywordMatched"] for r in result} == { + 101: False, 102: True, 103: False, + } + + +@pytest.mark.anyio +async def test_keyword_matched_is_false_for_all_when_flag_off( + monkeypatch, vector_rows +): + settings = _settings(monkeypatch) + service, _ = _service( + monkeypatch, settings, presets=ALIGNED, keyword_rows=MATCH_102 + ) + result = await _search(service) + assert all(r["keywordMatched"] is False for r in result) + + +@pytest.mark.anyio +async def test_keyword_matched_is_false_for_all_when_fetch_fails( + monkeypatch, vector_rows +): + settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, _ = _service( + monkeypatch, settings, presets=ALIGNED, error=RuntimeError("db down") + ) + result = await _search(service) + assert all(r["keywordMatched"] is False for r in result) + + +@pytest.mark.anyio +async def test_keyword_matched_is_false_for_all_when_no_candidate_preset( + monkeypatch, vector_rows +): + settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, _ = _service( + monkeypatch, settings, + presets=[_preset(2, OTHER_AXIS)], keyword_rows=MATCH_102, + ) + result = await _search(service) + assert all(r["keywordMatched"] is False for r in result) + + def test_candidate_rule_matches_the_harness(monkeypatch): """floor 를 top_k 보다 먼저 걸고 동점은 preset_id 오름차순 — 하네스와 같은 규칙. From e0e556a26a44de4f011d07109e1fd37af80deb69 Mon Sep 17 00:00:00 2001 From: colosair Date: Fri, 7 Aug 2026 18:52:12 +0900 Subject: [PATCH 30/34] =?UTF-8?q?feat(S15P11A705):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20LLM=20=EA=B4=80=EB=A0=A8=EB=8F=84=20?= =?UTF-8?q?=EC=9E=AC=ED=8C=90=EC=A0=95=204=EB=B2=88=EC=A7=B8=20=EC=8B=A0?= =?UTF-8?q?=ED=98=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit back이 3신호 병합까지 끝난 최종 후보(본문 포함)를 보내면 LLM이 4단계 관련도로 재판정하는 내부 엔드포인트를 신설한다. 문장형 질의에 포함된 고유명사가 재작성·문자열 검색·재정렬 세 신호 모두의 사각지대에 걸려 실제 배포에서 관련 기록이 무관한 기록보다 낮은 순위로 나온 사례를 교정한다. - app/schema/relevance.py: 요청/응답 스키마, contextId·placeName·body 후보 목록과 4단계 라벨(VERY_RELEVANT~NOT_RELEVANT) - app/client/relevance_client.py: rewrite_client.py를 본뜬 벤더 체인 전용 클라이언트 — 후보 전체를 한 번의 호출로 판정, 요청 밖 contextId·미지 라벨은 개별 항목만 버리고 나머지는 살린다 - app/api/internal/v1/search.py: POST /internal/v1/search/judge - app/core/config.py: search_relevance_judge_enabled(기본 false)· 타임아웃·재시도 설정 - app/main.py: 클라이언트 조립·app.state 등록 기본 꺼짐 — back이 플래그를 켜기 전까지 기존 검색 동작에 영향 없음. Co-Authored-By: Claude Fable 5 --- app/api/internal/v1/search.py | 21 +- app/client/relevance_client.py | 292 ++++++++++++++++++ app/core/config.py | 20 ++ app/main.py | 12 + app/schema/relevance.py | 37 +++ tests/test_relevance_client.py | 171 ++++++++++ tests/test_search_relevance_judge_endpoint.py | 111 +++++++ 7 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 app/client/relevance_client.py create mode 100644 app/schema/relevance.py create mode 100644 tests/test_relevance_client.py create mode 100644 tests/test_search_relevance_judge_endpoint.py diff --git a/app/api/internal/v1/search.py b/app/api/internal/v1/search.py index 6998cc1..fc6b7bd 100644 --- a/app/api/internal/v1/search.py +++ b/app/api/internal/v1/search.py @@ -1,12 +1,16 @@ """POST /internal/v1/search — 개인 자연어 검색. +POST /internal/v1/search/judge — 검색 후보 LLM 관련도 재판정 (4번째 검색 신호). router는 요청 검증과 서비스 호출만 한다. Profile 불일치의 422 변환은 main.py의 -예외 핸들러가 담당한다. +예외 핸들러가 담당한다. /search/judge 의 오류(TransientError·PermanentError)도 +같은 핸들러가 5xx 로 매핑한다 — back 은 그 5xx 를 강등 신호로 받는다(원래 순서 유지). """ from __future__ import annotations from fastapi import APIRouter, Depends, Request +from app.client.relevance_client import RelevanceJudgeClient +from app.schema.relevance import RelevanceJudgeRequest, RelevanceJudgeResponse from app.schema.search import SearchRequest, SearchResponse from app.service.search_service import SearchService @@ -17,6 +21,10 @@ def get_search_service(request: Request) -> SearchService: return request.app.state.search_service +def get_relevance_judge_client(request: Request) -> RelevanceJudgeClient: + return request.app.state.relevance_judge_client + + @router.post("/search", response_model=SearchResponse) async def search( req: SearchRequest, @@ -26,3 +34,14 @@ async def search( req.userId, req.query, req.limit, req.embeddingProfile ) return SearchResponse(results=results) + + +@router.post("/search/judge", response_model=RelevanceJudgeResponse) +async def judge( + req: RelevanceJudgeRequest, + client: RelevanceJudgeClient = Depends(get_relevance_judge_client), +) -> RelevanceJudgeResponse: + results = await client.judge( + req.query, [c.model_dump() for c in req.candidates] + ) + return RelevanceJudgeResponse(results=results) diff --git a/app/client/relevance_client.py b/app/client/relevance_client.py new file mode 100644 index 0000000..8ecea50 --- /dev/null +++ b/app/client/relevance_client.py @@ -0,0 +1,292 @@ +"""검색 결과 LLM 관련도 재판정 클라이언트 (4번째 검색 신호). + +검색 세 신호(재작성·재정렬·문자열 병합)를 전부 거친 최종 후보 목록도 벡터 유사도의 +사각지대를 완전히 없애지 못한다 — 질의 문자열이 본문에 그대로 있어도, 다른 후보의 +전체적인 "분위기"가 임베딩상 더 가까우면 그 후보가 밀린다. 세 신호 모두 이 실패를 +놓친다: 재작성은 짧은 질의에만, 문자열 검색은 단어형 질의에만 켜지고, 재정렬은 미리 +정의된 Preset 목록에 없는 개념(예: 소속 기관명)은 신호로 쓸 수 없다. + +이 클라이언트는 최종 후보 목록 전체를 LLM 에게 한 번에 보여주고 관련도 4단계로 +재판정받는다. 후보당 별도 호출이 아니라 **한 번의 호출로 전체 목록을 판정**한다 — +질의당 N회 호출은 지연·비용이 후보 수에 비례해 늘어난다. + +## 왜 back 이 이 클라이언트의 소비자가 아니라 ai 가 새 엔드포인트로 노출하는가 + +LLM 호출 인프라(벤더 폴백 체인·구조화 출력 파싱·재시도 정책)는 ai 에만 있다. 본문은 +back 소유이므로, back 이 최종 후보(본문 포함)를 조립해 이 엔드포인트에 보내고 판정만 +받아온다 — 처리 방향은 back→ai 요청이며, "FastAPI 는 조회 응답에 본문을 싣지 않는다"는 +기존 계약(back→ai **응답**에 대한 것)과 방향이 달라 저촉되지 않는다. + +## 판정 클라이언트·재작성 클라이언트와 무엇이 다른가 + +`llm_client.py`(키워드 판정)의 후보는 Preset 이고, 이 클라이언트의 후보는 검색 결과 +기록이다 — 모양이 달라 `vendors.py`의 request 빌더를 공유하지 않는다(`rewrite_client.py`와 +같은 이유). 재작성처럼 **사용자가 기다리는 동기 경로**라 예산은 검색 전용값을 쓰고 +판정(백그라운드, 90s·3회)의 값을 상속하지 않는다. + +캐시를 두지 않는다 — 입력(질의 + 후보 전체 목록)이 매 검색마다 사실상 달라, 캐시 +적중을 기대하기 어렵고 오히려 오래된 후보 조합을 잘못 재사용할 위험만 생긴다. +""" +from __future__ import annotations + +import itertools +import json +from typing import Sequence + +import httpx + +from app.client._calls import meter +from app.client._usage import record as record_usage +from app.client.retry import RetryPolicy, call_with_retry +from app.client.vendors import resolve_chain +from app.core.errors import ( + PermanentError, + SchemaViolationError, + TransientError, + classify_http_status, +) +from app.core.redact import redact, redact_body + +_MAX_OUTPUT_TOKENS = 1024 + +_LABELS = ("VERY_RELEVANT", "RELEVANT", "WEAKLY_RELEVANT", "NOT_RELEVANT") + +SYSTEM = ( + "당신은 장소 기록 검색 서비스의 결과 재판정기입니다.\n" + "사용자의 검색어와, 벡터 유사도로 이미 골라진 후보 기록 목록이 주어집니다.\n" + "각 후보가 검색어와 실제로 얼마나 관련 있는지 판정하세요.\n" + "규칙:\n" + "- 후보 목록에 있는 contextId 전부에 대해 정확히 하나씩 판정합니다. " + "빠뜨리거나 목록에 없는 contextId를 만들지 마세요.\n" + "- 판정은 반드시 다음 네 값 중 하나입니다: " + "VERY_RELEVANT, RELEVANT, WEAKLY_RELEVANT, NOT_RELEVANT.\n" + "- 검색어의 핵심 단어(고유명사·기관명·인명 등)가 본문에 그대로 있으면 " + "그 사실을 강하게 반영하세요 — 벡터 유사도가 낮아도 문자 그대로 일치하는 근거는 " + "관련성의 강한 증거입니다.\n" + "- 검색어의 전체적인 분위기만 비슷하고 핵심 단어·사실 관계가 다르면 낮게 판정하세요.\n" + "- placeName과 body만 근거로 삼습니다. 목록에 없는 정보를 지어내지 마세요." +) + +_ITEM_SCHEMA = { + "type": "object", + "properties": { + "contextId": {"type": "integer"}, + "relevance": {"type": "string", "enum": list(_LABELS)}, + }, + "required": ["contextId", "relevance"], +} + +_SCHEMA = { + "type": "object", + "properties": { + "results": {"type": "array", "items": _ITEM_SCHEMA}, + }, + "required": ["results"], + "additionalProperties": False, +} + +_OPENAI_ITEM_SCHEMA = {**_ITEM_SCHEMA, "additionalProperties": False} +_OPENAI_SCHEMA = { + "type": "object", + "properties": { + "results": {"type": "array", "items": _OPENAI_ITEM_SCHEMA}, + }, + "required": ["results"], + "additionalProperties": False, +} + + +def build_user(query: str, candidates: list[dict]) -> str: + lines = [ + f"- contextId={c['contextId']} | {c['placeName']} | 본문: {c['body']}" + for c in candidates + ] + return f"[검색어]\n{query}\n\n[후보 기록]\n" + "\n".join(lines) + + +def _openai_request(root: str, key: str, model: str, query: str, candidates: list[dict]): + return ( + f"{root}/api.openai.com/v1/chat/completions", + {"Authorization": f"Bearer {key}", "content-type": "application/json"}, + { + "model": model, + "messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": build_user(query, candidates)}, + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "relevance_judgment", + "strict": True, + "schema": _OPENAI_SCHEMA, + }, + }, + "max_completion_tokens": _MAX_OUTPUT_TOKENS, + }, + ) + + +def _openai_parse(payload: dict) -> dict: + return json.loads(payload["choices"][0]["message"]["content"]) + + +def _gemini_request(root: str, key: str, model: str, query: str, candidates: list[dict]): + return ( + f"{root}/generativelanguage.googleapis.com/v1beta/models/{model}:generateContent", + {"x-goog-api-key": key, "content-type": "application/json"}, + { + "systemInstruction": {"parts": [{"text": SYSTEM}]}, + "contents": [ + {"role": "user", "parts": [{"text": build_user(query, candidates)}]} + ], + "generationConfig": { + "responseMimeType": "application/json", + "responseSchema": _SCHEMA, + "maxOutputTokens": _MAX_OUTPUT_TOKENS, + # 판정·재작성과 같은 이유 — thinking 이 지연·토큰을 늘린다(테스트 C-2). + "thinkingConfig": {"thinkingBudget": 0}, + }, + }, + ) + + +def _gemini_parse(payload: dict) -> dict: + return json.loads(payload["candidates"][0]["content"]["parts"][0]["text"]) + + +def _anthropic_request(root: str, key: str, model: str, query: str, candidates: list[dict]): + return ( + f"{root}/api.anthropic.com/v1/messages", + { + "x-api-key": key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + { + "model": model, + "max_tokens": _MAX_OUTPUT_TOKENS, + "system": SYSTEM, + "messages": [{"role": "user", "content": build_user(query, candidates)}], + "tools": [ + { + "name": "judge_relevance", + "description": "후보 기록의 관련도 판정을 보고한다.", + "input_schema": _SCHEMA, + } + ], + # 판정·재작성과 같은 이유 — 강제하지 않으면 산문 응답이 스키마 위반이 된다. + "tool_choice": {"type": "tool", "name": "judge_relevance"}, + }, + ) + + +def _anthropic_parse(payload: dict) -> dict: + for block in payload.get("content", []): + if block.get("type") == "tool_use": + return block["input"] + raise KeyError("tool_use block not found") + + +_BUILDERS = { + "openai": (_openai_request, _openai_parse), + "gemini": (_gemini_request, _gemini_parse), + "anthropic": (_anthropic_request, _anthropic_parse), +} + + +class RelevanceJudgeClient: + def __init__( + self, + gms_base_url: str, + api_key: str, + chain: Sequence[tuple[str, str]], + *, + timeout: float, + retry: RetryPolicy, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._root = gms_base_url.split("/gmsapi/")[0] + "/gmsapi" + self._key = api_key + self._chain = [(c.adapter.vendor, c.model) for c in resolve_chain(chain)] + self._timeout = timeout + self._retry = retry + self._transport = transport + + def _for(self, attempt: int) -> tuple[str, str]: + return self._chain[min(attempt, len(self._chain) - 1)] + + async def judge(self, query: str, candidates: list[dict]) -> list[dict]: + """후보 전체를 한 번에 판정한다. 실패는 오류로 던진다 — 강등은 호출자 몫이다. + + 빠지거나 목록 밖 contextId 를 반환하는 것은 스키마 위반으로 취급하지 + 않는다 — 모델이 일부를 놓쳐도 나머지 판정은 쓸모 있고, 놓친 것은 호출자가 + "판정 없음"으로 처리하면 된다(back 이 원래 자리를 유지하는 강등 규칙). + 요청한 contextId 목록 밖의 값이 섞여 있으면 그 항목만 버린다. + """ + requested = {c["contextId"] for c in candidates} + attempts = itertools.count() + async with httpx.AsyncClient( + timeout=self._timeout, transport=self._transport + ) as client: + + async def attempt() -> list[dict]: + return await self._once( + client, self._for(next(attempts)), query, candidates + ) + + try: + results = await call_with_retry(attempt, self._retry, stage="relevance") + except SchemaViolationError as exc: + raise PermanentError( + f"relevance judge schema violation after " + f"{self._retry.attempts} attempt(s): {exc}" + ) from exc + + return [r for r in results if r["contextId"] in requested] + + async def _once( + self, + client: httpx.AsyncClient, + call: tuple[str, str], + query: str, + candidates: list[dict], + ) -> list[dict]: + vendor, model = call + build, parse = _BUILDERS[vendor] + url, headers, body = build(self._root, self._key, model, query, candidates) + async with meter.call("relevance", model=model, vendor=vendor) as rec: + try: + resp = await client.post(url, headers=headers, json=body) + except httpx.HTTPError as exc: + rec.status = type(exc).__name__ + raise TransientError( + f"relevance judge request failed ({vendor}:{model}): " + f"{redact(str(exc))}" + ) from exc + + rec.status = resp.status_code + if resp.status_code != 200: + raise classify_http_status( + resp.status_code, + f"relevance judge error: {vendor}:{model} {resp.status_code} " + f"{redact_body(resp.text)}", + ) + + try: + payload = resp.json() + record_usage("relevance", payload, vendor=vendor, model=model) + data = parse(payload) + items = data["results"] + return [ + { + "contextId": int(item["contextId"]), + "relevance": str(item["relevance"]), + } + for item in items + if str(item.get("relevance")) in _LABELS + ] + except (KeyError, IndexError, TypeError, ValueError) as exc: + raise SchemaViolationError( + f"relevance judge parse failed ({vendor}:{model}): {exc}" + ) from exc diff --git a/app/core/config.py b/app/core/config.py index b5d2e8a..7a3ee5a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -225,6 +225,26 @@ class Settings(BaseSettings): ) search_keyword_rerank_top_k: int = Field(3, alias="SEARCH_KEYWORD_RERANK_TOP_K") + # 검색 결과 LLM 관련도 재판정 (4번째 검색 신호). **기본 off** — 세 신호(재작성· + # 재정렬·문자열 병합)를 전부 거쳐도 놓치는 사각지대가 있다: 벡터 유사도는 "본문에 + # 그 단어가 있는가"보다 "전체적인 분위기"를 보므로, 질의의 핵심 단어가 본문에 + # 그대로 있는 후보가 없는 후보에 밀리는 사례가 실측됐다(소속 기관명처럼 Preset + # 목록에 없는 개념은 재정렬도 못 잡는다). 이 신호는 back 이 최종 후보(본문 포함)를 + # 새 엔드포인트(POST /internal/v1/search/judge)로 보내면 LLM 이 4단계로 재판정한다. + # off 면 이 엔드포인트를 back 이 호출하지 않아 검색은 현행과 동일하게 동작한다. + search_relevance_judge_enabled: bool = Field( + False, alias="SEARCH_RELEVANCE_JUDGE_ENABLED" + ) + # 사용자가 기다리는 동기 경로다(재작성과 같은 이유로 판정의 90s·3회를 상속하지 + # 않는다). 후보 최대 10건 × 본문 500자를 한 번에 넣어 재작성(1건)보다 입력이 커 + # 여유를 둔다. + search_relevance_judge_timeout_sec: float = Field( + 10.0, alias="SEARCH_RELEVANCE_JUDGE_TIMEOUT_SEC" + ) + search_relevance_judge_attempts: int = Field( + 2, alias="SEARCH_RELEVANCE_JUDGE_ATTEMPTS" + ) + # PROCESSING 재선점 만료 — Spring 재스캔 만료와 동일 값 processing_expiry_sec: int = Field(600, alias="PROCESSING_EXPIRY_SEC") diff --git a/app/main.py b/app/main.py index 2a1f520..d87bc8a 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,7 @@ from app.client.embedding_client import EmbeddingClient from app.client.kakao_local_client import HttpKakaoLocalClient from app.client.llm_client import LLMClient +from app.client.relevance_client import RelevanceJudgeClient from app.client.retry import RetryPolicy from app.client.rewrite_client import RewriteClient from app.client.vision_client import GmsGeminiVisionClient @@ -83,6 +84,16 @@ async def lifespan(app: FastAPI): cache_size=settings.search_rewrite_cache_size, ) + # 검색 결과 LLM 관련도 재판정 (4번째 검색 신호). 기본 off — back 은 + # SEARCH_RELEVANCE_JUDGE_ENABLED=false 면 이 엔드포인트를 호출하지 않는다. + relevance_judge_client = RelevanceJudgeClient( + gms_base_url=settings.gms_base_url, + api_key=settings.gms_api_key, + chain=settings.judge_vendors, + timeout=settings.search_relevance_judge_timeout_sec, + retry=RetryPolicy(attempts=settings.search_relevance_judge_attempts), + ) + preset_cache = PresetCache() async with db.acquire() as conn: rows = await keyword_preset_repo.load_active( @@ -124,6 +135,7 @@ async def lifespan(app: FastAPI): db, embedding_client, settings, rewrite_client=rewrite_client, preset_cache=preset_cache, ) + app.state.relevance_judge_client = relevance_judge_client app.state.context_processing_service = ContextProcessingService( db, embedding_service, keyword_service ) diff --git a/app/schema/relevance.py b/app/schema/relevance.py new file mode 100644 index 0000000..3cc38b1 --- /dev/null +++ b/app/schema/relevance.py @@ -0,0 +1,37 @@ +"""검색 결과 LLM 관련도 재판정 요청·응답 스키마 (S15P11A705-337 후속, 4번째 검색 신호). + +이 후보는 Preset 이 아니라 **검색 후보 기록**이다 — `contextId`·장소명·본문(`body`)을 +담는다. `llm_client.py`(키워드 판정)의 후보 dict 와 모양이 다르므로 새 pydantic 모델을 +둔다. Context 본문은 back(Spring)이 능동적으로 이 요청에 실어 보낸다 — back→ai 요청 +방향은 "FastAPI 는 조회 응답에 본문을 싣지 않는다"는 기존 계약(05_AI_설계 §9.4)의 +반대 방향이라 저촉되지 않는다. +""" +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +RelevanceLabel = Literal[ + "VERY_RELEVANT", "RELEVANT", "WEAKLY_RELEVANT", "NOT_RELEVANT" +] + + +class RelevanceCandidate(BaseModel): + contextId: int + placeName: str + body: str + + +class RelevanceJudgeRequest(BaseModel): + query: str = Field(min_length=1) + candidates: list[RelevanceCandidate] = Field(min_length=1) + + +class RelevanceJudgment(BaseModel): + contextId: int + relevance: RelevanceLabel + + +class RelevanceJudgeResponse(BaseModel): + results: list[RelevanceJudgment] diff --git a/tests/test_relevance_client.py b/tests/test_relevance_client.py new file mode 100644 index 0000000..932ba85 --- /dev/null +++ b/tests/test_relevance_client.py @@ -0,0 +1,171 @@ +"""검색 결과 LLM 관련도 재판정 클라이언트 — 벤더 어댑터·폴백·계약 (4번째 검색 신호). + +`test_llm_vendors.py`와 같은 계층이다(client 자신의 HTTP 계층, DB·Docker 불필요). +`httpx.MockTransport`로 응답 봉투를 직접 만들고 `RetryPolicy.sleep`은 기록만 한다. + +고정하는 계약은 넷이다. + + ① 후보 전체를 **한 번의 HTTP 요청**으로 판정한다 — 후보 수만큼 호출하지 않는다 + ② 일시 오류(429 등)는 다음 벤더로 넘어간다(판정 클라이언트와 같은 폴백 규칙) + ③ 재시도 소진 후 스키마 위반은 영구 오류로 승격한다 + ④ 요청한 contextId 목록 밖의 값이 응답에 섞여 있으면 그 항목만 버린다 +""" +from __future__ import annotations + +import json + +import httpx +import pytest + +from app.client.relevance_client import RelevanceJudgeClient +from app.client.retry import RetryPolicy +from app.core.errors import PermanentError, TransientError + +_BASE = "https://gms.example/gmsapi/api.openai.com/v1" +_KEY = "gms-key" +_CHAIN = (("openai", "primary-model"), ("gemini", "second-model")) + +_CANDS = [ + {"contextId": 101, "placeName": "피치플레이헬스", "body": "싸피 2학기 동안 다니던 헬스장"}, + {"contextId": 102, "placeName": "MH토탈휘트니스", "body": "군대 전역하고 다닌 헬스장"}, +] + + +class _SleepRecorder: + def __init__(self) -> None: + self.delays: list[float] = [] + + async def __call__(self, delay: float) -> None: + self.delays.append(delay) + + +def _client(handler, sleep, chain=_CHAIN, **kw): + seen: list[httpx.Request] = [] + + def wrapped(request: httpx.Request) -> httpx.Response: + seen.append(request) + return handler(request, len(seen)) + + client = RelevanceJudgeClient( + gms_base_url=_BASE, + api_key=_KEY, + chain=chain, + timeout=5.0, + retry=RetryPolicy(sleep=sleep, jitter=lambda d: d, **kw), + transport=httpx.MockTransport(wrapped), + ) + return client, seen + + +def _openai_response(results: list[dict]) -> httpx.Response: + body = json.dumps({"results": results}) + return httpx.Response( + 200, + json={"choices": [{"message": {"content": body}}]}, + ) + + +def _gemini_response(results: list[dict]) -> httpx.Response: + body = json.dumps({"results": results}) + return httpx.Response( + 200, + json={"candidates": [{"content": {"parts": [{"text": body}]}}]}, + ) + + +@pytest.mark.anyio +async def test_single_call_judges_the_whole_candidate_list(): + """① 후보 2건이 HTTP 요청 1건으로 판정된다 — 후보 수만큼 호출하지 않는다.""" + results = [ + {"contextId": 101, "relevance": "VERY_RELEVANT"}, + {"contextId": 102, "relevance": "NOT_RELEVANT"}, + ] + + def handler(request, call_no): + assert call_no == 1 + return _openai_response(results) + + client, seen = _client(handler, _SleepRecorder()) + out = await client.judge("싸피 다녔던 헬스장", _CANDS) + + assert len(seen) == 1 + assert out == results + + +@pytest.mark.anyio +async def test_transient_falls_back_to_next_vendor(): + """② 429는 다음 벤더로 넘어간다.""" + results = [{"contextId": 101, "relevance": "RELEVANT"}] + + def handler(request, call_no): + if call_no == 1: + return httpx.Response(429, json={}) + return _gemini_response(results) + + sleep = _SleepRecorder() + client, seen = _client(handler, sleep) + out = await client.judge("질의", _CANDS[:1]) + + assert len(seen) == 2 + assert "generativelanguage" in str(seen[1].url) + assert out == results + + +@pytest.mark.anyio +async def test_schema_violation_exhausted_becomes_permanent(): + """③ 재시도 소진 후에도 스키마 위반이면 영구 오류다.""" + + def handler(request, call_no): + return httpx.Response(200, json={"choices": [{"message": {"content": "not json"}}]}) + + client, seen = _client(handler, _SleepRecorder()) + with pytest.raises(PermanentError): + await client.judge("질의", _CANDS) + assert len(seen) == 3 # RetryPolicy 기본 attempts + + +@pytest.mark.anyio +async def test_connection_failure_is_transient(): + """HTTP 자체가 실패하면(연결 거부 등) TransientError — back 의 강등 대상이다.""" + + def handler(request, call_no): + raise httpx.ConnectError("refused") + + client, seen = _client(handler, _SleepRecorder()) + with pytest.raises(TransientError): + await client.judge("질의", _CANDS) + + +@pytest.mark.anyio +async def test_unrequested_context_id_is_dropped(): + """④ 요청 밖 contextId 는 버린다 — 모델이 지어낸 값이 back 으로 새지 않는다.""" + results = [ + {"contextId": 101, "relevance": "VERY_RELEVANT"}, + {"contextId": 999, "relevance": "RELEVANT"}, # 요청에 없던 id + ] + + def handler(request, call_no): + return _openai_response(results) + + client, seen = _client(handler, _SleepRecorder()) + out = await client.judge("질의", _CANDS) + + assert out == [{"contextId": 101, "relevance": "VERY_RELEVANT"}] + + +@pytest.mark.anyio +async def test_unknown_relevance_label_is_dropped(): + """모델이 4단계 밖의 값을 내면(환각) 그 항목만 버린다 — 스키마 위반으로 전체를 + 죽이지 않는다. 요청한 나머지 항목은 정상 판정된다.""" + results = [ + {"contextId": 101, "relevance": "SOMEWHAT_RELEVANT"}, # 4단계 밖 + {"contextId": 102, "relevance": "NOT_RELEVANT"}, + ] + + def handler(request, call_no): + return _openai_response(results) + + client, seen = _client(handler, _SleepRecorder()) + out = await client.judge("질의", _CANDS) + + assert out == [{"contextId": 102, "relevance": "NOT_RELEVANT"}] diff --git a/tests/test_search_relevance_judge_endpoint.py b/tests/test_search_relevance_judge_endpoint.py new file mode 100644 index 0000000..160fe27 --- /dev/null +++ b/tests/test_search_relevance_judge_endpoint.py @@ -0,0 +1,111 @@ +"""POST /internal/v1/search/judge — 엔드포인트 계약 (4번째 검색 신호). + +`test_api.py`와 같은 조립 방식(lifespan 우회, `app.state`에 직접 주입)이나 이 엔드포인트는 +DB·Preset 캐시를 쓰지 않아 그 두 상태는 필요 없다. 고정하는 계약은 셋이다. + + ① 정상 요청 → 클라이언트 판정을 그대로 응답 형태로 옮긴다 + ② 인증 헤더 없으면 401 (기존 SharedSecretMiddleware, 신규 라우트에도 적용됨을 확인) + ③ 클라이언트가 TransientError/PermanentError 를 던지면 기존 예외 핸들러가 5xx 로 + 매핑한다 — back 은 이 5xx 를 강등 신호로 받는다 +""" +from __future__ import annotations + +import httpx +import pytest + +from app.core.errors import PermanentError, TransientError +from app.main import create_app + +HDR = {"X-Internal-Secret": "test-secret"} + + +class _FakeRelevanceClient: + def __init__(self, result=None, error: Exception | None = None): + self._result = result or [] + self._error = error + self.calls: list[tuple[str, list[dict]]] = [] + + async def judge(self, query: str, candidates: list[dict]) -> list[dict]: + self.calls.append((query, candidates)) + if self._error is not None: + raise self._error + return self._result + + +def _client(fake, settings): + app = create_app() + app.state.relevance_judge_client = fake + transport = httpx.ASGITransport(app=app) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +_BODY = { + "query": "싸피 다녔던 헬스장", + "candidates": [ + {"contextId": 101, "placeName": "피치플레이헬스", "body": "싸피 2학기 동안 다니던 헬스장"}, + {"contextId": 102, "placeName": "MH토탈휘트니스", "body": "군대 전역하고 다닌 헬스장"}, + ], +} + + +@pytest.mark.anyio +async def test_judge_endpoint_returns_client_results(settings): + fake = _FakeRelevanceClient( + result=[ + {"contextId": 101, "relevance": "VERY_RELEVANT"}, + {"contextId": 102, "relevance": "NOT_RELEVANT"}, + ] + ) + async with _client(fake, settings) as client: + resp = await client.post("/internal/v1/search/judge", json=_BODY, headers=HDR) + + assert resp.status_code == 200 + assert resp.json() == { + "results": [ + {"contextId": 101, "relevance": "VERY_RELEVANT"}, + {"contextId": 102, "relevance": "NOT_RELEVANT"}, + ] + } + assert fake.calls == [(_BODY["query"], _BODY["candidates"])] + + +@pytest.mark.anyio +async def test_judge_endpoint_requires_secret_header(settings): + fake = _FakeRelevanceClient() + async with _client(fake, settings) as client: + resp = await client.post("/internal/v1/search/judge", json=_BODY) + + assert resp.status_code == 401 + assert fake.calls == [] + + +@pytest.mark.anyio +async def test_judge_endpoint_maps_transient_error_to_503(settings): + fake = _FakeRelevanceClient(error=TransientError("gms down")) + async with _client(fake, settings) as client: + resp = await client.post("/internal/v1/search/judge", json=_BODY, headers=HDR) + + assert resp.status_code == 503 + + +@pytest.mark.anyio +async def test_judge_endpoint_maps_permanent_error_to_502(settings): + fake = _FakeRelevanceClient(error=PermanentError("schema violation")) + async with _client(fake, settings) as client: + resp = await client.post("/internal/v1/search/judge", json=_BODY, headers=HDR) + + assert resp.status_code == 502 + + +@pytest.mark.anyio +async def test_judge_endpoint_rejects_empty_candidates(settings): + fake = _FakeRelevanceClient() + async with _client(fake, settings) as client: + resp = await client.post( + "/internal/v1/search/judge", + json={"query": "질의", "candidates": []}, + headers=HDR, + ) + + assert resp.status_code == 422 + assert fake.calls == [] From 63448a7fccf6e8941899be1c900542a15fd607dd Mon Sep 17 00:00:00 2001 From: ghkim1632 Date: Fri, 7 Aug 2026 18:59:24 +0900 Subject: [PATCH 31/34] =?UTF-8?q?feat(S15P11A705-402):=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=ED=9B=84=EB=B3=B4=20=EB=B6=88=EB=B3=80=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EA=B3=BC=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EC=9D=84=20=EB=B6=84=EB=A6=AC=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_search_rerank.py가 고정하는 다섯 계약(특히 ② 후보 불변)은 재정렬 자신의 것이고, back 레포의 결합 신뢰도 게이트(S15P11A705-400, BD-52)는 정반대로 의도적으로 후보를 지운다. 모듈 docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보도 재정렬은 지우지 않는다는 것을 새 테스트로 실행 고정했다(중앙 조정 세션 인계 문서 §4.5가 요구한 검증). 작업 중 docs/implements/README.md 색인 두 표 모두에서 I58(S15P11A705-399) 리포트가 빠져 있던 것을 test_docs_index.py가 잡아 함께 정정했다. --- docs/WORKLOG.md | 1 + ...026-08-07-rerank-gate-contract-boundary.md | 24 ++++++++ docs/implements/README.md | 4 ++ tests/test_search_rerank.py | 60 +++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 docs/implements/2026-08-07-rerank-gate-contract-boundary.md diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 8ed083d..1474ec3 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -78,3 +78,4 @@ | 2026-08-03 | 표시명 개정(`S15P11A705-292`)이 코드에서 운영 화면까지 간 **배포 체인을 기록했다**(I50). **체인 여섯 단계의 소관을 갈랐다** — 우리(`ai#102` dev · `ai#104` 릴리스) · 자동화(`image-publish` 는 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**) · 인프라 소관(`infra#185`) · 클러스터 sync · 화면. **핵심은 미결 판정 하나를 실측으로 닫은 것이다** — 프리셋 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 부트스트랩 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 이미지 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 「값이 같으면 재실행되지 않는다」로 판정했고 독립 검증이 논거를 반증했으나 **클러스터 관측 경로가 없어 「확인 불가」로 끝나 있었다** — 배포가 답을 냈다. **그래도 표기가 낡은 것은 기록 정합성 문제로 남는다**(배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다). **릴리스가 두 번 막혔고 둘 다 다음에 그대로 다시 만난다** — base 검사는 `main` base 를 `release/*`·`hotfix/*` 로 한정하므로 `dev` 를 그대로 열면 CI 가 막고(`ai#103` 이 그렇게 닫혔다), strict 상태 검사는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(병합 커밋 `032e391f`). 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 변경 없이 끝났고, 갱신을 만든 것은 수동 실행이라 **`success` 두 번의 뜻이 다르다.** 확인 수단이 화면뿐이라 「아직인가 실패인가」가 갈리지 않아 한 번 오판했다가 재확인에서 뒤집혔다. **값은 옮기지 않고 좌표만 적었다**(`T70` 적용) | [배포 체인 리포트](implements/2026-08-03-preset-display-name-deploy-chain.md), [I48](implements/2026-08-03-dev-deploy-gap.md), [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | | 2026-08-03 | 외부 리뷰 문서를 **P47 제안 문서로 다시 썼다** — 프리셋의 표시 라벨·축 정의·스키마 개정안(`S15P11A705-228`·`-269`·`-270`·`-271`·`-292`, `ai#90` 후속). **proposals 최초의 `Proposed`** 다 — §7 이후가 미실행이고 §11 실측이 없다. **다만 §6(표시 라벨)은 문서를 쓰는 사이에 반영이 끝났다** — `-292` 가 `display_name` 을 명사·명사구로 통일해 `ai#102`(`dev`)·`ai#104`(`main`)로 나갔고, 그래서 그 절만 제안이 아니라 **기록**으로 다시 썼다(제목에 「반영 완료」 · 표의 「권장 표시명」을 「반영값」으로 · 정본은 문서가 아니라 `data/keyword_preset.yaml` 임을 명시). **초안과 실제가 갈린 셋을 확정으로 남겼다** — `ALONE` 은 초안의 `1인` 을 기각하고 `혼자` 를 유지했고(축의 나머지가 관계어인데 수량 표현만 이질적이다), `TRENDY` 는 `examples` 가 유행이 아니라 미감을 가리키므로 `감성` 으로 확정했으며, `RETRO` 는 **초안에 없던 결정**으로 `복고` 가 됐다(같은 축의 어휘 층위 통일 · `description` 과 정합). **선결 조건이 통째로 바뀌었다** — 「프리셋을 고치면 기존 판정을 전부 재처리한다」로 정해지면서 「판을 구분할 경로」(행 `version` 을 올릴 경로가 없다는 사실, `-269`)가 선결에서 §3-F 로 내려갔고, 그 자리에 **「재처리할 수단이 없다」**가 들어갔다: 완료 State 를 되돌리는 코드가 없고 · 재스캔이 잡는 상태에 완료가 없으며 · 한 번에 밀면 게이트웨이 한도에 걸려 재시도 예산이 소진되면 실패로 굳는다(부재의 범위는 `ai#100`). **「정할 것」이 아니라 「만들 것」이라 §13 0단계가 구현 항목이 됐고**, 같은 이유로 §10.3 세트 release 는 운영 용도가 빠지고 평가 재현만 남아 우선순위가 내려갔다. **원본의 다섯 곳을 정정했다**: ① Feed 표시 정렬을 절째로 빼고 `back` P46 을 가리켰다(원본 제안이 그 결정이 **명시적으로 기각한** 방향이었다), ② 「CI 에서 검증되지 않는 것」 목록을 없애고 방향과 「테스트 코드가 정본」만 남겼다(이미 있는 테스트를 없다고 적고 있었다), ③ 배포 서술을 hook 메커니즘 존재라는 구조 사실로 줄였다(나머지는 `infra` 소관), ④ `version` 을 「세트 release 와 분리」가 아니라 판 식별 문제로 재배치, ⑤ 표시명 오염을 「임베딩 입력」이 아니라 **임베딩·판정 프롬프트 양쪽**으로 고쳤다 — 그래서 이 변경은 임베딩 실험이 아니라 프롬프트 실험 대상이다. **구조 사실 일곱을 보강했다**(개정에 기존 판정 재처리가 따라붙는다 · 후보 슬롯 경쟁으로 프리셋 단위 개선이 전역에서 상쇄된다 · 저빈도 프리셋 처분이 라벨 개정보다 앞선다 · 표시명 개정이 백엔드 조회의 정렬과 중복 흡수에 파급된다 · **`is_active` 를 끌 경로가 없어 tombstone 은 구멍 메우기다** · 공용 계약 개정 단계 · 임베딩 비결정성 때문에 1회 측정으로 채택을 가르지 않는다). **값을 쓰지 않았다** — 측정 수치·현행 상수·행 번호·배포 상태를 전부 출처 참조로 바꿨고 남은 숫자는 §6.2 의 반영값뿐인데 그것도 YAML 이 정본임을 표 앞에 못박았다. 문서가 값을 안고 있으면 값이 바뀔 때 문서만 낡는다. **`§6` 이 실측 없이 먼저 나간 것은 §14 에 감수 항목으로 남겼다** — 표시 계층만 손댄 범위였기 때문이고, 대가로 라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 §11 에 남는다 | [P47](proposals/P47-keyword-preset-label-axis.md), [근거 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md) | | 2026-08-07 | 검색 응답에 `keywordMatched` 필드를 추가했다(`S15P11A705-399`). 키워드 재정렬(`_rerank_by_keyword`, P49 §4)이 이미 계산하지만 정렬에만 쓰고 버리던 match 여부를 응답까지 살렸다 — 재정렬을 `tuple[list, set[int]]`로 바꿔 match 한 Record id 를 함께 반환하고, 재정렬이 생략되는 모든 경로에서는 빈 집합이라 자연히 `False` 다. `similarity` 는 원래 코사인 그대로 두어 정렬 점수가 새어 나가지 않는다는 기존 계약을 지켰다. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호를 준비하는 작업이다 | [리포트](implements/2026-08-07-search-keyword-matched-field.md) | +| 2026-08-07 | 재정렬 후보 불변 계약(`test_search_rerank.py` ②)과 결합 신뢰도 게이트(back 레포 `S15P11A705-400`, BD-52)의 계약을 분리 명시했다(`S15P11A705-402`, 중앙 조정 세션 인계 문서 §4.5가 요구한 검증). 재정렬은 후보를 안 지우지만 게이트는 정반대로 의도적으로 지운다 — 같은 파일에 다섯 계약만 있으면 게이트도 후보 불변을 지켜야 한다는 오독이 생긴다. docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보(기존 컷은 통과·게이트 임계값 미만·신호 없음)도 재정렬은 순서만 밀 뿐 지우지 않는다는 것을 새 테스트로 고정했다. 작업 중 `docs/implements/README.md`의 색인 두 표 모두에서 I58(`S15P11A705-399`) 리포트가 빠져 있던 것을 `test_docs_index.py`가 잡아 함께 정정했다 | [경계 리포트](implements/2026-08-07-rerank-gate-contract-boundary.md) | diff --git a/docs/implements/2026-08-07-rerank-gate-contract-boundary.md b/docs/implements/2026-08-07-rerank-gate-contract-boundary.md new file mode 100644 index 0000000..c30e0b1 --- /dev/null +++ b/docs/implements/2026-08-07-rerank-gate-contract-boundary.md @@ -0,0 +1,24 @@ +# 재정렬 후보 불변 계약과 결합 신뢰도 게이트 계약의 분리 + +- **티켓**: S15P11A705-402 +- **날짜**: 2026-08-07 +- **성격**: 문서·테스트 경계 명시. 런타임 코드는 바꾸지 않는다. + +## 배경 + +`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4.5는 착수 시 필요한 검증 중 하나로 다음을 명시했다. + +> 기존 계약 테스트(§4.1에서 인용한 "정렬은 후보를 추가·제거하면 안 된다")는 재정렬 로직의 계약이지 이 새 게이트의 계약이 아니라는 것을 별도로 명시해야 한다 — 헷갈리면 안 되는 지점이다. + +`tests/test_search_rerank.py`가 고정하는 다섯 계약 중 ②("같은 요청·같은 limit에서 on/off의 후보 Record id 집합이 같다")는 재정렬(`_rerank_by_keyword`)의 것이다. 결합 신뢰도 게이트(S15P11A705-400)는 back 레포에 구현됐고 정확히 그 반대 성질 — S1 신호만 있고 유사도가 낮은 후보를 **의도적으로 제거한다** — 을 가진다. 이 파일만 읽으면 "재정렬은 후보를 안 지운다"는 규칙이 게이트에도 적용돼야 한다는 오독이 생긴다. + +## 변경 + +- `tests/test_search_rerank.py`의 모듈 docstring에 "이 파일이 고정하지 않는 것" 절을 추가했다. 다섯 계약이 재정렬 자신의 것이며, 결합 신뢰도 게이트는 별도 저장소(`back`)의 별도 마지막 단계(BD-52)라는 것을 명시한다. +- 새 테스트 `test_rerank_never_gates_a_weak_unsupported_candidate`를 추가했다. 게이트라면 제외했을 모양의 후보(기존 컷은 통과하지만 게이트 채택 임계값보다 낮은 유사도, S2·S3 신호 없음)를 재정렬에 넣고, 그 후보가 순서만 밀릴 뿐 결과에서 사라지지 않는 것을 확인한다. + +## 검증 + +- `pytest tests/test_search_rerank.py` — 신규 1건 포함 전체 통과. +- `ruff check .` · `pytest --cov` 전체 · `tools/check_coverage_gate.py` — line 94.64%·branch 85.00%, 둘 다 통과. +- `pytest tests/test_docs_index.py` — 이 작업 도중 `docs/implements/README.md`의 두 색인 표(개별 리포트·구현·산출 전수) 모두에 S15P11A705-399 리포트 행이 빠져 있던 것을 발견해 함께 고쳤다(원래 이 티켓의 범위는 아니었으나, 색인 검사가 이 티켓의 변경으로 인해 실행되면서 잡았다). diff --git a/docs/implements/README.md b/docs/implements/README.md index 5411acf..385b77f 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -59,6 +59,8 @@ | I55 | [2026-08-06-rewrite-effect.md](2026-08-06-rewrite-effect.md) | 구현 | LLM 질의 재작성 효과 실측·길이 게이트 확정 — 실제 RewriteClient(운영 체인)로 무관 15건·사례 5건 전/후 비교. **약어 회복 확인**(`부캠`→`부트캠프` 컷 전 8위→1위·`신한 부캠` 4위→3위, 둘 다 반환 회복)·고유명사 유지 규칙 작동(`신한`·`그네`·`스팟` 원문 불변). **전면 적용은 무관 무노출 11/15→9/15 로 채택 기준 미달** — 의미 불변 표현 정규화(`파는 데`→`파는 곳`)가 컷 경계 아래 대역을 밀어 올린다. 게이트 후보 8종 전수 시뮬레이션에서 **형태(길이) 기준만 충족** — 성과 기반(결과 0건·top-1 임계)은 두 집단 대역이 겹쳐 분리 불가에 회복 대상까지 놓치고(원문도 오답 3건 반환), 단어형 판정 재사용은 `신한 부캠` 상실. **게이트 확정: strip 후 6자 이하만 재작성**(`SEARCH_REWRITE_MAX_CHARS`, 사용자 확정) — 게이트 반영 재측정에서 무노출 11/15 유지·회복 2건 유지로 채택 조건 충족. 한계: 6~12자 대역 무관측·모델 1종 1회차 관측 (S15P11A705-337 · 스냅샷 DB) | | I56 | [2026-08-06-rerank-adoption.md](2026-08-06-rerank-adoption.md) | 구현 | 키워드 재정렬 채택값 실측·런타임 구현 — P49 §4 재정렬 전용 구조(컷 통과 집합 고정·순서만 조정)에서 BASE·binary(floor 5값×weight 4값)·RRF(k=60) 재측정. **binary·floor 0.35·w 0.05 만 퇴행 없이 개선**(단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129, 문장형 유지+MRR 개선), RRF 는 문장형 hit@1 0.8333→0.5833~0.6667 퇴행으로 기각. 무관 세그먼트는 전 조합에서 BASE 와 동일(구조적 불변 실측 확인·스크립트 가드). floor 0.35~0.36 지표 동일로 T68 흔들림에 안전. 런타임: 기본 off 플래그·`keyword_status=COMPLETED` 신호·실패 시 벡터 순서 복귀·similarity 원값 유지, on/off 후보 집합 불변 계약 테스트 9건 + 픽스처 8건(총 511 통과). **실서버 on/off 대조 5판정 93건 전부 통과** — 재정렬 발화 7건 전건 오프라인 일치(`만두` 4위→1위), `신한`·`부캠` 미회복 재확인(역할 분담 검증), 흔들림 분류 미발동. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 색인 검사가 잡아 나중에 병합된 이쪽이 I56 으로 재확정(설계된 절차), 최종 확정은 dev 병합 직후 (S15P11A705-339 · 스냅샷 DB) | | I57 | [2026-08-07-preset-label-realign-gate.md](2026-08-07-preset-label-realign-gate.md) | 검증 | 프리셋 표시명 정합 회복과 게이트 재검증 — 표시명 명사형 통일(-292)이 YAML 정본에만 반영되고 두 DB에는 옛 표시명이 남아 **프리셋 임베딩·측정 자산이 정본과 어긋나 있던 결함**을 스냅샷 재적재로 해소. 조작 범위를 md5 4종으로 대조해 Context 임베딩·판정 상태·context_keyword 83행 불변 확인(판정 불변). 새 임베딩 격자에서 **채택값(binary·floor 0.35·w 0.05·top_k 3) 유지** — 퇴행 없이 단어형 개선(1위 0.8636→0.8788·3위내 0.9394→0.9545·MRR 0.9015→0.9121). **변화: 문장형 개선(MRR 0.9028→0.9167)이 사라지고 floor 0.40 구간으로 이동** — 재정렬의 이득이 단어형에 집중. 경계 감도 0.345~0.36 안정하나 기준선 0.35 인접 쌍 10건 중 9건이 임베딩 흔들림 폭 이내라는 한계 기록. 결정성 2회 일치. **게이트 5기준 전량 재통과**(3 phase 재실행 — judge가 세 결과를 함께 읽어 부분 재실행 불성립). keyword_matrix 포트 가드를 시연 DB에서 스냅샷으로 정정 동반 (검색 고도화 트랙 공통 · 스냅샷 DB) | +| I58 | [2026-08-07-search-keyword-matched-field.md](2026-08-07-search-keyword-matched-field.md) | 구현 | 검색 응답에 `keywordMatched` 필드 추가 — `_rerank_by_keyword`(I56)가 이미 계산하지만 정렬에만 쓰고 버리던 Preset 매치 여부를 `tuple[list, set[int]]`로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 그대로 두어 정렬 점수 비노출 계약(③)을 지켰다. 재정렬이 생략되는 모든 경로(off·오류·후보 없음)에서는 빈 집합이라 필드가 자연히 `False`다. 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4, back 레포 S15P11A705-400)가 쓸 S3 신호를 준비하는 작업이다 (S15P11A705-399) | +| I59 | [2026-08-07-rerank-gate-contract-boundary.md](2026-08-07-rerank-gate-contract-boundary.md) | 구현 | 재정렬 후보 불변 계약(②)과 결합 신뢰도 게이트(back 레포 S15P11A705-400)의 계약을 분리 명시 — `test_search_rerank.py` docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보도 재정렬은 지우지 않는다는 것을 `test_rerank_never_gates_a_weak_unsupported_candidate`로 실행 고정. 작업 중 I58 리포트가 이 색인 두 표 모두에서 빠져 있던 것을 함께 정정 (S15P11A705-402) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -121,5 +123,7 @@ | I55 | 재작성 효과 프로브 `tools/search_cut/rewrite_probe.py` — 실제 RewriteClient(운영 설정 체인) 호출 → 재작성문 임베딩 배치 1회 → 스냅샷 DB(:25432) 유사도 → 현행 컷 재구성(단어형/문장형 분기는 재작성문 기준, 서비스 미import 재구성 규칙). **재작성문 자체를 artifact 에 기록** — 무엇으로 바뀌었는지가 판정 근거의 절반. 원문 기준 값은 굳힌 행렬의 컷 재구성으로 얻어 GMS 호출 최소화, 재구성 무노출 11/15 가 기존 관측과 일치(교차 확인). 게이트(`should_rewrite` 재구성) 반영 후에는 게이트 통과 질의만 재작성·재임베딩 — 걸린 질의는 원문 값이 곧 결과(서비스와 동일). 강등 0건·전 질의 1순위 벤더 응답 관측 | [효과 리포트](2026-08-06-rewrite-effect.md), [I54](2026-08-06-lexical-merge-rule.md), [P49](../proposals/P49-multi-signal-search.md) | | I56 | 키워드 재정렬 하네스·런타임 `tools/search_cut/fusion_rerank_sweep.py`(재정렬 전용 격자 + 신선도 가드 + 후보 불변 검사) · `fusion.rerank()`(순수 함수) · `rerank_verify_live.py`(실서버 on/off 5판정) · `app/service/search_service.py`(`_rerank_by_keyword` — 질의 임베딩 재사용·추가 모델 호출 0) · `app/repository/context_keyword_repo.keywords_for_records` · `SEARCH_KEYWORD_RERANK_*` 4키(기본 off·floor 0.35·w 0.05·top_k 3) — 채택값은 재정렬 전용 구조 재측정으로 확정(I53 과 숫자가 같지만 다른 측정), 결정성 회차 JSON 바이트 일치, 실서버 대조 93건 5판정 전부 통과. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 재확정(설계된 절차), 최종 확정은 dev 병합 직후 | [재정렬 리포트](2026-08-06-rerank-adoption.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md), [tools/search_cut/](../../tools/search_cut/) | | I57 | 표시명 정합 회복·재측정·게이트 재검증 — `app/bootstrap/load_presets` 재적재로 프리셋 표시명(-292 명사형)을 정본에 맞추고, 그 조작이 무효화한 측정을 전부 다시 뜬다. 조작 범위는 md5 4종 대조로 고정(프리셋 임베딩만 변경 · Context 임베딩·판정 상태·context_keyword 83행 불변). `keyword_matrix` 재생성 → `fusion_rerank_sweep` 격자 재실행(결정성 2회 일치) → 게이트 3 phase 전량. **채택값 유지**(퇴행 없이 단어형 개선) · **문장형 개선 소멸**(floor 0.40 구간으로 이동) · 기준선 인접 쌍 9건이 임베딩 흔들림 폭 이내라는 한계. `keyword_matrix.py` 포트 가드를 시연 DB(:15432)에서 스냅샷(:25432)으로 정정 | [재검증 리포트](2026-08-07-preset-label-realign-gate.md), [I56](2026-08-06-rerank-adoption.md), [P49](../proposals/P49-multi-signal-search.md) | +| I58 | 검색 응답에 `keywordMatched` 필드 추가(S15P11A705-399) — `_rerank_by_keyword`(I56)가 이미 계산하지만 정렬에만 쓰고 버리던 Preset 매치 여부를 `tuple[list, set[int]]`로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 그대로 두어 정렬 점수 비노출 계약(③)을 지켰다. 재정렬이 생략되는 모든 경로(off·오류·후보 없음)에서는 빈 집합이라 필드가 자연히 `False`다. 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4, back 레포 S15P11A705-400)가 쓸 S3 신호를 준비하는 작업이다 | [필드 추가 리포트](2026-08-07-search-keyword-matched-field.md), [I56](2026-08-06-rerank-adoption.md), [test_search_rerank.py](../../tests/test_search_rerank.py) | +| I59 | 재정렬 후보 불변 계약과 게이트 계약 분리(S15P11A705-402) — `test_search_rerank.py`가 고정하는 다섯 계약(특히 ② 후보 불변)이 재정렬 자신의 것이며 결합 신뢰도 게이트(back 레포 S15P11A705-400, BD-52)의 계약이 아님을 모듈 docstring에 명시하고, `test_rerank_never_gates_a_weak_unsupported_candidate`로 "게이트라면 지웠을 후보도 재정렬은 지우지 않는다"를 실행 고정. 런타임 코드 변경 없음 | [경계 리포트](2026-08-07-rerank-gate-contract-boundary.md), [I58](2026-08-07-search-keyword-matched-field.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.5 | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. diff --git a/tests/test_search_rerank.py b/tests/test_search_rerank.py index a504515..b50e258 100644 --- a/tests/test_search_rerank.py +++ b/tests/test_search_rerank.py @@ -13,6 +13,24 @@ DB 는 가짜 커넥션으로 대체한다 — 여기서 재는 것은 재정렬 경로이지 SQL 이 아니다. 같은 계약의 오프라인 판은 tests/test_search_fusion.py §9(rerank 픽스처)에 있다. + +## 이 파일이 고정하지 않는 것 (S15P11A705-402) + +위 다섯은 **재정렬 자신의 계약**이다 — 순서를 바꾸되 후보를 추가·제거하지 않는다. +`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4가 제안하고 +`back` 레포가 구현한 **결합 신뢰도 게이트**(S15P11A705-400)는 정반대 성질을 가진다 — +S1(벡터) 신호 하나뿐이고 유사도가 낮은 후보를 **의도적으로 제거한다.** + +두 계약을 같은 파일·같은 절로 두면 "재정렬은 후보를 안 지운다"는 ②가 게이트에도 +적용돼야 한다는 오독이 생긴다. **그렇지 않다** — 게이트는 재정렬 *이후*, back 이 +문자열 병합 직후에 거는 별도의 마지막 단계이고(BD-52, back 레포), 이 파일이 재는 +`_rerank_by_keyword`·`rerank()`는 그 단계를 전혀 모른다. `keywordMatched` 필드 +(S15P11A705-399, 아래 절)는 그 게이트가 쓸 S3 신호를 실어 보내는 준비 작업일 뿐, +게이트 판정 자체는 이 레포가 아니라 `back`에 있다. + +아래 `test_rerank_never_gates_a_weak_unsupported_candidate`가 이 경계를 실행으로 +고정한다 — 게이트라면 제외했을 조건(신호 없음·낮은 유사도)의 후보도 재정렬은 +지우지 않는다. """ from __future__ import annotations @@ -303,6 +321,48 @@ async def test_keyword_matched_is_false_for_all_when_no_candidate_preset( assert all(r["keywordMatched"] is False for r in result) +# ── 재정렬 vs 게이트 경계 (S15P11A705-402) ───────────────────────────────── +# +# 위 다섯 계약과 keywordMatched 필드는 전부 **재정렬**의 것이다. 결합 신뢰도 +# 게이트(S15P11A705-400)는 back 레포에 있고 이 레포가 판정하지 않는다 — 아래 +# 테스트는 그 경계를 "재정렬은 게이트라면 지웠을 후보도 지우지 않는다"로 고정한다. + + +@pytest.mark.anyio +async def test_rerank_never_gates_a_weak_unsupported_candidate(monkeypatch): + """게이트라면 뺐을 모양의 후보(신호 없음·기존 컷은 통과·낮은 유사도)도 재정렬은 남긴다. + + 104는 기존 컷(`τ_word=0.24`·`r=0.6`)은 통과하지만(`0.31 ≥ 0.30`) 결합 신뢰도 + 게이트의 채택 임계값(0.35, ai 레포 S15P11A705-401)보다는 낮고 S2(문자열)·S3 + (키워드) 어느 신호도 없다 — back의 게이트였다면 정확히 이 모양의 후보를 + 제외한다. 이 레포의 재정렬은 그 판단 자체를 하지 않으므로 104는 순서만 + 맨 뒤로 밀릴 뿐 후보에서 사라지지 않는다(계약 ②는 여기서도 성립한다). + """ + weak_rows = [ + {"record_id": 101, "context_id": 11, "similarity": 0.50}, + {"record_id": 102, "context_id": 12, "similarity": 0.48}, + {"record_id": 103, "context_id": 13, "similarity": 0.40}, + {"record_id": 104, "context_id": 14, "similarity": 0.31}, + ] + + async def _rows(conn, user_id, profile, embedding, limit): + return [dict(r) for r in weak_rows] + + monkeypatch.setattr( + "app.service.search_service.context_embedding_repo.search", _rows + ) + settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, _ = _service( + monkeypatch, settings, presets=ALIGNED, keyword_rows=MATCH_102 + ) + result = await _search(service) + + assert {r["recordId"] for r in result} == {101, 102, 103, 104} + weakest = next(r for r in result if r["recordId"] == 104) + assert weakest["similarity"] == 0.31 + assert weakest["keywordMatched"] is False + + def test_candidate_rule_matches_the_harness(monkeypatch): """floor 를 top_k 보다 먼저 걸고 동점은 preset_id 오름차순 — 하네스와 같은 규칙. From b3e1aa01ab9be34bfc3099f7c1b4df5c8a189d88 Mon Sep 17 00:00:00 2001 From: ghkim1632 Date: Fri, 7 Aug 2026 19:00:37 +0900 Subject: [PATCH 32/34] =?UTF-8?q?docs(S15P11A705-401):=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=83=89=EC=9D=B8=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=EC=9D=84=20=EC=A0=95=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gate-threshold-remeasure.md 리포트를 docs/implements/README.md의 두 색인 표(개별 리포트·구현·산출 전수)에 모두 추가한다. test_docs_index.py가 이 누락을 잡았다. --- docs/WORKLOG.md | 1 + docs/implements/README.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index c03a515..4223ce8 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -78,3 +78,4 @@ | 2026-08-03 | 표시명 개정(`S15P11A705-292`)이 코드에서 운영 화면까지 간 **배포 체인을 기록했다**(I50). **체인 여섯 단계의 소관을 갈랐다** — 우리(`ai#102` dev · `ai#104` 릴리스) · 자동화(`image-publish` 는 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**) · 인프라 소관(`infra#185`) · 클러스터 sync · 화면. **핵심은 미결 판정 하나를 실측으로 닫은 것이다** — 프리셋 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 부트스트랩 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 이미지 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 「값이 같으면 재실행되지 않는다」로 판정했고 독립 검증이 논거를 반증했으나 **클러스터 관측 경로가 없어 「확인 불가」로 끝나 있었다** — 배포가 답을 냈다. **그래도 표기가 낡은 것은 기록 정합성 문제로 남는다**(배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다). **릴리스가 두 번 막혔고 둘 다 다음에 그대로 다시 만난다** — base 검사는 `main` base 를 `release/*`·`hotfix/*` 로 한정하므로 `dev` 를 그대로 열면 CI 가 막고(`ai#103` 이 그렇게 닫혔다), strict 상태 검사는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(병합 커밋 `032e391f`). 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 변경 없이 끝났고, 갱신을 만든 것은 수동 실행이라 **`success` 두 번의 뜻이 다르다.** 확인 수단이 화면뿐이라 「아직인가 실패인가」가 갈리지 않아 한 번 오판했다가 재확인에서 뒤집혔다. **값은 옮기지 않고 좌표만 적었다**(`T70` 적용) | [배포 체인 리포트](implements/2026-08-03-preset-display-name-deploy-chain.md), [I48](implements/2026-08-03-dev-deploy-gap.md), [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | | 2026-08-03 | 외부 리뷰 문서를 **P47 제안 문서로 다시 썼다** — 프리셋의 표시 라벨·축 정의·스키마 개정안(`S15P11A705-228`·`-269`·`-270`·`-271`·`-292`, `ai#90` 후속). **proposals 최초의 `Proposed`** 다 — §7 이후가 미실행이고 §11 실측이 없다. **다만 §6(표시 라벨)은 문서를 쓰는 사이에 반영이 끝났다** — `-292` 가 `display_name` 을 명사·명사구로 통일해 `ai#102`(`dev`)·`ai#104`(`main`)로 나갔고, 그래서 그 절만 제안이 아니라 **기록**으로 다시 썼다(제목에 「반영 완료」 · 표의 「권장 표시명」을 「반영값」으로 · 정본은 문서가 아니라 `data/keyword_preset.yaml` 임을 명시). **초안과 실제가 갈린 셋을 확정으로 남겼다** — `ALONE` 은 초안의 `1인` 을 기각하고 `혼자` 를 유지했고(축의 나머지가 관계어인데 수량 표현만 이질적이다), `TRENDY` 는 `examples` 가 유행이 아니라 미감을 가리키므로 `감성` 으로 확정했으며, `RETRO` 는 **초안에 없던 결정**으로 `복고` 가 됐다(같은 축의 어휘 층위 통일 · `description` 과 정합). **선결 조건이 통째로 바뀌었다** — 「프리셋을 고치면 기존 판정을 전부 재처리한다」로 정해지면서 「판을 구분할 경로」(행 `version` 을 올릴 경로가 없다는 사실, `-269`)가 선결에서 §3-F 로 내려갔고, 그 자리에 **「재처리할 수단이 없다」**가 들어갔다: 완료 State 를 되돌리는 코드가 없고 · 재스캔이 잡는 상태에 완료가 없으며 · 한 번에 밀면 게이트웨이 한도에 걸려 재시도 예산이 소진되면 실패로 굳는다(부재의 범위는 `ai#100`). **「정할 것」이 아니라 「만들 것」이라 §13 0단계가 구현 항목이 됐고**, 같은 이유로 §10.3 세트 release 는 운영 용도가 빠지고 평가 재현만 남아 우선순위가 내려갔다. **원본의 다섯 곳을 정정했다**: ① Feed 표시 정렬을 절째로 빼고 `back` P46 을 가리켰다(원본 제안이 그 결정이 **명시적으로 기각한** 방향이었다), ② 「CI 에서 검증되지 않는 것」 목록을 없애고 방향과 「테스트 코드가 정본」만 남겼다(이미 있는 테스트를 없다고 적고 있었다), ③ 배포 서술을 hook 메커니즘 존재라는 구조 사실로 줄였다(나머지는 `infra` 소관), ④ `version` 을 「세트 release 와 분리」가 아니라 판 식별 문제로 재배치, ⑤ 표시명 오염을 「임베딩 입력」이 아니라 **임베딩·판정 프롬프트 양쪽**으로 고쳤다 — 그래서 이 변경은 임베딩 실험이 아니라 프롬프트 실험 대상이다. **구조 사실 일곱을 보강했다**(개정에 기존 판정 재처리가 따라붙는다 · 후보 슬롯 경쟁으로 프리셋 단위 개선이 전역에서 상쇄된다 · 저빈도 프리셋 처분이 라벨 개정보다 앞선다 · 표시명 개정이 백엔드 조회의 정렬과 중복 흡수에 파급된다 · **`is_active` 를 끌 경로가 없어 tombstone 은 구멍 메우기다** · 공용 계약 개정 단계 · 임베딩 비결정성 때문에 1회 측정으로 채택을 가르지 않는다). **값을 쓰지 않았다** — 측정 수치·현행 상수·행 번호·배포 상태를 전부 출처 참조로 바꿨고 남은 숫자는 §6.2 의 반영값뿐인데 그것도 YAML 이 정본임을 표 앞에 못박았다. 문서가 값을 안고 있으면 값이 바뀔 때 문서만 낡는다. **`§6` 이 실측 없이 먼저 나간 것은 §14 에 감수 항목으로 남겼다** — 표시 계층만 손댄 범위였기 때문이고, 대가로 라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 §11 에 남는다 | [P47](proposals/P47-keyword-preset-label-axis.md), [근거 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md) | | 2026-08-07 | 결합 신뢰도 게이트(중앙 조정 세션 인계 문서 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) 의 threshold 후보 0.35 를 오프라인으로 재측정했다(`S15P11A705-401`). 0.35 는 원래 `SEARCH_KEYWORD_RERANK_FLOOR`(질의-Preset 코사인 판정값)에서 가져온 값이라 이 용도(S1 단독·저유사도 결과를 숨기는 게이트)로는 검증된 적이 없었다. `gate_sweep.py` 로 문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건에 게이트 규칙을 적용해 보니 threshold 0.35 에서 정답 손실 0 을 유지하면서 무관 노출이 크게 줄었고(단어무관 23/45·타인소유 55/96 신규 침묵), 손실 0 구간의 최적값(0.36)과도 사실상 같았다. **최초 실행에서는 프로브 6 건이 손실로 나왔는데, 원인은 `recall_probe.json` 이 질의별이 아니라 최상위에 `user_id` 를 두는 형식을 놓친 것이었다** — 고치자 손실이 threshold 0.38 까지 0 건으로 나왔다. 0.35 채택을 권고한다 | [리포트](implements/2026-08-07-gate-threshold-remeasure.md) | +| 2026-08-07 | 401 리포트 작성 뒤 `test_docs_index.py`가 `docs/implements/README.md`의 색인 두 표(개별 리포트·구현·산출 전수) 모두에서 I58(`2026-08-07-gate-threshold-remeasure.md`) 행이 빠진 것을 잡았다 — 리포트를 커밋하면서 색인 갱신을 놓친 것. 두 표 모두에 행을 추가해 정정했다 | [gate_sweep 리포트](implements/2026-08-07-gate-threshold-remeasure.md) | diff --git a/docs/implements/README.md b/docs/implements/README.md index 5411acf..5f3ab7f 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -59,6 +59,7 @@ | I55 | [2026-08-06-rewrite-effect.md](2026-08-06-rewrite-effect.md) | 구현 | LLM 질의 재작성 효과 실측·길이 게이트 확정 — 실제 RewriteClient(운영 체인)로 무관 15건·사례 5건 전/후 비교. **약어 회복 확인**(`부캠`→`부트캠프` 컷 전 8위→1위·`신한 부캠` 4위→3위, 둘 다 반환 회복)·고유명사 유지 규칙 작동(`신한`·`그네`·`스팟` 원문 불변). **전면 적용은 무관 무노출 11/15→9/15 로 채택 기준 미달** — 의미 불변 표현 정규화(`파는 데`→`파는 곳`)가 컷 경계 아래 대역을 밀어 올린다. 게이트 후보 8종 전수 시뮬레이션에서 **형태(길이) 기준만 충족** — 성과 기반(결과 0건·top-1 임계)은 두 집단 대역이 겹쳐 분리 불가에 회복 대상까지 놓치고(원문도 오답 3건 반환), 단어형 판정 재사용은 `신한 부캠` 상실. **게이트 확정: strip 후 6자 이하만 재작성**(`SEARCH_REWRITE_MAX_CHARS`, 사용자 확정) — 게이트 반영 재측정에서 무노출 11/15 유지·회복 2건 유지로 채택 조건 충족. 한계: 6~12자 대역 무관측·모델 1종 1회차 관측 (S15P11A705-337 · 스냅샷 DB) | | I56 | [2026-08-06-rerank-adoption.md](2026-08-06-rerank-adoption.md) | 구현 | 키워드 재정렬 채택값 실측·런타임 구현 — P49 §4 재정렬 전용 구조(컷 통과 집합 고정·순서만 조정)에서 BASE·binary(floor 5값×weight 4값)·RRF(k=60) 재측정. **binary·floor 0.35·w 0.05 만 퇴행 없이 개선**(단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129, 문장형 유지+MRR 개선), RRF 는 문장형 hit@1 0.8333→0.5833~0.6667 퇴행으로 기각. 무관 세그먼트는 전 조합에서 BASE 와 동일(구조적 불변 실측 확인·스크립트 가드). floor 0.35~0.36 지표 동일로 T68 흔들림에 안전. 런타임: 기본 off 플래그·`keyword_status=COMPLETED` 신호·실패 시 벡터 순서 복귀·similarity 원값 유지, on/off 후보 집합 불변 계약 테스트 9건 + 픽스처 8건(총 511 통과). **실서버 on/off 대조 5판정 93건 전부 통과** — 재정렬 발화 7건 전건 오프라인 일치(`만두` 4위→1위), `신한`·`부캠` 미회복 재확인(역할 분담 검증), 흔들림 분류 미발동. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 색인 검사가 잡아 나중에 병합된 이쪽이 I56 으로 재확정(설계된 절차), 최종 확정은 dev 병합 직후 (S15P11A705-339 · 스냅샷 DB) | | I57 | [2026-08-07-preset-label-realign-gate.md](2026-08-07-preset-label-realign-gate.md) | 검증 | 프리셋 표시명 정합 회복과 게이트 재검증 — 표시명 명사형 통일(-292)이 YAML 정본에만 반영되고 두 DB에는 옛 표시명이 남아 **프리셋 임베딩·측정 자산이 정본과 어긋나 있던 결함**을 스냅샷 재적재로 해소. 조작 범위를 md5 4종으로 대조해 Context 임베딩·판정 상태·context_keyword 83행 불변 확인(판정 불변). 새 임베딩 격자에서 **채택값(binary·floor 0.35·w 0.05·top_k 3) 유지** — 퇴행 없이 단어형 개선(1위 0.8636→0.8788·3위내 0.9394→0.9545·MRR 0.9015→0.9121). **변화: 문장형 개선(MRR 0.9028→0.9167)이 사라지고 floor 0.40 구간으로 이동** — 재정렬의 이득이 단어형에 집중. 경계 감도 0.345~0.36 안정하나 기준선 0.35 인접 쌍 10건 중 9건이 임베딩 흔들림 폭 이내라는 한계 기록. 결정성 2회 일치. **게이트 5기준 전량 재통과**(3 phase 재실행 — judge가 세 결과를 함께 읽어 부분 재실행 불성립). keyword_matrix 포트 가드를 시연 DB에서 스냅샷으로 정정 동반 (검색 고도화 트랙 공통 · 스냅샷 DB) | +| I58 | [2026-08-07-gate-threshold-remeasure.md](2026-08-07-gate-threshold-remeasure.md) | 검증 | 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) threshold 후보 0.35 오프라인 재측정 — `SEARCH_KEYWORD_RERANK_FLOOR`에서 값만 가져온 것이 아니라 이 용도(S1 단독·저유사도 결과 숨김)로 별도로 다시 쟀다. `gate_sweep.py`가 기존 행렬(문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건)에 게이트 규칙을 적용해, threshold 0.35에서 정답 손실 0을 유지하며 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵)는 것을 확인했다. 최초 실행은 `recall_probe.json`의 최상위 `user_id` 처리 버그로 프로브 손실 6건을 오탐했고, 고친 뒤 0.35 채택을 권고했다 (S15P11A705-401 · 스냅샷 없음 · DB·GMS 호출 0회) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -121,5 +122,6 @@ | I55 | 재작성 효과 프로브 `tools/search_cut/rewrite_probe.py` — 실제 RewriteClient(운영 설정 체인) 호출 → 재작성문 임베딩 배치 1회 → 스냅샷 DB(:25432) 유사도 → 현행 컷 재구성(단어형/문장형 분기는 재작성문 기준, 서비스 미import 재구성 규칙). **재작성문 자체를 artifact 에 기록** — 무엇으로 바뀌었는지가 판정 근거의 절반. 원문 기준 값은 굳힌 행렬의 컷 재구성으로 얻어 GMS 호출 최소화, 재구성 무노출 11/15 가 기존 관측과 일치(교차 확인). 게이트(`should_rewrite` 재구성) 반영 후에는 게이트 통과 질의만 재작성·재임베딩 — 걸린 질의는 원문 값이 곧 결과(서비스와 동일). 강등 0건·전 질의 1순위 벤더 응답 관측 | [효과 리포트](2026-08-06-rewrite-effect.md), [I54](2026-08-06-lexical-merge-rule.md), [P49](../proposals/P49-multi-signal-search.md) | | I56 | 키워드 재정렬 하네스·런타임 `tools/search_cut/fusion_rerank_sweep.py`(재정렬 전용 격자 + 신선도 가드 + 후보 불변 검사) · `fusion.rerank()`(순수 함수) · `rerank_verify_live.py`(실서버 on/off 5판정) · `app/service/search_service.py`(`_rerank_by_keyword` — 질의 임베딩 재사용·추가 모델 호출 0) · `app/repository/context_keyword_repo.keywords_for_records` · `SEARCH_KEYWORD_RERANK_*` 4키(기본 off·floor 0.35·w 0.05·top_k 3) — 채택값은 재정렬 전용 구조 재측정으로 확정(I53 과 숫자가 같지만 다른 측정), 결정성 회차 JSON 바이트 일치, 실서버 대조 93건 5판정 전부 통과. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 재확정(설계된 절차), 최종 확정은 dev 병합 직후 | [재정렬 리포트](2026-08-06-rerank-adoption.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md), [tools/search_cut/](../../tools/search_cut/) | | I57 | 표시명 정합 회복·재측정·게이트 재검증 — `app/bootstrap/load_presets` 재적재로 프리셋 표시명(-292 명사형)을 정본에 맞추고, 그 조작이 무효화한 측정을 전부 다시 뜬다. 조작 범위는 md5 4종 대조로 고정(프리셋 임베딩만 변경 · Context 임베딩·판정 상태·context_keyword 83행 불변). `keyword_matrix` 재생성 → `fusion_rerank_sweep` 격자 재실행(결정성 2회 일치) → 게이트 3 phase 전량. **채택값 유지**(퇴행 없이 단어형 개선) · **문장형 개선 소멸**(floor 0.40 구간으로 이동) · 기준선 인접 쌍 9건이 임베딩 흔들림 폭 이내라는 한계. `keyword_matrix.py` 포트 가드를 시연 DB(:15432)에서 스냅샷(:25432)으로 정정 | [재검증 리포트](2026-08-07-preset-label-realign-gate.md), [I56](2026-08-06-rerank-adoption.md), [P49](../proposals/P49-multi-signal-search.md) | +| I58 | 결합 신뢰도 게이트 threshold 재측정 하네스 `tools/search_cut/gate_sweep.py` — 기존 행렬(`matrix.json`·`word_grid.json`·`recall_probe.json`·`lexical_matrix.json`·`keyword_matrix.json`)만 읽어 S1(벡터)·S2(문자열, 채택 게이트 G1)·S3(키워드, 채택값 binary·floor 0.35·top_k 3) 신호를 재구성하고 threshold 격자(0.0·0.16~0.40)를 훑는다. **0.35에서 정답 손실 0**(문장형 12·단어형 66·프로브 22 전부)이면서 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵) — 안전 구간(0.25~0.37) 안의 최적값(0.36)과도 사실상 같다. DB·GMS 호출 0회, 결정성 확인(같은 입력 2회 실행 바이트 일치) | [재측정 리포트](2026-08-07-gate-threshold-remeasure.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.3, [I56](2026-08-06-rerank-adoption.md), [tools/search_cut/](../../tools/search_cut/) | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. From dbff5eb927322d99177c5b38cb6d0dafb1413857 Mon Sep 17 00:00:00 2001 From: ghkim1632 Date: Sat, 8 Aug 2026 11:26:19 +0900 Subject: [PATCH 33/34] =?UTF-8?q?docs(S15P11A705-399):=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=83=89=EC=9D=B8=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=EC=9D=84=20=EC=A0=95=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-07-search-keyword-matched-field.md 가 파일 표·전수 표 어디에도 등록되지 않아 check_docs_index.py 의 고아 검사에 걸렸다. I59 로 양쪽 표에 등록한다. --- docs/implements/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/implements/README.md b/docs/implements/README.md index 5f3ab7f..4d56270 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -60,6 +60,7 @@ | I56 | [2026-08-06-rerank-adoption.md](2026-08-06-rerank-adoption.md) | 구현 | 키워드 재정렬 채택값 실측·런타임 구현 — P49 §4 재정렬 전용 구조(컷 통과 집합 고정·순서만 조정)에서 BASE·binary(floor 5값×weight 4값)·RRF(k=60) 재측정. **binary·floor 0.35·w 0.05 만 퇴행 없이 개선**(단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129, 문장형 유지+MRR 개선), RRF 는 문장형 hit@1 0.8333→0.5833~0.6667 퇴행으로 기각. 무관 세그먼트는 전 조합에서 BASE 와 동일(구조적 불변 실측 확인·스크립트 가드). floor 0.35~0.36 지표 동일로 T68 흔들림에 안전. 런타임: 기본 off 플래그·`keyword_status=COMPLETED` 신호·실패 시 벡터 순서 복귀·similarity 원값 유지, on/off 후보 집합 불변 계약 테스트 9건 + 픽스처 8건(총 511 통과). **실서버 on/off 대조 5판정 93건 전부 통과** — 재정렬 발화 7건 전건 오프라인 일치(`만두` 4위→1위), `신한`·`부캠` 미회복 재확인(역할 분담 검증), 흔들림 분류 미발동. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 색인 검사가 잡아 나중에 병합된 이쪽이 I56 으로 재확정(설계된 절차), 최종 확정은 dev 병합 직후 (S15P11A705-339 · 스냅샷 DB) | | I57 | [2026-08-07-preset-label-realign-gate.md](2026-08-07-preset-label-realign-gate.md) | 검증 | 프리셋 표시명 정합 회복과 게이트 재검증 — 표시명 명사형 통일(-292)이 YAML 정본에만 반영되고 두 DB에는 옛 표시명이 남아 **프리셋 임베딩·측정 자산이 정본과 어긋나 있던 결함**을 스냅샷 재적재로 해소. 조작 범위를 md5 4종으로 대조해 Context 임베딩·판정 상태·context_keyword 83행 불변 확인(판정 불변). 새 임베딩 격자에서 **채택값(binary·floor 0.35·w 0.05·top_k 3) 유지** — 퇴행 없이 단어형 개선(1위 0.8636→0.8788·3위내 0.9394→0.9545·MRR 0.9015→0.9121). **변화: 문장형 개선(MRR 0.9028→0.9167)이 사라지고 floor 0.40 구간으로 이동** — 재정렬의 이득이 단어형에 집중. 경계 감도 0.345~0.36 안정하나 기준선 0.35 인접 쌍 10건 중 9건이 임베딩 흔들림 폭 이내라는 한계 기록. 결정성 2회 일치. **게이트 5기준 전량 재통과**(3 phase 재실행 — judge가 세 결과를 함께 읽어 부분 재실행 불성립). keyword_matrix 포트 가드를 시연 DB에서 스냅샷으로 정정 동반 (검색 고도화 트랙 공통 · 스냅샷 DB) | | I58 | [2026-08-07-gate-threshold-remeasure.md](2026-08-07-gate-threshold-remeasure.md) | 검증 | 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) threshold 후보 0.35 오프라인 재측정 — `SEARCH_KEYWORD_RERANK_FLOOR`에서 값만 가져온 것이 아니라 이 용도(S1 단독·저유사도 결과 숨김)로 별도로 다시 쟀다. `gate_sweep.py`가 기존 행렬(문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건)에 게이트 규칙을 적용해, threshold 0.35에서 정답 손실 0을 유지하며 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵)는 것을 확인했다. 최초 실행은 `recall_probe.json`의 최상위 `user_id` 처리 버그로 프로브 손실 6건을 오탐했고, 고친 뒤 0.35 채택을 권고했다 (S15P11A705-401 · 스냅샷 없음 · DB·GMS 호출 0회) | +| I59 | [2026-08-07-search-keyword-matched-field.md](2026-08-07-search-keyword-matched-field.md) | 구현 | 검색 응답에 `keywordMatched: bool` 필드 추가 — `_rerank_by_keyword`가 이미 계산하던 Preset 매치 여부를 정렬에만 쓰고 버리던 것을 `(list, matched_ids)` 튜플로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 값 그대로 유지(정렬 점수 비노출 계약 불변), 재정렬이 생략되는 모든 경로는 빈 집합이라 자연히 `False`. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호 준비 (S15P11A705-399) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -123,5 +124,6 @@ | I56 | 키워드 재정렬 하네스·런타임 `tools/search_cut/fusion_rerank_sweep.py`(재정렬 전용 격자 + 신선도 가드 + 후보 불변 검사) · `fusion.rerank()`(순수 함수) · `rerank_verify_live.py`(실서버 on/off 5판정) · `app/service/search_service.py`(`_rerank_by_keyword` — 질의 임베딩 재사용·추가 모델 호출 0) · `app/repository/context_keyword_repo.keywords_for_records` · `SEARCH_KEYWORD_RERANK_*` 4키(기본 off·floor 0.35·w 0.05·top_k 3) — 채택값은 재정렬 전용 구조 재측정으로 확정(I53 과 숫자가 같지만 다른 측정), 결정성 회차 JSON 바이트 일치, 실서버 대조 93건 5판정 전부 통과. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 재확정(설계된 절차), 최종 확정은 dev 병합 직후 | [재정렬 리포트](2026-08-06-rerank-adoption.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md), [tools/search_cut/](../../tools/search_cut/) | | I57 | 표시명 정합 회복·재측정·게이트 재검증 — `app/bootstrap/load_presets` 재적재로 프리셋 표시명(-292 명사형)을 정본에 맞추고, 그 조작이 무효화한 측정을 전부 다시 뜬다. 조작 범위는 md5 4종 대조로 고정(프리셋 임베딩만 변경 · Context 임베딩·판정 상태·context_keyword 83행 불변). `keyword_matrix` 재생성 → `fusion_rerank_sweep` 격자 재실행(결정성 2회 일치) → 게이트 3 phase 전량. **채택값 유지**(퇴행 없이 단어형 개선) · **문장형 개선 소멸**(floor 0.40 구간으로 이동) · 기준선 인접 쌍 9건이 임베딩 흔들림 폭 이내라는 한계. `keyword_matrix.py` 포트 가드를 시연 DB(:15432)에서 스냅샷(:25432)으로 정정 | [재검증 리포트](2026-08-07-preset-label-realign-gate.md), [I56](2026-08-06-rerank-adoption.md), [P49](../proposals/P49-multi-signal-search.md) | | I58 | 결합 신뢰도 게이트 threshold 재측정 하네스 `tools/search_cut/gate_sweep.py` — 기존 행렬(`matrix.json`·`word_grid.json`·`recall_probe.json`·`lexical_matrix.json`·`keyword_matrix.json`)만 읽어 S1(벡터)·S2(문자열, 채택 게이트 G1)·S3(키워드, 채택값 binary·floor 0.35·top_k 3) 신호를 재구성하고 threshold 격자(0.0·0.16~0.40)를 훑는다. **0.35에서 정답 손실 0**(문장형 12·단어형 66·프로브 22 전부)이면서 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵) — 안전 구간(0.25~0.37) 안의 최적값(0.36)과도 사실상 같다. DB·GMS 호출 0회, 결정성 확인(같은 입력 2회 실행 바이트 일치) | [재측정 리포트](2026-08-07-gate-threshold-remeasure.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.3, [I56](2026-08-06-rerank-adoption.md), [tools/search_cut/](../../tools/search_cut/) | +| I59 | 검색 응답 `keywordMatched: bool` 필드 `app/schema/search.py`·`app/service/search_service.py`(`_rerank_by_keyword` 반환을 `(list, matched_ids)` 튜플화) — 이미 계산되던 Preset 매치 여부를 응답까지 배선한다. `similarity`는 원래 코사인 값 유지, 재정렬 생략 경로는 빈 집합이라 자연히 `False`. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호 준비 | [필드 추가 리포트](2026-08-07-search-keyword-matched-field.md), [I56](2026-08-06-rerank-adoption.md) | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. From aacd6c82f4ab54b01720002fef14d810022e6624 Mon Sep 17 00:00:00 2001 From: ghkim1632 Date: Sat, 8 Aug 2026 12:02:39 +0900 Subject: [PATCH 34/34] =?UTF-8?q?docs(S15P11A705-402):=20dev=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=ED=9B=84=20=EA=B5=AC=ED=98=84=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EB=B2=88=ED=98=B8=20=EC=B6=A9=EB=8F=8C=EC=9D=84=20?= =?UTF-8?q?=EC=9E=AC=ED=99=95=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev 병합 전 이 브랜치가 0bfa94a(-399) 위에서 독립적으로 색인 누락을 I58(search-keyword-matched-field)·I59(rerank-gate-contract-boundary)로 번호 매겼는데, dev 는 그 사이 -399 자신의 PR #121 이 같은 문서를 이미 I59 로 확정 병합해 두고 있었다. 3-way 병합이 문법적으로는 깨끗이 붙어(두 브랜치가 다른 줄에 추가) 같은 번호가 중복된 채 남았다 — check_docs_index.py 가 경고하는 T60 패턴 그대로다. dev 의 I58·I59(이미 병합된 -399·-401)는 그대로 두고, 이 브랜치가 새로 낸 rerank-gate-contract-boundary.md 하나만 I60 으로 재확정한다. WORKLOG.md 의 설명도 실제로 무슨 일이 있었는지에 맞춰 정정했다. --- docs/WORKLOG.md | 2 +- docs/implements/README.md | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 44c86a1..30ea306 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -78,6 +78,6 @@ | 2026-08-03 | 표시명 개정(`S15P11A705-292`)이 코드에서 운영 화면까지 간 **배포 체인을 기록했다**(I50). **체인 여섯 단계의 소관을 갈랐다** — 우리(`ai#102` dev · `ai#104` 릴리스) · 자동화(`image-publish` 는 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**) · 인프라 소관(`infra#185`) · 클러스터 sync · 화면. **핵심은 미결 판정 하나를 실측으로 닫은 것이다** — 프리셋 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 부트스트랩 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 이미지 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 「값이 같으면 재실행되지 않는다」로 판정했고 독립 검증이 논거를 반증했으나 **클러스터 관측 경로가 없어 「확인 불가」로 끝나 있었다** — 배포가 답을 냈다. **그래도 표기가 낡은 것은 기록 정합성 문제로 남는다**(배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다). **릴리스가 두 번 막혔고 둘 다 다음에 그대로 다시 만난다** — base 검사는 `main` base 를 `release/*`·`hotfix/*` 로 한정하므로 `dev` 를 그대로 열면 CI 가 막고(`ai#103` 이 그렇게 닫혔다), strict 상태 검사는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(병합 커밋 `032e391f`). 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 변경 없이 끝났고, 갱신을 만든 것은 수동 실행이라 **`success` 두 번의 뜻이 다르다.** 확인 수단이 화면뿐이라 「아직인가 실패인가」가 갈리지 않아 한 번 오판했다가 재확인에서 뒤집혔다. **값은 옮기지 않고 좌표만 적었다**(`T70` 적용) | [배포 체인 리포트](implements/2026-08-03-preset-display-name-deploy-chain.md), [I48](implements/2026-08-03-dev-deploy-gap.md), [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | | 2026-08-03 | 외부 리뷰 문서를 **P47 제안 문서로 다시 썼다** — 프리셋의 표시 라벨·축 정의·스키마 개정안(`S15P11A705-228`·`-269`·`-270`·`-271`·`-292`, `ai#90` 후속). **proposals 최초의 `Proposed`** 다 — §7 이후가 미실행이고 §11 실측이 없다. **다만 §6(표시 라벨)은 문서를 쓰는 사이에 반영이 끝났다** — `-292` 가 `display_name` 을 명사·명사구로 통일해 `ai#102`(`dev`)·`ai#104`(`main`)로 나갔고, 그래서 그 절만 제안이 아니라 **기록**으로 다시 썼다(제목에 「반영 완료」 · 표의 「권장 표시명」을 「반영값」으로 · 정본은 문서가 아니라 `data/keyword_preset.yaml` 임을 명시). **초안과 실제가 갈린 셋을 확정으로 남겼다** — `ALONE` 은 초안의 `1인` 을 기각하고 `혼자` 를 유지했고(축의 나머지가 관계어인데 수량 표현만 이질적이다), `TRENDY` 는 `examples` 가 유행이 아니라 미감을 가리키므로 `감성` 으로 확정했으며, `RETRO` 는 **초안에 없던 결정**으로 `복고` 가 됐다(같은 축의 어휘 층위 통일 · `description` 과 정합). **선결 조건이 통째로 바뀌었다** — 「프리셋을 고치면 기존 판정을 전부 재처리한다」로 정해지면서 「판을 구분할 경로」(행 `version` 을 올릴 경로가 없다는 사실, `-269`)가 선결에서 §3-F 로 내려갔고, 그 자리에 **「재처리할 수단이 없다」**가 들어갔다: 완료 State 를 되돌리는 코드가 없고 · 재스캔이 잡는 상태에 완료가 없으며 · 한 번에 밀면 게이트웨이 한도에 걸려 재시도 예산이 소진되면 실패로 굳는다(부재의 범위는 `ai#100`). **「정할 것」이 아니라 「만들 것」이라 §13 0단계가 구현 항목이 됐고**, 같은 이유로 §10.3 세트 release 는 운영 용도가 빠지고 평가 재현만 남아 우선순위가 내려갔다. **원본의 다섯 곳을 정정했다**: ① Feed 표시 정렬을 절째로 빼고 `back` P46 을 가리켰다(원본 제안이 그 결정이 **명시적으로 기각한** 방향이었다), ② 「CI 에서 검증되지 않는 것」 목록을 없애고 방향과 「테스트 코드가 정본」만 남겼다(이미 있는 테스트를 없다고 적고 있었다), ③ 배포 서술을 hook 메커니즘 존재라는 구조 사실로 줄였다(나머지는 `infra` 소관), ④ `version` 을 「세트 release 와 분리」가 아니라 판 식별 문제로 재배치, ⑤ 표시명 오염을 「임베딩 입력」이 아니라 **임베딩·판정 프롬프트 양쪽**으로 고쳤다 — 그래서 이 변경은 임베딩 실험이 아니라 프롬프트 실험 대상이다. **구조 사실 일곱을 보강했다**(개정에 기존 판정 재처리가 따라붙는다 · 후보 슬롯 경쟁으로 프리셋 단위 개선이 전역에서 상쇄된다 · 저빈도 프리셋 처분이 라벨 개정보다 앞선다 · 표시명 개정이 백엔드 조회의 정렬과 중복 흡수에 파급된다 · **`is_active` 를 끌 경로가 없어 tombstone 은 구멍 메우기다** · 공용 계약 개정 단계 · 임베딩 비결정성 때문에 1회 측정으로 채택을 가르지 않는다). **값을 쓰지 않았다** — 측정 수치·현행 상수·행 번호·배포 상태를 전부 출처 참조로 바꿨고 남은 숫자는 §6.2 의 반영값뿐인데 그것도 YAML 이 정본임을 표 앞에 못박았다. 문서가 값을 안고 있으면 값이 바뀔 때 문서만 낡는다. **`§6` 이 실측 없이 먼저 나간 것은 §14 에 감수 항목으로 남겼다** — 표시 계층만 손댄 범위였기 때문이고, 대가로 라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 §11 에 남는다 | [P47](proposals/P47-keyword-preset-label-axis.md), [근거 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md) | | 2026-08-07 | 검색 응답에 `keywordMatched` 필드를 추가했다(`S15P11A705-399`). 키워드 재정렬(`_rerank_by_keyword`, P49 §4)이 이미 계산하지만 정렬에만 쓰고 버리던 match 여부를 응답까지 살렸다 — 재정렬을 `tuple[list, set[int]]`로 바꿔 match 한 Record id 를 함께 반환하고, 재정렬이 생략되는 모든 경로에서는 빈 집합이라 자연히 `False` 다. `similarity` 는 원래 코사인 그대로 두어 정렬 점수가 새어 나가지 않는다는 기존 계약을 지켰다. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호를 준비하는 작업이다 | [리포트](implements/2026-08-07-search-keyword-matched-field.md) | -| 2026-08-07 | 재정렬 후보 불변 계약(`test_search_rerank.py` ②)과 결합 신뢰도 게이트(back 레포 `S15P11A705-400`, BD-52)의 계약을 분리 명시했다(`S15P11A705-402`, 중앙 조정 세션 인계 문서 §4.5가 요구한 검증). 재정렬은 후보를 안 지우지만 게이트는 정반대로 의도적으로 지운다 — 같은 파일에 다섯 계약만 있으면 게이트도 후보 불변을 지켜야 한다는 오독이 생긴다. docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보(기존 컷은 통과·게이트 임계값 미만·신호 없음)도 재정렬은 순서만 밀 뿐 지우지 않는다는 것을 새 테스트로 고정했다. 작업 중 `docs/implements/README.md`의 색인 두 표 모두에서 I58(`S15P11A705-399`) 리포트가 빠져 있던 것을 `test_docs_index.py`가 잡아 함께 정정했다 | [경계 리포트](implements/2026-08-07-rerank-gate-contract-boundary.md) | +| 2026-08-07 | 재정렬 후보 불변 계약(`test_search_rerank.py` ②)과 결합 신뢰도 게이트(back 레포 `S15P11A705-400`, BD-52)의 계약을 분리 명시했다(`S15P11A705-402`, 중앙 조정 세션 인계 문서 §4.5가 요구한 검증). 재정렬은 후보를 안 지우지만 게이트는 정반대로 의도적으로 지운다 — 같은 파일에 다섯 계약만 있으면 게이트도 후보 불변을 지켜야 한다는 오독이 생긴다. docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보(기존 컷은 통과·게이트 임계값 미만·신호 없음)도 재정렬은 순서만 밀 뿐 지우지 않는다는 것을 새 테스트로 고정했다. 이 브랜치는 `-399`(I59) 병합 전 갈라져 같은 색인 누락을 독립적으로 I58 로 잡아 두고 있었다 — `dev` 병합에서 두 번호가 부딪혀(`-399` 가 먼저 `I59` 로 확정 병합) 이 리포트는 **I60 으로 재확정**했다(T60 과 같은 패턴, `check_docs_index.py` 의 번호는 병합 시점 이후에 확정한다는 원칙대로) | [경계 리포트](implements/2026-08-07-rerank-gate-contract-boundary.md) | | 2026-08-07 | 결합 신뢰도 게이트(중앙 조정 세션 인계 문서 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) 의 threshold 후보 0.35 를 오프라인으로 재측정했다(`S15P11A705-401`). 0.35 는 원래 `SEARCH_KEYWORD_RERANK_FLOOR`(질의-Preset 코사인 판정값)에서 가져온 값이라 이 용도(S1 단독·저유사도 결과를 숨기는 게이트)로는 검증된 적이 없었다. `gate_sweep.py` 로 문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건에 게이트 규칙을 적용해 보니 threshold 0.35 에서 정답 손실 0 을 유지하면서 무관 노출이 크게 줄었고(단어무관 23/45·타인소유 55/96 신규 침묵), 손실 0 구간의 최적값(0.36)과도 사실상 같았다. **최초 실행에서는 프로브 6 건이 손실로 나왔는데, 원인은 `recall_probe.json` 이 질의별이 아니라 최상위에 `user_id` 를 두는 형식을 놓친 것이었다** — 고치자 손실이 threshold 0.38 까지 0 건으로 나왔다. 0.35 채택을 권고한다 | [리포트](implements/2026-08-07-gate-threshold-remeasure.md) | | 2026-08-07 | 401 리포트 작성 뒤 `test_docs_index.py`가 `docs/implements/README.md`의 색인 두 표(개별 리포트·구현·산출 전수) 모두에서 I58(`2026-08-07-gate-threshold-remeasure.md`) 행이 빠진 것을 잡았다 — 리포트를 커밋하면서 색인 갱신을 놓친 것. 두 표 모두에 행을 추가해 정정했다 | [gate_sweep 리포트](implements/2026-08-07-gate-threshold-remeasure.md) | diff --git a/docs/implements/README.md b/docs/implements/README.md index d279b26..4039a4e 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -59,10 +59,9 @@ | I55 | [2026-08-06-rewrite-effect.md](2026-08-06-rewrite-effect.md) | 구현 | LLM 질의 재작성 효과 실측·길이 게이트 확정 — 실제 RewriteClient(운영 체인)로 무관 15건·사례 5건 전/후 비교. **약어 회복 확인**(`부캠`→`부트캠프` 컷 전 8위→1위·`신한 부캠` 4위→3위, 둘 다 반환 회복)·고유명사 유지 규칙 작동(`신한`·`그네`·`스팟` 원문 불변). **전면 적용은 무관 무노출 11/15→9/15 로 채택 기준 미달** — 의미 불변 표현 정규화(`파는 데`→`파는 곳`)가 컷 경계 아래 대역을 밀어 올린다. 게이트 후보 8종 전수 시뮬레이션에서 **형태(길이) 기준만 충족** — 성과 기반(결과 0건·top-1 임계)은 두 집단 대역이 겹쳐 분리 불가에 회복 대상까지 놓치고(원문도 오답 3건 반환), 단어형 판정 재사용은 `신한 부캠` 상실. **게이트 확정: strip 후 6자 이하만 재작성**(`SEARCH_REWRITE_MAX_CHARS`, 사용자 확정) — 게이트 반영 재측정에서 무노출 11/15 유지·회복 2건 유지로 채택 조건 충족. 한계: 6~12자 대역 무관측·모델 1종 1회차 관측 (S15P11A705-337 · 스냅샷 DB) | | I56 | [2026-08-06-rerank-adoption.md](2026-08-06-rerank-adoption.md) | 구현 | 키워드 재정렬 채택값 실측·런타임 구현 — P49 §4 재정렬 전용 구조(컷 통과 집합 고정·순서만 조정)에서 BASE·binary(floor 5값×weight 4값)·RRF(k=60) 재측정. **binary·floor 0.35·w 0.05 만 퇴행 없이 개선**(단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129, 문장형 유지+MRR 개선), RRF 는 문장형 hit@1 0.8333→0.5833~0.6667 퇴행으로 기각. 무관 세그먼트는 전 조합에서 BASE 와 동일(구조적 불변 실측 확인·스크립트 가드). floor 0.35~0.36 지표 동일로 T68 흔들림에 안전. 런타임: 기본 off 플래그·`keyword_status=COMPLETED` 신호·실패 시 벡터 순서 복귀·similarity 원값 유지, on/off 후보 집합 불변 계약 테스트 9건 + 픽스처 8건(총 511 통과). **실서버 on/off 대조 5판정 93건 전부 통과** — 재정렬 발화 7건 전건 오프라인 일치(`만두` 4위→1위), `신한`·`부캠` 미회복 재확인(역할 분담 검증), 흔들림 분류 미발동. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 색인 검사가 잡아 나중에 병합된 이쪽이 I56 으로 재확정(설계된 절차), 최종 확정은 dev 병합 직후 (S15P11A705-339 · 스냅샷 DB) | | I57 | [2026-08-07-preset-label-realign-gate.md](2026-08-07-preset-label-realign-gate.md) | 검증 | 프리셋 표시명 정합 회복과 게이트 재검증 — 표시명 명사형 통일(-292)이 YAML 정본에만 반영되고 두 DB에는 옛 표시명이 남아 **프리셋 임베딩·측정 자산이 정본과 어긋나 있던 결함**을 스냅샷 재적재로 해소. 조작 범위를 md5 4종으로 대조해 Context 임베딩·판정 상태·context_keyword 83행 불변 확인(판정 불변). 새 임베딩 격자에서 **채택값(binary·floor 0.35·w 0.05·top_k 3) 유지** — 퇴행 없이 단어형 개선(1위 0.8636→0.8788·3위내 0.9394→0.9545·MRR 0.9015→0.9121). **변화: 문장형 개선(MRR 0.9028→0.9167)이 사라지고 floor 0.40 구간으로 이동** — 재정렬의 이득이 단어형에 집중. 경계 감도 0.345~0.36 안정하나 기준선 0.35 인접 쌍 10건 중 9건이 임베딩 흔들림 폭 이내라는 한계 기록. 결정성 2회 일치. **게이트 5기준 전량 재통과**(3 phase 재실행 — judge가 세 결과를 함께 읽어 부분 재실행 불성립). keyword_matrix 포트 가드를 시연 DB에서 스냅샷으로 정정 동반 (검색 고도화 트랙 공통 · 스냅샷 DB) | -| I58 | [2026-08-07-search-keyword-matched-field.md](2026-08-07-search-keyword-matched-field.md) | 구현 | 검색 응답에 `keywordMatched` 필드 추가 — `_rerank_by_keyword`(I56)가 이미 계산하지만 정렬에만 쓰고 버리던 Preset 매치 여부를 `tuple[list, set[int]]`로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 그대로 두어 정렬 점수 비노출 계약(③)을 지켰다. 재정렬이 생략되는 모든 경로(off·오류·후보 없음)에서는 빈 집합이라 필드가 자연히 `False`다. 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4, back 레포 S15P11A705-400)가 쓸 S3 신호를 준비하는 작업이다 (S15P11A705-399) | -| I59 | [2026-08-07-rerank-gate-contract-boundary.md](2026-08-07-rerank-gate-contract-boundary.md) | 구현 | 재정렬 후보 불변 계약(②)과 결합 신뢰도 게이트(back 레포 S15P11A705-400)의 계약을 분리 명시 — `test_search_rerank.py` docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보도 재정렬은 지우지 않는다는 것을 `test_rerank_never_gates_a_weak_unsupported_candidate`로 실행 고정. 작업 중 I58 리포트가 이 색인 두 표 모두에서 빠져 있던 것을 함께 정정 (S15P11A705-402) | | I58 | [2026-08-07-gate-threshold-remeasure.md](2026-08-07-gate-threshold-remeasure.md) | 검증 | 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) threshold 후보 0.35 오프라인 재측정 — `SEARCH_KEYWORD_RERANK_FLOOR`에서 값만 가져온 것이 아니라 이 용도(S1 단독·저유사도 결과 숨김)로 별도로 다시 쟀다. `gate_sweep.py`가 기존 행렬(문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건)에 게이트 규칙을 적용해, threshold 0.35에서 정답 손실 0을 유지하며 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵)는 것을 확인했다. 최초 실행은 `recall_probe.json`의 최상위 `user_id` 처리 버그로 프로브 손실 6건을 오탐했고, 고친 뒤 0.35 채택을 권고했다 (S15P11A705-401 · 스냅샷 없음 · DB·GMS 호출 0회) | | I59 | [2026-08-07-search-keyword-matched-field.md](2026-08-07-search-keyword-matched-field.md) | 구현 | 검색 응답에 `keywordMatched: bool` 필드 추가 — `_rerank_by_keyword`가 이미 계산하던 Preset 매치 여부를 정렬에만 쓰고 버리던 것을 `(list, matched_ids)` 튜플로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 값 그대로 유지(정렬 점수 비노출 계약 불변), 재정렬이 생략되는 모든 경로는 빈 집합이라 자연히 `False`. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호 준비 (S15P11A705-399) | +| I60 | [2026-08-07-rerank-gate-contract-boundary.md](2026-08-07-rerank-gate-contract-boundary.md) | 구현 | 재정렬 후보 불변 계약(②)과 결합 신뢰도 게이트(back 레포 S15P11A705-400)의 계약을 분리 명시 — `test_search_rerank.py` docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보도 재정렬은 지우지 않는다는 것을 `test_rerank_never_gates_a_weak_unsupported_candidate`로 실행 고정 (S15P11A705-402) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -125,9 +124,8 @@ | I55 | 재작성 효과 프로브 `tools/search_cut/rewrite_probe.py` — 실제 RewriteClient(운영 설정 체인) 호출 → 재작성문 임베딩 배치 1회 → 스냅샷 DB(:25432) 유사도 → 현행 컷 재구성(단어형/문장형 분기는 재작성문 기준, 서비스 미import 재구성 규칙). **재작성문 자체를 artifact 에 기록** — 무엇으로 바뀌었는지가 판정 근거의 절반. 원문 기준 값은 굳힌 행렬의 컷 재구성으로 얻어 GMS 호출 최소화, 재구성 무노출 11/15 가 기존 관측과 일치(교차 확인). 게이트(`should_rewrite` 재구성) 반영 후에는 게이트 통과 질의만 재작성·재임베딩 — 걸린 질의는 원문 값이 곧 결과(서비스와 동일). 강등 0건·전 질의 1순위 벤더 응답 관측 | [효과 리포트](2026-08-06-rewrite-effect.md), [I54](2026-08-06-lexical-merge-rule.md), [P49](../proposals/P49-multi-signal-search.md) | | I56 | 키워드 재정렬 하네스·런타임 `tools/search_cut/fusion_rerank_sweep.py`(재정렬 전용 격자 + 신선도 가드 + 후보 불변 검사) · `fusion.rerank()`(순수 함수) · `rerank_verify_live.py`(실서버 on/off 5판정) · `app/service/search_service.py`(`_rerank_by_keyword` — 질의 임베딩 재사용·추가 모델 호출 0) · `app/repository/context_keyword_repo.keywords_for_records` · `SEARCH_KEYWORD_RERANK_*` 4키(기본 off·floor 0.35·w 0.05·top_k 3) — 채택값은 재정렬 전용 구조 재측정으로 확정(I53 과 숫자가 같지만 다른 측정), 결정성 회차 JSON 바이트 일치, 실서버 대조 93건 5판정 전부 통과. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 재확정(설계된 절차), 최종 확정은 dev 병합 직후 | [재정렬 리포트](2026-08-06-rerank-adoption.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md), [tools/search_cut/](../../tools/search_cut/) | | I57 | 표시명 정합 회복·재측정·게이트 재검증 — `app/bootstrap/load_presets` 재적재로 프리셋 표시명(-292 명사형)을 정본에 맞추고, 그 조작이 무효화한 측정을 전부 다시 뜬다. 조작 범위는 md5 4종 대조로 고정(프리셋 임베딩만 변경 · Context 임베딩·판정 상태·context_keyword 83행 불변). `keyword_matrix` 재생성 → `fusion_rerank_sweep` 격자 재실행(결정성 2회 일치) → 게이트 3 phase 전량. **채택값 유지**(퇴행 없이 단어형 개선) · **문장형 개선 소멸**(floor 0.40 구간으로 이동) · 기준선 인접 쌍 9건이 임베딩 흔들림 폭 이내라는 한계. `keyword_matrix.py` 포트 가드를 시연 DB(:15432)에서 스냅샷(:25432)으로 정정 | [재검증 리포트](2026-08-07-preset-label-realign-gate.md), [I56](2026-08-06-rerank-adoption.md), [P49](../proposals/P49-multi-signal-search.md) | -| I58 | 검색 응답에 `keywordMatched` 필드 추가(S15P11A705-399) — `_rerank_by_keyword`(I56)가 이미 계산하지만 정렬에만 쓰고 버리던 Preset 매치 여부를 `tuple[list, set[int]]`로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 그대로 두어 정렬 점수 비노출 계약(③)을 지켰다. 재정렬이 생략되는 모든 경로(off·오류·후보 없음)에서는 빈 집합이라 필드가 자연히 `False`다. 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4, back 레포 S15P11A705-400)가 쓸 S3 신호를 준비하는 작업이다 | [필드 추가 리포트](2026-08-07-search-keyword-matched-field.md), [I56](2026-08-06-rerank-adoption.md), [test_search_rerank.py](../../tests/test_search_rerank.py) | -| I59 | 재정렬 후보 불변 계약과 게이트 계약 분리(S15P11A705-402) — `test_search_rerank.py`가 고정하는 다섯 계약(특히 ② 후보 불변)이 재정렬 자신의 것이며 결합 신뢰도 게이트(back 레포 S15P11A705-400, BD-52)의 계약이 아님을 모듈 docstring에 명시하고, `test_rerank_never_gates_a_weak_unsupported_candidate`로 "게이트라면 지웠을 후보도 재정렬은 지우지 않는다"를 실행 고정. 런타임 코드 변경 없음 | [경계 리포트](2026-08-07-rerank-gate-contract-boundary.md), [I58](2026-08-07-search-keyword-matched-field.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.5 | | I58 | 결합 신뢰도 게이트 threshold 재측정 하네스 `tools/search_cut/gate_sweep.py` — 기존 행렬(`matrix.json`·`word_grid.json`·`recall_probe.json`·`lexical_matrix.json`·`keyword_matrix.json`)만 읽어 S1(벡터)·S2(문자열, 채택 게이트 G1)·S3(키워드, 채택값 binary·floor 0.35·top_k 3) 신호를 재구성하고 threshold 격자(0.0·0.16~0.40)를 훑는다. **0.35에서 정답 손실 0**(문장형 12·단어형 66·프로브 22 전부)이면서 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵) — 안전 구간(0.25~0.37) 안의 최적값(0.36)과도 사실상 같다. DB·GMS 호출 0회, 결정성 확인(같은 입력 2회 실행 바이트 일치) | [재측정 리포트](2026-08-07-gate-threshold-remeasure.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.3, [I56](2026-08-06-rerank-adoption.md), [tools/search_cut/](../../tools/search_cut/) | -| I59 | 검색 응답 `keywordMatched: bool` 필드 `app/schema/search.py`·`app/service/search_service.py`(`_rerank_by_keyword` 반환을 `(list, matched_ids)` 튜플화) — 이미 계산되던 Preset 매치 여부를 응답까지 배선한다. `similarity`는 원래 코사인 값 유지, 재정렬 생략 경로는 빈 집합이라 자연히 `False`. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호 준비 | [필드 추가 리포트](2026-08-07-search-keyword-matched-field.md), [I56](2026-08-06-rerank-adoption.md) | +| I59 | 검색 응답에 `keywordMatched` 필드 추가(S15P11A705-399) — `_rerank_by_keyword`(I56)가 이미 계산하지만 정렬에만 쓰고 버리던 Preset 매치 여부를 `tuple[list, set[int]]`로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 그대로 두어 정렬 점수 비노출 계약(③)을 지켰다. 재정렬이 생략되는 모든 경로(off·오류·후보 없음)에서는 빈 집합이라 필드가 자연히 `False`다. 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4, back 레포 S15P11A705-400)가 쓸 S3 신호를 준비하는 작업이다 | [필드 추가 리포트](2026-08-07-search-keyword-matched-field.md), [I56](2026-08-06-rerank-adoption.md), [test_search_rerank.py](../../tests/test_search_rerank.py) | +| I60 | 재정렬 후보 불변 계약과 게이트 계약 분리(S15P11A705-402) — `test_search_rerank.py`가 고정하는 다섯 계약(특히 ② 후보 불변)이 재정렬 자신의 것이며 결합 신뢰도 게이트(back 레포 S15P11A705-400, BD-52)의 계약이 아님을 모듈 docstring에 명시하고, `test_rerank_never_gates_a_weak_unsupported_candidate`로 "게이트라면 지웠을 후보도 재정렬은 지우지 않는다"를 실행 고정. 런타임 코드 변경 없음 | [경계 리포트](2026-08-07-rerank-gate-contract-boundary.md), [I59](2026-08-07-search-keyword-matched-field.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.5 | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다.