멀티 에이전트 문서 검색 코파일럿 구축 — 파트 2: 적응형 하이브리드(Adaptive Hybrid) 및 랭크 이후의 권한 게이트
요약
멀티 에이전트 문서 검색 코파일럿 구축의 두 번째 파트로, 적응형 하이브리드(Adaptive Hybrid) 검색 전략과 권한 필터링 최적화 방법을 다룹니다. 데이터의 선택성에 따라 필터 우선 또는 랭크 후 필터 방식을 동적으로 선택하여 검색 정확도와 성능을 동시에 확보하는 기술적 해결책을 제시합니다.
핵심 포인트
- 선택성(selectivity)에 따른 적응형 하이브리드 검색 전략 구현
- 필터링된 문서 수에 따라 filter-first 또는 rank-then-filter 분기
- 검색 성능과 정확도 사이의 트레이드오프 최적화
- 권한 게이트(permission gate)를 통한 보안 및 필터링 강제
멀티 에이전트 문서 검색 코파일럿 구축 — 파트 2: 적응형 하이브리드(Adaptive Hybrid) 및 랭크 이후의 권한 게이트
이 글은 두 파트 중 두 번째 파트입니다. 파트 1에서는 v1 버전에서 모호한 결과가 발생했던 문서 검색 코파일럿(document-search copilot)에 대해 다루었습니다. 당시에는 두 개의 검색 경로(retrieval lanes)가 하나의 재순위화(rerank) 과정으로 융합되어, 텍스트를 포함하지 않는 메타데이터 행이 관련성 점수(relevance scores)를 오염시키는 문제가 있었습니다. 해결책은 두 가지 프레임워크 재설정(reframes)이었습니다. 첫째, 라우터(router)를 하나의 구조화된 Bedrock 호출(결정론적 폴백(deterministic fallback) 포함)로 통합하고, 둘째, 검색 경로를 융합하는 대신 쿼리당 정확히 하나의 검색 전략 — MetadataOnly, ContentOnly, 또는 Hybrid — 를 선택하는 것이었습니다. 우리는 깔끔하게 정리되지 않는 단 하나의 전략인 Hybrid 단계에서 이야기를 마쳤습니다. 이는 사용자가 주제(topic)와 필터(filters)를 동시에 원하는 경우입니다.
파트 1이 멈춘 지점부터 다시 시작하여, 이제 우리는 동일한 파이프라인의 후반부인 검색 그래프의 direct_search → rerank → permission_filter → finalize_results 말단 부분에 와 있습니다. 이제 두 가지 결정 사항이 남았습니다: Hybrid가 실제로 어떻게 검색을 수행하는지, 그리고 권한(permissions)이 어디에서 강제되는지입니다.
3️⃣ 적응형 하이브리드(Adaptive Hybrid): 한 번 확인한 후, 필터 우선(filter-first) 또는 랭크 후 필터(rank-then-filter) 중 선택
Hybrid는 매우 흥미로운 부분입니다. 왜냐하면 "주제 + 필터"는 진정한 트레이드오프(tradeoff) 관계이며 단 하나의 정답이 존재하지 않기 때문입니다. 이에 대해 화이트보드에서 여러 차례 논쟁이 벌어질 만큼 실제적인 토론이 있었습니다. 필터와 랭크를 **병렬(parallel)**로 실행할 것인가(빠르지만 두 세트를 조정해야 함), 아니면 **순차적(sequence)**으로 실행할 것인가(정확하지만 느림)? 우리는 어느 한 쪽을 선택하는 대신, _선택성(selectivity)_에 따라 라우팅함으로써 이 문제를 해결했습니다.
핵심 비결은 단 한 번 던지는 저렴한 질문입니다: 필터와 일치하는 문서가 몇 개인가? direct_search는 Documents 서비스로부터 필터링된 유니버스(filtered-universe)의 크기를 임계값(기본값 1000)과 함께 단 한 번 확인(peek)하며, 그에 따라 분기합니다:
elif strategy == "Hybrid":
threshold = cfg.get("filter_first_threshold", 1000)
id_page = cfg.get("filter_id_page_size", 100)
...
- Bounded set (≤ threshold) → filter-first (필터 우선). peek(엿보기) 단계에서 실제 ID 세트를 반환합니다. 시맨틱 쿼리 (semantic query)는 OpenSearch의
termsID 스코프(청크의version필드 또는 첨부 파일 청크의parent필드 대상)를 통해 정확히 해당 ID들로 범위가 제한됩니다. 모든 필터는 소스 단계에서 권위 있게(authoritatively) 강제됩니다. 즉, 필터 범위를 벗어난 것은 절대 랭킹(rank)될 수 없으며, 필터 범위 내의 것은 절대 누락되지 않습니다. 또한, 이후의 권한 게이트(permission gate)가 재필터링을 하지 않도록 플래그를 설정합니다:
# filter-first는 토픽 랭킹(topic ranking)의 범위를 서비스에서 필터링된 ID 세트로 제한했으므로,
# 필터가 이미 권위 있게 적용되었습니다. 따라서 permission_filter가 이를 재강제해서는 안 됩니다.
filters_enforced_upstream = True
- Broad set (> threshold) → rank-then-filter (랭킹 후 필터). 수천 개의 ID로 시맨틱 쿼리의 범위를 제한하는 것은 무제한적이고 느려질 수 있습니다. 따라서 대신 토픽의 범위를 제한하지 않고 랭킹을 매긴 뒤,
_row_passes_filters를 사용하여 필터 범위를 벗어난 히트(hits)를 로컬에서 사후에 제거합니다. 이는 모든 검색 가능한 컬럼(string / number / date / boolean / user_list,contains/equal/greaterThan/ 기타 포함)에 대해 Documents 서비스의 동작 의미론(operation semantics)을 충실히 반영한 것입니다:
def _row_passes_filters(row: dict, filters: list[dict]) -> bool:
"""Documents 서비스 필터 의미론을 충실히 반영한 로컬 미러 ...
하드 필터(hard filter)를 통과하지 못하는 행의 모든 히트는 제거되며, fail-closed 방식으로 동작합니다."""
...
이 방식이 단순한 근사치가 아닌 안전한(safe) 방식이 되는 이유는 다음과 같습니다. 필터링된 세트가 매우 클 경우, 필터는 어차피 거의 아무런 동작도 하지 않는 것과 다름없으므로(라이브러리의 대부분과 일치함), 먼저 랭킹을 매긴 후 제거하는 방식에서 발생하는 재현율(recall)의 상한선 문제는 해롭지 않습니다. 반대로, 제거했을 때 좋은 매칭을 놓칠 수 있는 경우, 즉 선택적(selective) 필터의 경우는 정확히 필터 우선(filter-first) 경로를 따르며, 여기서는 아무것도 제거되지 않습니다. 즉, 분기(branch)를 통해 자신의 약점이 적용되지 않는 전략을 선택하는 것입니다. 솔직히 말씀드리면, 저도 이 방식을 신뢰하는 데 시간이 좀 걸렸습니다. 깊이 고민해 보기 전까지는 일종의 편법처럼 느껴지기도 하지만, 두 가지 실패 모드(failure modes)가 실제로 서로를 상쇄합니다.
| filter-first (selective, 필터 우선 (선택적)) | rank-then-filter (broad, 랭크 후 필터 (광범위)) | |
|---|---|---|
| Filtered universe (필터링된 유니버스) | ≤ threshold (임계값, 기본 1000) | > threshold |
| ... |
한 번의 확인으로 추가적인 왕복(round trips) 없이, 정밀도/지연 시간(precision/latency) 트레이드오프(tradeoff)를 아키텍처에 내장된 동전 던지기가 아닌, _명시적이고 데이터 기반(explicit and data-driven)_으로 결정할 수 있습니다.
💡 일반화된 패턴: 두 가지 검색 전략(retrieval strategies) 사이에서 결정할 수 없을 때는, 저렴한 단일 프로브(probe)로 두 전략을 구분하는 단 하나의 속성(여기서는 필터 선택성(filter selectivity))을 측정하고, 이를 기반으로 라우팅(route)하십시오. 각 분기(branch)는 해당 분기의 실패 모드(failure mode)가 무해한 지점에서 정확하게 선택됩니다.
4️⃣ Cohere rerank, 그 다음 fail-closed 방식의 뷰 게이트(view gate) — 그리고 왜 "나중"이어도 괜찮은가
콘텐츠 경로는 Cohere에 의해 랭킹(ranked)됩니다 (상위 rerank_candidates 개수 기준, 기본값 30). 리랭커(reranker)에 접근할 수 없는 경우 OpenSearch의 퓨즈드 스코어(fused score) / RRF가 폴백(fallback)으로 작동합니다:
results = await bedrock_client.rerank(query=query_text, documents=documents, model_id=model_id)
# ... 모든 실패 또는 빈 결과 발생 시 -> _fallback()이 OpenSearch 원시 스코어(raw score)를 기준으로 정렬
메타데이터(Metadata) 결과는 점수를 매길 대상이 없으므로 리랭커를 완전히 건너뛰며, Documents 서비스의 순서를 유지합니다:
if all(c.get("match_kind") == "metadata" for c in candidates):
return {"reranked": candidates, "cached_top_30": candidates}
그다음 보안 담당자들이 의자에서 몸을 앞으로 기울이게 만들 부분이 나옵니다. 문서별 뷰 권한 게이트(view-permission gate)가 검색(retrieval) 전이 아니라, 리랭크(rerank) "이후"에 실행된다는 점입니다. 제 설명을 끝까지 들어보세요. 이는 안전하며, 안전한 이유가 바로 이 방식의 핵심입니다. permission_filter는 리랭크된 콘텐츠 히트(content hits)를 가져와, 시스템 레코드(system of record)에 대해 단일 벌크 export_by_ids 호출을 수행하여 각 항목이 열람 가능한지 확인하고, 그렇지 않은 항목은 제외합니다. 이는 fail-closed(실패 시 차단) 방식으로 작동하므로, 서비스에 접근할 수 없는 경우 콘텐츠 히트가 유출되는 대신 모두 차단됩니다:
rows = await documents_service.export_by_ids(content_ids, jwt)
for row in rows:
dvid = row.get("document_version_id")
...
재순위화(rerank) 이후에 게이팅을 하는 것이 안전한 이유는 두 가지 다른 경계가 서로 다른 역할을 수행하기 때문입니다:
- **테넌트 격리(Tenant isolation)**는 단단한 벽이며, 검색 단계에서 강제됩니다. 모든 OpenSearch 쿼리는
member_id로 범위가 지정되며, 이 ID는 JWT에서 가져오고 요청 본문이나 인덱스에서는 절대 가져오지 않습니다. 사용자는 물리적으로 다른 테넌트의 벡터를 검색할 수 없습니다. 이 경계는 상류(upstream)에 있으며 절대적입니다. - **재순위화 후 게이트(post-rerank gate)**는 부드러운 경계이며, 사용자 자신의 테넌트 내에서 문서별 보기 가시성입니다. 초안(Drafts), 제한된 카테고리, 세 명에게 공유된 문서 등. 이러한 미묘한 차이는 매시간 변하기 때문에 인덱스에 저장되지 않으며, 검색 후 기록의 출처(source of record)와 비교하여 실시간으로 확인됩니다.
재순위화는 단지 후보군을 정렬할 뿐입니다. 아무것도 노출하지 않습니다. 재순위화가 보는 모든 후보군은 이미 테넌트 벽을 통과했으며, 정렬하는 집합(reranked top set)은 경계가 있으므로, 보기 게이트는 팬아웃(fan-out)이 아니라 하나의 제한된 벌크 호출입니다. 메타데이터 레인은 서비스 자체의 SQL에 의해 이미 보기 필터링이 된 행들로 돌아왔기 때문에 게이트를 완전히 건너뜁니다. 신원 확인은 토큰에서, 테넌트는 검색 시점에서, 보기 가시성은 이후에, 그리고 전체적으로 실패 시 폐쇄(fail-closed)됩니다.
🧵 프론트엔드에서 제가 담당했던 부분: null 관련성 점수
'쿼리당 하나의 전략'이라는 것의 작지만 실제적인 결과가 스택의 제 쪽으로 떨어졌고, 거의 저를 지나쳐 갔습니다. 콘텐츠 검색은 관련성 점수를 가집니다. 메타데이터 검색은 그렇지 않습니다. 만족스러운 필터링은 관련성 측정치가 아니며, OpenSearch의 결합된 점수(exact field match의 경우 약 0점)를 보여주면 실제로는 완벽한 일치임에도 불구하고
def _shape_source_for_emit(s: dict) -> dict:
# 메타데이터 일치는 만족된 필터(satisfied filter)이지 관련성 측정치(relevance measure)가 아니므로, 그 점수는 null로 유지됩니다.
# (카드는 단지 일치 이유만을 보여줍니다). 콘텐츠 일치는 결합된/재순위화된(fused/rerank) 점수를 유지합니다.
...
이는 아주 작은 디테일이지만, 프론트엔드가 "모든 결과에는 숫자가 있다"라고 가정한다면 문제가 생길 수 있는 부분입니다. null 점수는 "이것은 필터에 일치했으며, 보여줄 관련성이 없다"라는 의미를 가진 일급 상태(first-class state)이지, 0으로 기본값을 설정해야 하는 누락된 값이 아닙니다. 이를 0.0으로 기본 설정했다면, 모든 정확한 메타데이터 일치 항목들이 느슨하게 관련된 콘텐츠보다 낮은 순위로 밀려나 리스트의 맨 아래에 조용히 묻혀버렸을 것입니다. 완벽한 일치가 맨 마지막에 위치하게 되는데, 왜 그런지 알려주는 에러조차 어디에도 없을 것입니다. 따라서 이 계약(contract)은 의도적으로 그 부재(absence)를 전달해야 했습니다.
파트 1에서의 두 가지 결정과 여기에서의 두 가지 결정을 포함한 네 가지 결정 전체를 관통하는 맥락은 동일한 본능입니다. 즉, 시스템이 턴(turn)당 하나의 명확한 형태(shape)를 채택하도록 하고, 절충안(tradeoffs)을 평균화하여 없애버리는 대신 명시적으로 만드는 것입니다. 세 번의 라우터 호출 대신 한 번의 호출을. 결합된 혼합(fused blend) 대신 하나의 검색 전략(retrieval strategy)을. 병렬 대 순차 방식의 추측 대신 하나의 선택성 조사(selectivity probe)를. 그리고 제한된 재순위화(rerank) 이후의 제한된 권한 확인(permission check)을 선택했습니다. 모호한 결과(muddy-results) 버전은 모든 것을 한꺼번에 처리하고 재순위화기(reranker)가 이를 해결하도록 맡기려 했습니다. 하지만 그것은 불가능했습니다. 의도적으로 선택하는 것이 바로 순위(rank)에 다시 의미를 부여하는 방법이었습니다.
👋 두 파트 모두 함께해주셔서 감사합니다. 만약 여러분도 동일한 모호한 순위(muddy-rank) 문제로 싸워본 적이 있다면, 여러분은 어떻게 해결했는지 진심으로 듣고 싶습니다. 실패 모드(failure modes)는 이상할 정도로 보편적이니까요.
설정을 놓치셨나요? 파트 1에서는 모호한 결과 문제, 단일 호출 라우터(single-call router), 그리고 쿼리당 하나의 전략으로의 재구성(reframe)을 다룹니다.
끝까지 읽어주셔서 감사합니다 🙌 만약 여러분이 에이전트를 구축하며 "이 검색 결과가 진짜인가?"라는 동일한 문제와 싸우고 있다면, 진심으로 의견을 나누고 싶습니다. LinkedIn에서 인사해 주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기