
문맥 인식 ServiceNow: RAG를 통해 적시에 정확한 데이터 제공하기
요약
LangChain과 ChromaDB 같은 고수준 프레임워크 대신, 저수준(low-level) API와 표준 데이터베이스를 사용하여 RAG 아키텍처를 직접 구현하는 방법을 다룹니다. 이를 통해 RAG의 핵심 메커니즘을 깊이 이해하고 ServiceNow 환경에 최적화된 가벼운 시스템을 구축하는 과정을 설명합니다.
핵심 포인트
- 고수준 프레임워크 없이 RAG의 핵심 메커니즘을 직접 구현하여 아키텍처 이해도 향상
- 데이터 수집, 청킹, 임베딩, 저장, 인덱싱, 검색, 생성의 7단계 프로세스 정의
- ServiceNow 환경에서 커스텀 테이블을 활용한 벡터 저장 및 HNSW 인덱싱 구현 방식 제시
- 비대해진 기술 스택 없이도 효과적인 RAG 시스템 구축이 가능함을 증명
최근 저는 Python을 사용하여 검색 증강 생성 (Retrieval-Augmented Generation, RAG)을 깊이 있게 탐구하고 있으며, 그 잠재력은 정말 놀랍습니다. 1,000페이지에 달하는 문서에 토큰을 낭비하거나 사용자가 끝없는 검색 결과 속을 뒤지게 만들지 않고도, LLM (Large Language Model)에 정확하고 문맥적인 정보를 제공하여 정밀한 답변을 생성할 수 있다는 아이디어는 혁신적입니다.
자연스럽게 저의 여정은 LangChain 및 ChromaDB와 같은 인기 있는 추상화 도구들을 사용하여 프로토타입을 구축하는 것으로 시작되었습니다. 작동은 했지만, 한 가지 의문이 생겼습니다. '내부적으로 실제로 어떤 일이 일어나고 있는 걸까?' 상위 수준의 프레임워크는 속도 측면에서는 훌륭하지만, 종종 저수준의 복잡성을 가리고 아키텍처를 특정 생태계에 강하게 결합(tightly coupled)되게 만듭니다. 메커니즘을 진정으로 이해하기 위해, 저는 LangChain과 ChromaDB를 완전히 버리기로 결심했습니다. 저는 기본으로 돌아갔습니다. API 호출을 수동으로 처리하고, 표준 데이터베이스에서 데이터 저장을 관리하며, 직접 검색 쿼리(retrieval queries)를 작성했습니다.
그리고 결과는 어땠을까요? 잘 작동했습니다.
마법 같은 요소들을 걷어내자 RAG 아키텍처에 대해 훨씬 더 깊은 이해를 할 수 있었고, 매우 효과적인 무언가를 구축하기 위해 비대해진 기술 스택(tech stack)이 반드시 필요한 것은 아니라는 점을 증명했습니다. 핵심 메커니즘을 확실히 파악한 후, 저는 이 가볍고 저수준인(low-level) 접근 방식을 ServiceNow 생태계로 직접 이식하기로 결정했습니다. 어떻게 작동하는지 바로 살펴보겠습니다.
일반 개요:
아키텍처를 핵심 구성 요소로 나누어 살펴보겠습니다:
-
데이터 수집 (Data Ingestion): 원시 데이터를 일반 텍스트로 추출합니다 (예: PDF 파싱).
-
텍스트 청킹 (Text Chunking): 문맥 제한 (context limits)을 유지하기 위해 텍스트를 작고 소화하기 쉬운 세그먼트로 나눕니다.
-
벡터 임베딩 (Vector Embeddings): 각 텍스트 청크를 고차원 벡터 배열(32비트 부동 소수점으로 표현됨)로 변환합니다.
-
저장 (Storage): 원시 텍스트 청크와 그에 상응하는 부동 소수점 배열을 모두 표준 데이터베이스에 저장합니다.
Python에서는sqlite를 사용하고, ServiceNow에서는 두 개의 커스텀 테이블인u_embeddings와u_hnsw_relations를 사용합니다. -
커스텀 인덱싱 (Custom Indexing): 전용 벡터 데이터베이스를 사용하지 않으므로, 최적화된 빠른 벡터 검색을 위해 처음부터 다층 그래프(HNSW 인덱스와 같은 형태)를 구축해야 합니다.
-
유사도 검색 (Similarity Search): 사용자의 쿼리 임베딩과 저장된 청크 사이의 벡터 거리를 계산하여 가장 관련성이 높은 상위 K(top-K)개의 결과를 추출합니다.
-
LLM 생성 (LLM Generation): 추출된 상위 K개의 청크를 시스템 프롬프트 및 사용자의 원래 쿼리와 함께 컨텍스트 (context)로 LLM에 전달하여 최종 답변을 합성합니다.
모든 과정을 펼쳐놓고 보니 마치 힘겨운 싸움처럼 들리는데, 솔직히 말해서 실제로도 그렇습니다. 하지만 처음부터 직접 구축하는 것이야말로 진짜 재미가 시작되는 지점입니다. 이제 코드로 들어가 보겠습니다.
데이터 수집 (Data Ingestion):
파이프라인의 첫 번째 단계는 데이터 수집 (Data Ingestion)입니다. 저의 Python 프로토타입은 정적 PDF 파일을 파싱하기 위해 pypdf와 같은 라이브러리에 의존했지만, ServiceNow 아키텍처는 동적이고 플랫폼 네이티브한 데이터 소스로 전환됩니다. PDF 대신, 우리는 표준 GlideRecord 쿼리를 활용하여 원시 텍스트 필드를 프로그래밍 방식으로 추출함으로써 지식 문서 (Knowledge Articles, kb_knowledge)로부터 데이터를 직접 수집할 것입니다.
Python:
reader = pypdf.PdfReader(file_path)
text = ""
for page in reader.pages:
...
ServiceNow:
var kbGr = new GlideRecord("kb_knowledge");
kbGr.addEncodedQuery("workflow_state=published");
kbGr.query();
...
이제 원문 텍스트(raw text)를 추출했으므로, 다음 단계는 텍스트 청킹 (Text Chunking)입니다. 우리는 LLM (Large Language Model)이 경계 너머에서도 의미론적 문맥 (semantic context)을 유지할 수 있도록 오버랩 (overlap)을 포함한 단어 기반 청킹 전략을 구현할 것입니다.
이것이 어떻게 작동하는지 간단한 예시를 통해 살펴보겠습니다. 다음과 같은 텍스트가 있다고 가정해 봅시다:
"ServiceNow is the best automation tool in the world"
만약 청크 크기 (chunk size)를 5로, 오버랩을 2로 설정한다면, 우리의 스크립트는 문자열을 각 요소가 정확히 다섯 개의 단어를 포함하는 배열로 자르게 되며, 특정 청크의 마지막 두 단어는 다음 청크의 첫 두 단어가 됩니다.
시각적으로, 이 슬라이딩 윈도우 (sliding window)는 다음과 같습니다:
[
"ServiceNow is the best automation",
"best automation tool in the",
...
Python:
words = re.findall(r"\b[\w'-]+\b", text)
text_len = len(words)
start = 0
...
ServiceNow:
if (!text) return [];
var words = text.match(/\b[\w'-]+\b/g) || [];
var textLen = words.length;
...
청크가 성공적으로 생성되면, 다음 단계는 벡터 임베딩 (Vector Embeddings)을 통해 해당 텍스트를 수학적 표현으로 변환하는 것입니다. 이 아키텍처에서는 HuggingFace의 all-MiniLM-L6-v2 텍스트 임베딩 모델을 활용할 것입니다.
여기서 핵심 메커니즘은 간단합니다. 문자열 청크 리스트를 임베딩 모델에 전달합니다. 그 결과로, 모델은 각 청크의 의미론적 의미를 고차원 벡터 공간 (high-dimensional vector space)으로 매핑합니다. 응답은 float32 값의 2D 배열이며, 각 행은 하나의 텍스트 청크가 밀집된 384차원 벡터 임베딩 (dense 384-dimensional vector embedding, 즉 float32 숫자 배열)으로 변환된 것을 나타냅니다.
Python:
embed_api_url = f"https://router.huggingface.co/hf-inference/models/sentence-transformers/all-MiniLM-L6-v2/pipeline/feature-extraction"
headers = {"Authorization": f"Bearer {os.environ['HF_TOKEN']}"}
response = requests.post(
...
ServiceNow:
var provider = new sn_cc.StandardCredentialsProvider();
var credential = provider.getCredentialByAliasID("fa0f862d93950710c987fc532bba1010");
...
이 시점에서 우리는 인제스션 파이프라인 (ingestion pipeline)을 위한 모든 구성 요소를 갖추었습니다. 하지만 해결해야 할 거대한 난관이 있습니다. 바로 ServiceNow는 벡터 데이터베이스 (vector database)가 아니라는 점입니다.
제3자 통합 (third-party integrations)에 의존하지 않고 이를 작동시키려면, 플랫폼의 네이티브 아키텍처 (native architecture) 내에 벡터 인덱싱 (vector indexing) 기능을 직접 구축해야 합니다. 여기서 계층적 탐색 가능 소세계 (Hierarchical Navigable Small World, HNSW) 알고리즘이 등장합니다. HNSW는 근사 최근접 이웃 (approximate nearest neighbor, ANN) 검색의 골드 표준이며, 이 다층 그래프 프레임워크 (multi-layered graph framework)를 처음부터 구축하는 것이 바로 ServiceNow를 지능형 검색 엔진으로 변모시키는 방법입니다.
HNSW 알고리즘을 시작하기 전에, 이러한 청크 (chunks)와 벡터 임베딩 (vector embeddings)을 저장하는 방법에 대해 논의해 보겠습니다. Python에서는 다음과 같은 스키마 (schema)를 사용하여 SQLite를 사용할 것입니다:
id INTEGER PRIMARY KEY AUTOINCREMENT,
chunk_text TEXT,
embedding_blob BLOB
구축 단계의 부하를 가볍게 유지하기 위해, 우리는 거대한 원시 벡터 배열 (raw vector arrays)을 계속 들고 다니는 대신 노드 id만을 포인터로 사용하여 메모리 내에 HNSW 그래프를 구축합니다. 인제스션 파이프라인이 완료되면, 최종 확정된 그래프 토폴로지 (graph topology)를 직렬화하여 로컬 JSON 파일로 덤프 (dump)합니다. 검색 쿼리 (search query)가 시작되면, 이 JSON 인덱스를 다시 메모리로 로드하기만 하면 됩니다. 이를 통해 무거운 연산 오버헤드 (computational overhead) 없이 번개처럼 빠른 라우팅 (routing)이 가능해집니다.
이를 ServiceNow 플랫폼에 구현할 때는 전형적인 엔지니어링 제약 사항이 발생합니다. 바로 실행 메모리 (execution memory)와 프로세싱 리소스 (processing resources)가 엄격하게 제한된다는 점입니다. 거대하고 휘발적인 그래프를 메모리에 유지하는 것은 완전히 불가능합니다.
이에 적응하기 위해, 우리는 아키텍처를 인메모리 (in-memory) 구조에서 지속성 우선 (persistence-first) 설계로 전환해야 합니다. 텍스트 청크와 벡터 임베딩은 커스텀 u_embeddings 테이블에 깔끔하게 저장되는 반면, 전체 HNSW 그래프 토폴로지는 두 번째 커스텀 테이블인 u_hnsw_relations를 사용하여 관계형 스키마 (relational schema)로 오프로드 (offload)할 것입니다. 이 테이블은 다음과 같은 스키마로 구조화된 지속성 그래프 원장 (persistent graph ledger) 역할을 합니다:
Number: ServiceNow 자동 증가 ID
u_base_node: 현재 노드 ID
u_neighbour_node: 이웃 노드 ID
...
노드 A가 다음과 같은 이웃 노드 B, C, D, E를 가지고 있다고 가정해 봅시다.
우리는 u_hnsw_relations에 다음과 같이 상세 정보를 저장할 것입니다:
| Number | u_base_node | u_neighbour_node | layer |
|---|---|---|---|
| ID 1 | A | B | 0 |
| ... |
이런 방식으로 우리의 그래프는 테이블에 저장되며, GlideRecord를 사용하여 이를 탐색(traverse)할 수 있습니다.
그럼 이제 HNSW 알고리즘에 대해 깊이 파고들어 볼까요?
Hierarchical Navigable Small World (HNSW):
Hierarchical Navigable Small World (HNSW)는 빠른 근사 최근접 이웃 (Approximate Nearest Neighbor, ANN) 검색에 사용되는 최첨단 알고리즘입니다. 이 알고리즘은 고차원 데이터를 다층 그래프 (multi-layer graph) 구조로 구성하여, 검색 쿼리가 데이터셋의 큰 섹션들을 건너뛰고 가장 유사한 일치 항목을 빠르게 찾아낼 수 있도록 합니다.
-
다층 구조 (Multi-Layered Architecture): 데이터 벡터를 다층 그래프로 조직화하며, 이는 상위 레이어에는 적고 멀리 떨어진 노드들이 있고 최하위 레이어에는 모든 벡터가 포함되는 스킵 리스트 (skip-list)를 모방합니다.
-
진입점 (The Entry Point): 검색은 최상위 레이어의 지정된 진입 노드(entry node)에서 시작되며, 사용자의 쿼리 벡터와 가장 가까운 지점을 찾기 위해 이웃 노드들과의 거리를 계산합니다.
-
거친 라우팅 (Coarse Routing): 크고 빠른 도약을 통해 희소한(sparse) 상위 레이어들을 탐색함으로써 타겟 데이터 공간의 일반적인 영역을 빠르게 좁힙니다.
-
레이어 하강 (Dropping Down Layers): 로컬 최솟값(local minimum)에 도달하면 (해당 레이어에서 더 가까운 이웃을 찾을 수 없으면), 검색은 바로 아래 레이어의 해당 노드로 내려갑니다.
-
세밀한 탐색 (Fine-Grained Navigation): 이 줌인(zoom-in) 과정은 더 밀집된 레이어들을 거치며 반복되며, 더 작고 정밀한 단계로 검색 반경을 정교화합니다.
-
최하위 레이어 (The Ground Layer, Layer 0): 최하위 레이어에서는 고도로 클러스터링된 데이터 포인트들 사이에서 최종적인 로컬 검색을 수행하여 정확한 최근접 이웃을 찾아냅니다.
-
삽입 및 확률적 감쇠 (Insertion & Probabilistic Decay): 새로운 벡터는 확률적 감쇠 (probability decay) 공식을 사용하여 최대 레이어가 할당됩니다. 이를 통해 대부분의 벡터는 최하위에 위치하게 하고, 오직 소수만이 상위 수준의 진입 마커 역할을 하도록 보장합니다.
-
Heuristic Linkage: 노드를 배치할 때, 이들은 'M'개의 가장 가까운 이웃과 연결되어 빠른 전역 라우팅(global routing)과 조밀한 국소 클러스터링(local clustering)의 균형을 맞추어 "작은 세계(small world)" 효과를 유지합니다.
이 HNSW 구현을 위해서는 몇 가지 헬퍼 함수(helper functions)가 필요하므로, 먼저 이들부터 설명하겠습니다.
내적 (Dot Product):
내적(dot product), 또는 스칼라 곱(scalar product)이라고도 알려진 것은 두 개의 동일한 길이의 숫자 시퀀스를 받아 단일 스칼라 값을 반환하는 대수 연산입니다.
만약 $n$차원 벡터 $\mathbf{a}$와 $\mathbf{b}$가 있다면, 내적은 해당 구성 요소들의 곱을 모두 더한 값입니다:
$$\mathbf{a}\cdot\mathbf{b} = \sum_{i=1}^{n} a_i b_i = a_1 b_1 + a_2 b_2 + \cdots + a_n b_n$$
Python:
total_sum = 0
for i in range(len(vec_a)):
total_sum += vec_a[i] * vec_b[i]
...
ServiceNow:
let totalSum = 0;
let vecLen = Math.min(vecA.length, vecB.length);
for (i = 0; i < vecLen) {
...
벡터의 크기 (Magnitude of vector):
벡터의 크기는 그 길이 또는 크기를 의미합니다. 이는 벡터가 가리키는 방향에 관계없이, 벡터의 꼬리(시작점)부터 머리(종료점)까지의 총 거리를 나타냅니다.
크기는 길이를 나타내기 때문에 항상 스칼라 값(단일 숫자)이며 항상 비음수입니다.
구성 요소가 몇 개든 상관없이 벡터 $\mathbf{a} = [a_1, a_2, \dots, a_n]$에 대해 크기는 다음과 같습니다:
$$|\mathbf{a}| = \sqrt{a_1^2 + a_2^2 + \cdots + a_n^2}$$
Python:
return math.sqrt(sum(x**2 for x in vec))
ServiceNow:
return Math.sqrt(vec.reduce((sum, x) => sum + x ** 2, 0));
코사인 유사도 (Cosine Similarity):
코사인 유사도는 두 벡터가 얼마나 비슷한지 측정하는 지표로, 크기와는 무관합니다. 이는 다차원 공간에 투영된 두 벡터 사이의 각도의 코사인을 측정합니다.
두 점 사이의 거리(유클리드 거리 (Euclidean distance)와 같은 방식)를 측정하는 대신, 코사인 유사도 (Cosine Similarity)는 벡터가 가리키는 방향을 측정합니다.
코사인 유사도 (Cosine Similarity) = $\frac{a \cdot b}{|a||b|}$
Python:
mag_a = magnitude(vec_a)
mag_b = magnitude(vec_b)
...
ServiceNow:
let magA = this.magnitude(vecA);
let magB = this.magnitude(vecB);
...
코사인 거리 (Cosine Distance):
코사인 거리는 두 벡터 사이의 각도 불일치 또는 비유사성을 측정하는 지표입니다. 이는 코사인 유사도의 직접적인 보완 관계에 있습니다.
코사인 유사도가 두 벡터가 얼마나 유사한 방향을 가리키는지 알려준다면, 코사인 거리는 두 벡터가 얼마나 떨어져 있는지를 알려줍니다.
코사인 거리 (Cosine Distance) = 1 − 코사인 유사도 (Cosine Similarity)
Python:
return 1.0 - cosine_similarity(vec_a, vec_b)
ServiceNow:
return 1.0 - this.cosineSimilarity(vec_a, vec_b);
이제 모든 도우미 함수들을 갖추었으니, 메인 HNSW 클래스 설계를 시작해 보겠습니다.
우리의 HNSW 클래스는 다음과 같은 속성 (properties)을 가질 것입니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기