콘텐츠로 이동

Fan-out 검색 패턴

하나의 질문을 언제 여러 검색으로 펼쳐야 하고, 언제 단일 검색으로 좁혀야 할까?

  • Fan-out 검색은 하나의 요청을 여러 하위 검색, 샤드, 파티션, 도구 호출로 펼친 뒤 결과를 다시 병합하는 패턴이다.
  • 전통 검색엔진과 분산 DB에서는 같은 쿼리를 여러 shard에 보내는 방식이었고, LLM 검색에서는 질문 자체를 여러 의미적 하위 쿼리로 나누는 방식이 더 중요해졌다.
  • production에서는 많이 펼치는 것이 아니라 branch 수, timeout, top-k, permission filter, dedup, rerank를 제한하는 bounded fan-out이 핵심이다.

Fan-out은 “한 번 더 검색”이 아니라 탐색 공간을 여러 갈래로 나누고, 각 갈래를 병렬 실행한 뒤, 다시 하나의 답으로 접는 구조다.

검색에서 fan-out을 생각할 때는 항상 두 동사를 같이 둔다. **펼친다(fan-out)**는 것은 coverage를 얻기 위해 query, shard, source, tool을 여러 branch로 나누는 일이다. **접는다(fan-in)**는 것은 branch가 가져온 후보를 같은 기준으로 모아 중복을 제거하고, 점수를 맞추고, 다시 순위를 매기고, 답변에 쓸 근거만 남기는 일이다. 앞쪽만 강하면 “많이 찾았지만 못 고르는 시스템”이 되고, 뒤쪽만 강하면 “잘 고르지만 정답 후보가 애초에 없는 시스템”이 된다.

그래서 fan-out 검색의 안정적인 정의는 다음처럼 잡는 것이 좋다.

fan-out search =
bounded branches
+ branch-local filters and budgets
+ parallel retrieval
+ bounded fan-in ranking
+ evidence-grounded answer

bounded라는 단어가 중요하다. branch 수, branch별 top-k, source별 timeout, rerank 후보 수, context에 넣을 chunk 수가 모두 상한을 가져야 한다. 상한이 없으면 recall을 올리려던 장치가 latency, 비용, noise, 권한 사고를 키우는 증폭기가 된다.

이 문서는 RAG 기초의 query transformation과 retrieval coverage 문제를 깊게 다룬다. RAG pipeline 전체에서는 user query -> query rewrite -> fan-out plan -> branch retrieval -> dedup/rerank/context packing -> generation -> faithfulness check 위치에 들어간다. Fan-out은 RAG와 동의어가 아니라, RAG 안에서 정답 근거를 더 안정적으로 찾기 위한 실행 전략이다.

1.0 선행 기술의 한계 — 단일 검색과 무제한 병렬화가 풀지 못한 문제

섹션 제목: “1.0 선행 기술의 한계 — 단일 검색과 무제한 병렬화가 풀지 못한 문제”

RAG 기초에서 본 naive RAG는 사용자 query 하나를 embedding하고 top-K 문서를 가져오는 단순 경로다. 이 방식은 명확한 FAQ에는 충분하지만, 질문이 복합적이거나 문서 표현과 사용자 표현이 다르면 정답 chunk가 top-K 밖으로 밀린다. 반대로 분산 DB와 검색엔진의 오래된 해법인 “모든 shard를 다 뒤진다”는 방식은 coverage를 얻지만 비용, tail latency, 권한 필터 복잡도를 키운다.

Fan-out 검색은 이 둘의 한계를 제한된 병렬 탐색과 fan-in 품질 관리로 푼다. 필요한 하위 쿼리와 검색 범위만 펼치고, 각 branch에 top-k·timeout·권한 필터를 적용한 뒤, dedup·rerank·faithfulness gate(선택된 근거로만 답했는지 확인하는 단계)로 다시 하나의 근거 묶음으로 접는다. 그래서 이 토픽의 핵심은 “많이 검색하기”가 아니라 “어디까지 펼치고 어디서 멈출지”를 설계하는 것이다.

전통적인 분산 검색에서는 같은 쿼리를 여러 shard나 partition에 보낸다. Elasticsearch와 Azure Cosmos DB 문서의 설명은 이 계열을 이해하는 좋은 예다. routing key나 partition key로 검색 범위를 좁히지 못하면 여러 shard·partition을 확인해야 하고, key가 있으면 fan-out 범위를 줄일 수 있다. 이 제품 설명은 현재 구현 세부가 아니라 scatter-gather의 일반 원리를 보여주는 사례로 읽는다. 출처: Elasticsearch routing, Azure Cosmos DB query container.

