에이전트·RAG

AGENT / 55번째 글

필터를 어디에 거느냐가 검색 결과를 바꾼다

메타데이터 필터링은 조건을 하나 더 붙이는 일이 아니라 벡터 탐색의 앞뒤 어디에 끼우느냐의 문제입니다. 사전·사후 필터링의 차이, 조건이 좁을 때 근사 탐색이 무너지는 지점, 필터로 쓸 값을 어떻게 설계할지 정리합니다.

PALDYN Team11 MIN READ

지난 글까지는 문서를 어떻게 읽어 색인에 넣을 것인가를 다뤘다. 이번에는 방향이 반대다. 넣어 둔 것 중에서 어떤 범위만 보게 할 것인가.

「지난달 계약서에서 해지 조항 찾아 줘」라는 질문에는 두 종류의 조건이 섞여 있다. 「해지 조항」은 뜻으로 찾는 것이고 「지난달」과 「계약서」는 맞거나 틀리거나 둘 중 하나다. 앞엣것은 벡터가 하고 뒤엣것은 필터가 한다. 문제는 이 둘을 어떤 순서로 실행하느냐인데, 순서만 바꿔도 결과가 달라진다.

필터를 앞에 두느냐 뒤에 두느냐

필터를 벡터 탐색 앞에 두느냐 뒤에 두느냐

사후 필터링(post-filtering)은 벡터 탐색을 먼저 돌려 가까운 순 50개를 뽑고, 그중 조건에 맞는 것만 남긴다. 구현이 가장 쉽다 — 검색은 그대로 두고 결과 목록을 한 번 훑어 거르면 되니 저장소가 필터를 몰라도 된다.

무너지는 자리는 정해져 있다. 조건에 맞는 문서가 전체의 2%뿐이면, 가까운 순 50개 중 조건을 통과하는 것은 평균 한 개다. 열 개를 요청했는데 한 개가 온다. 더 나쁜 것은 좋은 후보가 51등에 있었다는 사실조차 모른다는 점이다. 뽑히지 않았으니 거를 기회도 없었다.

사전 필터링(pre-filtering)은 순서를 뒤집는다. 조건에 맞는 청크 800개를 먼저 정하고, 그 안에서만 가까운 것을 찾는다. 개수는 언제나 채워진다. 대신 다른 값을 치른다.

후보가 줄면 근사 탐색이 흔들린다

대부분의 벡터 저장소는 전부 비교하지 않는다. 근사 최근접 탐색은 벡터끼리 이웃 관계를 그래프로 미리 엮어 두고, 한 노드에서 출발해 더 가까운 이웃으로 걸어가며 답에 접근한다. 수백만 개를 다 재지 않고도 답에 닿는 것은 이 그래프의 연결 덕분이다.

조건이 좁아지면 근사 탐색의 길이 끊긴다

여기서 조건을 통과하지 못한 노드를 빼면, 그 노드가 맡고 있던 길목도 함께 사라진다. 그림 오른쪽처럼 남은 노드가 두 덩어리로 갈라지면 출발점에서 걸어가 닿을 수 있는 것은 한쪽뿐이다. 다른 쪽에 더 가까운 답이 있어도 있는데 안 나온다. 조용히 빠지는 종류의 오류라 로그만 봐서는 알아채기 어렵다.

그래서 실제 저장소들은 조건이 얼마나 좁히느냐 — 선택도(selectivity), 즉 전체 중 조건을 통과하는 비율 — 를 먼저 재고 방식을 갈아탄다.

선택도 남는 후보 저장소가 보통 하는 일
30% 이상 넉넉함 그래프를 걸으며 조건에 맞는 것만 결과에 담는다
1~30% 애매함 그래프를 걷되 탐색 폭을 크게 늘린다. 가장 비싼 구간
1% 미만 몇천 개 그래프를 버리고 남은 것을 전부 비교한다

가운데 구간이 가장 어렵다. 그래프는 아직 쓸 만한데 군데군데 끊겨서, 놓치지 않으려면 훨씬 넓게 훑어야 한다. 지연이 몇 배로 뛰는 것이 보통 이 구간이다.

직접 고르지 말고 저장소가 판단하게 두는 편이 낫다. 사전·사후를 강제로 지정하는 옵션이 있어도, 선택도는 질의마다 달라지므로 고정값으로 맞히기 어렵다. 다만 어떤 조건이 얼마나 좁히는지는 알고 있어야 한다 — 사용자 한 명으로 좁히는 조건과 「문서 종류: 계약서」로 좁히는 조건은 자릿수가 다르다.

필터로 쓸 값을 따로 설계한다

색인에 넣을 수 있는 값과 필터로 쓸 만한 값은 다르다. 셋만 지키면 된다.

갈래가 적은 값을 쓴다. 문서 종류, 부서, 연월, 언어처럼 값의 가짓수가 손에 꼽히는 것이 좋은 필터다. 반대로 자유 문장이나 제목처럼 값이 거의 다 다른 것은 필터가 아니라 검색 대상이다.

시각은 잘라서 넣는다. 초 단위 타임스탬프로 범위 조건을 걸면 갈래가 사실상 무한해져 색인이 커지고 느려진다. 2026-08처럼 월로 자른 값을 따로 두고, 정확한 시각은 결과를 정렬하거나 표시할 때만 쓴다.

