LangChain에서 LangGraph로 RAG 지식베이스 개편
하나의 질문을 예로 RAG 지식베이스를 LangChain에서 LangGraph 구조로 개편하는 과정을 설명했다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
사용자가 "아주(阿朱)의 결말은 무엇인가"라고 묻는다면, 지식베이스는 먼저 소설 속에서 관련 장면을 찾아낸 뒤, 대규모 모델이 그 장면을 근거로 답하게 해야 한다. 이것이 바로 RAG(검색 증강 생성)다. 먼저 자료를 검색하고, 그 자료를 모델에 답변 근거로 넘긴다.
이 글은 동일한 질문 하나로 두 가지 아키텍처를 관통한다. 먼저 LangChain이 어떻게 RAG를 엮어내는지 이해하고, 그다음 같은 업무 단계를 LangGraph에 맡겨 편성한다. 이전과 이후 모두 《천룡팔부(天龙八部)》, Milvus, 그리고 동일한 모델을 계속 사용하며, 변화는 흐름 조직 방식에 집중된다.
프로젝트에서 ebook-write.mjs는 라이브러리 구축을 담당하고, naive-rag.mjs는 이미 LangGraph 버전 질의응답을 구현해 두었다. 아래의 일반 LangChain 버전은 대비를 위해 정리한 교육용 코드다. 그래프 버전은 프로젝트의 Annotation.Root 작성 방식을 따르며 노드 반환값을 단순화했다. 이 조각들은 기존 파일을 이해하고 개조하기 위한 것이지, 독립적으로 완결된 프로그램이 아니다.
먼저 이 프로젝트에서 LangChain의 역할을 알아보자. LangChain은 통일된 컴포넌트 인터페이스를 제공하여, 프로그램이 문서를 로드하고, 텍스트를 분할하고, 벡터를 생성하고, 자료를 검색하고, 모델을 호출할 수 있게 한다.
컴포넌트 | 역할 | 프로젝트에서의 구현 문서 로더 | 파일에서 본문 읽기 | EPubLoader 텍스트 분할기 | 긴 문서를 작은 조각으로 나누기 | RecursiveCharacterTextSplitter Embedding | 텍스트를 의미를 나타내는 숫자 벡터로 변환 | OpenAIEmbeddings 벡터 저장소 인터페이스 | 데이터베이스와 연동하여 유사 조각 검색 | LangChain의 Milvus 래퍼 대화 모델 인터페이스 | 질문과 자료를 모델에 넘겨 답변 받기 | ChatOpenAI
Milvus는 실제로 데이터를 저장하고 검색하는 데이터베이스이며, LangChain의 벡터 저장소 컴포넌트가 이를 호출하는 역할을 한다. Embedding 모델은 벡터 생성을 담당하고, 대화 모델은 텍스트 답변 생성을 담당한다. 이 두 모델은 서로 다른 임무를 맡는다.
LangChain RAG는 라이브러리 구축과 질의응답 두 단계로 나눌 수 있다.
flowchart TD A[전자책] --> B[본문 로드 및 청킹] B --> C[Embedding: 조각을 벡터로] C --> D[(Milvus: 원문, 벡터, 메타데이터)] E[사용자 질문] --> F[Embedding: 질문을 벡터로] F --> G[가장 유사한 k개 조각 검색] D --> G G --> H[질문, 조각, 답변 요구사항 이어붙이기] H --> I[대화 모델이 답변 생성]
라이브러리 구축은 보통 최초 가져오기나 자료 갱신 시 실행되고, 질의응답은 사용자 요청에 따라 실행된다. 매번 질문할 때마다 전자책 전체를 다시 읽고 문서 벡터를 다시 생성하면 불필요한 비용이 발생한다.
ebook-write.mjs는 먼저 챕터별로 EPUB를 로드한 뒤, 챕터를 목표 크기 500, 중첩 크기 50의 텍스트 조각으로 분할한다.
const loader = new EPubLoader ( EPUB_FILE , { splitChapters : true }); const chapters = await loader. load (); const splitter = new RecursiveCharacterTextSplitter ({ chunkSize : 500 , chunkOverlap : 50 , });
청킹은 각 자료 조각의 길이를 제어하고, 중첩은 분할 지점의 맥락을 최대한 보존한다. 코드의 크기는 텍스트 길이 기준으로 계산되며 토큰 수가 아니다. 분할된 각 블록이 모두 정확히 같은 길이인 것도 보장되지 않는다.
각 블록이 벡터를 생성한 뒤, 프로그램은 세 가지 정보를 ebook_collection 에 기록한다.
정보 | 필드 예시 | 용도 원문 | content | 검색 후 모델이 읽도록 전달 벡터 | vector | 질문 벡터와 유사도 비교 메타데이터 | id , book_id , chapter_num , index | 조각 식별 및 위치 지정
조회 측은 Milvus.fromExistingCollection() 을 사용하여 기존 컬렉션에 연결한다. 필드 매핑은 라이브러리 구축 스크립트와 일치해야 한다.
const vectorStore = await Milvus . fromExistingCollection (embeddings, { collectionName : "ebook_collection" , url : "localhost:19530" , textField : "content" , primaryField : "id" , vectorField : "vector" , });
사용자가 "아주의 결말은 무엇인가"라고 묻는다고 가정하면, 온라인 질의응답은 두 개의 업무 함수로 정리할 수 있다. retrieve 는 자료를 찾고, generate 는 답변을 작성한다. 먼저 이것들을 명확히 작성해 두면, 나중에 그래프로 마이그레이션할 때 그대로 재사용할 수 있다.
아래의 검색 함수는 기존 vectorStore 를 사용한다. similaritySearchWithScore() 는 설정된 Embedding 컴포넌트를 통해 질문 벡터를 생성한 뒤 데이터베이스를 조회하여 문서와 점수를 반환한다.
async function retrieve ( question, k ) { const matches = await vectorStore. similaritySearchWithScore (question, k); return matches. map ( ( [doc, score] ) => ({ document : doc. pageContent , score, chapter_num : doc. metadata ?. chapter_num ?? "알 수 없음" , })); }
여기서 k=5 는 최대 5개의 유사 조각을 가져온다는 뜻이며, 정답 5개를 찾는다는 뜻이 아니다. 검색 결과가 답변을 뒷받침할 수 있는지는 조각 내용과 결합하여 판단해야 한다. 유사도 점수도 답변 정확도가 아니다.
생성 함수는 질문과 검색 결과를 받아 Prompt를 구성하고, 프로젝트의 모델 스트리밍 출력을 그대로 따른다.
async function generate ( question, documents ) { if (documents. length === 0 ) { return "지식베이스에서 답변에 사용할 조각을 찾지 못했습니다." ; } const context = documents . map ( ( doc, i ) => `[조각 ${i + 1 } ] 챕터: ${doc.chapter_num} \n ${doc. document } ` ) . join ("\n\n"); const prompt = `다음 《천룡팔부》 조각을 근거로 질문에 답하세요. 자료가 부족하면 조각에서 확인할 수 없다고 설명하고, 뒷받침되지 않은 줄거리를 보충하지 마세요. 근거를 인용할 때는 조각 번호를 표시하세요. 자료: ${context} 질문: ${question} ` ; let generation = "" ; const stream = await model. stream (prompt); for await ( const chunk of stream) { if ( typeof chunk. content === "string" ) { generation += chunk. content ; process. stdout . write (chunk. content ); } } return generation; }
이 교육용 버전은 모델을 호출하기 전에 빈 결과를 처리한다. 원본 코드는 전체 그래프 실행이 끝난 뒤에야 documents 를 검사하는데, 그 시점에는 생성 노드가 이미 실행된 뒤라 검사가 그 이전의 모델 호출을 막을 수 없다.
자료, 질문, 답변 제약이 함께 Prompt를 구성한다. "검색 증강"은 바로 여기서 일어난다. 데이터베이스에서 찾아낸 원문을 모델의 이번 입력에 추가하는 것이다. 프롬프트가 조각 인용을 요구하는 것은 출처 확인에 도움이 되지만, 그래도 답변이 실제로 조각에 의해 뒷받침되는지는 점검해야 한다.
이 두 함수가 있으면 평범한 LangChain RAG 흐름은 매우 직관적이다.
async function answerWithLangChain ( question, k = 5 ) { const documents = await retrieve (question, k); const generation = await generate (question, documents); return { question, k, documents, generation }; } const result = await answerWithLangChain ( "아주의 결말은 무엇인가?" );
이 코드를 읽을 때는 변수를 따라가기만 하면 된다. question 이 검색으로 들어가고, 검색이 documents 를 얻고, 자료가 생성으로 들어가고, 생성이 generation 을 얻는다. 실행 순서는 함수 내부의 두 번의 await 가 결정한다.
고정된 "한 번 검색, 한 번 생성"은 계속 이런 방식으로 쓸 수 있다. LangGraph를 도입하는 것은 실행 단계, 데이터 상태, 연결 관계를 명시적으로 표현하여 이후 확장과 관찰을 편하게 하기 위해서다. LangChain 자체도 체인과 분기를 조합할 수 있다. 여기서 LangGraph를 선택한 것은 이 시리즈의 흐름 편성 방식을 통일하기 위해서다.
마이그레이션할 때 먼저 세 가지 개념을 기억하자.
개념 | 이 예에서의 의미 | 대응하는 원래 흐름 State(상태) | 이번 질의응답의 데이터 | 질문, 검색 개수, 문서, 답변 Node(노드) | 상태를 읽고, 작업을 수행하고, 갱신을 반환하는 함수 | 검색 함수, 생성 함수 Edge(엣지) | 노드 간의 실행 순서 | 먼저 검색, 그다음 생성
LangGraph 노드는 현재 상태를 받은 뒤 갱신이 필요한 필드를 반환한다. 노드 내부에서는 여전히 LangChain의 모델과 검색 컴포넌트를 호출할 수 있다. LangGraph 공식 설명
먼저 방금의 네 변수를 그래프 상태로 정의한다.
import { Annotation , StateGraph , START , END } from "@langchain/langgraph" ; const GraphState = Annotation . Root ({ question : Annotation ({ default : () => "" , reducer : ( _prev, next ) => next, }), k : Annotation ({ default : () => 5 , reducer : ( _prev, next ) => next, }), documents : Annotation ({ default : () => [], reducer : ( _prev, next ) => next, }), generation : Annotation ({ default : () => "" , reducer : ( _prev, next ) => next, }), });
default 는 초기값을 제공하고, reducer 는 기존 값과 노드 갱신을 어떻게 병합할지 결정한다. 이 예의 (_prev, next) => next 는 새 값으로 기존 값을 덮어쓴다. 예를 들어 검색 노드가 { documents: [...] } 를 반환하면 문서 필드만 갱신되고, 질문과 검색 개수는 그대로 남는다.
여기서 State 는 한 번의 그래프 실행에서 데이터 전달에 사용된다. 현재 코드에는 checkpointer 가 설정되어 있지 않으므로, 이를 디스크에 자동 저장되는 장기 기억으로 이해해서는 안 되며, 재시작 후 작업을 복구하는 능력도 자동으로 얻지 못한다.
다음으로, 이미 있는 두 함수에 노드 래퍼를 추가한다.
const retrieveNode = async ( state ) => { const documents = await retrieve (state. question , state. k ); return { documents }; }; const generateNode = async ( state ) => { const generation = await generate (state. question , state. documents ); return { generation }; };
일반 함수는 매개변수와 반환값으로 데이터를 전달하고, 노드는 state 를 통해 입력을 읽고 상태 갱신 객체를 반환한다. 원래 프로젝트의 노드는 변경되지 않은 question , k 등의 필드도 함께 반환하는데, 여기서는 각 노드가 실제로 무엇을 쓰는지 쉽게 보기 위해 그것들을 생략했다.
마지막으로, 노드를 등록하고 실행 순서를 연결한다.
const graph = new StateGraph ( GraphState ) . addNode ( "retrieve" , retrieveNode) . addNode ( "generate" , generateNode) . addEdge ( START , "retrieve" ) . addEdge ( "retrieve" , "generate" ) . addEdge ( "generate" , END ) . compile (); const result = await graph. invoke ({ question : "아주의 결말은 무엇인가?" , k : 5 , });
addNode 는 작업 단계를 등록하고, addEdge 는 다음 단계를 규정하며, compile() 은 실행 가능한 그래프를 얻고, invoke() 는 상태를 전달해 실행한다. 위에서 생략한 documents 와 generation 은 기본값을 사용한다.
프로젝트의 a.md 는 바로 이 기본 그래프에 대응한다:
flowchart LR S([START]) --> R[retrieve] R --> G[generate] G --> E([END])
여전히 "아주의 결말은 무엇인가"를 예로 들면, 상태는 다음과 같은 변화를 겪는다. 표의 문서와 답변은 데이터 형태를 보여주는 예시일 뿐이다:
실행 위치 question k documents generation 초기 입력 아주의 결말은 무엇인가? 5 빈 배열 빈 문자열 검색 완료 유지 유지 검색된 조각 및 점수 빈 문자열 생성 완료 유지 유지 유지 조각에 따라 생성된 답변
최종적으로 result.documents 는 증거를 확인하는 데 사용할 수 있고, result.generation 은 완전한 답변이다. 예시는 생성 시 이미 블록 단위로 답변을 출력하므로, 다시 완전한 generation 을 출력하면 터미널에 중복 내용이 나타난다. 검색 로그만 출력하고 필요에 따라 최종 답변을 읽어도 된다.
원래 코드의 model.stream() 은 모델 텍스트의 스트리밍 출력을 나타낸다. 바깥에서 graph.invoke() 를 사용하더라도 노드 내부에서는 여전히 블록 단위로 출력할 수 있다. 이는 노드 단위로 그래프 상태 변화를 관찰하는 것과는 다른 차원이므로, 스트리밍 텍스트를 보았다고 해서 모든 그래프 상태 또한 호출 측에 점진적으로 출력되었다고 생각해서는 안 된다.
마이그레이션 전후의 대응 관계는 복습용으로 남겨둘 수 있다:
복습 포인트 LangChain 컴포넌트 직접 연결 LangGraph 로 오케스트레이션 사용 흐름 진입점 answerWithLangChain(question) graph.invoke({ question }) 중간 데이터 함수 지역 변수 GraphState 의 필드 검색 구현 retrieve(question, k) retrieveNode 가 동일 함수 호출 생성 구현 generate(question, documents) generateNode 가 동일 함수 호출 실행 순서 함수 본문의 await 순서 addEdge() 로 정의된 연결 데이터 기반 EPUB, Embedding, Milvus 계속 재사용
실행 전에 코드 안의 몇 가지 설정도 확인해야 한다. 이 설정들은 RAG 가 정상적으로 동작할 수 있는지에 영향을 주며, 그래프 오케스트레이션을 사용하는지와는 무관하다.
- 라이브러리 구축 시의 벡터 차원은 1024 다. 조회 측은 동일한 Embedding 모델과 호환 설정을 사용해야 하며, 실제 출력 역시 1024 차원인지 확인해야 한다. 서비스가 dimensions 매개변수를 지원하면 양쪽을 통일해 설정하고, 서로 다른 모델을 같은 차원으로 맞추는 것만으로는 벡터 의미 공간이 일치하게 되지 않는다.
- 라이브러리 구축 스크립트는 IVF_FLAT 인덱스를 생성하지만, 조회 코드에는 HNSW 와 ef 설정이 나타난다. Milvus 컬렉션의 실제 인덱스를 기준으로 삼아 검색 매개변수를 통일해 대응해야 하며, 기존 컬렉션에 연결했다고 해서 인덱스 전환이 완료되었다고 생각해서는 안 된다.
- 원래 검색 함수는 모든 예외를 포착한 뒤 빈 배열을 반환하여, 데이터베이스 장애를 결과 없음으로 처리한다. 명확히 오류를 내야 하며, "검색 실패"와 "검색 성공이지만 비어 있음"을 구분할 수 있어야 한다.
- book_id 는 컬렉션에서 VarChar 로 정의되었지만, 쓰기 스크립트는 숫자 1 을 사용하므로 문자열로 통일해야 한다. chapter_num 은 로드된 문서 순서에서 오며, 공식적으로 장 출처를 표시할 때는 EPUB 목차 정보도 확인해야 한다.
advanced-rag 디렉터리에서 실행하면 EPUB 상대 경로를 코드 안의 약속과 일치시킬 수 있다:
cd D:\workspace\ysh_ai\ai\agent\agentic_rag\advanced-rag npm install
프로젝트 루트 디렉터리의 .env 는 다음 변수들을 사용한다:
OPENAI_API_KEY=당신의 키 OPENAI_BASE_URL=모델 서비스 주소 MODEL_NAME=대화 모델 이름 EMBEDDINGS_MODEL_NAME=벡터 모델 이름
Milvus 를 시작하고 설정을 확인한 뒤, 처음 자료를 임포트할 때 라이브러리 구축 스크립트를 실행하고, 이어서 질의응답 스크립트를 실행한다:
node .\src\ebook-write.mjs node .\src\naive-rag.mjs
이미 사용 가능한 컬렉션이 있다면 바로 질의응답 스크립트를 실행하면 된다. 라이브러리 구축 스크립트는 아직 완전한 중복 임포트 관리를 제공하지 않으므로, 라이브러리 구축을 매번 질의응답 전에 반드시 해야 하는 단계로 여겨서는 안 된다. 위 코드와 흐름은 소스 파일에 근거해 정리한 것이며, 실제로 모델이나 데이터베이스를 호출해 검증하지는 않았다.
나중에 복습할 때는 세 가지 질문으로 숙지 여부를 점검할 수 있다: documents 는 누가 기록하는가? 생성 노드는 왜 다시 검색할 필요가 없는가? 실행 순서를 조정한다면, 비즈니스 함수를 수정해야 하는가, 아니면 그래프의 엣지를 수정해야 하는가? 답은 각각 검색 노드, 공유 상태가 이미 결과를 보존했기 때문, 그리고 엣지의 연결 관계를 조정하는 것이다.
이번 글에서는 "질문 → 검색 → 생성"을 고정 상태 그래프로 정리했다. 다음 글에서는 이 지식베이스, 필드, 노드를 그대로 이어 사용하면서, 실행 중인 정보에 따라 다음 단계를 어떻게 선택할지 다시 논의하여, 시리즈의 아키텍처 진화에 명확한 출발점을 마련한다.