LLM/RAG에서는 쿼리 자체가 바뀐다. Google Search Central은 query fan-out을 모델이 사용자의 질문을 해결하기 위해 관련된 여러 동시 쿼리를 생성하고 추가 검색 결과를 가져오는 방식으로 설명한다. 예를 들어 “잡초가 많은 잔디를 고치는 법”은 제초제, 화학약품 없는 제거, 재발 방지 같은 하위 검색으로 나뉠 수 있다. 이것도 특정 검색 제품의 최신 규칙이라기보다, LLM이 사용자 의도를 여러 검색 관점으로 분해할 수 있음을 보여주는 사례로 다룬다. 출처: Google Search Central AI optimization guide.

용어

Fan-out / Fan-in

Fan-out은 요청을 여러 branch로 펼치는 단계이고, fan-in은 branch 결과를 모아 dedup, merge, rerank, answer synthesis를 수행하는 단계다.

검색에서는 scatter-gather, shard search, cross-partition query, query fan-out이 같은 계열의 이름으로 등장한다.
Bounded fan-out 검색 흐름
flowchart TD
A["사용자 질문"] --> B["의도 분석"]
B --> C{"fan-out 필요?"}
C -->|no| D["단일 검색"]
C -->|yes| E["하위 쿼리 생성"]
E --> F["branch별 권한/범위 필터"]
F --> G["병렬 retrieval"]
G --> H["timeout, top-k, budget 적용"]
H --> I["dedup + score normalization"]
I --> J["rerank"]
J --> K{"근거 충분?"}
K -->|yes| L["답변 생성"]
K -->|no| M["재검색 또는 refusal"]

fan-out은 recall을 올리지만, fan-in 품질이 약하면 noise와 latency만 커진다.

Fan-out은 LLM 등장 이후 생긴 방법론이 아니다. DB sharding, 검색엔진, 분산 캐시, map-reduce, scatter-gather RPC에서 오래전부터 쓰던 구조다. Azure Architecture Center의 sharding pattern도 여러 shard에서 데이터를 가져와야 할 때 shard별 병렬 fan-out query를 실행하고 aggregate하지만, 병렬이어도 가장 느린 shard가 전체 latency를 결정한다고 설명한다. 출처: Azure Architecture Center - Sharding pattern.

LLM이 바꾼 것은 무엇을 펼치느냐다.

  • 전통 분산 검색: 쿼리는 하나이고, 실행 위치가 여러 shard로 펼쳐진다.
  • LLM query fan-out: 사용자 의도 하나가 여러 하위 쿼리와 관점으로 펼쳐진다.
  • Agent fan-out: 같은 작업을 여러 agent, tool, worker가 병렬로 수행한다.

Azure AI agent design patterns도 concurrent orchestration을 fan-out/fan-in, scatter-gather, map-reduce와 같은 계열로 설명한다. 즉 LLM은 기존 패턴을 없앤 것이 아니라, 쿼리 분해와 작업 분해를 더 쉽게 만든 셈이다. 출처: Azure AI agent orchestration patterns.

이 계보를 알면 설계 질문이 선명해진다. “LLM이 branch를 몇 개 만들게 할까?”보다 먼저 “이 branch들이 서로 독립적으로 정보를 늘리는가, 아니면 같은 내용을 다른 말로 반복하는가?”를 물어야 한다. scatter-gather의 오래된 교훈은 branch가 많아질수록 평균 latency보다 tail latencymerge 품질이 전체 경험을 좌우한다는 점이다. RAG에서도 같은 일이 일어난다. 가장 느린 source 하나가 전체 답변을 붙잡고, 가장 noisy한 branch 하나가 context window를 오염시킬 수 있다.

1.2 LLM 검색에서 fan-out이 필요한 이유

섹션 제목: “1.2 LLM 검색에서 fan-out이 필요한 이유”

RAG에서 단일 검색은 세 가지 한계에 자주 부딪힌다.

  1. 표현 mismatch: 사용자는 “그거 비용은?”처럼 짧게 묻고, 문서는 “RAG retrieval pipeline operating cost”처럼 다른 표현을 쓴다.
  2. 복합 질문: “RAG와 fine-tune의 비용, 최신성, 보안 차이는?”은 비용, 갱신, 권한, hallucination을 각각 검색해야 한다.
  3. multi-source 요구: 답이 vector store, web search, DB, 제품 문서, 로그 trace에 나뉘어 있다.

Fan-out은 이 한계를 branch별 검색으로 완화한다.

원본 질문: "Fan-out search는 오래된 방법론인가, LLM 때문에 생긴 건가?"
branch 1: "distributed search scatter gather fan-out history"
branch 2: "Elasticsearch search request fan out shards custom routing"
branch 3: "Google Search query fan-out AI Mode"
branch 4: "RAG query decomposition multi query retrieval"
branch 5: "fan-out fan-in agent orchestration pattern"

