https://ehdnsdlek.tistory.com/87
소스코드 분석부터 RAG까지, 사내 문서 검색 시스템 구축하기 (1)
API를 연동해야 하는데 문서가 없다. Swagger는 있지만 필요한 정보가 빠져있고, API 명세와 실제 동작이 다른 경우도 많다. 담당자가 바뀌면서 "이 API가 왜 이렇게 동작하는지" 아는 사람도 없다. 결
ehdnsdlek.tistory.com
1편에서 전체 아키텍처를 설계했다. 이번에는 실제 구현 내용을 다루어보겠다. MDX 문서를 어떻게 쪼개고, BM25로 어떻게 점수를 매기고, RAG 챗봇이 어떻게 문서를 찾아서 답변하는지를 코드와 함께 설명한다.
전체 흐름은 두 개의 파이프라인으로 나뉜다. 오프라인에서 인덱스를 만드는 파이프라인과, 런타임에서 검색하는 파이프라인이다.

1. 인덱싱 파이프라인
npm run reindex를 실행하면 MDX 문서를 읽어서 검색 인덱스를 만든다.
(1) MDX 파일 수집 + frontmatter 파싱
MDX 파일 상단에는 YAML 형식의 메타데이터가 있다.
---
title: 주문 취소
category: endpoint
platform: [mobile]
tags: [주문, 취소, API]
related: [order/register.mdx]
---
gray-matter 라이브러리로 이 frontmatter를 파싱하고, 본문과 같이 분리한다.
import matter from "gray-matter";
const raw = fs.readFileSync(filePath, "utf-8");
const { data: fm, content: body } = matter(raw);
// fm = { title: "주문 취소", category: "endpoint", ... }
// body = 본문 마크다운 텍스트
이 메타데이터가 나중에 검색 필터로 쓰인다. "entpoint 카테고리에서만 검색해라" 같은 식으로.
(2) 조건부 청킹
모든 문서를 같은 방식으로 쪼개지 않는다. 문서 유형에 따라 전략이 다르다.

