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 4223ce8..46ccadc 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -77,5 +77,6 @@ | 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) | | 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/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/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`에 있습니다. 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 오름차순 — 하네스와 같은 규칙.