From 761eb760df9a83f10df10ece2faa746593d42c3c Mon Sep 17 00:00:00 2001 From: ghkim1632 Date: Fri, 7 Aug 2026 18:29:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(S15P11A705-401):=20=EA=B2=B0=ED=95=A9?= =?UTF-8?q?=20=EC=8B=A0=EB=A2=B0=EB=8F=84=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=84=EA=B3=84=EA=B0=92=20=EC=98=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=9E=AC=EC=B8=A1=EC=A0=95=20=E2=80=94=200.35=20?= =?UTF-8?q?=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 b3e1aa01ab9be34bfc3099f7c1b4df5c8a189d88 Mon Sep 17 00:00:00 2001 From: ghkim1632 Date: Fri, 7 Aug 2026 19:00:37 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(S15P11A705-401):=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=83=89=EC=9D=B8=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=EC=9D=84=20=EC=A0=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 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`에 있습니다.