OpenSearch에서 중복 데이터 저장을 막기 위한 방법을 정리하기 위해 글을 작성하게 되었습니다.
1. 왜 관심을 가지게 되었을까?
OpenSearch에서 문서를 수정하면 기존 데이터를 직접 덮어쓰지 않고, 기존 문서를 삭제 표시한 뒤 새로운 문서를 다시 색인 합니다. 즉, 겉으로는 단순한 수정처럼 보이지만 내부적으로는 INSERT + DELETE 방식으로 동작합니다. 이 구조가 왜 필요한지 이해하기 위해 세그먼트 내부 구조에 관심을 가지게 되었습니다.
2. 세그먼트 내부는 어떻게 구성되어 있을까?
Lucene은 문서를 하나의 레코드로 저장하지 않습니다. 아래와 같은 json 문서는 색인 과정에서 목적별 내부 구조로 분해 되어 각각의 세그먼트 파일에 기록됩니다.
1
2
3
4
5
6
{
"id": 1,
"title": "서울 강남 카페",
"reviewCount": 100,
"rating": 4.5
}
문서는 저장 시점에 다음과 같은 구조로 나뉩니다. 각 구조는 서로 다른 파일에 기록 되며, 하나의 필드를 수정하면 구조 전반에 영향을 주기 때문에 물리적 부분 수정은 불가능합니다. 대신 기존 문서를 삭제 표시하고 새로운 문서를 추가하는 방식으로 처리됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Segment
├── Inverted Index
│ ├── Term Dictionary (.tim)
│ ├── Term Index (.tip)
│ ├── Postings (.doc)
│ ├── Positions (.pos)
│ └── Payloads (.pay)
│
├── DocValues (.dvd / .dvm)
├── Stored Fields (.fdt / .fdx)
├── Norms (.nvd / .nvm)
├── Points (.kdi / .kddx / .kdm)
├── Term Vectors (.tvx / .tvd)
├── Vectors (.vec / .vem) ← KNN
│
├── Field Infos (.fnm)
├── Segment Info (.si)
├── Live Docs (.liv)
│
└── Compound Wrapper (.cfs / .cfe)
2-1. Inverted Index
역색인은 문서를 저장하는 구조가 아니라, 단어와 문서를 연결하는 구조입니다. 텍스트는 색인 시 토큰으로 분해되고, 각 단어는 자신을 포함한 문서 목록과 함께 기록됩니다. 그래서 검색은 문서를 하나씩 훑는 방식이 아닌, 검색어에 해당하는 단어를 통해 곧바로 관련 문서 집합을 찾아내는 방식으로 동작합니다.
Elasticsearch uses a structure called an inverted index, which is designed to allow very fast full-text searches. An inverted index consists of a list of all the unique words that appear in any document, and for each word, a list of the documents in which it appears.
예를 들어, title이 “서울 강남 카페” 라면, 색인 과정에서 분석기를 거쳐 다음과 같이 토큰으로 분해됩니다.
1
"서울", "강남", "카페"
그리고 역색인에는 각 단어가 포함된 문서 ID가 다음과 같이 기록됩니다. 즉, 역색인은 이 단어가 어떤 문서에 있는지 를 저장하는 구조입니다. 그래서 사용자가 “강남”을 검색하면, Lucene은 모든 문서를 탐색하지 않고 곧바로 “강남” 항목의 문서 목록([doc0])을 찾아 결과를 반환할 수 있죠.
1
2
3
서울 → [doc0]
강남 → [doc0]
카페 → [doc0]
2-2. DocValues
DocValues는 문서 단위가 아니라 필드 단위 로 정렬된 저장 구조입니다. 즉, 특정 필드의 값을 모든 문서에 대해 한 번에 읽을 수 있도록 설계되어 있어, 정렬 이나 집계 같은 연산이 효율적으로 수행됩니다. 문서 기준 저장인 것이죠.
The doc_values field is an on-disk data structure that is built at document index time and enables efficient data access. It stores the same values as _source, but in a columnar format that is more efficient for sorting and aggregation.
예를 들어, 상품이 세 개 있다고 가정해보겠습니다. 하나의 문서를 읽으면 reviewCount와 rating이 함께 묶여 있습니다. _source는 이런 식으로 JSON 전체를 보관합니다.
1
2
3
4
5
6
7
8
9
10
11
doc0:
reviewCount = 100
rating = 4.5
doc1:
reviewCount = 50
rating = 3.8
doc2:
reviewCount = 200
rating = 4.9
이를 reviewCount 기준으로 내림차순 정렬”을 한다고 해보겠습니다. 이때는 rating 값은 필요 없습니다. reviewCount 값들만 필요합니다. 그래서 doc values는 저장할 때부터 필드 기준으로 따로 정리해 둡니다. 이렇게 필드 하나의 값만 모든 문서에 대해 모아 둡니다. 정렬할 때는 이 배열만 읽어서 docID와 함께 정렬하면 됩니다. 문서 전체를 열 필요가 없습니다.
1
2
reviewCount:
[100, 50, 200]
내부적으로는 다음과 같이 필드 를 기준으로 저장됩니다. 즉, doc values는 필드 값들이 문서별로 어떻게 배열되어 있는가 를 저장하는 구조입니다. 그래서 정렬과 집계는 역색인이 아니라 이 영역을 기반으로 동작합니다.
1
2
reviewCount → [100, ...]
rating → [4.5, ...]
집계도 마찬가지입니다. 평균 rating을 구한다고 하면 필요한 것은 rating 값들뿐입니다. 이 배열만 순회하면서 평균을 계산합니다. reviewCount는 읽지 않습니다.
1
2
rating:
[4.5, 3.8, 4.9]
2-3. Stored Fields
Stored Fields는 필드의 원본 값을 그대로 저장해두고, 문서 ID를 기준으로 꺼내올 수 있게 하는 구조입니다. 역색인이나 DocValues처럼 검색이나 집계에 사용되는 구조가 아니라, 검색이 끝난 뒤 결과를 응답할 때 원본 값을 돌려주기 위한 용도입니다.
A field whose value is stored so that IndexSearcher.doc(int) will return the field and its value.
예를 들어, title 필드에 stored: true가 설정되어 있다면, 색인 시점에 입력된 “서울 강남 카페”라는 문자열이 그대로 보관됩니다. 이후 역색인을 통해 조건에 맞는 문서 ID를 찾고, 해당 문서의 Stored Fields를 읽어 실제 응답 데이터를 구성합니다. 즉, 역색인은 “찾는 역할”, Stored Fields는 “돌려주는 역할”을 담당합니다.
1
2
3
4
5
6
7
8
{
"doc0": {
"id": 1,
"title": "서울 강남 카페",
"reviewCount": 100,
"rating": 4.5
}
}
참고로 Elasticsearch의 _source 필드는 이 Stored Fields 위에 구현된 메타 필드입니다. Lucene 자체에는 _source라는 개념이 없으며, Elasticsearch가 문서 전체 JSON을 _source라는 이름의 stored field에 저장하는 방식으로 활용하고 있습니다.
.fdt: 문서들을 LZ4로 압축한 16KB 이상의 블록 단위로 저장하는 데이터 파일 Apache Lucene..fdx: 각 압축 블록의 첫 번째 docID와 디스크 오프셋을 monotonic array로 저장하는 인덱스 파일로, 검색 시 이 배열을 이진 탐색해서 해당 문서가 포함된 블록을 찾습니다..fdm: 인덱스 파일에 저장된 monotonic array의 메타데이터를 담는 파일입니다.
2-4. norm
norm은 검색 점수를 계산할 때 사용하는 정규화 값입니다. 텍스트 필드가 분석되면 토큰으로 분리되고, 이때 생성된 토큰 개수(문서 길이) 가 함께 계산됩니다. 이 길이 정보가 norm에 저장됩니다. 같은 단어가 등장하더라도 토큰 수가 적은 짧은 문서는 해당 단어의 비중이 높고, 토큰 수가 많은 긴 문서는 상대적으로 낮습니다. BM25와 같은 점수 계산 방식은 이 문서 길이를 함께 고려하므로, 인덱싱 시점에 길이 정보를 norm으로 저장해 두고 검색 시 점수 계산에 사용합니다.
Norms store various normalization factors that are later used at query time in order to compute the score of a document relatively to a query.
BM25는 단어 등장 횟수뿐 아니라 문서 길이를 함께 고려해 점수를 계산하므로, 인덱싱 시 문서 길이를 norm으로 저장해 두고 검색 시 이를 활용합니다.
예를 들어, 아래와 같은 문서에서는 norm이 title 필드에 적용됩니다. title은 텍스트 필드이므로 분석 과정에서 생성된 토큰 수가 저장되고, 이 값이 검색 시 점수 계산에 사용됩니다. reviewCount, rating은 숫자 필드이므로 길이 개념이 없고, 점수 정규화도 필요하지 않기 때문에 norm이 생성되지 않습니다.
1
2
3
4
5
6
7
8
{
"doc0": {
"id": 1,
"title": "서울 강남 카페",
"reviewCount": 100,
"rating": 4.5
}
}
title이 “서울 강남 카페”로 분석되면 토큰은 3개가 됩니다. 이 토큰 개수가 해당 필드의 문서 길이이며, norm은 이 길이 값을 저장합니다. 검색 시 BM25는 단어 등장 여부뿐 아니라 문서 길이도 함께 고려하는데요. 문서가 짧을수록 같은 단어가 등장해도 점수가 더 높게 계산되고, 길수록 보정되어 낮아지기 때문입니다.
1
["서울", "강남", "카페"] -> 길이 3
1
title field -> doc0 length = 3
2-5. Points
Points는 숫자, 날짜, geo 좌표 같은 범위 기반 검색을 위한 구조입니다. 텍스트 검색이 역색인을 사용한다면, 숫자/좌표 검색은 BKD tree 기반의 point 구조를 사용합니다.
Points encode dimensional numeric values in a block KD-tree structure for fast range and spatial queries.
예를 들어, rating이 4.0 이상인 문서를 찾는다고 가정해보겠습니다. 이 경우 역색인을 쓰지 않습니다. 대신 rating 필드에 대해 구성된 BKD tree를 탐색합니다. 이 트리는 값 범위를 기준으로 공간을 분할해 저장하므로, 조건에 맞는 문서 집합을 빠르게 찾을 수 있습니다. 내부적으로는 다음과 같이 저장됩니다.
- .kdi → KD-tree index 정보
- .kddx → 문서 ID 매핑 정보
- .kdm → 메타데이터
예를 들어 rating 필드가 다음과 같이 색인되어 있다고 가정해보겠습니다.
1
2
3
4
5
doc0 → 4.5
doc1 → 3.8
doc2 → 4.9
doc3 → 2.1
doc4 → 4.2
이 값들은 단순 배열로 저장되는 것이 아니라, BKD tree 구조로 정렬·분할되어 저장됩니다. 개념적으로는 다음처럼 공간이 분할됩니다. rating ≥ 4.0을 검색하면 BKD tree는 전체 값을 하나씩 비교하지 않습니다. 값 범위를 기준으로 분할된 노드 중에서 조건과 겹치는 구간만 내려가며 탐색하고, 4.0보다 작은 범위는 통째로 건너뜁니다.
1
2
3
4
5
6
7
8
9
[2.1 ~ 4.9]
/ \
[2.1 ~ 3.8] [4.2 ~ 4.9]
/ \
[2.1] [4.2 ~ 4.9]
/ \
[4.2] [4.5 ~ 4.9]
/ \
[4.5] [4.9]
그 결과 4.5, 4.9, 4.2처럼 조건을 만족하는 문서 ID만 빠르게 수집됩니다.
1
2
3
4
5
6
7
8
9
10
[2.1 ~ 4.9]
/ \
[2.1 ~ 3.8] [4.2 ~ 4.9]
/ \
[2.1] [4.2 ~ 4.9]
(doc3) / \
[4.2] [4.5 ~ 4.9]
(doc4) / \
[4.5] [4.9]
(doc0) (doc2)
2-6. Term Vectors
Term vectors는 각 문서 내부에서 단어의 등장 횟수와 위치 정보를 문서 기준으로 저장하는 선택적 인덱스 구조입니다. 기본 검색에는 필요하지 않지만, 하이라이팅이나 문서 유사도 분석처럼 문서 내부의 단어 분포를 정밀하게 활용해야 하는 기능에서 사용됩니다. 역색인과 별도로 문서 기준 정보를 추가 저장하므로 디스크 공간을 더 사용하며, 필요한 경우에만 선택적으로 활성화하는 옵션입니다.
Term vectors store term frequency and optionally positions and offsets for each document.
예를 들어 다음과 같은 문서가 있다고 가정해보겠습니다. 하나의 필드 안에 동일 단어가 여러 번 등장하는 상황입니다.
1
2
3
{
"title": "서울 강남 카페 강남 맛집"
}
이는 분석 후 토큰은 다음과 같이 나뉘어 집니다. 분석기는 문자열을 위치 정보를 유지한 채 토큰 배열로 변환합니다.
1
["서울", "강남", "카페", "강남", "맛집"]
Term vectors가 활성화되어 있다면, 이 정보가 문서 단위로 저장됩니다. 각 단어의 등장 횟수와 위치 정보가 문서 기준으로 함께 기록됩니다. 이 정보를 통해 검색 결과에서 해당 단어가 문서의 어디에 등장했는지 정확히 찾아 하이라이팅할 수 있습니다. 또한 문서 내부 단어 분포를 기반으로 문서 간 유사도 분석이나 키워드 추출 같은 기능을 효율적으로 수행할 수 있습니다.
1
2
3
4
5
doc0:
서울 → freq=1, position=0
강남 → freq=2, position=1,3
카페 → freq=1, position=2
맛집 → freq=1, position=4
파일 구조는 다음과 같습니다. 반면 term vector는 문서를 기준으로 그 안에 어떤 단어들이 들어 있는지를 저장하는 구조입니다. 하나의 문서에 포함된 단어 목록과 각 단어의 빈도, 위치 정보를 보관합니다. 따라서 방향은 “문서 → 단어”입니다.
- .tvx → 문서별 오프셋 인덱스
- .tvd → 실제 term vector 데이터
2-7. Knn Vectors
Vectors는 임베딩 기반 유사도 검색을 위한 전용 인덱스 구조입니다. 텍스트를 숫자 벡터로 변환한 뒤, 그 벡터 간의 거리(유사도)를 계산해 가장 가까운 문서를 찾는 데 사용됩니다.
Vector values store numeric vectors optimized for nearest-neighbor search.
예를 들어 다음과 같이 embedding 필드가 있다고 가정합니다. 이 embedding 값은 단순 숫자 필드처럼 저장되지 않습니다. KNN(Nearest Neighbor) 검색을 빠르게 수행하기 위해 별도의 벡터 전용 구조에 기록됩니다.
1
2
3
4
{
"title": "서울 강남 카페",
"embedding": [0.12, -0.33, 0.98, ...]
}
파일 구조는 다음과 같습니다.
- .vec → 실제 벡터 데이터 저장
- .vem → 벡터 메타데이터 및 그래프 구조 (HNSW 등)
벡터 검색은 단어를 찾는 방식이 아니라, 벡터 간의 거리(유사도) 를 계산해 가장 가까운 문서를 찾는 방식입니다. 사용자가 query vector를 보내면, Lucene은 모든 벡터를 비교하지 않고 HNSW 그래프를 따라 탐색하며 가까운 후보를 점진적으로 좁혀 갑니다. 그 결과, 쿼리 벡터와 가장 유사한 docID들이 반환됩니다. 예를 들어 다음과 같은 벡터가 저장되어 있다고 가정해보겠습니다.
1
2
3
doc0 → [0.12, -0.33, 0.98]
doc1 → [0.02, 0.77, -0.10]
doc2 → [0.15, -0.30, 0.95]
사용자가 다음과 같은 query vector를 보낸다면, HNSW 탐색을 통해 query와 가장 거리가 가까운 doc0, doc2가 반환됩니다. 이렇게 반환된 문서는 의미적으로 가장 비슷한 문서입니다. 예를 들어 문장 임베딩 기반 검색이라면, 키워드가 정확히 일치하지 않더라도 의미가 유사한 문서(비슷한 리뷰, 유사한 상품, 비슷한 질문 등)를 찾는 데 사용됩니다.
1
query → [0.14, -0.31, 0.97]
2-8. Field Infos
이 세그먼트에 어떤 필드들이 있고, 각 필드가 텍스트인지 숫자인지, doc values가 있는지, norms를 쓰는지 같은 설정 정보를 저장하는 메타데이터 파일입니다. 검색 구조라기보다는 “필드 스키마 정의서”에 가깝습니다.
2-9. Segment Info
세그먼트 전체의 상태 정보를 담습니다. 문서 수, 삭제 수, 사용 중인 파일 목록, 생성 버전, 정렬 정보 등이 들어 있으며, 세그먼트를 관리하기 위한 요약 정보입니다. 말 그대로 세그먼트의 메타 정보입니다.
2-10. Live Docs
삭제된 문서를 표시하는 비트셋 파일입니다. Lucene은 문서를 물리적으로 바로 지우지 않고 삭제 플래그만 세팅하기 때문에, 검색 시 이 파일을 참고해 살아 있는 문서만 반환합니다.
2-11. Compound File
Compound File은 하나의 세그먼트에 속한 여러 Lucene 파일들을 하나의 파일로 묶어 관리하는 래퍼 구조입니다. 세그먼트에는 .tim, .doc, .pos, .fdx, .fdt 등 다양한 파일이 존재하는데, 파일 개수가 많아지면 파일 핸들 관리 비용이 커집니다. 이를 줄이기 위해 Lucene은 작은 세그먼트의 경우 여러 파일을 하나의 .cfs 파일로 합쳐 저장합니다.
.cfs: 실제로 합쳐진 데이터 파일.cfe: 그 안에 어떤 파일이 들어 있는지 기록한 엔트리 목록 파일
.cfs는 실제 데이터 덩어리이고, .cfe는 내부 목차라고 보면 됩니다. Compound File은 “새로운 인덱스 구조”가 아니라 기존 구조들을 포장하는 컨테이너라는 것입니다. 내부의 inverted index, doc values, points, norms 등의 구조는 그대로 유지되며, 단지 파일 시스템 관점에서 하나로 묶여 있을 뿐입니다.
3. 정리
세그먼트가 어떻게 구성되어 있는지 살펴봤는데요. 다음은 이렇게 설계한 것이 어떤 동작을 하게 되는지, 어떤 이점이 있는지 살펴보겠습니다.