Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/schema/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ class SearchResultItem(BaseModel):
recordId: int
contextId: int
similarity: float
# 재정렬(S15P11A705-339)이 이미 계산하는 키워드 매치 여부를 버리지 않고 싣는다
# (S15P11A705-399). 결과를 보여줄지 정하는 데는 쓰지 않는다 — 이 필드는 신호를
# 실어 보내는 것이고, 그 신호로 결과 유무를 정하는 것은 별도 게이트(S15P11A705-400)
# 의 몫이다.
keywordMatched: bool


class SearchResponse(BaseModel):
Expand Down
24 changes: 17 additions & 7 deletions app/service/search_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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
]
Expand Down Expand Up @@ -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건이면 재정렬
Expand All @@ -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]
)
Expand All @@ -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()
1 change: 1 addition & 0 deletions docs/WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
24 changes: 24 additions & 0 deletions docs/implements/2026-08-07-search-keyword-matched-field.md
Original file line number Diff line number Diff line change
@@ -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%).
Loading
Loading