이후 fan-in 단계에서 같은 말을 하는 결과를 합치고, 오래된 시스템 패턴과 LLM식 의미 분해를 구분해 답변한다.

이 예제에서 branch는 아무렇게나 늘린 목록이 아니다. branch 1은 계보, branch 2는 shard fan-out, branch 3은 LLM 검색의 제품 사례, branch 4는 RAG query decomposition, branch 5는 orchestration 패턴을 맡는다. 좋은 branch는 서로 다른 실패 가능성을 덮는다. 나쁜 branch는 “fan-out search”, “query fan-out”, “multi query search”처럼 같은 표면어를 반복해 candidate만 중복시킨다.

작게 손으로 계산해 보면 fan-out의 trade-off가 더 잘 보인다.

단일 검색:
topK=10
후보 10개
정답 근거 2개 발견
3-branch fan-out:
branch별 topK=5
원시 후보 15개
dedup 후 후보 9개
정답 근거 4개 발견
rerank 후 context에 6개만 사용
잘못된 8-branch fan-out:
branch별 topK=10
원시 후보 80개
dedup 후 후보 18개
정답 근거 4개 발견
rerank와 synthesis 비용만 증가

두 번째가 좋은 이유는 branch가 recall을 올렸고, fan-in이 후보를 다시 줄였기 때문이다. 세 번째는 branch가 많아졌지만 새 근거를 거의 늘리지 못했다. 이 경우 문제는 “검색을 더 해야 한다”가 아니라 branch 생성 기준이나 dedup, rerank, source 선택을 다시 봐야 한다.

Fan-out을 설계할 때 가장 중요한 경계는 “같은 쿼리를 어디에 보낼 것인가”와 “질문을 어떤 하위 쿼리로 바꿀 것인가”다.

Fan-out 유형 비교

Shard fan-out

같은 쿼리를 여러 shard, partition, index에 보내고 결과를 병합한다.

partition key가 없거나 전체 corpus에서 top-k를 찾아야 할 때. key가 있으면 routing으로 fan-out을 줄이는 편이 먼저다.

Semantic query fan-out

하나의 의도를 여러 하위 질문, 동의어, 관점, 검색 backend로 확장한다.

복합 질문, 표현 mismatch, broad comparison, exploratory search일 때. branch가 서로 다른 근거를 찾는지 확인해야 한다.

Agent/tool fan-out

여러 agent나 tool을 동시에 실행하고 결과를 모아 판단한다.

서로 독립적인 분석, 검증, 정보 수집을 병렬화할 수 있을 때. 권한과 side effect가 있으면 검색 fan-out보다 훨씬 조심해야 한다.

뒤의 설계 표를 읽기 전에 네 용어만 짧게 잡으면 된다.

  • branch: 하나의 검색 경로다. 하위 query, source, shard, tool이 branch가 될 수 있다.
  • fan-in: branch 결과를 모아 dedup, score normalization, rerank, context packing으로 접는 단계다.
  • top-k: 각 검색 branch가 가져오는 상위 후보 수.
  • BM25: 단어 일치 기반 ranking. 정확한 문자열, 코드, 조항 번호에 강하다.
  • rerank: 1차 후보를 질문과 함께 다시 읽어 최종 순서를 고치는 단계다.
  • faithfulness gate: 답변 문장이 선택된 근거에서 나오는지 확인하는 단계다.
  • RRF(Reciprocal Rank Fusion): 서로 다른 ranking 목록을 원점수 대신 순위 기반으로 섞는 방법.
  • ACL(Access Control List): 사용자가 볼 수 있는 문서 권한 목록.

처음에는 “후보를 얼마나 가져오고, 어떤 방식으로 점수를 섞고, 누가 볼 수 있는가”의 언어로 이해하면 충분하다.

  • 사용자의 질문이 여러 축의 비교를 요구한다.
  • 검색 로그에서 recall@k가 낮고, 정답 문서가 다른 표현으로 쓰여 있다.
  • 단일 retriever가 숫자, 고유명사, 코드, 자연어 paraphrase를 모두 잘 처리하지 못한다.
  • web, vector store, DB, graph처럼 서로 다른 source를 함께 봐야 한다.
  • 지연 예산 안에서 병렬 실행으로 coverage를 넓힐 수 있다.

