<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Benjamin Trent - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Benjamin Trent - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/kr/search-labs/author/benjamin-trent</link>
    </image>
    <link>https://www.elastic.co/kr/search-labs/author/benjamin-trent</link>
    <atom:link href="https://www.elastic.co/kr/search-labs/rss/author/benjamin-trent.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[kr]]></language>
    <lastBuildDate>Mon, 28 Sep 2026 05:13:20 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Elasticsearch에서 지연 상호작용 모델 확장하기 - 2부]]></title>
    <description><![CDATA[이 문서에서는 디스크 공간 사용량을 줄이고 계산 효율성을 개선하는 등 대규모 프로덕션 워크로드에 대비하여 지연 상호작용 벡터를 준비하는 기술을 살펴봅니다.]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/search-labs/blog/elastiacsearch-colpali-document-search">ColPali에 대한 이전 블로그</a>에서는 Elasticsearch로 시각적 검색 애플리케이션을 생성하는 방법을 살펴보았습니다. 주로 ColPali와 같은 모델이 애플리케이션에 가져다주는 가치에 초점을 맞췄지만, 이러한 모델은 E5와 같은 바이인코더를 사용하는 벡터 검색에 비해 성능상의 단점이 있습니다.</p><p><a href="https://www.elastic.co/search-labs/blog/elastiacsearch-colpali-document-search">1부</a>의 예시를 바탕으로, 이 블로그는 다양한 기법과 Elasticsearch의 강력한 벡터 검색 도구를 사용하여 대규모 프로덕션 작업에 적합한 지연 상호작용 벡터를 준비하는 방법을 탐구합니다.</p><p>전체 코드 예시는 <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/colpali">GitHub</a>에서 확인하실 수 있습니다.</p><h2>후기 상호작용 모델의 과제</h2><p>ColPali는 인덱스에 있는 문서에 대해 페이지당 1000개 이상의 벡터를 생성합니다.</p><p>이로 인해 지연 상호작용 벡터를 사용할 때 두 가지 문제가 발생합니다.</p><ol><li><p>디스크 공간: 이러한 모든 벡터를 디스크에 저장하면 상당한 저장 공간 사용량이 발생하며, 이는 확장 시 비용이 많이 들 수 있습니다.</p></li><li><p>계산: <code>maxSimDotProduct()</code> 비교를 사용하여 문서 순위를 매길 때는 각 문서에 대한 이러한 모든 벡터를 쿼리의 N 벡터와 비교해야 합니다.</p></li></ol><p>이러한 문제를 해결하기 위한 몇 가지 기술을 살펴보겠습니다.</p><h2>지연 상호작용 모델을 최적화하는 기술</h2><h3>비트 벡터</h3><p>디스크 공간을 줄이기 위해 이미지를 비트 벡터로 압축할 수 있습니다. 다음과 같은 간단한 Python 함수를 사용하여 멀티 벡터를 비트 벡터로 변환할 수 있습니다.</p>def to_bit_vectors(embeddings: list) -&gt; list:
    return [
        np.packbits(np.where(np.array(embedding) &gt; 0, 1, 0))
        .astype(np.int8)
        .tobytes()
        .hex()
        for embedding in embeddings
    ]<p>이 함수의 핵심 개념은 간단합니다. 0보다 큰 값은 1이 되고, 0보다 작은 값은 0이 됩니다. 그 결과 0과 1로 이루어진 배열이 생성되며, 이를 16진수 스트링으로 변환하여 비트 벡터를 나타냅니다.</p><p>인덱스 매핑을 위해 <code>element_type</code> 매개변수를 <code>bit</code>로 설정했습니다.</p>mappings = {
    "mappings": {
        "properties": {
            "col_pali_vectors": {
                "type": "rank_vectors",
                "element_type": "bit"
            }
        }
    }
}

es.indices.create(index=INDEX_NAME, body=mappings)<p>인덱스에 새로운 비트 벡터를 모두 작성한 후, 다음 코드를 사용하여 비트 벡터의 순위를 매길 수 있습니다.</p>query = "What do companies use for recruiting?"
query_vector = to_bit_vectors(create_col_pali_query_vectors(query))
es_query = {
    "_source": False,
    "query": {
        "script_score": {
            "query": {
                "match_all": {}
            },
            "script": {
                "source": "maxSimInvHamming(params.query_vector, 'col_pali_vectors')",
                "params": {
                    "query_vector": query_vector
                }
            }
        }
    },
    "size": 5
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcaf0c8f999d68701/6a17f46696142a6f46eb1c56/51b989446e4099745971e1eac27d147a78d13e0a-1600x480.png" alt="" /><p>약간의 정확도를 희생하는 대신 비트 마스크, SIMD 등과 같은 최적화를 활용할 수 있는 해밍 거리(<code>maxSimInvHamming(...)</code>)를 사용할 수 있습니다. <a href="https://www.elastic.co/search-labs/blog/bit-vectors-in-elasticsearch">비트 벡터와 해밍 거리에 대한 자세한 내용은 블로그</a>에서 확인하세요.</p><p>대안으로, 쿼리 벡터를 비트 벡터로 변환하지 않고 완전한 충실도의 지연 상호작용 벡터로 검색할 수 있습니다.</p>query = "What do companies use for recruiting?"
query_vector = create_col_pali_query_vectors(query)
es_query = {
    "_source": False,
    "query": {
        "script_score": {
            "query": {
                "match_all": {}
            },
            "script": {
                "source": "maxSimDotProduct(params.query_vector, 'col_pali_vectors')",
                "params": {
                    "query_vector": query_vector
                }
            }
        }
    },
    "size": 5
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2fa6c89fb451b6e0/6a17f468af47b65da9cde0d9/89f3c795c6dc44b2f7d46801320288316b47e1b2-1600x488.png" alt="비트 벡터를 사용하여 지연 상호작용 모델을 최적화한 결과" /><p>이는 비대칭 유사도 함수를 사용하여 벡터를 비교합니다.</p><p></p><p>두 비트 벡터 사이의 일정한 해밍 거리에 대해 생각해 보겠습니다. 문서 벡터 <em>D</em>가 있다고 가정해 봅시다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4b6ece1ec4dedac/6a17f4694b055d248143234e/366a70a23e37d403788327b7aefc873fd4482f5e-1235x86.png" alt="" /><p>그리고 쿼리 벡터 <em>Q</em>가 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7721df9c8f0d84f1/6a17f46b3e03d77cf54f2dcb/978ec31e0afbc0eaebf548a015b628c0cad84625-1247x84.png" alt="" /><p></p><p>간단한 이진 양자화는 벡터 <em>D</em>를 <code>10101101</code>로, <em>Q</em>를 <code>11111011</code>로 변환합니다. 해밍 거리를 찾으려면 직접적인 비트 연산이 필요하며, 이는 매우 빠릅니다. 이 경우 해밍 거리는 <code>01010110</code>이며 비트 수는 4입니다. 따라서 점수 계산은 해당 해밍 거리의 역수가 됩니다. 유사한 벡터일수록 해밍 거리가 작아지므로 이를 반전시키면 유사한 벡터일수록 더 높은 점수를 받을 수 있습니다. 구체적으로 여기서는 1/4 = <code>0.25</code>가 됩니다.</p><p>그러나 각 차원의 크기가 어떻게 줄어드는지 주목하세요. <code>1</code> 은 <code>1</code>입니다. 그래서 <em>Q</em>의 경우, <code>0.01</code>과 <code>0.79</code> 사이의 차이점이 사라집니다. <code>&gt;0</code>에 따라 단순히 양자화하기 때문에 Q 벡터가 양자화되지 않은 곳에서 작은 트릭을 수행할 수 있습니다. 이렇게 하면 매우 빠른 비트 단위 연산을 할 수는 없지만, D가 여전히 양자화되어 있기 때문에 스토리지 비용을 낮게 유지할 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blted50315518e2d599/6a17f46c414c6463709452d3/508e8498ba969534d2a0131d431c75735fc27cb9-1399x611.png" alt="" /><p>간단히 말해서, 이는 <em>Q</em>에 제공된 정보를 유지하여 거리 추정 품질을 향상시키고 저장 공간을 최소화합니다.</p><p>비트 벡터를 사용하면 디스크 공간과 쿼리 시 계산 부하를 크게 줄일 수 있습니다. 하지만 할 수 있는 일은 더 많습니다.</p><h3>평균 벡터</h3><p>수십만 개의 문서에 걸쳐 검색을 확장하려면 비트 벡터가 제공하는 성능상의 이점만으로는 충분하지 않습니다. 이러한 유형의 워크로드에 맞게 확장하려면 벡터 검색을 위해 Elasticsearch의 HNSW 인덱스 구조를 활용해야 합니다.</p><p>ColPali는 문서당 약 천 개의 벡터를 생성하는데, 이는 HNSW 그래프에 추가하기에는 너무 많습니다. 따라서 벡터의 수를 줄여야 합니다. 이를 위해 ColPali에 의해 생성된 모든 문서 벡터의 평균을 계산하여 이미지를 임베드할 때 문서의 의미에 대한 단일 표현을 만들 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc053425fbfcf8de8/6a17f46f148009faf1b488aa/7c3b4dffb70bd95f67deb35f8c01e73c5286c2ab-1476x1102.png" alt="모든 지연 상호작용 벡터에 대한 평균 벡터" /><p>현재로서는 Elastic 자체 내에서 이 작업이 불가능하며, Elasticsearch에서 벡터를 수집하기 전에 벡터를 전처리해야 합니다. </p><p>Logstash 또는 수집 파이프라인으로 이 작업을 수행할 수 있지만 여기서는 간단한 Python 함수를 사용하겠습니다:</p>def to_avg_vector(vectors):
    vectors_array = np.array(vectors)
    
    avg_vector = np.mean(vectors_array, axis=0)
    
    norm = np.linalg.norm(avg_vector)
    if norm &gt; 0:
        normalized_avg_vector = avg_vector / norm
    else:
        normalized_avg_vector = avg_vector

    return normalized_avg_vector.tolist()<p>또한 내적 유사도를 사용할 수 있도록 벡터를 정규화하고 있습니다.</p><p>모든 ColPali 벡터를 평균 벡터로 변환한 후, 이를 dense_vector 필드에 인덱싱할 수 있습니다.</p>mappings = {
    "mappings": {
        "properties": {
            "avg_vector": {
                "type": "dense_vector",
                "dims": 128,
                "index": True,
                "similarity": "dot_product"
            },
            "col_pali_vectors": {
                "type": "rank_vectors",
                "element_type": "bit"
            }
        }
    }
}

es.indices.create(index=INDEX_NAME, body=mappings)<p>이로 인해 지연 상호작용 벡터와 함께 더 많은 정보를 저장하게 되므로 전체 디스크 사용량이 증가할 것이라는 점을 고려해야 합니다. 추가로, 수십억 개의 벡터에 대한 검색을 확장할 수 있도록 HNSW 그래프를 보유하기 위해 추가 RAM을 사용할 것입니다. RAM 사용량을 줄이기 위해 우리는 인기 있는 <a href="https://www.elastic.co/search-labs/blog/optimized-scalar-quantization-elasticsearch">BBQ 기능</a>을 활용할 수 있습니다. 그 결과, 기존 방식으로는 불가능했던 대규모 데이터 세트에 대한 빠른 검색 결과를 얻을 수 있습니다.</p><p>이제 knn 쿼리로 검색하면 가장 관련성 높은 문서를 찾을 수 있습니다.</p>query = "What do companies use for recruiting?"
query_vector = to_avg_vector(create_col_pali_query_vectors(query))
es_query = {
    "_source": False,
    "knn": {
        "field": "avg_vector",
        "query_vector": query_vector,
        "k": 10,
        "num_candidates": 100
    },
    "size": 5
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf4ac6b7fd444f935/6a17f471a29299d76ad02dc0/a500f9907020f032c3b1c7a48ed8c759dc2a1dd4-1600x498.png" alt="" /><p>불행히도 이전의 베스트 매칭은 3위로 떨어졌습니다.</p><p>이 문제를 해결하기 위해 다단계 검색을 수행할 수 있습니다. 첫 번째 단계에서는 knn 쿼리를 사용하여 수백만 개의 문서에서 쿼리에 가장 적합한 후보를 검색하고 있습니다. 두 번째 단계에서는 ColPali 지연 상호작용 벡터의 충실도가 높은 상위 k(여기서는 10)만 다시 순위를 매깁니다. </p>query = "What do companies use for recruiting?"
col_pali_vector = create_col_pali_query_vectors(query)
avg_vector = to_avg_vector(col_pali_vector)
es_query = {
  "_source": False,
  "retriever": {
    "rescorer": {
      "retriever": {
        "knn": {
          "field": "avg_vector",
          "query_vector": avg_vector,
          "k": 10,
          "num_candidates": 100
        }
      },
      "rescore": {
        "window_size": 10,
        "query": {
          "rescore_query": {
            "script_score": {
              "query": {
                "match_all": {}
              },
              "script": {
                "source": "maxSimDotProduct(params.query_vector, 'col_pali_vectors')",
                "params": {
                  "query_vector": col_pali_vector
                }
              }
            }
          }
        }
      }
    }
  },
  "size": 5
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt265edd5f37be40b7/6a17f4733e9e45583dba15e6/797f490860af87fcf29f34668a5c4511419c6fd0-1600x501.png" alt="지연 상호작용 모델 최적화를 위해 평균 벡터를 사용한 결과" /><p>여기서는 8.18에서 도입된 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.18/retriever.html#rescorer-retriever">재점수 검색기</a>를 사용하여 결과를 다시 순위 지정합니다. 점수를 재지정한 후 다시 가장 좋은 매치가 첫 번째 위치에 있음을 확인합니다. </p><p>참고: 프로덕션 애플리케이션에서는 최대 시뮬레이션 함수가 여전히 비교적 성능이 좋으므로 10보다 훨씬 높은 k를 사용할 수 있습니다.</p><h3>토큰 풀링</h3><p>토큰 풀링은 중복 정보(예: 흰색 배경 패치)를 풀링하여 멀티 벡터 임베딩의 시퀀스 길이를 줄입니다. 이 기법은 페이지의 신호 대부분을 보존하면서 임베딩 수를 줄입니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3d643e6ac908f640/6a17f4754b055d3783432352/09eae0b768f4b450e555d7e53e57561bd52de2c0-1412x1056.png" alt="지연 상호작용 모델 최적화를 위한 토큰 풀링" /><p>토큰 풀링은 클러스터링 알고리즘을 사용하여 문서 내에서 유사한 토큰 임베딩을 클러스터로 그룹화하는 방식으로 작동합니다. 그런 다음, 각 클러스터 내 벡터의 평균을 계산하여 단일한 집계 표현을 생성합니다. 이 집계된 벡터는 그룹 내의 원래 토큰을 대체하여 문서 신호의 상당한 손실 없이 총 벡터 수를 줄입니다.</p><p>ColPali 논문에서는 대부분의 데이터 세트에 대해 초기 풀 팩터 값을 3으로 제안하고 있는데, 이는 원래 성능의 97.8%를 유지하면서 총 벡터 수를 66.7%로 줄입니다. </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2d78a15c6944a1ea/6a17f477414c64a2929452d9/343cc9eaf54af8c7a125d4115838f3c7d5659de0-1600x1007.png" alt="지연 상호작용 모델 최적화를 위한 풀 요소" /><p>그러나 주의해야 합니다. 매우 밀도가 높고 텍스트가 많으며 공백이 거의 없는 문서를 포함하는 "Shift" 데이터 세트는 풀 요소가 증가함에 따라 성능이 급격히 저하됩니다.</p><p>풀링된 벡터를 생성하려면 colpali_engine 라이브러리를 사용할 수 있습니다.</p>from colpali_engine.compression.token_pooling import HierarchicalTokenPooler

pooler = HierarchicalTokenPooler(pool_factor=3) # test on your data for a good pool_factor

def pool_vectors(embedding: list) -&gt; list:
    tensor = torch.tensor(embedding).unsqueeze(0)
    pooled = pooler.pool_embeddings(tensor)
    return pooled.squeeze(0).tolist()<p>이제 차원이 약 66.7% 감소한 벡터를 가지고 있습니다. 평소처럼 인덱싱하고 <code>maxSimDotProduct()</code> 함수로 검색할 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc460c14031814ede/6a17f4794202292bf629f72d/2412c5db7d79a590b96d42fe01f140c42f010612-1600x481.png" alt="지연 상호작용 모델 결과" /><p>검색 결과의 정확도가 약간 떨어지는 대신, 우수한 검색 결과를 얻을 수 있습니다.</p><p>힌트: pool_factor 값을 높이면(100~200) 평균 벡터 솔루션과 여기서 논의한 솔루션 사이의 중간 지점을 찾을 수도 있습니다. 문서당 약 5~10개의 벡터가 있을 때, 중첩된 필드에 인덱스를 만들어 HNSW 인덱스를 활용하는 것이 현실적이 됩니다.</p><h2>코스 인코더 vs. 지연 상호작용 vs. 바이 인코더</h2><p>지금까지 배운 바에 따르면, ColPali 또는 ColBERT와 같은 지연 상호작용 모델은 다른 AI 검색 기술과 비교할 때 어떤 위치에 있을까요?</p><p>최대 시뮬레이션 함수는 크로스 인코더에 비해 저렴하지만, 쿼리-문서 쌍마다 두 벡터를 비교하는 바이 인코더를 사용한 벡터 검색보다 여전히 더 많은 비교와 계산이 필요합니다. </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbfd97f3bba3e5b17/6a17f47be31791b0052d5943/75e1fc9e601aa7a6e88f137565c1919166c7a71c-1480x458.png" alt="코스 인코더 vs. 지연 상호작용 모델 vs. 바이 인코더" /><p>이러한 이유로, 지연 상호작용 모델은 일반적으로 상위 k 검색 결과의 순위를 재조정하는 데에만 사용하는 것을 권장합니다. 또한 이를 필드 유형 이름인 rank_vectors로 캡처합니다.</p><p>그렇다면 크로스 인코더는 어떨까요? 쿼리 시점에 실행 비용이 저렴하기 때문에 지연 상호작용 모델이 더 나은 것일까요? 종종 그렇듯이 답은 '상황에 따라 다르다'입니다. 크로스 인코더는 일반적으로 더 높은 품질의 결과를 생성하지만, 쿼리 문서 쌍이 트랜스포머 모델을 통해 완전한 패스를 수행해야 하기 때문에 많은 계산 자원이 필요합니다. 또한 벡터 인덱싱이 필요하지 않고 상태 비저장 방식으로 작동할 수 있다는 장점도 있습니다. 그 결과는 다음과 같습니다.</p><ul><li><p>디스크 공간 사용량 감소</p></li><li><p>더욱 간소화된 시스템</p></li><li><p>더 높은 검색 결과 품질</p></li><li><p>더 높은 대기 시간으로 인해 순위 재지정을 깊이 수행할 수 없음</p></li></ul><p>반면, 지연 상호작용 모델은 이 계산 일부를 인덱싱할 때 분산시켜 쿼리 비용을 낮출 수 있습니다. 그 대가로 벡터를 인덱싱해야 하므로 인덱스 파이프라인이 더 복잡해지고 이러한 벡터를 저장하는 데 더 많은 디스크 공간이 필요합니다.</p><p>특히 ColPali의 경우, 이미지에서 정보를 분석하는 것은 많은 데이터를 포함하기 때문에 매우 비용이 많이 듭니다. 이 경우 쿼리 시점에 이 정보를 평가하는 것은 너무 리소스 집약적이고 느리기 때문에 ColPali와 같은 후기 상호 작용 모델을 사용하는 것이 유리합니다. </p><p>ColBERT와 같은 지연 상호작용 모델의 경우, 대부분의 크로스 인코더(예: elastic-rerank-v1)처럼 텍스트 데이터를 기반으로 작동하기 때문에 디스크 공간 절약 및 간편성 측면에서 크로스 인코더를 사용하는 것이 더 유리할 수 있습니다.</p><p>사용 사례에 대한 장단점을 비교하고 Elasticsearch가 제공하는 다양한 도구를 실험하여 최고의 검색 애플리케이션을 구축하는 것이 좋습니다.</p><h2>결론</h2><p>이 블로그에서는 Elasticsearch에서 대규모 벡터 검색을 위한 ColPali와 같은 지연 상호작용 모델을 최적화하는 다양한 기술을 탐구했습니다. 지연 상호작용 모델은 검색 효율성과 순위 품질 사이에서 뛰어난 균형을 제공하지만, 저장 공간 및 계산과 관련된 문제점도 야기합니다.</p><p>이러한 과제를 해결하기 위해 다음을 살펴보았습니다.</p><ul><li><p><strong>비트 벡터</strong>를 사용하여 해밍 거리 또는 비대칭 최대 유사도와 같은 효율적인 유사도 계산을 활용하면서 디스크 공간을 크게 줄일 수 있습니다.</p></li><li><p>여러 임베딩을 하나의 밀집 표현으로 압축하기 위해 <strong>평균 벡터</strong>를 사용하여 HNSW 인덱싱을 통한 효율적인 검색을 가능하게 합니다.</p></li><li><p><strong>토큰 풀링</strong>을 통해 의미적 무결성을 유지관리하면서 중복된 임베딩을 지능적으로 병합하여 쿼리 시 계산 오버헤드를 줄입니다.</p></li></ul><p>Elasticsearch는 사용자의 요구에 따라 검색 애플리케이션을 맞춤 설정하고 최적화할 수 있는 강력한 툴킷을 제공합니다. 검색 속도, 순위 품질 또는 저장 공간 효율성 중 무엇을 우선시하든, 이러한 도구와 기술을 사용하면 실제 응용 분야에 필요한 성능과 품질의 균형을 맞출 수 있습니다.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/late-interaction-model-colpali-scale</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/late-interaction-model-colpali-scale</guid>
    <category><![CDATA[정확도]]></category>
    <category><![CDATA[벡터 데이터베이스]]></category>
    <dc:creator><![CDATA[Peter Straßer,Benjamin Trent]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt97a536033e6b0a56/6a17f47dfbc5f88c86491c0d/c780b78a07573f2df1cfef8b29a7109f839b0ab3-1200x628.png" length="0" type="image/png"/>
    <pubDate>Thu, 20 Mar 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Lucene의 동시성 버그: 낙관적인 동시성 실패를 수정하는 방법]]></title>
    <description><![CDATA[CMU 파스타 연구소의 결정론적 동시성 테스트 프레임워크인 Fray 덕분에 까다로운 루씬 버그를 추적하여 해결했습니다.]]></description>
    <content:encoded><![CDATA[<p>네, 또 다른 버그 수정 블로그입니다. 하지만 이번 이야기는 반전이 있습니다. 오픈소스 영웅이 나타나서 하루를 구해줍니다. </p><p>동시성 버그를 디버깅하는 것은 쉬운 일이 아니지만, 이제부터 시작하겠습니다. 불안정한 장애를 안정적으로 재현 가능한 장애로 전환하는 CMU의 PASTA Lab의 결정론적 동시성 테스트 프레임워크인 Fray를 사용해 보세요. 프레이의 영리한 섀도 잠금 설계와 정밀한 스레드 제어 덕분에 저희는 까다로운 루씬 버그를 추적하여 마침내 문제를 해결했습니다. 이 게시물에서는 오픈 소스 영웅과 도구가 어떻게 동시성 디버깅의 고통을 덜어주고 소프트웨어 세계를 훨씬 더 나은 곳으로 만드는지 살펴봅니다.</p><h2>동시성 버그: 소프트웨어 엔지니어의 골칫거리</h2><p>동시성 버그는 최악입니다. 고치기 어려울 뿐만 아니라 안정적으로 실패하게 만드는 것이 가장 어려운 부분입니다. 이 테스트 실패( <a href="https://github.com/apache/lucene/issues/13127"><code>TestIDVersionPostingsFormat#testGlobalVersions</code></a>)를 예로 들어 보겠습니다. 여러 문서 작성 및 업데이트 스레드를 생성하여 루씬의 낙관적인 동시성 모델에 도전합니다. 이 테스트는 낙관적인 동시성 제어에서 경쟁 조건을 노출했습니다. 즉, 문서 작업이 일련의 작업 중 최신 작업이라고 거짓으로 주장할 수 있습니다 😱. 즉, 특정 조건에서는 낙관적인 동시성 제약 조건에 따라 실패해야 할 업데이트 또는 삭제 작업이 실제로 성공할 수도 있습니다.</p><p></p>org.apache.lucene.sandbox.codecs.idversion.TestIDVersionPostingsFormat &gt; testGlobalVersions FAILED
    java.lang.AssertionError: maxSeqNo must be greater or equal to 7442 but was 7441
        at __randomizedtesting.SeedInfo.seed([B97A2BDBC7E40BF6:B4D76006D5101E6]:0)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.DocumentsWriterDeleteQueue.close(DocumentsWriterDeleteQueue.java:325)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.DocumentsWriter.flushAllThreads(DocumentsWriter.java:659)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.IndexWriter.getReader(IndexWriter.java:576)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.StandardDirectoryReader.doOpenFromWriter(StandardDirectoryReader.java:381)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.StandardDirectoryReader.doOpenIfChanged(StandardDirectoryReader.java:355)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.StandardDirectoryReader.doOpenIfChanged(StandardDirectoryReader.java:345)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.DirectoryReader.openIfChanged(DirectoryReader.java:170)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.SearcherManager.refreshIfNeeded(SearcherManager.java:144)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.SearcherManager.refreshIfNeeded(SearcherManager.java:52)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.ReferenceManager.doMaybeRefresh(ReferenceManager.java:167)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.ReferenceManager.maybeRefresh(ReferenceManager.java:213)<p>Java 스택 추적을 싫어하는 분들께 사과드립니다. 삭제가 반드시 '삭제'를 의미하는 것은 아닙니다. Lucene의 세그먼트는 읽기 전용이므로 문서 "업데이트"를 나타낼 수도 있습니다.
</p><p>Apache Lucene은 <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java"><code>DocumentsWriter</code></a> 클래스를 통해 문서를 작성하는 각 스레드를 관리합니다. 이 클래스는 문서 작성을 위한 스레드를 생성하거나 재사용하며 각 쓰기 작업은 <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java"><code>DocumentsWriterPerThread</code></a> (DWPT) 클래스 내에서 해당 정보를 제어합니다. 또한 작성자는 <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java"><code>DocumentsWriterDeleteQueue</code></a> (DWDQ)에서 어떤 문서가 삭제되었는지 추적합니다. 이러한 구조는 모든 문서 변경 작업을 메모리에 보관하고 주기적으로 플러시하여 인메모리 리소스를 확보하고 구조를 디스크에 지속합니다.</p><p></p><p><a href="https://en.wikipedia.org/wiki/Blocking_(computing)">스레드 차단을</a> 방지하고 동시 시스템에서 높은 처리량을 보장하기 위해 Apache Lucene은 매우 중요한 섹션에서만 <a href="https://docs.oracle.com/javase/tutorial/essential/concurrency/syncmeth.html">동기화를</a> 시도합니다. 이는 실제로는 좋을 수 있지만 다른 동시 시스템과 마찬가지로 용두사미가 될 수 있습니다.</p><h2>
거짓 희망</h2><p>초기 조사를 통해 적절하게 동기화되지 않은 몇 가지 중요한 섹션을 발견했습니다. 주어진 <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java"><code>DocumentsWriterDeleteQueue</code></a> 에 대한 모든 상호 작용은 둘러싸는 <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java"><code>DocumentsWriter</code></a> 에 의해 제어됩니다. 따라서 개별 메소드가 <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java"><code>DocumentsWriterDeleteQueue</code></a> 에서 적절하게 동기화되지 않을 수 있지만 월드에 대한 액세스는 동기화되어 있습니다(또는 동기화되어야 합니다). (소유권과 액세스 권한이 어떻게 혼동되는지 자세히 설명하지 않겠습니다. 많은 기여자가 작성한 오랜 프로젝트이므로 여기서는 다루지 않겠습니다. 조금만 여유를 가지세요.)</p><p></p><p>하지만 <a href="https://github.com/apache/lucene/blob/40060f8b7080d06a218518445a0a1dfc520c812a/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java#L572-L576">플러시 중</a> 동기화되지 않은 한 곳을 발견했습니다.</p>// Advance the queue, meaning create a new one to keep track of deletes
// Since we have been flushed, let's starting tracking again
DocumentsWriterDeleteQueue newQueue = documentsWriter.deleteQueue.advanceQueue(perThreadPool.size());
// OK, get the current new maximum sequence number for optimistic concurrency
seqNo = documentsWriter.deleteQueue.getMaxSeqNo();
// Reset to the new queue
documentsWriter.resetDeleteQueue(newQueue);<p>이러한 작업은 단일 원자 작업으로 동기화되지 않습니다. 즉, <code>newQueue</code> 이 생성되고 <code>getMaxSeqNo</code> 을 호출하는 사이에 다른 코드가 <code>documentsWriter</code> 클래스에서 시퀀스 번호를 증가시키면서 실행되었을 수 있습니다. 버그를 찾았습니다!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc55033280b45913/6a1706b4dc55dec27fe00d30/3f2617a102d28735e755a7539b047c28beda41be-1600x453.jpg" alt="" /><p>
하지만 대부분의 복잡한 버그가 그렇듯 근본 원인을 찾는 것도 쉽지 않았습니다. 바로 그때 영웅이 등장했습니다.</p><h2>전투의 영웅</h2><p>우리의 영웅을 소개합니다: 파스타 연구소의 <a href="https://aoli.al/">아오 리와</a> 그의 동료들. 프레이가 어떻게 하루를 구했는지 설명해 드리겠습니다.</p><p><a href="https://github.com/cmu-pasta/fray">Fray는</a> 카네기멜론 대학교의 <a href="https://pastalab.org/">PASTA</a> 연구소의 연구진이 개발한 결정론적 동시성 테스트 프레임워크입니다. 결정론적 동시성 테스트는 20년 이상 학계에서 광범위하게 연구되어 왔지만, 실무자들은 여전히 신뢰할 수 없고 불안정한 것으로 널리 알려진 스트레스 테스트에 의존하여 동시성 프로그램을 테스트하고 있습니다. 따라서 저희는 일반성과 실제 적용 가능성을 주요 목표로 삼아 결정론적 동시성 테스트 프레임워크를 설계하고 구현하고자 했습니다.</p><p></p><h2>핵심 아이디어</h2><p>프레이의 핵심은 순차적 실행이라는 간단하지만 강력한 원칙을 활용합니다. Java의 동시성 모델은 프로그램에 데이터 경합이 없는 경우 모든 실행이 순차적으로 일관되게 표시된다는 핵심 속성을 <a href="https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.3">제공합니다.</a> 즉, 프로그램의 동작은 일련의 프로그램 문으로 표현할 수 있습니다.</p><p>Fray는 대상 프로그램을 순차적으로 실행하는 방식으로 작동하며, 각 단계에서 하나의 스레드를 제외한 모든 스레드를 일시 중지하여 Fray가 스레드 스케줄링을 정밀하게 제어할 수 있도록 합니다. 스레드는 동시성을 시뮬레이션하기 위해 무작위로 선택되지만, 이후 결정론적 리플레이를 위해 선택 사항이 기록됩니다. 실행을 최적화하기 위해 Fray는 스레드가 잠금 또는 원자/휘발성 액세스와 같은 동기화 명령을 실행하려고 할 때만 컨텍스트 전환을 수행합니다. 데이터 레이스 자유의 좋은 특성은 이러한 제한된 컨텍스트 전환만으로도 스레드 인터리빙으로 인해 관찰 가능한 모든 동작을 탐색하기에 충분하다는 것입니다<a href="https://arxiv.org/abs/2501.12618">(저희 논문에는</a> 증명 스케치가 있습니다).</p><p></p><h2>과제: 스레드 스케줄링 제어</h2><p>핵심 아이디어는 간단해 보이지만, 프레이를 구현하는 데는 상당한 어려움이 있었습니다. 스레드 스케줄링을 제어하려면 Fray가 각 애플리케이션 스레드의 실행을 관리해야 합니다. 언뜻 보기에는 동시성 프리미티브를 사용자 정의 구현으로 대체하는 것이 간단해 보일 수 있습니다. 하지만 <a href="https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-6.html">바이트코드 명령어</a>, <a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/locks/ReentrantLock.html">하이레벨 라이브러리</a>, <a href="https://github.com/openjdk/jdk/blob/b720517cb33c2119ec6ed85504bce321de748228/src/java.base/share/classes/java/lang/Object.java#L394">네이티브 메서드가</a> 혼합되어 있는 JVM의 동시성 제어는 복잡합니다.</p><p></p><p>이것은 토끼굴로 밝혀졌습니다:</p><p></p><ul><li><p>예를 들어, 모든 <code>MONITORENTER</code> 인스트럭션에는 동일한 방법의 <code>MONITOREXIT</code> 인스트럭션이 있어야 합니다. Fray가 <code>MONITORENTER</code> 를 스텁/모의 메서드 호출로 대체하는 경우, <code>MONITOREXIT</code> 도 대체해야 합니다.</p></li><li><p><code>object.wait/notify</code> 을 사용하는 코드에서 <code>MONITORENTER</code> 을 대체하는 경우 해당 <code>object.wait</code> 도 대체해야 합니다. 이 교체 체인은 <code>object.notify</code> 이상으로 확장됩니다.</p></li><li><p>JVM은 네이티브 코드 내에서 특정 동시성 관련 메서드(예: 스레드가 종료될 때 <code>object.notify</code> )를 호출합니다. 이러한 작업을 대체하려면 JVM 자체를 수정해야 합니다.</p></li><li><p>클래스 로더 및 가비지 컬렉션(GC) 스레드와 같은 JVM 함수도 동시성 프리미티브를 사용합니다. 이러한 프리미티브를 수정하면 해당 JVM 함수와 불일치가 발생할 수 있습니다.</p></li><li><p>JDK에서 동시성 프리미티브를 교체하면 초기화 단계에서 JVM 충돌이 발생하는 경우가 많습니다.</p></li></ul><p></p><p>이러한 문제들로 인해 동시성 프리미티브의 포괄적인 대체가 불가능하다는 것이 분명해졌습니다.</p><h2>
솔루션: 섀도락 디자인</h2><p>이러한 문제를 해결하기 위해 Fray는 새로운 섀도 잠금 메커니즘을 사용해 동시성 프리미티브를 대체하지 않고 스레드 실행을 오케스트레이션합니다. 섀도 락은 스레드 실행을 안내하는 중개자 역할을 합니다. 예를 들어 잠금을 획득하기 전에 애플리케이션 스레드는 해당 섀도 잠금과 상호 작용해야 합니다. 섀도 잠금은 스레드가 잠금을 획득할 수 있는지 여부를 결정합니다. 스레드를 진행할 수 없는 경우 섀도 락이 스레드를 차단하고 다른 스레드가 실행되도록 허용하여 교착 상태를 방지하고 동시성을 제어할 수 있습니다. 이러한 설계를 통해 Fray는 동시성 시맨틱의 정확성을 유지하면서 스레드 인터리빙을 투명하게 제어할 수 있습니다. 각 동시성 프리미티브는 섀도 잠금 프레임워크 내에서 신중하게 모델링되어 건전성과 완성도를 보장합니다. 자세한 기술적인 내용은 백서에서 확인할 수 있습니다.</p><p></p><p>또한 이 디자인은 미래에도 사용할 수 있도록 설계되었습니다. 동시성 프리미티브에 대한 섀도 잠금의 계측만 요구함으로써 최신 버전의 JVM과의 호환성을 보장합니다. 이는 JVM의 동시성 프리미티브 인터페이스가 비교적 안정적이며 수년 동안 변경되지 않았기 때문에 가능합니다.</p><h2>
테스트 프레이</h2><p>Fray를 구축한 후 다음 단계는 평가였습니다. 다행히도 Apache Lucene과 같은 많은 애플리케이션에는 이미 동시성 테스트가 포함되어 있습니다. 이러한 동시성 테스트는 여러 스레드를 생성하고 일부 작업을 수행한 다음 (일반적으로) 해당 스레드가 완료될 때까지 기다린 다음 일부 속성을 어설트하는 일반 JUnit 테스트입니다. 대부분의 경우 이러한 테스트는 인터리빙을 한 번만 실행하기 때문에 통과합니다. 더 큰 문제는 앞서 설명한 대로 일부 테스트는 CI/CD 환경에서 가끔씩만 실패하기 때문에 이러한 실패를 디버깅하기가 매우 어렵다는 것입니다. Fray로 동일한 테스트를 실행했을 때 수많은 버그를 발견했습니다. 특히, 프레이는 이 블로그의 초점인 <a href="https://github.com/apache/lucene/issues/13127"><code>TestIDVersionPostingsFormat.testGlobalVersions</code></a> 을 포함하여 이전에 보고된 버그 중 신뢰할 수 있는 재현이 없어 수정되지 않은 채로 남아 있던 버그를 재발견했습니다. 다행히도 프레이를 사용하면 문제를 결정적으로 재생하고 개발자에게 자세한 정보를 제공하여 문제를 안정적으로 재현하고 수정할 수 있습니다.</p><p></p><h2>프레이의 다음 단계</h2><p>Elastic의 개발자들로부터 Fray가 동시성 버그 디버깅에 도움이 되었다는 이야기를 듣게 되어 매우 기쁩니다. 앞으로도 더 많은 개발자가 프레이를 사용할 수 있도록 지속적으로 노력할 것입니다.</p><p>우리의 단기 목표는 무작위 값 생성기나 <code>object.hashcode</code> 사용과 같은 다른 비결정적 연산이 있는 경우에도 일정을 결정론적으로 재생하는 Fray의 기능을 강화하는 것입니다. 또한 개발자가 수동 개입 없이 기존 동시성 테스트를 분석하고 디버깅할 수 있도록 Fray의 사용성을 개선하는 것을 목표로 하고 있습니다. 무엇보다도 프로그램에서 동시성 문제를 디버깅하거나 테스트하는 데 어려움을 겪고 계신다면 여러분의 의견을 듣고 싶습니다. 주저하지 마시고 <a href="https://github.com/cmu-pasta/fray">프레이 깃허브 리포지토리에서</a> 이슈를 생성해 주세요.</p><p></p><h2>동시성 버그 수정 시간</h2><p>Ao Li와 PASTA 연구소 덕분에 이제 이 테스트가 안정적으로 실패한 사례가 생겼습니다! 드디어 이 문제를 해결할 수 있게 되었습니다. 핵심 문제는 <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThreadPool.java"><code>DocumentsWriterPerThreadPool</code></a> 에서 스레드 및 리소스 재사용을 허용하는 방식에 있었습니다.</p>1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_0, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t0 20 getNextSequenceNumber 1 called from stack:
&lt;snip&gt;...&lt;/snip&gt;
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_1, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_5, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_2, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_6, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_3, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_4, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]<p>여기에서 각 스레드가 생성되는 것을 볼 수 있으며, 0 생성 시 초기 삭제 대기열을 참조합니다.</p><p>그러면 대기열에서 이전 7개의 작업이 올바르게 표시되는 플러시에서 대기열 진행이 이루어집니다.</p>1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t020 advanceQueue called from stack with maxSeq 9 lastSeqNo: 1 maxNumPendingOps: 7:
&lt;snip&gt;...&lt;/snip&gt;
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t525 getNextSequenceNumber 2 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t828 getNextSequenceNumber 3 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t727 getNextSequenceNumber 4 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t626 getNextSequenceNumber 5 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t424 getNextSequenceNumber 6 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t323 getNextSequenceNumber 7 called from stack:<p>그러나 모든 스레드가 플러싱을 완료하기 전에 두 개의 스레드가 추가 문서에 재사용됩니다:</p>1&gt; getAndLock: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_0, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; getAndLock: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_3, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]<p><code>numDocsInRAM</code> 그러면 플러시 중에 7로 계산된 가정된 최대값보다 <code>seqNo</code> 이 증가합니다. 세그먼트 <code>_3</code> 및 <code>_0</code></p>1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_2, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_3, aborted=false, numDocsInRAM=2, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_6, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_4, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_5, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_1, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_0, aborted=false, numDocsInRAM=2, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove<p>따라서 Lucene이 플러시 중 문서 작업의 순서를 잘못 설명하여 이 테스트가 실패하게 됩니다.</p><p>모든 좋은 버그 수정이 그렇듯, 실제 수정 사항은 약 <a href="https://github.com/apache/lucene/pull/13627/files">10줄의 코드입니다</a>. 하지만 두 명의 엔지니어가 실제로 알아내는 데 며칠이 걸렸습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt71d78f22ce6ec215/6a1706b61949f7bcd0e7a94a/7915e5ba2feba0fb8e846da0af11bfdca470c467-1600x622.jpg" alt="" /><h2>모든 영웅이 망토를 입는 것은 아닙니다</h2><p>네, 진부한 표현이지만 사실입니다.</p><p></p><p>동시 프로그램 디버깅은 매우 중요합니다. 이러한 까다로운 동시성 버그는 디버깅하고 해결하는 데 엄청난 시간이 걸립니다. Rust와 같은 새로운 언어에는 이와 같은 경쟁 조건을 방지하는 메커니즘이 내장되어 있지만, 전 세계 대부분의 소프트웨어는 이미 <a href="https://www.rust-lang.org/">Rust가</a> 아닌 다른 언어로 작성되어 있습니다. 자바는 오랜 시간이 지난 지금도 여전히 가장 많이 사용되는 언어 중 하나입니다. JVM 기반 언어에서 디버깅을 개선하면 소프트웨어 엔지니어링 세계가 더 좋아집니다. 그리고 일부 사람들은 코드가 대규모 언어 모델에 의해 작성될 것이라고 생각하기 때문에, 엔지니어로서 우리가 하는 일은 결국 나쁜 코드가 아니라 나쁜 LLM 코드를 디버깅하는 것일지도 모릅니다. 그러나 소프트웨어 엔지니어링의 미래와 상관없이 동시 프로그램 디버깅은 소프트웨어를 유지 관리하고 구축하는 데 여전히 중요할 것입니다.</p><p></p><p>이보다 훨씬 더 나은 서비스를 만들어준 PASTA Lab의 Ao Li와 동료들에게 감사드립니다.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/concurrency-bugs-lucene-debugging</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/concurrency-bugs-lucene-debugging</guid>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Benjamin Trent,Ao Li]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf9fcbf843affc566/6a1706b8964ceaea3208bab9/7884e35f40c15fe349379ef7ae4648b21708962f-1070x712.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 07 Feb 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[루씬 버그 모험: 손상된 인덱스 예외 수정]]></title>
    <description><![CDATA[때로는 한 줄의 코드를 작성하는 데 며칠이 걸리기도 합니다. 여기에서는 잠재적인 Apache Lucene 인덱스 손상을 해결하기 위해 며칠에 걸쳐 디버깅을 진행했던 엔지니어의 고군분투를 엿볼 수 있습니다.]]></description>
    <content:encoded><![CDATA[<h2>준비하세요: </h2><p>이 특별한 블로그는 평소와 다릅니다. 새로운 기능에 대한 설명이나 튜토리얼이 아닙니다. 이것은 작성하는 데 3일이 걸린 한 줄의 코드에 관한 것입니다. 잠재적인 Apache Lucene 인덱스 손상을 수정할 예정입니다. 몇 가지 팁을 알려드리고자 합니다:</p><ul><li><p>충분한 시간과 올바른 도구만 있다면 모든 결함 테스트는 반복할 수 있습니다.</p></li><li><p>견고한 시스템을 위해서는 여러 단계의 테스트가 중요합니다. 그러나 테스트 수준이 높아질수록 디버깅과 재현이 점점 더 어려워집니다.</p></li><li><p>수면은 훌륭한 디버거입니다</p></li></ul><h2>Elasticsearch 테스트 방법</h2><p>Elastic에서는 Elasticsearch 코드베이스에 대해 실행되는 수많은 테스트가 있습니다. 일부는 단순하고 집중적인 기능 테스트이고, 다른 일부는 단일 노드 "해피 경로" 통합 테스트이며, 또 다른 일부는 장애 시나리오에서 모든 것이 올바르게 작동하는지 확인하기 위해 클러스터를 중단하려고 시도합니다. 테스트가 계속 실패하면 엔지니어 또는 도구 자동화가 깃허브 이슈를 생성하고 특정 팀에서 조사할 수 있도록 플래그를 지정합니다. 이 <a href="https://github.com/elastic/elasticsearch/issues/105122">특정 버그는</a> 지난 테스트에서 발견되었습니다. 이러한 테스트는 까다로워서 여러 번 실행해야만 반복할 수 있는 경우도 있습니다.</p><h2>이 테스트는 실제로 무엇을 테스트하나요?</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt58aab468587a7d35/6a17dd8e3e03d74b8d4f2b5f/63268f3b5714ebf9df8070ec2e3c4f0486ed822e-1600x1088.jpg" alt="깃허브 이슈: https://github.com/elastic/elasticsearch/issues/105122" /><p>이 특별한 테스트는 흥미로운 테스트입니다. 특정 매핑을 생성하여 기본 샤드에 적용합니다. 그런 다음 복제본을 만들려고 할 때. 주요 차이점은 복제본이 문서 구문 분석을 시도할 때 테스트가 예외를 삽입하여 예상치 못한(그러나 예상했던) 방식으로 복구가 실패한다는 것입니다.</p><p></p><p>하지만 모든 것이 예상대로 작동했지만 한 가지 중요한 문제가 있었습니다. 테스트 정리 과정에서 일관성을 검증하는 과정에서 이 테스트에 문제가 발생했습니다.</p><p>
이 테스트는 예상대로 실패했습니다. 일관성 검사 중에 모든 복제된 파일과 기본 Lucene 세그먼트 파일이 일관성이 있는지 확인합니다. 즉, 손상되지 않고 완전히 복제된다는 의미입니다. 부분적인 데이터나 손상된 데이터는 완전히 실패하는 것보다 훨씬 더 나쁩니다. 다음은 실패에 대한 무섭고 축약된 스택 추적입니다.</p><p></p>Caused by: org.apache.lucene.index.CorruptIndexException: Problem reading index from store(ByteSizeCachingDirectory(ElasticsearchMockDirectoryWrapper(HybridDirectory@/opt/buildkite-agent/builds/bk-agent-prod-gcp-1707109485745743789/elastic/elasticsearch-periodic/server/build/testrun/internalClusterTest/temp/org.elasticsearch.indices.recovery.IndexRecoveryIT_40853F21F419B395-001/tempDir-005/node_t0/indices/ZNwxG7VvShuwYV78RTjknA/0/index lockFactory=org.apache.lucene.store.NativeFSLockFactory@2c169f59))) (resource=store(ByteSizeCachingDirectory(ElasticsearchMockDirectoryWrapper(HybridDirectory@/opt/buildkite-agent/builds/bk-agent-prod-gcp-1707109485745743789/elastic/elasticsearch-periodic/server/build/testrun/internalClusterTest/temp/org.elasticsearch.indices.recovery.IndexRecoveryIT_40853F21F419B395-001/tempDir-005/node_t0/indices/ZNwxG7VvShuwYV78RTjknA/0/index lockFactory=org.apache.lucene.store.NativeFSLockFactory@2c169f59))))

    at org.apache.lucene.index.SegmentCoreReaders.&lt;init&gt;(SegmentCoreReaders.java:165)
    at org.apache.lucene.index.SegmentReader.&lt;init&gt;(SegmentReader.java:96)
    at org.apache.lucene.index.ReadersAndUpdates.getReader(ReadersAndUpdates.java:178)
    at org.apache.lucene.index.ReadersAndUpdates.getLatestReader(ReadersAndUpdates.java:243)
    at org.apache.lucene.index.SoftDeletesRetentionMergePolicy.keepFullyDeletedSegment(SoftDeletesRetentionMergePolicy.java:82)
    at org.apache.lucene.index.FilterMergePolicy.keepFullyDeletedSegment(FilterMergePolicy.java:118)
    at org.apache.lucene.index.FilterMergePolicy.keepFullyDeletedSegment(FilterMergePolicy.java:118)
    at org.apache.lucene.index.ReadersAndUpdates.keepFullyDeletedSegment(ReadersAndUpdates.java:822)
    at org.apache.lucene.index.IndexWriter.isFullyDeleted(IndexWriter.java:6078)
    &lt;snip&gt;

    Caused by: java.io.FileNotFoundException: No sub-file with id .kdi found in compound file "_0.cfs" (fileName=_0.kdi files: [_0.pos, .nvm, .fnm, _0.tip, _Lucene90_0.dvd, _0.doc, _0.tim, _Lucene90_0.dvm, _ES87BloomFilter_0.bfm, .fdm, .nvd, _ES87BloomFilter_0.bfi, _0.tmd, .fdx, .fdt])

      at org.apache.lucene.codecs.lucene90.Lucene90CompoundReader.openInput(Lucene90CompoundReader.java:170)
      at org.apache.lucene.codecs.lucene90.Lucene90PointsReader.&lt;init&gt;(Lucene90PointsReader.java:63)
      at org.apache.lucene.codecs.lucene90.Lucene90PointsFormat.fieldsReader(Lucene90PointsFormat.java:74)
      at org.apache.lucene.index.SegmentCoreReaders.&lt;init&gt;(SegmentCoreReaders.java:152)
      &lt;snip&gt;
<p>강제 복제 실패로 인해 복제된 샤드가 결국 손상되었습니다! 오류의 핵심 부분을 쉬운 말로 설명해 드리겠습니다.</p><p></p><p>Lucene은 세그먼트 기반 아키텍처로, 각 세그먼트가 자체 읽기 전용 파일을 알고 관리합니다. 이 특정 <a href="https://github.com/apache/lucene/blob/add9c09c84ee66d4522c566c9f679035a0dfec13/lucene/core/src/java/org/apache/lucene/index/SegmentCoreReaders.java">세그먼트는 세그먼트코어리더를</a> 통해 모든 것이 정상적으로 작동하는지 검증하고 있었습니다. 각 핵심 리더에는 특정 세그먼트에 대해 어떤 필드 유형과 파일이 존재하는지 나타내는 메타데이터가 저장되어 있습니다. 그러나 <a href="https://github.com/apache/lucene/blob/add9c09c84ee66d4522c566c9f679035a0dfec13/lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90PointsFormat.java">Lucene90PointsFormat의</a> 유효성을 검사할 때 예상되는 특정 파일이 누락되었습니다. 세그먼트 <code>_0.cfs</code> 파일을 사용하면 <code>kdi</code> 이라는 포인트 형식 파일이 예상됩니다. <code>cfs</code> 는 "복합 파일 시스템" 의 약자로, Lucene은 때때로 모든 필드 유형과 모든 작은 파일을 하나의 큰 파일로 결합하여 보다 효율적인 복제 및 리소스 활용을 위해 사용합니다. 실제로 세 가지 포인트 파일 확장자( <code>kdd</code>, <code>kdi</code>, <code>kdm</code> 의 세 가지 확장자가 모두 누락되었습니다. 루씬 세그먼트가 포인트 파일을 찾을 것으로 예상되는 위치에 어떻게 들어가야 하는데 파일이 없습니다!!! 무서운 손상 버그인 것 같습니다!</p><p></p><h2>모든 버그 수정의 첫 번째 단계, 복제하기</h2><p></p><p>이 특정 버그의 오류를 재현하는 것은 매우 고통스러운 작업이었습니다. Elasticsearch에서는 <a href="https://en.wikipedia.org/wiki/Random_testing">무작위 값 테스트를</a> 활용하지만, 모든 실패를 조사할 수 있도록 모든 실패에 대해 (희망적으로) 재현 가능한 무작위 시드를 제공해야 합니다. 이 방법은 <a href="https://en.wikipedia.org/wiki/Race_condition">경쟁 조건으로</a> 인한 장애를 제외한 모든 장애에 대해 잘 작동합니다.</p>./gradlew ':server:internalClusterTest' --tests "org.elasticsearch.indices.recovery.IndexRecoveryIT.testDoNotInfinitelyWaitForMapping" -Dtests.seed=40853F21F419B395 -Dtests.jvm.argline="-Des.concurrent_search=true" -Dtests.locale=id-ID -Dtests.timezone=Asia/Jerusalem -Druntime.java=21<p>몇 번을 시도해도 특정 시드는 로컬에서 실패를 반복하지 않았습니다. 그러나 테스트를 실행하고 더 반복 가능한 실패를 향해 나아갈 수 있는 방법이 있습니다.</p><p></p><p>특정 테스트 스위트를 사용하면 <code>-Dtests.iters</code> 매개 변수를 통해 동일한 명령에서 지정된 테스트를 두 번 이상 실행할 수 있습니다. 하지만 이것만으로는 충분하지 않았고, 실행 스레드가 전환되어 이 경쟁 조건이 발생할 가능성이 높아지는지 확인해야 했습니다. 시스템의 또 다른 문제점은 테스트 실행 시간이 너무 오래 걸려 테스트 러너가 타임아웃을 일으킨다는 점입니다. 결국 다음과 같은 악몽 배쉬를 사용하여 테스트를 반복적으로 실행했습니다:</p>for run in {1..10}; do ./gradlew ':server:internalClusterTest' --tests "org.elasticsearch.indices.recovery.IndexRecoveryIT.testDoNotInfinitelyWaitForMapping" -Dtests.jvm.argline="-Des.concurrent_search=true" -Dtests.iters=10 ; done || exit 1<p><a href="https://github.com/ColinIanKing/stress-ng">스트레스를</a> 받습니다. 이렇게 하면 점심시간에 CPU 코어를 먹는 프로세스를 빠르게 시작할 수 있습니다. 실패한 테스트를 여러 번 반복하면서 무작위로 스트레스를 스팸으로 전송한 결과 마침내 실패를 재현할 수 있었습니다. 한 걸음 더 가까이. 시스템에 스트레스를 주려면 다른 터미널 창을 열고 실행하면 됩니다:</p>stress-ng --cpu 16<h2>
버그 공개</h2><p>

테스트 실패로 버그가 대부분 반복 가능하므로 이제 원인을 찾아야 할 때입니다. 이 특정 테스트를 이상하게 만드는 것은 루씬이 포인트 값을 기대하기 때문에 던지는 것이지만, 테스트에서 직접 추가하는 것은 없다는 것입니다. 텍스트 값만 입력할 수 있습니다. 이 때문에 저는 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/optimistic-concurrency-control.html">낙관적인 동시성 제어</a> 필드에 대한 최근 변경 사항을 살펴보기로 했습니다: <code>_seq_no</code> 와 <code>_primary_term</code>. 이 두 가지 모두 포인트로 색인되며 모든 Elasticsearch 문서에 존재합니다.</p><p></p><p>실제로 <a href="https://github.com/elastic/elasticsearch/pull/105036">커밋으로</a> 인해 <code>_seq_no</code> 매퍼가 변경되었습니다! 예! 이것이 원인일 것입니다! 하지만 흥분은 잠시뿐이었습니다. 이렇게 하면 문서에 필드가 추가되는 순서만 변경됩니다. 이 변경 전에는 <code>_seq_no</code> 필드가 문서의 마지막에 추가되었습니다. 그 후에 먼저 추가되었습니다. Lucene 문서에 필드를 추가하는 순서 때문에 이 오류가 발생할 리가 없습니다...</p><p></p><p>네, 필드를 추가한 순서를 변경한 것이 실패의 원인이었습니다. 놀랍게도 이것은 Lucene 자체의 버그로 밝혀졌습니다! 구문 분석되는 필드의 순서를 변경해도 문서 구문 분석의 동작은 변경되지 않습니다.</p><p></p><h2>Lucene의 버그</h2><p>실제로 Lucene의 버그는 다음 조건에 초점을 맞췄습니다:</p><ul><li><p>포인트 값 필드 색인화(예 <code>_seq_no</code>)</p></li><li><p>분석 중 텍스트 필드 던지기를 색인화하려고 합니다.</p></li><li><p>이 이상한 상태에서 텍스트 인덱스 분석 예외를 경험하는 작성자로부터 <a href="https://blog.mikemccandless.com/2011/06/lucenes-near-real-time-search-is-fast.html">거의 실시간에 가까운 리더를</a> 엽니다.</p></li></ul><p>하지만 아무리 여러 가지 방법을 시도해도 완벽하게 복제할 수 없었습니다. 저는 Lucene 코드베이스 전체에 디버깅을 위한 일시 중지 지점을 직접 추가했습니다. 예외 경로 중에 무작위로 리더를 열려고 시도했습니다. 이 장애가 발생한 정확한 경로를 찾기 위해 몇 메가바이트의 로그를 출력하기도 했습니다. 도저히 할 수 없었습니다. 하루 종일 싸우고 지는 데 시간을 보냈습니다.</p><p></p><p>그리고 잠을 잤습니다.</p><p></p><p>다음 날 원본 스택 추적을 다시 읽다가 다음 줄을 발견했습니다:</p><p>
</p>    at org.apache.lucene.index.SoftDeletesRetentionMergePolicy.keepFullyDeletedSegment(SoftDeletesRetentionMergePolicy.java:82)<p>모든 재창조를 시도할 때 보존 병합 정책을 구체적으로 설정한 적이 없습니다. <a href="https://github.com/apache/lucene/blob/5f0fa2b291ff9e7d878642f025a70c15b788a470/lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java">SoftDeletesRetentionMergePolicy는</a> 복제본에서 삭제를 정확하게 복제하고 문서가 실제로 제거될 때 모든 동시성 제어를 담당할 수 있도록 Elasticsearch에서 사용됩니다. 그렇지 않으면 Lucene이 모든 권한을 가지며 병합 시 해당 항목을 제거합니다.</p><p></p><p>이 정책을 추가하고 위에서 언급한 가장 기본적인 단계를 복제하자 장애가 즉시 복제되었습니다.</p><p>
<a href="https://github.com/apache/lucene/issues/13353">루씬에서 버그를</a> 발견한 것이 이렇게 기뻤던 적이 없었습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt89d37c3454ec298b/6a17dd90ec0f8993c75a6511/c7d9ce3de5278923a8454097e2bdf487c861168a-1600x920.jpg" alt="깃허브 이슈 https://github.com/apache/lucene/issues/13353" /><p>
Elasticsearch에서는 경쟁 조건으로 표시되었지만, 모든 조건이 충족되면 Lucene에서는 반복적으로 실패하는 테스트를 작성하는 것이 간단했습니다.</p><p></p><p>결국 모든 좋은 버그가 그렇듯 단 한 줄의 코드로 해결되었습니다. 단 한 줄의 코드로 며칠 동안 작업할 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdab62db3402b27f7/6a17dd92dbb4ff23e7fb55ad/2c10a02eb41da181b1dceee47186c076c9b14a90-1600x539.jpg" alt="한 줄의 코드 수정" /><p>하지만 그만한 가치가 있었습니다.</p><h2>
끝이 아닙니다.</h2><p>저와 함께 이 거친 여정이 즐거우셨기를 바랍니다! 소프트웨어, 특히 Elasticsearch와 Apache Lucene처럼 널리 사용되고 복잡한 소프트웨어를 작성하는 것은 보람 있는 일입니다. 하지만 때로는 매우 실망스러울 때도 있습니다. 저는 소프트웨어를 좋아하기도 하고 싫어하기도 합니다. 버그 수정은 끝나지 않았습니다!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/lucene-corrupted-index-exception</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/lucene-corrupted-index-exception</guid>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Benjamin Trent]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc25119c4add97ba/6a17dd94420229633929f4f7/2c918bad62530ff6fbe419092b2ca44bfe9408c4-944x612.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 27 Dec 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[루씬의 스칼라 양자화 이해하기]]></title>
    <description><![CDATA[자동 바이트 양자화, 세그먼트별 양자화, &amp; 성능 인사이트를 포함하여 Elastic이 어떻게 Lucene에 스칼라 양자화를 도입했는지 살펴보세요.]]></description>
    <content:encoded><![CDATA[<h2>Lucene의 자동 바이트 정량화</h2><p>HNSW는 벡터를 저장하고 검색하는 강력하고 유연한 방법이지만, 빠르게 실행하려면 상당한 양의 메모리를 필요로 합니다. 예를 들어, 768차원의 1MM float32 벡터를 쿼리하려면 약  램이 필요합니다. 상당한 수의 벡터를 검색하기 시작하면 비용이 많이 듭니다. 약  적은 메모리를 사용하는 한 가지 방법은 바이트 정량화를 사용하는 것입니다. Lucene과 그에 따른 Elasticsearch는 한동안  벡터 인덱싱을 지원했지만 이러한 벡터를 구축하는 것은 사용자의 책임이었습니다. 이제 곧 Lucene에  스칼라 양자화가 도입될 예정입니다.</p><h2>스칼라 양자화 101</h2><p>모든 양자화 기술은 원시 데이터의 손실 변환으로 간주됩니다. 즉, 공간 확보를 위해 일부 정보가 손실된다는 의미입니다. 스칼라 양자화에 대한 자세한 설명은 다음을 참조하세요: <a href="https://www.elastic.co/search-labs/scalar-quantization-101">스칼라 양자화 101을</a> 참조하세요. 높은 수준에서 스칼라 양자화는 손실 압축 기술입니다. 간단한 계산을 통해 리콜에 거의 영향을 미치지 않으면서도 공간을 크게 절약할 수 있습니다.</p><h2>아키텍처 살펴보기</h2><p>Elasticsearch 작업에 익숙하신 분들은 이러한 개념에 이미 익숙하실 수도 있지만, 검색을 위한 문서 배포에 대한 간략한 개요는 다음과 같습니다.</p><p>각 Elasticsearch 인덱스는 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/size-your-shards.html#size-your-shards">여러 개의 샤드로</a> 구성됩니다. 각 샤드는 단일 노드에만 할당할 수 있지만, 인덱스당 여러 개의 샤드를 사용하면 노드 간에 컴퓨팅 병렬 처리를 할 수 있습니다.</p><p>각 샤드는 하나의 <a href="https://lucene.apache.org/core/9_8_0/core/org/apache/lucene/index/package-summary.html">루씬 인덱스로</a> 구성됩니다. Lucene 인덱스는 여러 개의 읽기 전용 세그먼트로 구성됩니다. 색인하는 동안 문서가 버퍼링되고 주기적으로 읽기 전용 세그먼트로 플러시됩니다. 특정 조건이 충족되면 이러한 세그먼트는 백그라운드에서 더 큰 세그먼트로 병합될 수 있습니다. 이 모든 것은 구성할 수 있으며 나름의 복잡성을 가지고 있습니다. 그러나 세그먼트와 병합에 대해 이야기할 때는 읽기 전용 Lucene 세그먼트와 이러한 세그먼트의 자동 주기적 병합에 대해 이야기하고 있습니다. 세그먼트 병합 및 디자인 결정에 대해 <a href="https://www.elastic.co/search-labs/vector-search-elasticsearch-rationale">자세히 알아보세요</a>.</p><h2>루씬의 세그먼트별 정량화</h2><p>Lucene의 모든 세그먼트에는 개별 벡터, HNSW 그래프 인덱스, 양자화된 벡터, 계산된 사분위수 등이 저장됩니다. 간결성을 위해 여기서는 Lucene이 정량화된 벡터와 원시 벡터를 저장하는 방식에 초점을 맞추겠습니다. 모든 세그먼트에 대해  파일의 원시 벡터, 양자화된 벡터 및 단일 보정 승수 플로트( ), 그리고  파일 내의 양자화 관련 메타데이터를 추적합니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1861d0af970fd096/6a17d9887f6f15a45dc099c0/507a4d578f8a5d2225b11d16ac1fc0b7dd39eaa1-1207x228.png" alt=".vec 파일" /><p>그림 1: 원시 벡터 스토리지 파일의 단순화된 레이아웃.  값은 4바이트이므로  디스크 공간을 차지합니다. 정량화 중이므로 HNSW 검색 중에는 로드되지 않습니다. 특별히 요청된 경우에만 사용됩니다(예 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/filter-search-results.html#query-rescorer">재점수를</a> 통한 무차별 대입) 또는 세그먼트 병합 중 재정량화를 위해 사용합니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8c52b51939357280/6a17d9897b54f9130f8b3761/ffc6e8bffefc5cd36f14aae315184c89e04a6587-1440x207.png" alt=".veq 파일" /><p>그림 2:  단순화된 레이아웃 파일을 만듭니다.  공간을 차지하며 검색 중에 메모리에 로드됩니다.  정확도와 기억력을 높이기 위해 점수를 조정하는 데 사용되는 보정 승수 플로트를 설명하는 값입니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt28e007988f81e5c0/6a17d98b25daabe23208a0d9/555cc92d7d179d8c9711cefbaaac3e0b815d2e42-1440x182.png" alt=".vmq 파일" /><p>그림 3: 메타데이터 파일의 단순화된 레이아웃. 여기에서 이 세그먼트에 대해 계산된 사분위수와 함께 양자화 및 벡터 구성을 추적합니다.</p><p>따라서 각 세그먼트에 대해 양자화된 벡터뿐만 아니라 이러한 양자화된 벡터를 만드는 데 사용된 분위수와 원래의 원시 벡터를 저장합니다. 그렇다면 왜 원시 벡터를 보관하는 것일까요?</p><h2>사용자와 함께 성장하는 정량화</h2><p>Lucene은 주기적으로 읽기 전용 세그먼트를 플러시하기 때문에 각 세그먼트는 모든 데이터의 일부만 볼 수 있습니다. 즉, 전체 데이터의 해당 샘플 세트에 대해서만 계산된 사분위수가 직접 적용됩니다. 샘플이 전체 말뭉치를 적절히 대표한다면 큰 문제가 되지 않습니다. 하지만 Lucene을 사용하면 다양한 방식으로 인덱스를 정렬할 수 있습니다. 따라서 세그먼트별 사분위수 계산에 편향을 추가하는 방식으로 정렬된 데이터를 인덱싱할 수 있습니다. 또한 원할 때마다 데이터를 플러시할 수 있습니다! 샘플 세트는 하나의 벡터일 정도로 작을 수 있습니다. 또 다른 장점은 병합이 발생하는 시기를 제어할 수 있다는 점입니다. 기본값과 주기적 병합이 설정되어 있지만, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.10/indices-forcemerge.html">_force_merge</a> API를 통해 원할 때마다 병합을 요청할 수 있습니다. 그렇다면 어떻게 하면 이 모든 유연성을 허용하면서도 좋은 리콜을 제공하는 우수한 정량화를 제공할 수 있을까요?</p><p>루씬의 벡터 양자화는 시간이 지남에 따라 자동으로 조정됩니다. Lucene은 읽기 전용 세그먼트 아키텍처로 설계되었기 때문에 각 세그먼트의 데이터가 변경되지 않았음을 보장하고 업데이트가 가능한 시점을 코드에 명확하게 구분합니다. 즉, 세그먼트 병합 중에 필요에 따라 사분위수를 조정하고 벡터를 다시 정량화할 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0d9a2390958c3e82/6a17d98cbe6086e4fd0045d5/646baa8279719d06c05c1a4fdb80a0c060e0dd94-1440x446.png" alt="여러 세그먼트 백분위수" /><p>그림 4: 서로 다른 사분위수를 가진 세 가지 예시 세그먼트.</p><p>하지만 재정량화에는 비용이 많이 들지 않을까요? 약간의 오버헤드가 있긴 하지만, Lucene은 지능적으로 사분위수를 처리하고 필요한 경우에만 완전히 정량화합니다. 그림 4의 세그먼트를 예로 들어 보겠습니다. 세그먼트   각각  문서를, 세그먼트   문서만 제공한다고 가정해 보겠습니다. Lucene은 사분위수의 가중 평균을 취하고 그 결과 병합된 사분위수가 세그먼트의 원래 사분위수에 충분히 근접하면 해당 세그먼트를 다시 정량화할 필요가 없으며 새로 병합된 사분위수를 활용합니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte2596c10ee616cf3/6a17d98eec0f8903b85a6497/58fa20d60dc337ca7108eb01e84a07c330e87ee6-1440x447.png" alt="병합된 사분위수" /><p>그림 5: 세그먼트    문서가 있고   문서만 있는 병합된 사분위수의 예입니다.</p><p>그림 5에서 시각화된 상황을 보면 병합된 결과 사분위수가   원래 사분위수와 매우 유사하므로 벡터를 정량화하는 것이 정당화되지 않음을 알 수 있습니다. 세그먼트 , 너무 많이 벗어난 것 같습니다. 결과적으로  벡터는 새로 병합된 사분위수 값으로 다시 정량화됩니다.</p><p>실제로 병합된 사분위수가 원래의 사분위수와 극적으로 다른 극단적인 경우가 있습니다. 이 경우 각 세그먼트에서 샘플을 가져와서 사분위수를 완전히 다시 계산합니다.</p><h2>정량화 성능 &amp; 숫자</h2><p>그렇다면 속도가 빠르며 여전히 좋은 기억력을 제공하나요? <code>c3-standard-8</code> GCP 인스턴스에서 실험을 실행하여 수집한 수치는 다음과 같습니다.  공정한 비교를 위해 메모리에 원시 벡터를 저장할 수 있을 만큼 큰 인스턴스를 사용했습니다. 최대 내부 제품을 사용하여  <a href="https://huggingface.co/datasets/Cohere/wikipedia-22-12-simple-embeddings">Cohere Wiki</a> 벡터를 색인화했습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfa48e3724ad97fed/6a17d98f445de96aca4cff88/81958f99b8a1397f669db48de00f264cacc6b49f-576x455.png" alt="정량화 리콜" /><p>그림 6: 양자화된 벡터와 원시 벡터의 Recall@10. 양자화된 벡터의 검색 성능은 원시 벡터보다 훨씬 빠르며, 5개만 더 수집해도 리콜을 빠르게 복구할 수 있습니다(  표시).</p><p>그림 6은 그 이야기를 보여줍니다. 예상대로 리콜 차이가 있긴 하지만, 그 차이는 크지 않습니다. 그리고 벡터를 5개만 더 수집하면 리콜 차이가 사라집니다. 이 모든 것이  빠른 세그먼트 병합과  벡터의 1/4 메모리로 가능합니다.</p><h2>결론</h2><p>Lucene은 어려운 문제에 대한 고유한 솔루션을 제공합니다. 정량화에는 '훈련' 또는 '최적화' 단계가 필요하지 않습니다. Lucene에서는 그냥 작동합니다. 데이터가 바뀌어도 벡터 인덱스를 '다시 학습'해야 할 염려가 없습니다. Lucene은 중요한 변경 사항을 감지하고 데이터의 수명 기간 동안 이를 자동으로 처리합니다. 이 기능을 Elasticsearch에 언제 도입할지 기대해 주세요!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/scalar-quantization-in-lucene</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/scalar-quantization-in-lucene</guid>
    <category><![CDATA[Lucene]]></category>
    <category><![CDATA[ML 연구]]></category>
    <dc:creator><![CDATA[Benjamin Trent]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb382d99ffac9992c/6a17d991b1e113640179f12c/dc07f16d347a68476bded5df9adb831452f61278-1024x1024.jpg" length="0" type="image/jpeg"/>
    <pubDate>Sat, 11 Nov 2023 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>