const CHUNK_CHAR_THRESHOLD = 3000;
const CATEGORIES_ALWAYS_WHOLE = new Set(["endpoint", "domain-hub", "glossary"]);
const shouldSplitByH2 =
!CATEGORIES_ALWAYS_WHOLE.has(category) && // endpoint 등은 항상 통째로
cleanBody.length > CHUNK_CHAR_THRESHOLD; // 3000자 이상만 분할
- endpoint 문서 : 하나의 API를 설명하는 완결된 문서. 쪼개면 문맥이 손실된다. 통째로 1청크
- flow, data-mapping 등 긴 문서 : 수천자의 문서를 통쨰로 넣으면 BM25에서 노이즈가 생긴다. H2(##) 기준으로 논리 단위로 분할한다.
H2 기준으로 분할하는 이유는, 대부분의 기술 문서가 H2 단위로 하나의 토픽을 다루기 때문이다. 코드블록이나 테이블 중간에서 자르지 않도록 H2 경계에서만 분할한다.
원본 문서 (3500자, category: flow)
├── ## 주문 생성 요청 → 청크 1
├── ## 결제 처리 → 청크 2
└── ## 주문 확정 → 청크 3
원본 문서 (800자, category: endpoint)
└── 통째로 1청크
최종적으로 약 500개 문서에서 약 1,500개 청크가 생성된다.
(3) 토큰화
BM25 스코어링을 하려면 텍스트를 토큰(단어) 단위로 쪼개야 한다. 한국어는 형태소 분석기를 쓰는 게 이상적이지만, MVP에서는 공백 분리 + camelCase 분리로 시작했다.
export function tokenize(text: string): string[] {
const cleaned = text.replace(/[^\w가-힣\s/.-]/g, " ");
const raw = cleaned.split(/\s+/).filter((t) => t.length > 0);
const result: string[] = [];
for (const token of raw) {
// camelCase 분리: getOrderList → [getorderlist, get, order, list]
const parts = token
.replace(/([a-z])([A-Z])/g, "$1 $2")
.toLowerCase()
.split(/\s+/);
if (parts.length > 1) {
result.push(token.toLowerCase()); // 원본 합성 토큰 보존
result.push(...parts); // 분리된 서브워드 추가
} else {
result.push(parts[0]);
}
}
return result;
}
camelCase 분리가 중요한 이유는 API 메소드명이 getOrderList인 문서를 order로도 검색할 수 있기 때문이다. 원본 합성 토큰도 보존해서, 정확히 getOrderList로 검색하면 더 높은 점수를 받도록 했다.
토큰화 후에는 각 청크별로 term frequency(단어 빈도) 맵을 만든다.
function termFrequency(tokens: string[]): Record<string, number> {
const tf: Record<string, number> = {};
for (const t of tokens) {
tf[t] = (tf[t] ?? 0) + 1;
}
return tf;
// { "주문": 5, "취소": 3, "api": 2, ... }
}
(4) BM25 글로벌 통계
모든 청크의 토큰화가 끝나면, BM25 계산에 필요한 글로벌 통계를 만든다.
export function buildBM25Index(chunks: Chunk[]): BM25Index {
const df: Record<string, number> = {};
let totalLength = 0;
for (const chunk of chunks) {
const docLen = Object.values(chunk.terms).reduce((a, b) => a + b, 0);
totalLength += docLen;
for (const term of Object.keys(chunk.terms)) {
df[term] = (df[term] ?? 0) + 1; // 이 단어가 몇 개 청크에 등장하는지
}
}
return {
avgDl: totalLength / chunks.length, // 평균 청크 길이
df, // 단어별 등장 청크 수
};
}
두 가지 값이 핵심이다.
- df (document frequency) : 주문이라는 단어가 1,500개 청크 중 몇 개에 등장하는지. 많은 청크에 나오는 단어일수록 변별력이 낮다.
- avgDl (average document length) : 전체 청크의 평균 토큰 수. 짧은 청크에서의 매칭이 긴 청크에서의 매칭보다 유의미하도록 정규화하는데 쓰인다.
최종 산출물은 search-index.json 하나이다.
{
buildAt: "2026-05-20T...",
docCount: 488, // 약 500
chunkCount: 1494, // 약 1,500
documents: [...], // 문서 메타데이터 + 원본 전문
chunks: [...], // 청크 (본문 + term frequency)
bm25: { avgDl, df } // 글로벌 통계
}
2. 검색 파이프라인
사용자가 검색어를 입력하면 실행되는 런타임 파이프라인이다.

(1) 쿼리 라우팅
주문 취소 API라는 쿼리가 들어오면, BM25 스코어링 전에 먼저 검색 범위를 좁힌다.
const CATEGORY_HINTS: [RegExp, string][] = [
[/(엔드포인트|\bendpoint\b)/i, "endpoint"],
[/\S+\s+API\b/i, "endpoint"], // "주문 취소 API" → endpoint
[/(흐름|\bflow\b|플로우|시퀀스)/i, "flow"],
[/(매핑|\bmapping\b|변환)/i, "data-mapping"],
// ...
];
export function routeQuery(query: string): RouteResult {
return {
category: firstMatch(query, CATEGORY_HINTS), // "endpoint"
domain: firstMatch(query, DOMAIN_HINTS), // "order"
platform: firstMatch(query, PLATFORM_HINTS), // undefined
};
}
쿼리에서 정규식으로 키워드를 추출해서 메타데이터 필터를 자동 생성한다.
| 쿼리 | 추출결과 |
| 주문 취소 API | category : endpoint, domain : order |
| 주문 흐름 | category : flow, domain : order |
| 관리자 권한 관리 | platform : admin |
왜 이게 필요한가? BM25는 키워드 매칭이라 "주문 취소 API"를 검색하면 flow 문서도 높은 점수를 받을 수 있다. 라우팅으로 "API라고 했으니 endpoint 문서에만 찾아라"라고 범위를 좁혀주는 것이다.
(2) 메타데이터 필터링 + 점진적 완화
라우팅 결과로 청크를 필터링한다.
let candidates = this.index.chunks; // 약 1,500개 전체
if (category) candidates = candidates.filter(c => c.category === category);
if (platform) candidates = candidates.filter(c => c.platform.includes(platform));
if (domain) candidates = candidates.filter(c => c.domain === domain);
문제는 필터가 너무 빡빡하면 후보가 0이 될 수 있다는 것이다. 그래서 점진적 완화 전략을 쓴다.
1차: category + platform + domain → 결과 없음
2차: domain 제거, category + platform만 → 결과 없음
3차: 전체 청크 대상으로 폴백
결과가 아예 없는 것보다 느슨한 결과라도 보여주는 게 낫다.
(3) BM25 스코어링
필터링된 후보 청크들에 대해 BM25 점수를 계산한다. BM25의 핵심은 한 줄로 요약된다.
검색어가 이 청크에 많이 나오면(TF↑) 점수를 올리고, 전체적으로 흔한 단어면(IDF↓) 점수를 깎고, 청크가 길면 정규화한다.
export function scoreBM25(
queryTerms: string[],
chunk: Chunk,
index: BM25Index,
totalDocs: number,
): number {
const { avgDl, df } = index;
const docLen = Object.values(chunk.terms).reduce((a, b) => a + b, 0);
let score = 0;
for (const term of queryTerms) {
const termDf = df[term] ?? 0;
if (termDf === 0) continue; // 인덱스에 없는 단어 → 스킵
const tf = chunk.terms[term] ?? 0;
if (tf === 0) continue; // 이 청크에 없는 단어 → 스킵
// IDF: 많은 청크에 등장하는 단어일수록 낮은 점수
const idf = Math.log((totalDocs - termDf + 0.5) / (termDf + 0.5) + 1);
// TF 정규화: 문서 길이를 고려한 빈도 보정
const tfNorm = (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * (docLen / avgDl)));
score += idf * tfNorm;
}
return score;
}
K1 = 1.2, B = 0.75는 BM25의 표준 파라미터이다. K1은 term frequency의 포화 속도를 조절하고, B는 문서 길이 정규화의 강도를 조절한다. 대부분의 경우 이 기본값으로 충분하다.
예를 들어 "주문 취소"를 검색하면
- "주문"이 300개 청크에 등장 -> IDF 낮음 (흔한 단어)
- "취소"가 50개 청크에 등장 -> IDF 높음 (변별력 있는 단어)
- "취소"가 많이 나오는 청크가 높은 점수를 받는다.
(4) 중복 제거 + Top-K
하나의 문서가 여러 청크로 분할된 경우, 같은 문서의 청크가 Top-K를 독점할 수 있다. 문서 단위로 최고 점수 청크 하나만 남긴다.
scored.sort((a, b) => b.score - a.score);
const seen = new Set<string>();
const deduped: SearchResult[] = [];
for (const r of scored) {
if (!seen.has(r.docPath)) {
seen.add(r.docPath);
deduped.push(r);
}
if (deduped.length >= limit) break;
}
이렇게 해서 다양한 문서가 결과에 나오도록 한다.
3. RAG 챗봇
RAG는 Retrieval-Augmented Generation의 약자이다. 검색(Retrieval)으로 관련 문서를 찾고, 그 문서를 AI 프롬프트에 첨부해서(Augmented), 문서 기반으로 답변을 생성(Generation)하는 구조이다.
왜 RAG가 필요한가? AI에게 그냥 질문하면 학습 데이터에 있는 일반 지식으로 답변한다. 사내 API 문서는 학습 데이터에 없다. RAG를 쓰면 "이 문서를 읽고 답변해라"라고 컨텍스트를 주입할 수 있다.