좋은 fan-out trigger는 관측 가능한 증거와 연결된다. “질문이 어려워 보인다”는 부족하다. 여기서 gold dataset은 버전이 관리되고 사람이 검토한 기준 데이터셋이다. gold dataset에서 단일 검색 recall@10이 낮거나, 실패 trace를 봤을 때 정답 문서가 다른 용어로 쓰였거나, 질문이 비용·보안·최신성처럼 서로 다른 축을 동시에 요구한다는 근거가 있어야 한다. 특히 RAG에서는 branch를 만들기 전에 질문을 세 종류로 나누면 판단이 쉬워진다.

질문 유형추천 경로이유
명확한 단일 사실단일 retrieval + rerankfan-out 이득보다 latency와 noise가 큼
표현 mismatchrewriting 또는 2~3개 multi-query같은 의도를 다른 표현으로 찾는 것이 목적
복합 비교decomposition 기반 3~5개 branch축마다 필요한 근거가 다르기 때문
multi-source 조사source별 branch + fan-in rerankvector, BM25, DB, web의 강점이 다름
권한 민감한 질문pre-filter 가능한 source만 사용candidate 단계의 권한 누출을 막아야 함
  • partition key나 document id가 명확해 단일 shard 조회가 가능한데 전체 shard를 뒤진다.
  • 질문이 단순한데 LLM이 10개 branch를 만들어 비용과 noise를 늘린다.
  • permission filter가 fan-out 이후에만 적용되어 권한 없는 문서가 candidate에 섞인다.
  • branch별 점수 체계가 다른데 정규화 없이 단순 합친다.
  • 전체 latency SLO가 300ms인데 외부 web search, rerank, LLM synthesis를 모두 동기 경로에 넣는다.

반례를 하나 보자.

질문: "우리 회사 휴가 신청 마감일은 언제야?"
조건: tenant_id와 policy_doc_id가 이미 세션에서 확정됨
나쁜 선택:
- HR 문서 전체 vector search
- web search
- 비용 정책 DB 검색
- 보안 정책 검색
- 8개 query decomposition
좋은 선택:
- tenant_id + policy_doc_id pre-filter
- 해당 문서 또는 parent chunk에서 단일 retrieval
- 근거 문단이 없으면 "해당 문서에서 찾지 못했다"고 멈춤

이 반례에서 fan-out은 똑똑한 검색이 아니라 scope 확대다. 이미 정확한 key와 권한 범위가 있는데 검색 범위를 넓히면, 지연과 leakage surface만 증가한다. fan-out은 모르는 것을 넓히는 도구이지, 알고 있는 범위를 무시하는 도구가 아니다.

퀴즈

Fan-out 검색에서 recall만 높이면 품질도 자동으로 좋아질까요?

힌트: 검색 품질은 후보를 많이 모으는 단계와 좋은 후보를 고르는 단계가 함께 필요하다.

정답 보기

아니다. branch가 늘면 관련 없는 후보도 함께 늘어난다. fan-in 단계의 dedup, score normalization, rerank, 권한 필터, faithfulness gate가 약하면 recall 향상이 noise와 비용 증가로 바뀐다.

3. 15분 청크: 실무 적용과 실패 신호

섹션 제목: “3. 15분 청크: 실무 적용과 실패 신호”

Production fan-out은 반드시 bounded로 설계한다. 기본값은 “필요할 때만 작게 펼치고, 빠르게 모으고, 근거가 부족하면 멈춘다”다.

type FanoutPlan = {
branches: Array<{
id: string;
query: string;
source: "vector" | "bm25" | "web" | "db" | "graph";
topK: number;
timeoutMs: number;
requiredFilters: {
tenantId: string;
acl: string[];
language?: string;
freshnessAfter?: string;
};
}>;
maxBranches: number;
globalTimeoutMs: number;
maxRawCandidates: number;
maxRerankCandidates: number;
finalContextChunks: number;
partialResultPolicy: "allow_with_citation_warning" | "retry" | "refuse";
mergeStrategy: "rrf" | "rerank" | "score_normalize_then_rerank";
};

핵심은 branch를 만들기 전에 제약을 같이 만드는 것이다. 특히 tenant, ACL, language, freshness 같은 filter는 retrieval 이후가 아니라 branch 실행 전에 들어가야 한다. 권한 없는 후보를 가져온 뒤 마지막에 숨기는 방식은 RAG permission leak의 대표 silent failure다.

maxRawCandidates, maxRerankCandidates, finalContextChunks는 fan-in의 안전장치다. 예를 들어 maxBranches=4, branch별 topK=10이면 원시 후보는 최대 40개다. 여기서 dedup 후 22개가 남고, reranker에는 20개만 보내고, 최종 context에는 6~8개만 넣는 식으로 단계마다 좁혀야 한다. 이 상한이 없으면 fan-out의 비용은 retrieval에서 끝나지 않고 rerank, context packing, generation token, faithfulness 평가까지 번진다.

