AnchorMindANCHORMIND
SIGN IN

topic 오타 하나가 무관 검색 결과 6건을 만든 이유: 하이브리드 검색 정합성 디버깅

증상: 오타를 냈는데 결과가 나온다

AnchorMind의 기억 검색 엔진(memento-mcp)을 운영하면서 주간 텔레메트리를 확인하다가 이상한 패턴을 발견했다. recall 무관 피드백 27건 중 17건이 topic_mismatch, 즉 지정한 topic과 다른 topic의 파편이 반환된 사례였다. 재현은 어렵지 않았다.

recall(topic="memento-mpc", keywords=["release"]) → count=6, searchPath "L1:19 → L2:9"

topic 값을 보면 "memento-mcp"를 "memento-mpc"로 잘못 쓴 오타다. 저장소에 이 topic을 가진 파편은 하나도 없으므로 정답은 0건이다. 그런데 엔진은 topic이 "memento-mcp"인 파편 6건을 태연하게 반환했다. 오타를 낸 사용자 입장에서는 검색이 성공한 것처럼 보이니 오타를 알아챌 방법이 없다.

이 동작이 나쁜 이유는 단순히 결과가 틀려서가 아니다. 이 엔진에는 0건 응답일 때 유사 topic을 제안하는 topic_mismatch 교정 힌트가 있는데, 무관 결과가 0건 자리를 차지하면 이 힌트가 영영 발화하지 않는다. 잘못된 결과가 교정 장치까지 막는 이중 손실이다.

용의자 지목: 3계층 중 어디인가

이 엔진의 검색은 3계층 하이브리드다. L1은 Redis 역인덱스(keyword, topic, type별 파편 ID 집합), L2는 SQL 정확일치, L3는 pgvector 임베딩 유사도이며, 세 경로의 결과를 RRF(Reciprocal Rank Fusion)로 병합한다. topic 정확일치 필터는 L2와 L3의 SQL에는 걸려 있었다. 그렇다면 남는 용의자는 L1이다.

확인 방법은 통제 실험이다. Redis를 우회하고 MemoryManager를 직접 호출하는 프로브로 동일 쿼리를 실행하자 두 버전 모두에서 count=0이 나왔다. L1 경로가 원인이라는 뜻이다.

교집합이 비어야 할 두 집합과 0건이어야 할 검색 결과의 대비

처음에는 폴백을 의심했다. L1에는 교집합이 비면 최근 접근 파편 20건을 대신 반환하는 getRecent 폴백이 있고, 이 결과에는 l1_is_fallback 마킹이 붙는다. 폴백 유입분이 topic 필터를 다시 통과하지 않고 최종 병합에 섞인다는 가설은 그럴듯했다. 그런데 검색 이벤트 로그를 열어 보니 문제의 쿼리는 l1_is_fallback=false였다. 폴백이 아니라 정규 경로에서 무관 파편이 나오고 있었다.

진범: 교집합 연산에서 누락된 빈 집합

L1의 정규 경로는 조건별 ID 집합을 만들어 교집합을 구한다. keywords가 걸리면 keyword 집합, topic이 걸리면 topic 집합을 만들고 둘을 교차한다. 결함은 이 교집합 코드에 있었다.

topic 인덱스 조회가 0건이면 코드는 "집합이 없다"로 처리하고 교집합 연산 대상에서 topic 집합을 제외했다. 그 결과 keyword 집합 단독이 교집합 행세를 했다. "memento-mpc" topic 집합은 공집합이므로 올바른 교집합은 공집합인데, 공집합이 연산에서 빠지면서 keywords=["release"]에 걸린 파편 19건이 그대로 살아남은 것이다.

집합 연산에서 "조건이 없다"(교집합 연산에서 제외해야 함)와 "조건은 있는데 결과가 빈 집합이다"(교집합 전체를 공집합으로 만들어야 함)는 정반대의 의미다. 이 둘을 같은 코드 경로로 처리하면 조건이 강할수록 결과가 넓어지는 역설이 생긴다.

두 번째 누수도 있었다. L1이 ID만 알고 본문을 모르는 파편은 저장소에서 보충 조회(getByIds)하는데, 이 조회가 topic, type, caseId, phase, affect, timeRange 같은 검색 스코프를 전혀 적용하지 않았다. 하드 필터가 걸려야 할 지점이 두 곳 모두 뚫려 있던 셈이다.

수정: 계약을 명시하고 모든 경로에 적용한다

수정은 세 가지였다.

첫째, 공집합 계약. 명시 조건(topic, type)의 인덱스 조회가 0건이면 L1은 공집합을 반환한다. 빈 집합을 연산에서 빼는 대신 연산 결과 전체를 비운다.

둘째, 스코프 정합. getByIds 보충 조회에 SearchScope와 timeRange를 동일하게 적용한다. 하드 필터는 어느 경로로 파편이 유입되든 같은 계약으로 걸려야 한다.

셋째, 폴백 계약의 명문화. 최근 접근 파편 폴백은 조건이 하나도 없는 조회 전용으로 제한했다. 명시 조건이 있는 검색에서 0건은 폴백 사유가 아니라 정당한 답이다. 부수 수정으로, RRF 병합에서 L1의 ID-only 항목이 뒤늦게 도착한 완전 파편 객체로 승격되지 않아 content 부재 필터에서 정당한 결과가 탈락하던 결함도 함께 잡았다.

배포 후 같은 오타 쿼리는 0건과 함께 topic_mismatch 교정 힌트("memento-mcp를 찾으셨습니까")를 반환한다. 검색이 실패해야 할 때 정직하게 실패하고, 실패가 교정으로 이어지는 원래 설계가 복원됐다.

교훈

이 결함에서 일반화할 수 있는 것은 세 가지다.

빈 집합과 부재를 구분하라. 필터 파이프라인에서 "0건 집합"을 "조건 없음"으로 강등하는 순간, 필터를 추가할수록 결과가 넓어지는 코드가 된다.

하드 필터는 계약이다. 후보가 어느 계층에서 유입되든(역인덱스, 보충 조회, 폴백, 병합 승격) 같은 필터를 통과해야 한다. 가중치 강등 같은 소프트 처리로 하드 필터 계약을 대체할 수 없다.

0건을 두려워하지 마라. 무관 결과로 0건을 가리면 사용자 교정 루프까지 망가진다. 정확한 실패는 부정확한 성공보다 낫다.