[AI-GENERATED] 이 문서는 AI가 작성한 것이며, 작성자는 사람이 아닙니다.
Redmine 위키·이슈와 여러 git 저장소를 한꺼번에 LLM 이 검색·인용할 수 있게 만든 인덱스 시스템 이야기. 이 글은 "검색이 뭐고, 왜 두 종류가 필요하고, 왜 합쳐야 하는지" 같은 개념 설명에 초점을 맞춘다. FTS·벡터·임베딩 같은 단어를 처음 듣는 사람도 끝까지 읽을 수 있게 썼다.
본문에 등장하는 제품명·함수명·이슈 번호 등은 설명을 위한 가상의 예시다.
1. 풀려는 문제
회사에 쌓인 자료는 두 가지 형태다.
- Redmine: 위키 페이지(수천 단위), 이슈와 댓글(수만 단위), 사람·프로젝트 목록.
- Git 저장소: C/C++/C#/Python 등으로 짠 제품 소스 코드.
LLM 에게 "결제 모듈의 중복 결제 방지 로직이 어떻게 동작하지?" 같은 질문을 던지면, LLM 은 해당 위키 페이지 + 관련 이슈 + 실제 코드 함수까지 같이 보면서 답해야 한다.
문제는 LLM 의 컨텍스트 창이 유한하다는 것. 위키 전체와 코드 전체를 그냥 던져넣을 수는 없다. LLM 이 보기 전에 사람이 읽을 수 있는 분량으로 잘 골라주는 모듈 이 필요하다. 이걸 보통 검색 인덱스(retrieval index) 라고 부른다.
2. 텍스트를 찾는 두 가지 방식
2.1 키워드 검색 (FTS)
가장 직관적인 방식. 사용자가 친 단어가 문서에 그대로 들어 있는지 본다.
"결제 인증 모듈" 이라고 치면, 이 세 단어를 모두(또는 일부) 포함한 문서를 점수 매겨 정렬해 돌려준다.
이걸 풀텍스트 검색(Full-Text Search, FTS) 이라고 한다. 우리가 쓰는 SQLite 에는
FTS5 라는 풀텍스트 검색 엔진이 내장돼 있어서, 별도 검색 서버를 띄우지 않아도 된다.
점수 계산은 BM25 라는 표준 알고리즘이 담당한다 — 흔한 단어는 점수를 낮게,
그 문서에서만 자주 나오는 단어는 점수를 높게 주는 식.
장점: 정확한 단어 매칭에 강하다. 이슈 번호 #1234, 함수 이름 verifyAuthToken,
헥스 값 0x90 같은 식별자는 글자 그대로 찾는 게 가장 정확하다.
단점: 표현이 달라지면 못 찾는다. 사용자가 "인증 모듈" 이라 쳤는데 문서엔 "사용자 검증 컴포넌트"라고 써있으면 매치 0. 사람한테는 같은 뜻인데 글자가 다르니 키워드 검색은 0 점을 준다.
2.2 의미 기반 검색 (벡터)
다른 접근: 문장의 의미를 어떻게든 숫자로 바꿔서 비교한다. 의미가 비슷한 두 문장은 숫자도 비슷하게 나오면 좋겠다.
이걸 가능하게 하는 게 임베딩 모델(embedding model) 이다. 신경망이 문장을 받아서 1024 개짜리 숫자 배열(=벡터)을 출력한다. 의미가 비슷한 두 문장은 그 두 벡터가 "가까운" 위치에 놓이게끔 학습돼 있다.
"인증 모듈" →
[0.12, -0.07, 0.31, ..., 0.05](1024 개) "사용자 검증 컴포넌트" →[0.11, -0.06, 0.33, ..., 0.04](꽤 비슷함) "오늘 점심 뭐 먹지" →[0.91, 0.02, -0.44, ..., -0.20](전혀 다름)
질의도 같은 모델로 벡터화해서, 모든 문서 벡터 중 가장 가까운 것을 찾는다. "단어가 안 겹쳐도 의미가 통하면 잡아내는" 검색이 된다.
우리는 BGE-M3 라는 다국어 임베딩 모델을 쓴다 (한국어 + 영어 둘 다 잘 잡혀야 해서).
SQLite 에 sqlite-vec 라는 확장을 끼워서 벡터까지 같은 DB 파일에 저장한다 — 별도
벡터 DB 인프라 없음.
장점: 표현이 달라도 찾는다. 동의어, 의역, 다른 언어로 쓴 같은 개념까지 잡는다.
단점: 정확한 식별자엔 약하다. #1234 와 #1235 는 의미상 거의 같게 보여서
임베딩이 둘을 구분 못 할 수 있다. 문서에 등장하는 SHA 해시 같은 건 의미가 없는
랜덤 문자열이라 벡터가 신뢰할 수 없다.
2.3 한쪽이 답이 아니라, 둘 다 필요하다
위 두 단점을 정리하면:
| 키워드 검색 (FTS) | 의미 검색 (벡터) | |
|---|---|---|
| 단어가 일치할 때 | 매우 정확 | 보통 |
| 표현이 다를 때 | 못 찾음 | 잘 찾음 |
| 식별자(#1234, 함수명) | 매우 정확 | 부정확할 수 있음 |
| 자연어 질의 | 보통 | 매우 좋음 |
서로 정반대의 약점을 가진다. 그래서 두 방식을 동시에 돌리고 결과를 합치는 게 요즘 표준 — 보통 하이브리드 검색(hybrid retrieval) 이라고 부른다.
3. 두 검색 결과를 어떻게 합칠까
문제: 키워드 검색은 BM25 점수(0∞), 의미 검색은 거리(02). 단위가 다르다. 그냥 더할
수 없다.
해결: 점수 자체는 버리고, 순위(랭크) 만 본다. 키워드 쪽에서 1등인 문서, 의미 쪽에서 3등인 문서… 식으로. 그 다음 각 문서의 최종 점수는
score = 1/(60 + 키워드 순위) + 1/(60 + 의미 순위)같은 식으로 매긴다. 1 등은 큰 값, 50 등은 거의 0. 양쪽에서 모두 나오는 문서는 점수가 두 배로 쌓이니까 자연스럽게 위로 올라온다. 양쪽 단위를 안 맞춰도 됨.
이걸 RRF(Reciprocal Rank Fusion) 이라 한다. 60 은 흔히 쓰는 표준값.
직관: "둘 다 적당히 잘 본 문서" 가 "한 쪽에서만 압도적으로 1 등인 문서" 보다 보통 더 좋은 답이라는 가정에 기댄다. 실제 RAG 결과 품질이 잘 올라가는 게 검증된 방식.
4. 마지막에 한 번 더 거르는 단계 — Rerank
위에서 합친 결과 상위 50 개 정도를 가져온 뒤, 마지막에 한 번 더 정밀 채점 한다. 이게 재정렬(rerank) 이다.
검색 단계의 임베딩 모델은 빠르려고 질의와 문서를 따로 벡터화해서 거리만 쟀다. 재정렬 단계는 다른 종류의 모델을 써서 질의-문서 한 쌍을 같이 보고 점수를 매긴다. 훨씬 비싸지만 훨씬 정확하다.
50 개 → 정밀 채점 → 상위 10 개를 LLM 한테 보낸다.
비유: 책을 살 때 (1) 서점 검색창에 단어를 쳐서 후보를 100 권 추리고 (2) 표지/목차를 훑어서 20 권으로 줄이고 (3) 진짜로 한 페이지씩 읽어보면서 살 책 3 권을 고르는, 그 마지막 단계가 rerank 라고 생각하면 비슷하다.
5. 한국어와 코드 식별자가 동시에 매끄러워야 한다
키워드 검색은 "단어 단위로 쪼갠다 → 인덱스에 넣는다" 가 기본 동작이다. 이 쪼개는 작업을 토크나이징 이라고 한다. 영어는 공백으로만 쪼개도 어지간히 동작하는데, 한국어는 조사가 붙는다.
"인증 모듈을" 이라고 쳤는데 문서엔 "인증 모듈에"라고 쓰여 있으면, 글자 단위 비교만 하는 토크나이저는 둘을 다른 단어로 본다. 매치 실패.
해결: 한국어 형태소 분석기 (kiwipiepy) 를 거쳐서 어간만 남긴다.
"인증 모듈을" → ["인증", "모듈"]
"인증 모듈에" → ["인증", "모듈"] ← 같은 토큰 → 매치 성공이걸 인덱스를 만들 때 한 번, 검색할 때 한 번 같은 방식으로 적용해야 둘이 맞붙는다.
추가로 두 가지 디테일:
- 고유명사 보호: 자사 제품명, 프로젝트 코드네임, 사내 약어 같은 단어는 형태소 분석기가 일반 명사로 오인하고 이상하게 쪼갤 수 있다. 사용자 사전에 등록해서 통째로 한 토큰으로 유지.
- 코드 식별자 보존:
#1234,0x90,verifyAuthToken같은 패턴은 형태소 분석기가 모른다. 정규식으로 따로 잡아서 토큰 목록에 추가. 그래야 이슈 번호나 함수 이름이 그대로 검색된다.
6. 청킹 — 한 입에 들어가게 자르기
문서 전체를 검색 단위로 쓰면 두 가지 문제가 생긴다.
- 위키 페이지 한 장이 너무 길면 임베딩 모델의 입력 한계를 넘는다.
- 길수록 "이 페이지 어딘가에 있다" 식의 모호한 매치만 되고, 어느 부분 인지를 짚어주지 못한다.
그래서 적당한 크기로 자른다. 이 단위를 청크(chunk) 라고 부른다.
자르는 기준은 자료 종류마다 다르다.
- 위키:
#,##마크다운 헤딩 단위로 자른다. 의미 경계가 자연스럽게 맞아떨어지고, 사람이 그 글을 그렇게 쓰기 때문. - 이슈: 본문 1 청크 + 댓글마다 1 청크. 댓글은 보통 다른 사람이 다른 시점에 쓴 내용이라 합쳐 두면 의미가 섞인다.
- 코드 함수: 가능하면 함수/클래스 단위로 자른다 (자세한 건 §8).
각 청크 맨 앞에 한 줄짜리 메타 헤더를 박아둔다.
[#1234] 결제 실패 케이스 분석 — author=홍길동, status=Resolved, updated=2025-04-12
... 이슈 본문 ...이 한 줄 덕분에:
- 키워드 검색에서 "홍길동 #1234" 같은 합성 질의가 자연스럽게 매치된다.
- 임베딩이 청크의 정체(어떤 종류의 글, 누구 글, 언제 글)를 같이 학습 입력으로 받는다.
- LLM 이 결과를 인용할 때 "이슈 #1234 에서…" 처럼 출처를 자연스럽게 꺼낸다.
7. 한 번 만들고 끝이 아니라, 매일 갱신해야 한다
위키도 이슈도 매일 갱신된다. 인덱스는 그걸 따라가야 한다.
단순히 매일 전체를 다시 만든다 = 사실 가능하지만, 임베딩 비용이 크다 (문서 수 × 모델 호출 수). 안 바뀐 건 그대로 두는 게 좋다.
해결: 각 문서의 안정 필드를 모아 해시(hash) 를 찍어 둔다. 다음 날 같은 문서를 다시 가져왔을 때, 새 해시와 저장된 해시가 같으면 "내용 안 바뀜" 으로 판정 → 청킹·임베딩 단계 통째로 건너뛴다.
함정: Redmine 의
updated_on필드는 의미 없는 편집(상태값 토글, 워크플로 노이즈)에도 bumping 된다. 이 필드까지 해시에 넣으면 본문이 안 바뀌었는데도 매일 재임베딩된다. 그래서updated_on은 의도적으로 해시에서 제외한다.
코드 저장소는 한 단계 더 — git 이 차이를 알려준다. 마지막으로 인덱스에 반영한 커밋
SHA 를 저장해 두고, 다음 실행 때 git diff <저장된 SHA>..HEAD 만 본다. 바뀐 파일만
후보가 되고, 후보 안에서도 다시 해시 비교로 한 번 더 거른다 (rename 만 된 경우 같은
가짜 변경 제거).
결과: 매일 5-10 분 안에 갱신이 끝나고, 안 바뀐 99% 의 문서는 손도 안 댄다.
8. 코드는 "함수 단위" 로 자르는 게 핵심
위키는 헤딩으로 자르면 됐지만, 코드는 자연스러운 경계가 다르다. 코드의 경계는 함수, 메서드, 클래스, 구조체 다.
같은 200 라인을 자른다고 해도
- 라인 100~300 으로 자르면 함수 중간이 잘려서 의미가 깨진다.
- 함수 단위로 자르면 "이 함수가 무엇을 하는지" 가 한 청크에 온전히 담긴다.
함수 경계를 알아내려면 코드를 파싱해야 한다. 그래서 tree-sitter 라는 다언어 파서를 쓴다. C/C++/Python/C# 등의 함수·클래스·메서드 노드를 추출해서 각각을 한 청크로 만든다.
- 너무 큰 함수(500 줄 초과) 는 다시 200 줄 윈도우로 쪼갠다 (모델 입력 한계 보호).
- tree-sitter 가 모르는 언어는 그냥 200 줄 윈도우로 잘라준다 (graceful degradation).
- 청크 첫 줄에 anchor 코멘트를 박는다:
→ 청크가 함수 중간
// src/auth/verifier.cpp:TokenVerifier::Verify (lines 142-318)}부터 시작해도 임베딩이 갈피를 잡는다.
민감 정보는 인덱싱 직전에 가린다. AWS 키, JWT, 사설 PEM, GitHub 토큰 같은 패턴을
정규식으로 잡아서 [REDACTED:aws_access_key] 같은 자리표시자로 바꾼 텍스트만 DB 에
저장한다. 디스크의 원본 파일은 그대로. 검색 결과를 통해 시크릿이 LLM 으로 흘러
나가는 사고를 사전 차단.
9. 이슈와 코드를 잇는 그래프
위키·이슈·코드를 다같이 검색 하는 것 외에, 서로 어떻게 연결되는지 도 인덱스에 남겨둔다.
git 커밋 메시지엔 보통 #1234 같은 이슈 번호가 들어 있다. 이걸 정규식으로 뽑아서
(이슈 ↔ 커밋) 매핑 테이블에 적재한다.
이 테이블 위에 세 가지 검색 함수가 붙는다.
- "이 이슈를 고친 커밋들?" (issue → commits)
- "이 커밋이 닿은 이슈들?" (commit → issues)
- "이 파일을 만진 커밋들이 닿은 이슈들?" (file → issues)
세 번째가 특히 가치 있다. 어떤 파일에 버그가 났을 때, 그 파일을 과거에 건드린 커밋들이 어떤 이슈에서 시작된 작업이었는지를 한 번에 본다 → 같은 부류의 버그가 이전에도 있었는지 즉답.
작은 디테일: 이슈 번호
#1같은 작은 숫자는 GitHub PR 번호가 squash 메시지에 섞여 들어온 노이즈일 가능성이 크다. 운영 환경의 실제 이슈 번호 자릿수에 맞춰 최소 임계값을 두고, 그 미만은 일괄 무시.
10. 전체 흐름 정리
매일 한 번 도는 파이프라인은 대략 이렇다.
1. Redmine 백업본 동기화
└ NAS 의 위키·이슈·메타 덤프
2. Redmine 인덱싱 (build_index)
├ 각 위키 / 이슈 / 메타 → 해시 비교 → 안 바뀌면 skip
└ 청킹 → SQLite 의 chunks 테이블
3. 임베딩 (embed)
└ 아직 벡터가 없는 청크만 BGE-M3 로 인코딩 → chunks_vec
4. 엔티티 페이지 빌드 (build_entities)
└ LLM 이 사람·제품·시스템별 요약 페이지를 자동 작성
5. 코드 저장소 fast-forward (code_clone pull)
6. 코드 인덱싱 (code_build_index)
├ git diff 로 바뀐 파일만
├ tree-sitter 로 함수 단위 청킹 (안 되면 라인 윈도우)
└ 시크릿 redact 적용
7. 이슈↔커밋 크로스레퍼런스 (code_xref)검색은 그 반대 흐름. 사용자가 질의를 던지면
질의
├→ 키워드 검색 (FTS5 + BM25) ─┐
└→ 의미 검색 (sqlite-vec + BGE-M3) ─┴→ RRF 합산 → 상위 50개
└→ rerank → 상위 10개 → LLMLLM 은 받은 청크를 근거로 답하면서 #1234, wiki:proj/page 같은 식별자를 인용한다.
필요하면 그 식별자들을 다시 fetch 해서 (resolve_references) 인용을 한 단계 더
따라간다.
11. 왜 이 디자인을 택했나 — 한 줄씩
- 단일 SQLite 파일: 별도 벡터 DB 서버 안 띄움. 백업은
cp. 노트북에서도 돈다. - FTS + 벡터 같은 DB: 벡터가 비어 있어도 키워드 검색만으로 동작 — 인덱싱이 진행 중인 중간 상태에도 가용하다.
- 헤더 한 줄로 메타 누설: 청크가 자기를 어디 출신인지 들고 다닌다. 검색 결과가 자기소개를 한다.
- 해시 기반 incremental: 매일 돌려도 안 바뀐 99 % 는 0 비용.
- 함수 단위 청킹: 코드 검색 품질의 가장 큰 변수. 라인 윈도우만으로는 답이 안 됨.
- 하이브리드 + rerank: 한 가지 검색만 쓰면 어느 한쪽 질의 패턴에서 무너진다. 세 단계로 안전망.
- 이슈↔커밋 그래프: 같은 인덱스 안에서 "왜 이 코드가 이렇게 짜였는지" 의 추적이 한 번의 SQL 조인으로 끝난다.
큰 그림으로 보면 RAG(Retrieval-Augmented Generation) 표준 레시피지만, 실제 품질을 결정하는 건 모델이 아니라 무엇을 한 청크로 묶고 / 어떤 헤더를 박고 / 어떻게 토크나이즈하고 / 무엇을 incremental skip 하는가 같은 자잘한 결정들이다. 이 글이 다룬 게 그 부분이다.