partialResultPolicy는 느린 branch가 있을 때의 제품 결정을 미리 정한 값이다. allow_with_citation_warning은 일부 source가 빠졌음을 밝히고 제한된 답을 내는 선택이고, retry는 짧게 다시 시도하는 선택이며, refuse는 근거가 부족하다고 멈추는 선택이다. 정책이 없으면 timeout 순간마다 구현자가 다른 결정을 하게 되고, 사용자는 같은 질문에 서로 다른 신뢰 수준의 답을 받는다.

Fan-out을 켰다면 fan-in은 다음 순서로 고정된 계약을 가져야 한다.

1. branch-local pre-filter
tenant, ACL, language, freshness, source scope를 retrieval 전에 적용한다.
2. candidate collection
branch별 topK와 timeout을 지키고, 실패한 branch는 partial result policy로 처리한다.
3. dedup
같은 문서, 같은 parent chunk, 거의 같은 passage를 하나로 묶는다.
4. score normalization 또는 rank fusion
BM25, vector, web rank의 점수 체계가 다르므로 원점수를 그대로 더하지 않는다.
5. rerank
질문과 후보 passage를 함께 보고 최종 순위를 다시 매긴다.
6. context packing
상위 후보만 넣고, 서로 같은 근거를 반복하는 chunk는 제외한다.
7. faithfulness check
답변 문장이 선택된 근거에서 나오는지 확인하고, 부족하면 재검색 또는 refusal로 멈춘다.

이 흐름에서 특히 위험한 지점은 1번과 4번이다. 권한 필터를 나중으로 미루면 사용자가 볼 수 없는 문서가 trace, reranker input, prompt context에 잠깐이라도 들어갈 수 있다. 점수 정규화를 건너뛰면 vector score 0.82, BM25 score 14.3, web rank 2처럼 의미가 다른 숫자를 같은 척도로 오해한다. score soup는 보통 에러를 내지 않고, “왜 이 문서가 1등이지?” 같은 품질 저하로만 나타난다.

처음부터 최적 branch 수를 맞히려고 하지 말고, 질문 난이도와 latency budget으로 상한을 둔다.

대략적 온라인 비용 =
max(branch retrieval latency)
+ fan-in merge/rerank latency
+ generation latency
대략적 후보 비용 =
branch_count * topK
-> dedup candidates
-> rerank candidates
-> final context chunks

병렬 실행이라 retrieval latency가 단순 합으로 늘지는 않는다. 하지만 전체 응답은 가장 느린 branch, fan-in 처리, LLM generation을 모두 기다린다. 따라서 branch_count=4branch_count=2보다 항상 두 배 느린 것은 아니지만, timeout 가능성과 후보 처리 비용은 확실히 늘어난다.

초기값은 아래처럼 보수적으로 시작한다.

상황시작값늘리는 조건줄이는 조건
단순 FAQmaxBranches=1, topK=5~10gold dataset에서 정답 문서가 자주 빠질 때없음. fan-out보다 scope 정확도를 먼저 본다
표현 mismatchmaxBranches=2~3, branch별 topK=5~10recall@k가 오르고 dedup ratio가 낮을 때dedup 후 70% 이상 사라지면 branch가 중복됨
복합 비교maxBranches=3~5, 축별 branch각 branch가 서로 다른 citation에 기여할 때특정 branch가 rerank top result에 계속 못 들 때
외부 web/tool 포함maxBranches=1~2로 제한freshness가 답 품질에 필수일 때p95 latency나 timeout이 SLO를 넘을 때
권한 민감 domainpre-filter 가능한 branch만 허용audit trace에서 filter 선적용이 확인될 때cross-tenant 후보가 1건이라도 발견될 때
지표왜 보는가이상 신호
fanout_branch_count질문 1개가 몇 갈래로 펼쳐지는지 확인단순 FAQ에도 p95가 6 이상
branch_timeout_rate느린 source가 전체 답변을 붙잡는지 확인특정 backend만 timeout 집중
candidate_dedup_ratiobranch가 중복 결과만 가져오는지 확인dedup 후 후보가 70% 이상 사라짐
retrieval_recall@k정답 문서가 후보군에 들어오는지 확인fan-out 후에도 recall 개선 없음
rerank_drop_ratefan-out 후보가 reranker에서 얼마나 버려지는지 확인대부분 branch가 top result에 기여하지 않음
answer_faithfulness모은 근거로만 답했는지 확인citation은 있는데 문장 근거가 없음
fanout_cost_per_querybranch 증가가 비용으로 얼마나 번지는지 확인hard query 비율보다 비용 증가가 큼
source_contribution어떤 source·branch가 최종 답에 기여했는지 확인특정 source가 비용만 쓰고 citation 0건
acl_filter_miss권한 필터가 retrieval 전에 적용됐는지 확인candidate trace에 권한 밖 문서가 등장

