RAG에서 Agentic RAG로: 복잡한 문제 분해 후 검색
기본 RAG를 넘어 복잡한 질문을 먼저 분해한 뒤 검색하는 Agentic RAG 두 번째 단계의 필요성과 접근을 설명한다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
지난 글에서 우리는 LLM이 "검색을 해야 할지 말지"를 결정하게 했다. 하지만 지식 베이스에 들어간 뒤에는 두 번째 문제가 남아 있다. 복잡한 문제 하나를 정말 한 번의 벡터 검색으로 해결할 수 있을까? 이번 글에서는 RAG를 계속 개조한다. 먼저 문제를 분해하고, 하나씩 검색하며, State로 증거를 누적하고, 모델이 증거가 이미 충분한지 판단하게 한다. 마지막으로 아주 은밀한 문제 하나를 살펴본다. LLM이 문제를 분해할 때, 이미 몰래 우리 대신 문제를 "답해" 버렸을 수도 있다는 것이다.
지난 글에서 우리는 이미 RAG에게 한 가지를 가르쳤다.
이 문제는 도대체 지식 베이스를 조회해야 하는가?
그래서 흐름이
Question ↓ Retrieve ↓ Generate
에서
┌→ direct_answer │ Question → Router │ └→ retrieve → generate
로 바뀌었다.
이 단계에서 해결한 것은:
조회할지 말지.
하지만 조금만 복잡한 문제를 다루기 시작하면 곧 두 번째 층의 골칫거리를 마주친다.
한 번 조회하면 충분한가?
一、한 번의 검색이 왜 버거워지기 시작하는가?
역시 《천룡팔부》를 예로 들어 보자.
《천룡팔부》에서 "사대악인" 중 서열 두 번째는 누구인가? 이 사람의 아들이 신세가 밝혀지기 전에, 그의 생부가 무림에서 공개적으로 가진 신분은 무엇인가?
이 문장은 얼핏 보면 그냥 질문 하나로 보인다.
하지만 조금만 쪼개 보면, 그 안에는 실제로 여러 정보 포인트가 숨어 있다.
사대악인 둘째는 누구? ↓ 그의 아들은 누구? ↓ 아들의 생부는 누구? ↓ 이 사람의 공개 신분은 무엇?
그런데 가장 일반적인 벡터 검색이 하는 일은 무엇인가?
전체 Question ↓ Embedding ↓ 하나의 Vector ↓ Milvus ↓ Top K
문제는 바로 여기서 발생한다.
사용자가 물은 것은 복잡한 문제지만, 벡터 데이터베이스가 최종적으로 받는 것은 여전히:
하나의 Query Vector
그것은 자동으로 이렇게 말하지 않는다.
여기에는 사실 네 가지 일이 있다. 나는 먼저 첫 번째를 조회하고, 그다음 두 번째를 조회하고, 그다음 세 번째를……
그것이 하는 일은 단 하나다.
전체 Query와 벡터 공간에서 가장 유사한 Top K 문서를 찾아낸다.
二、복잡한 문제는 여러 검색 의도를 한데 뭉친다
조금 단순하게 생각해 보자.
단순한 질문 하나.
사대악인 서열 두 번째는 누구인가?
의미가 매우 집중되어 있다.
사대악인 서열 두 번째
Embedding에 넣으면 검색 방향도 비교적 명확하다.
하지만 복잡한 문제에는 동시에
사대악인 아들 생부 무림 신분
이런 정보들이 들어가고, 이 정보들은 최종적으로 모두 같은 벡터 표현으로 들어간다.
이때 Milvus가 찾는 것은:
"문제 전체 문장"과 가장 유사한 문서다.
다음과 같은 것이 아니라:
이 몇 개의 정보 포인트 각각에 대해 몇 개씩 문서를 찾아주는 것.
이 두 작업은 같은 일이 아니다.
게다가:
k = 5 ;
결국 자리는 5개뿐이다.
만약 어떤 부분의 의미가 특히 강하면, 다음과 같은 일이 생길 가능성이 크다.
Top 5 사대악인 관련 3건 허죽 관련 2건 현자 공개 신분 0건
데이터베이스에 없는 게 아니다.
단지 그것이 이번 Top K에 끼어들지 못한 것뿐이다.
그래서 복잡한 문제의 골칫거리가 반드시 "너무 어려워서"인 것은 아니다.
많은 경우 그것은 단지:
하나의 Query에 너무 많은 검색 의도가 섞여 들어간 것뿐이다.
그렇다면 가장 자연스러운 방법은 계속 Top K를 조정하는 것이 아니다.
바로:
먼저 쪼개는 것이다.
三、LLM이 먼저 문제를 쪼개게 한다
그래서 Graph에 새로운 노드를 추가한다.
decompose_question
그것은 질문에 답하는 일을 맡지 않는다.
단 하나만 맡는다.
복잡한 문제를 검색에 더 적합한 하위 문제로 바꾸는 것.
코드에서는 먼저 그것의 출력을 제약한다.
const DecomposeSchema = z .object ({ sub_questions: z. array (z. string ()). min ( 1 ). max ( 8 ), reason: z. string () });
그런 다음 LLM이 하위 문제 묶음을 반환하게 한다. 현재 Prompt는 특히 다음과 같이 요구한다. 하위 문제는 완전해야 하고, 독립적으로 검색할 수 있어야 하며, "그, 그녀, 이 사람"과 같은 지시대명사를 최대한 피해야 한다.
예를 들어 다음과 같은 결과를 얻을 수 있다.
Q1: 사대악인 서열 두 번째는 누구인가? Q2: 섭이랑의 아들은 누구인가? Q3: 허죽의 생부는 누구인가? Q4: 현자가 무림에서 공개적으로 가진 신분은 무엇인가?
그래서 원래의
하나의 복잡한 Query ↓ 한 번의 벡터 검색
이
Q1 → Retrieve Q2 → Retrieve Q3 → Retrieve Q4 → Retrieve
로 바뀐다.
매번 Embedding은 비교적 명확한 검색 의도 하나만 표현하면 된다.
이것이 바로 decomposeQuestionNode가 진짜로 해결하는 문제다.
문제를 더 "지능적으로" 보이게 만드는 것이 아니다.
한 번의 모호한 큰 검색을, 목표가 분명한 여러 번의 작은 검색으로 쪼개는 것이다.
四、쪼갠 뒤에는 어떻게 하나씩 조회하는가?
여기서부터 State의 가치가 드러나기 시작한다.
이번에 몇 가지 상태가 새로 추가되었다.
subQuestions: Annotation, nextSubIdx: Annotation, currentQuery: Annotation, retrievalCount: Annotation, maxRetrievals: Annotation, plannedNext: Annotation, documents: Annotation,
그중 가장 핵심적인 것은 사실:
nextSubIdx
처음에는:
nextSubIdx = 0
그래서 Retrieve가 조회한다.
subQuestions [0]
조회한 뒤에는:
nextSubIdx: idx + 1
다음 라운드에는 이렇게 바뀐다.
subQuestions [1]
그래서 겉보기에는 "다중 라운드 검색"이지만, 실제로 그것을 앞으로 나아가게 하는 것은 아주 단순하다.
0 → 1 → 2 → 3
retrieveNode는 매 라운드 현재 하위 문제를 꺼내 Milvus에 넘기고, 그런 다음 인덱스를 한 칸 뒤로 옮길 뿐이다.
五、여러 번 검색한 뒤에는 Documents도 따라 바뀌어야 한다
예전에는 Retrieve가 한 번뿐이었다.
Retrieve ↓ documents ↓ Generate
지금은 여러 라운드를 조회한다.
그래서:
첫 번째 라운드 documents 두 번째 라운드 documents 세 번째 라운드 documents ...
매번 이전 결과를 덮어써서는 안 된다.
그래서 여기에는 끊임없이 누적되는
증거 풀
이 필요하다.
코드에서는 mergeUnique()로 병합한다.
const merged = mergeUnique( state.documents ?? [] , newDocs ) ;
같은 문서가 중복으로 매칭되면:
한 부만 남긴다.
여러 번 매칭될 때 점수가 다르면:
score가 더 높은 결과를 보존한다.
이렇게 documents의 의미가 바뀐다.
그것은 더 이상:
이번 라운드에 무엇을 찾았는가.
가 아니다.
지금까지 우리 손에 어떤 증거가 있는가.
이 차이는 매우 중요하다.
왜냐하면 다음 단계에서 모델이 보는 것이 바로 이 누적 증거이기 때문이다.
六、한 라운드 조회 후에는 서둘러 Generate하지 않는다
만약 단순히:
Q1 → Retrieve Q2 → Retrieve Q3 → Retrieve Q4 → Retrieve ↓ Generate
라면, 그것은 사실 여전히 하드코딩된 흐름이다.
단지 Retrieve가 몇 번 더 실행되었을 뿐이다.
그래서 여기에 노드를 하나 더 추가했다.
plan_next_step
그것이 하는 일도 매우 절제되어 있다.
현재 증거를 보아, 원래 문제에 답하기에 충분한가.
구조에는 두 가지 결과만 있다.
const NextStepSchema = z. object ({ nextAction: z. enum ([ "retrieve" , "generate" ]), reason: z. string () });
증거가 충분하지 않으면:
retrieve
이미 충분하면:
generate
그래서 Graph는 이렇게 된다.
retrieve ↓ plan_next_step ↓ 충분한가? ↙ ↘ 부족 충분 ↓ ↓ retrieve generate
여기서 구분할 만한 디테일이 하나 있다.
planNextStepNode는 현재의 documents를 보긴 하고, 심지어 이 문서들로부터 다음과 같이 판단할 수도 있다.
지금은 이미 섭이랑을 알고 있다 지금은 이미 허죽을 알고 있다 ……
하지만 그것은 이 답들을 하위 문제에 다시 써 넣지 않는다.
그것이 실제로 State에 다시 쓰는 것은 오직:
return { plannedNext: finalNext };
그래서 이 Planner의 책임은:
다음 Query를 생성하는 것
이 아니다.
증거가 충분한가?
이 점을 먼저 기억해 두자.
곧바로 쓰이게 될 것이다.
七、여기까지 오면 Graph는 이미 순환을 형성하기 시작했다
이제 전체 흐름은:
graph TD; __start__([<p>__start__</p>]):::first route_question(route_question) direct_answer(direct_answer) decompose_question(decompose_question) retrieve(retrieve) plan_next_step(plan_next_step) generate(generate) __end__([<p>__end__</p>]):::last __start__ --> route_question; decompose_question --> retrieve; direct_answer --> __end__; generate --> __end__; retrieve --> plan_next_step; route_question -.-> direct_answer; route_question -.-> decompose_question; plan_next_step -.-> retrieve; plan_next_step -.-> generate; classDef default fill:#f2f0ff,line-height:1.2; classDef first fill-opacity:0; classDef last fill:#bfb6fc;
대응하는 코드도 매우 직접적이다.
const graph = new StateGraph (GraphState) . addNode ( "route_question" , routeQuestionNode) . addNode ( "direct_answer" , directAnswerNode) . addNode ( "decompose_question" , decomposeQuestionNode) . addNode ( "retrieve" , retrieveNode) . addNode ( "plan_next_step" , planNextStepNode) . addNode ( "generate" , generateNode) . addEdge (START, "route_question" ) . addConditionalEdges ( "route_question" , afterRoute, { direct_answer : "direct_answer" , decompose_question : "decompose_question" }) . addEdge ( "decompose_question" , "retrieve" ) . addEdge ( "retrieve" , "plan_next_step" ) . addConditionalEdges ( "plan_next_step" , afterPlan, { retrieve : "retrieve" , generate : "generate" }) . addEdge ( "direct_answer" , END) . addEdge ( "generate" , END) . compile ();
지난 글과 비교하면 가장 큰 변화는 이미 뚜렷하다.
지난 글에서 해결한 것은:
검색이 필요한가?
이번 글은 계속 앞으로 나아간다.
한 번의 검색으로 부족하면 어떻게 하는가?
답은:
문제를 쪼개고 ↓ 각각 검색하고 ↓ 증거를 누적하고 ↓ 충분한지 판단하고 ↓ 부족하면 계속 조회한다
여기까지 오면 이 설계는 이미 매우 매끄러워 보인다.
하지만 문제가 하나 더 있다.
그리고 이 문제는 앞의 것들보다 더 깊이 숨어 있다.
八、잠깐, Q2에 나오는 "섭이랑"은 어디서 온 것인가?
방금의 분해 결과를 다시 보자.
언뜻 보면 별문제 없어 보인다.
하지만 자세히 생각해 보면:
Q1 검색을 실행하기 전에, 우리는 정말로 다음을 알고 있었을까.
사대악인 둘째 = 섭이랑
이라는 것을?
모른다.
그런데 decomposeQuestionNode는 이미 곧바로 이렇게 써 버렸다.
叶二娘
계속 뒤를 보면:
虚竹 玄慈
이것들도 모두 마찬가지다.
다시 말해, 실제로 벌어진 일은 이것이다.
원래 질문 ↓ LLM이 자신이 이미 가진 지식에 따라 ↓ 미리 추측해 낸다: 叶二娘 → 虚竹 → 玄慈 ↓ 그리고 이 Query들을 가지고 지식베이스를 검색한다
여기에 아주 은밀한 함정이 하나 나타난다.
우리는 원래 지식베이스가 답의 사슬을 제공하기를 바랐는데, 결과적으로 LLM이 문제를 분해할 때 이미 답의 사슬을 미리 채워 넣은 것이다.
현재 Prompt는 심지어 이렇게 요구한다.
「그 / 그녀 / 이 사람」 사용 금지, 인물 이름 전체를 쓸 수 있음
「독립적으로 검색 가능한」 질문을 생성하기 위해 LLM은 어쩔 수 없이
이 사람
을
로 바꿔야 한다.
문제는 바로 여기에 있다.
만약 모델이 맞혔다면, 모든 것이 아주 아름다워 보인다.
만약 틀렸다면?
예를 들어 첫 단계부터 틀렸다면:
사대악인 둘째 = X
그러면 이후에는 줄곧 이렇게 된다.
X의 아들을 조회 ↓ 어떤 사람의 친부를 조회 ↓ 또 다른 사람의 신분을 조회
검색기는 아주 성실하게:
잘못된 길을 따라 계속 검색한다.
그래서 이 버전은 엄밀히 말해 아직 이런 것이 아니다.
직전 단계의 증거 ↓ 다음 단계를 결정
LLM이 미리 전체 경로를 계획 ↓ 지식베이스가 단계별로 검증
이 둘의 차이는 매우 크다.
九、진짜 다음 단계: 미리 답을 채우지 말고, 검색 후에 다시 채워라
더 안정적인 사고방식은 마땅히 이래야 한다.
Q1: 사대악인 둘째는 누구인가?
먼저 조회한다.
문서에서 다음을 얻었다고 가정하자.
그것을 State에 저장한다.
lastAnswer: "叶二娘"
그런 다음 다시 생성한다.
Q2: 叶二娘의 아들은 누구인가?
다시 조회한다.
虚竹
계속 다시 채운다.
lastAnswer: "虚竹"
그리고 나서:
Q3: 虚竹의 친부는 누구인가?
전체 과정은 이렇게 된다.
Query 1 ↓ Retrieve ↓ Extract Answer ↓ lastAnswer ↓ Build Next Query ↓ Query 2
문제를 분해할 때 먼저 자리 표시자를 남겨 둘 수도 있다.
Q1: 사대악인 중 서열 둘째는 누구인가? Q2: {{answer_1}}의 아들은 누구인가? Q3: {{answer_2}}의 친부는 누구인가? Q4: {{answer_3}}의 무림에서의 공개 신분은 무엇인가?
직전 단계가 정말로 지식베이스에서 결과를 얻은 뒤에야 비로소
{{answer_1}}
이것이 바로 진정한 의미의:
직전 단계의 결과가 다음 단계 검색을 이끈다.
그때가 되면 Graph도 한 단계를 더 거친다.
retrieve ↓ extract_answer ↓ build_next_query ↓ retrieve
이것이 더 완전한 Multi-hop RAG다.
마지막으로
이번 버전의 Demo는 처음에는 아주 실제적인 문제 하나를 해결하고자 했다.
복잡한 Query는 왜 한 번의 검색으로 자주 부족한가?
그래서 우리는 이렇게 했다.
Decompose ↓ Retrieve ↓ Accumulate ↓ Plan ↓ Loop
이미 맨 처음의
Retrieve → Generate
보다 훨씬 멀리 왔다.
그런데 정말 주목할 만한 것은, 오히려 마지막에 드러난 문제다.
LLM은 우리가 문제를 분해하도록 도울 수 있지만, 일단 알지 못하는 실체를 미리 채우게 하면 자신의 파라미터 지식을 몰래 검색 사슬에 끌어들일 수도 있다.
그래서 진정으로 신뢰할 수 있는 다중 홉 검색은 마땅히 이런 것이어서는 안 된다.
먼저 전체 경로를 추측하고 나서 검증한다
오히려 이래야 한다.
한 걸음 걷고 증거를 얻은 뒤 다음 걸음을 결정한다
RAG는 여기에 이르러서야 비로소 진짜로 약간 「Agentic」한 의미를 갖기 시작한다.
东风破_
110
글
22k
읽음
65
팔로워