(1) 검색 (Retrieval)
사용자의 마지막 메세지로 BM25 검색을 실행해서 관련 문서 TOP 5를 찾는다.
const lastUserMsg = [...messages].reverse().find(m => m.role === "user");
const results = searcher.search(lastUserMsg.content, { limit: 5 });
여기서 중요한 결정이 있다. 검색 결과의 스니펫이 아니라 문서 전문을 GPT에게 넘긴다.
function buildContext(searcher: Searcher, query: string): string {
const results = searcher.search(query, { limit: 5 });
return results.map((r, i) => {
const doc = searcher.getDocument(r.docPath);
const content = doc?.fullContent ?? r.snippet; // 문서 전문 사용
const url = docPathToUrl(r.docPath);
return `--- 문서 ${i + 1}: ${r.title} ---\nurl: ${url}\n\n${content}`;
}).join("\n\n");
}
검색은 "어떤 문서가 관련 있는지" 찾는 용도이고, 실제 답변은 GPT가 문서 전문을 읽고 만든다. 스니펫만 넘기면 맥락이 부족해서 답변 품질이 떨어진다.
(2) 생성 (Generation)
시스템 프롬프트에 검색된 문서를 붙이고, GPT-4o-mini에 전달한다.
const SYSTEM_PROMPT = `당신은 사내 문서를 기반으로 질문에 답변하는 도우미입니다.
규칙:
- 아래 제공된 문서 내용만을 기반으로 답변하세요.
- 문서에 없는 내용은 "문서에서 해당 내용을 찾을 수 없습니다"라고 답변하세요.
- 답변 마지막에 출처 문서를 마크다운 링크로 표기하세요.`;
const stream = await openai.chat.completions.create({
model: "gpt-4o-mini",
stream: true,
messages: [
{
role: "system",
content: `${SYSTEM_PROMPT}\n\n## 참고 문서\n\n${context}`
// ↑ 규칙 ↑ 검색된 5개 문서 전문
},
...messages, // 사용자 대화 히스토리 전체
],
});
두 가지 핵심 규칙이 있다.
1. 제공된 문서 기반으로 답변하라
-> 이게 hallucination 방지 장치이다. AI가 자기 학습 데이터에서 지어낸 답변이 아니라, 실제 문서에 있는 내용만 답변하게 한다.
2. 출처를 표기하라
-> 답변의 근거를 확인할 수 있게 된다. 틀린 답변이 나와도 출처를 따라가면 원본을 확인할 수 있다.
(3) SSE 스트리밍
응답은 SSE(Server-Sent Events)로 실시간 스트리밍한다. 사용자 답변이 글자 단위로 생성되는 걸 볼 수 있다.
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
}
res.write("data: [DONE]\n\n");
res.end();
프로토콜은 단순하다.
→ POST /chat { messages: [{role: "user", content: "주문 취소 API 알려줘"}] }
← data: {"content": "주문 취소는 "}\n\n
← data: {"content": "다음과 같이 "}\n\n
← data: {"content": "처리됩니다..."}\n\n
← data: [DONE]\n\n
(4) 전체 흐름 정리
검색 엔진(2단계)은 웹 검색에서도 똑같이 쓰인다. RAG는 여기에 GPT 호출(4~5단계)을 얹은 것이다.

다음 편에서는 MCP 서버를 다룬다. 사람이 아니라 AI가 직접 문서를 조회하는 구조이다. RAG와 MCP의 차이, 그리고 이 프로젝트의 다음 단계에 대해 정리한다.
'AI' 카테고리의 다른 글
| BM25 알고리즘 / 검색 엔진은 점수를 어떻게 매길까? (0) | 2026.06.17 |
|---|---|
| 소스코드 분석부터 RAG까지, 사내 문서 검색 시스템 구축하기 (3) (0) | 2026.06.16 |
| 소스코드 분석부터 RAG까지, 사내 문서 검색 시스템 구축하기 (1) (1) | 2026.06.15 |
| Claude Code Skill — 반복되는 맥락 설명에서 벗어나는 법 (0) | 2026.02.06 |
| 2026 Agentic Coding Trends Report 리뷰 (1) | 2026.02.02 |