지표는 한 개씩 보면 오해하기 쉽다. candidate_dedup_ratio가 높다는 것은 중복 branch가 많다는 뜻일 수도 있지만, 같은 중요한 문서를 여러 표현으로 안정적으로 찾는다는 뜻일 수도 있다. 그래서 rerank_drop_rate, source_contribution, answer_faithfulness를 함께 봐야 한다. 중복이 많아도 최종 답의 근거가 안정되고 비용이 예산 안이면 유지할 수 있다. 반대로 recall은 올랐는데 faithfulness가 떨어지면 검색 확대가 아니라 context packing이나 prompt 제약이 실패한 것이다.

Fan-out 실패 모드

Branch explosion

LLM이 비슷한 하위 쿼리를 많이 만들어 retrieval, rerank, synthesis 비용이 선형 증가한다.

복구: maxBranches 3~5, branch dedup, query similarity threshold 적용

Slowest branch wins

병렬 실행이어도 fan-in은 가장 늦은 branch나 global timeout을 기다린다.

복구: source별 timeout, partial result policy, late result cache 적용

Permission leak

branch 실행 후에 권한 필터를 걸어 candidate 단계에서 민감 문서가 섞인다.

복구: tenant/ACL pre-filter, namespace 분리, trace audit 강제

Score soup

BM25, vector, web rank 점수를 같은 숫자처럼 합쳐 엉뚱한 결과가 올라온다.

복구: RRF, score normalization, cross-encoder rerank, source weight 실험

여기서 cross-encoder rerank는 query와 candidate passage를 한 쌍으로 다시 읽어 순위를 매기는 방식이다. 1차 검색보다 느리지만 후보 수를 줄인 뒤 precision을 회복하는 데 쓴다. source weight는 web, BM25, vector, graph 같은 source별 신뢰도나 비용을 다르게 주는 가중치다. 가중치는 품질 개선 도구이지만, 잘못 주면 특정 source가 항상 이기는 편향이 된다.

실패 모드는 로그에서 다음처럼 보인다.

관측 증상먼저 의심할 개념이유
fanout_branch_count만 늘고 recall이 그대로다branch decomposition 실패branch가 서로 다른 근거를 찾지 못하고 있다
timeout은 없는데 p95가 오른다fan-in 후보 과다rerank·context packing·generation token 비용이 늘었다
citation은 많지만 답이 문서와 어긋난다faithfulness gate 실패좋은 후보 수집과 근거 기반 생성은 다른 문제다
특정 tenant 문서가 다른 tenant trace에 보인다permission pre-filter 실패검색 뒤 필터링은 candidate 단계의 노출을 막지 못한다
BM25 결과만 계속 상위에 남는다score normalization 실패source별 score scale이 다르거나 source weight가 과하다
web branch가 항상 늦고 답에 기여하지 않는다source contribution 없는 branchfreshness 이득 없이 tail latency만 키우고 있다

Score soup는 작은 숫자 예제로도 확인된다.

candidate A: vector score 0.84, BM25 score 1.2, web rank 없음
candidate B: vector score 0.62, BM25 score 18.0, web rank 없음
candidate C: vector score 없음, BM25 score 없음, web rank 1
나쁜 병합:
0.84 + 1.2 = 2.04
0.62 + 18.0 = 18.62
web rank 1을 점수 1로 간주
문제:
BM25의 18.0과 vector의 0.84는 같은 단위가 아니다.
web rank 1은 낮을수록 좋은 값인데 단순 합산하면 의미가 뒤집힐 수 있다.
더 나은 병합:
source별 ranking을 만든 뒤 RRF로 섞거나,
source별 score를 calibration한 뒤 reranker가 최종 판단하게 한다.

이 예제의 목적은 RRF 공식을 외우는 것이 아니다. 서로 다른 검색기의 원점수는 같은 온도계가 아니라는 점을 기억하는 것이다. fan-out은 여러 검색기를 동시에 쓰게 만들기 때문에, fan-in에서 점수 언어를 맞추지 않으면 recall 개선이 ranking 퇴화로 바뀐다.

시나리오

RAG 챗봇의 fan-out 도입 판단

사내 정책 챗봇의 정답 문서 recall@10이 0.62다. 사용자 질문은 짧고 구어체이며, 문서는 HR, 보안, 비용 정책 저장소에 나뉘어 있다. 현재 p95 latency는 1.2초, 목표는 2초 이하다.