나중에 쓸지 모르는 값도 지금 넣는다. 값을 추가하는 것 자체는 싸지만, 색인이 다 만들어진 뒤에 값을 채우려면 전체를 다시 훑어야 한다. 뒤에 나올 갱신 이야기에서 다시 보겠지만, 이 재작업 비용이 생각보다 크다.

chunk = {
    "text": "...",
    "vector": [...],
    "doc_type": "contract",      # 갈래 넷
    "dept": "legal",             # 갈래 열둘
    "year_month": "2026-08",     # 월로 자름
    "published_at": 1755734400,  # 정렬·표시용, 필터 아님
}

질의에서 조건을 뽑아내기

사용자는 필터 문법으로 묻지 않는다. 「지난달 계약서」라고 쓸 뿐이다. 그래서 자연어를 조건으로 옮기는 단계가 하나 필요하고, 보통 모델에게 시킨다 — 질의 재작성에서 다룬 그 자리에 조건 추출을 함께 붙인다.

이때 지킬 것이 하나 있다. 모델이 만든 조건은 후보일 뿐 확정이 아니다. 「지난달」을 잘못 계산하거나 없는 부서 이름을 지어내면, 조건에 걸려 결과가 0건이 되고 사용자는 「자료가 없다」는 답을 받는다. 실제로는 있는데 말이다.

그래서 조건은 허용 목록에 대조한 뒤에 쓴다. 부서 이름이 실제 목록에 없으면 그 조건은 버리고 나머지로 검색한다. 그리고 조건을 걸어 0건이 나오면 조건을 하나씩 떼면서 다시 시도하고, 답할 때 「부서 조건은 빼고 찾았습니다」라고 밝힌다. 조용히 0건을 돌려주는 것이 가장 나쁘다.

조건이 곧 권한일 때는 이야기가 다르다

지금까지 필터는 「더 잘 찾기 위한 것」이었다. 그런데 조건이 접근 권한이면 성격이 완전히 달라진다. 필터가 한 번 새면 그것은 검색 품질 문제가 아니라 사고다.

차이가 세 가지에서 드러난다.

품질을 위한 필터 권한을 위한 필터
빠뜨렸을 때 결과가 조금 나빠진다 남의 문서가 노출된다
정하는 주체 모델이 질의에서 뽑아도 된다 서버가 세션에서 정한다. 모델은 손대지 않는다
0건일 때 조건을 떼고 재시도 그대로 0건. 절대 떼지 않는다

권한 조건을 모델이 만들게 두면 안 된다. 위에서 「0건이면 조건을 떼고 다시」라고 한 규칙이 권한에 그대로 적용되면 곧바로 유출이다. 두 종류의 조건을 코드에서 아예 다른 통로로 다루고, 권한 쪽은 검색 함수 안쪽에서 무조건 덧붙이는 편이 안전하다.

여러 고객사의 데이터를 한 색인에 담을 때 이 문제가 본격적으로 커진다. 다음 글들에서 이어 다룬다.

정리

  • 필터는 벡터 탐색의 앞이나 뒤에 놓이고, 순서가 결과 개수를 바꾼다
  • 사후 필터링은 조건이 좁을수록 결과가 비고, 뽑히지 않은 좋은 후보는 알 길이 없다
  • 사전 필터링은 개수를 채우지만 후보가 줄면 근사 탐색 그래프가 끊겨 조용히 빠뜨린다
  • 선택도에 따라 저장소가 방식을 갈아탄다. 1~30% 구간이 가장 비싸다
  • 필터로 쓸 값은 갈래를 적게, 시각은 월 단위로 잘라 따로 넣는다
  • 모델이 뽑은 조건은 허용 목록에 대조하고, 0건이면 떼면서 재시도하되 그 사실을 밝힌다
  • 권한 조건은 서버가 정하고 절대 떼지 않는다. 품질 필터와 같은 통로로 다루지 않는다

읽어주셔서 감사합니다. 😊

LATEST

에이전트·RAG의 최신 글

에이전트·RAG2026.08.23

에이전트는 어디서 어긋나기 시작하는가

에이전트가 이상한 답을 낼 때 증상은 마지막에 보이지만 어긋난 자리는 그보다 앞입니다. 한 걸음이 무너지는 여섯 자리를 나누고, 증상에서 원인을 거슬러 찾는 방법과 자리별 처방을 정리합니다.

11 MIN
에이전트·RAG2026.08.23

에이전트가 무엇을 했는지 나중에 알 수 있게 만들기

에이전트는 같은 입력에도 다른 경로로 갑니다. 그래서 로그 몇 줄로는 왜 그렇게 됐는지 복원이 안 됩니다. 트레이스를 어떻게 나누고 구간마다 무엇을 붙이며 어떤 지표를 볼지 정리합니다.

11 MIN
에이전트·RAG2026.08.23

에이전트 비용은 걸음 수보다 빨리 늘어난다

걸음이 두 배면 비용은 두 배가 아니라 서너 배입니다. 왜 그렇게 되는지, 어디서 새는지, 상한을 몇 겹으로 어떻게 거는지와 실제로 효과가 큰 순서대로의 대응을 정리합니다.

13 MIN