semantic query fan-out을 몇 branch까지 허용하고, 어떤 지표가 악화되면 rollback할지 결정해 보세요.

장애 대응은 “fan-out을 끈다”보다 더 좁게 시작한다. 먼저 fanout_branch_count, branch_timeout_rate, candidate_dedup_ratio, rerank_drop_rate, fanout_cost_per_query, retrieval 단계 p95 latency를 같은 시간창에서 본다. 예를 들어 단순 FAQ route에서 fanout_branch_count p95가 갑자기 6 이상으로 올라가고 candidate_dedup_ratio도 높으면 branch explosion이고, 특정 source의 branch_timeout_rate만 튀면 slowest branch 문제다. 권한 사고가 의심되면 답변 로그보다 먼저 branch 실행 trace에서 tenant/ACL filter가 retrieval 전 조건으로 들어갔는지 확인한다.

Rollback knob는 세 단계로 둔다. 1단계는 feature flag로 semantic fan-out만 끄고 shard routing이나 단일 검색은 유지한다. 2단계는 maxBranches=1~2, topK=5, globalTimeoutMs 하향, 외부 web/tool branch 비활성화처럼 비용과 tail latency를 줄이는 제한을 건다. 3단계는 권한 필터 누락이나 cross-tenant candidate가 발견된 경우인데, 이때는 fan-out 전체를 단일 검색 fallback으로 돌리고 trace audit이 통과할 때까지 재활성화하지 않는다. 복구는 한 번에 원래 값으로 되돌리지 않고 maxBranches 2 -> 3 -> 4처럼 올리며, 각 단계에서 p95 latency가 SLO(Service Level Objective, 서비스 목표 지연) 안에 있고 timeout과 faithfulness가 배포 전 baseline으로 돌아온 뒤 다음 단계로 간다.

3.6 선택 실습 — 작은 corpus로 튜닝하기

섹션 제목: “3.6 선택 실습 — 작은 corpus로 튜닝하기”

처음 회독에서는 아래 실습을 펼치지 않아도 된다. 예상 출력과 해석만 읽어도 fan-out의 핵심 trade-off는 잡을 수 있다.

외부 라이브러리 없이 tiny corpus에서 단일 검색과 bounded fan-out 비교하기

아래 스크립트는 외부 라이브러리 없이 tiny corpus에서 단일 검색과 bounded fan-out을 비교한다. 점수 모델은 단어 overlap뿐이라 production 품질을 대표하지는 않지만, maxBranchestopK를 바꾸면 recall, latency budget, 비용 단위가 어떻게 함께 움직이는지 재현하기에는 충분하다.

Terminal window
cat > /tmp/fanout-demo.mjs <<'EOF'
const corpus = [
{ id: "d1", text: "RAG retrieval cost rises with vector search and reranking candidates" },
{ id: "d2", text: "Fine tuning cost is paid at training time and update cycles" },
{ id: "d3", text: "Freshness needs current documents instead of stale model weights" },
{ id: "d4", text: "Password reset policy has MFA enrollment steps" },
{ id: "d5", text: "Security permissions need tenant ACL pre filtering before retrieval" },
{ id: "d6", text: "Hallucination is reduced when answers cite retrieved evidence" },
{ id: "d7", text: "Search shard routing can avoid cross partition fan out" },
{ id: "d8", text: "Caching lowers repeated query latency" },
];
const relevant = new Set(["d1", "d2", "d3", "d5"]);
const branches = [
"rag retrieval cost",
"fine tuning training update cost",
"freshness current documents stale model",
"security permissions tenant acl",
];
const tokenize = (text) => text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
function search(query, topK) {
const terms = tokenize(query);
return corpus
.map((doc) => {
const words = new Set(tokenize(doc.text));
const score = terms.reduce((sum, term) => sum + (words.has(term) ? 1 : 0), 0);
return { id: doc.id, score };
})
.filter((hit) => hit.score > 0)
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
.slice(0, topK);
}
function evaluate(label, queryBranches, topK, budgetMs) {
const hitsById = new Map();
for (const query of queryBranches) {
for (const hit of search(query, topK)) {
const prev = hitsById.get(hit.id);
if (!prev || hit.score > prev.score) hitsById.set(hit.id, hit);
}
}
const hits = [...hitsById.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
const recallFound = hits.filter((hit) => relevant.has(hit.id)).length;
const branchMs = queryBranches.map((query) => 50 + tokenize(query).length * 5 + topK * 8);
const latencyMs = Math.max(...branchMs) + hits.length * 6 + queryBranches.length * 12 + 20;
return {
label,
branches: queryBranches.length,
topK,
candidates: hits.length,
recallFound,
latencyMs,
costUnits: queryBranches.length * topK,
withinBudget: latencyMs <= budgetMs,
ids: hits.map((hit) => hit.id),
};
}
const budgetMs = 185;
const rows = [
evaluate("single", ["compare rag and fine tuning for enterprise answers"], 3, budgetMs),
evaluate("fanout-2x2", branches.slice(0, 2), 2, budgetMs),
evaluate("fanout-3x2", branches.slice(0, 3), 2, budgetMs),
evaluate("fanout-4x2", branches.slice(0, 4), 2, budgetMs),
evaluate("fanout-4x3", branches.slice(0, 4), 3, budgetMs),
];
for (const row of rows) {
console.log(
`${row.label.padEnd(11)} b=${row.branches} topK=${row.topK} ` +
`candidates=${row.candidates} recall=${row.recallFound}/${relevant.size} ` +
`latencyMs=${row.latencyMs} costUnits=${row.costUnits} ` +
`withinBudget=${row.withinBudget} ids=${row.ids.join(",")}`,
);
}
const choice = rows
.filter((row) => row.withinBudget)
.sort((a, b) => b.recallFound - a.recallFound || a.costUnits - b.costUnits || a.latencyMs - b.latencyMs)[0];
console.log(
`choice=${choice.label} because it reaches recall=${choice.recallFound}/${relevant.size} ` +
`within ${budgetMs}ms with lower cost than 4x3`,
);
EOF
node /tmp/fanout-demo.mjs

예상 출력은 다음과 같다.

single b=1 topK=3 candidates=3 recall=2/4 latencyMs=164 costUnits=3 withinBudget=true ids=d2,d1,d6
fanout-2x2 b=2 topK=2 candidates=2 recall=2/4 latencyMs=147 costUnits=4 withinBudget=true ids=d2,d1
fanout-3x2 b=3 topK=2 candidates=3 recall=3/4 latencyMs=165 costUnits=6 withinBudget=true ids=d2,d3,d1
fanout-4x2 b=4 topK=2 candidates=4 recall=4/4 latencyMs=183 costUnits=8 withinBudget=true ids=d2,d3,d5,d1
fanout-4x3 b=4 topK=3 candidates=4 recall=4/4 latencyMs=191 costUnits=12 withinBudget=false ids=d2,d3,d5,d1
choice=fanout-4x2 because it reaches recall=4/4 within 185ms with lower cost than 4x3

이 작은 실험에서는 fanout-4x2가 선택된다. 단일 검색은 비용 단위가 3으로 가장 낮지만 cost, freshness, security 중 일부 근거를 놓쳐 recall이 2/4에 머문다. fanout-4x3은 recall을 더 올리지 못하면서 latency budget을 넘고 비용 단위만 8에서 12로 늘린다. 따라서 이 corpus와 185ms 예산에서는 maxBranches=4, topK=2가 가장 작은 비용으로 필요한 근거를 모두 회수하는 지점이다. 실제 서비스에서는 이 스크립트의 relevant 집합을 offline gold dataset으로 바꾸고, latencyMs를 trace에서 관측한 source별 p95로 바꿔 같은 결정을 반복하면 된다.

실무 기본값은 3 branch x topK 10 -> dedup -> rerank top 8 -> answer 정도로 시작하되, 위 실험처럼 gold dataset과 latency budget을 먼저 두고 조정한다. 예를 들어 사내 정책 챗봇에서 목표가 p95 2초 이하이고 현재 단일 검색 recall@10이 0.62라면, maxBranches=3, topK=10부터 시작해 recall과 faithfulness가 함께 오르는지 본다. recall이 0.62에서 0.78로 올라도 faithfulness가 떨어지면 fan-out이 아니라 fan-in 설계가 실패한 것이다. 반대로 maxBranches=4에서 recall 이득이 1~2%p뿐인데 p95가 2초를 넘거나 비용이 크게 늘면 maxBranches=3으로 되돌리는 것이 더 나은 선택이다.

  • scatter-gather search
  • fan-out fan-in pattern
  • query decomposition RAG
  • multi-query retrieval
  • reciprocal rank fusion
  • shard routing
  • cross-partition query
  • bounded parallelism

이해 점검

  • 전통적 shard fan-out과 LLM query fan-out의 차이를 설명할 수 있다
  • fan-out이 recall을 올리는 조건과 noise를 늘리는 조건을 구분할 수 있다
  • branch 수, timeout, top-k, 권한 필터, rerank를 포함한 bounded fan-out 설계를 그릴 수 있다
  • permission leak, branch explosion, slowest branch wins 같은 실패 신호를 지표로 잡을 수 있다