<?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[Jeffrey Rengifo - 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[Jeffrey Rengifo - 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/jeffrey-rengifo</link>
    </image>
    <link>https://www.elastic.co/kr/search-labs/author/jeffrey-rengifo</link>
    <atom:link href="https://www.elastic.co/kr/search-labs/rss/author/jeffrey-rengifo.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[kr]]></language>
    <lastBuildDate>Mon, 28 Sep 2026 04:16:12 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Elasticsearch 검색 리콜을 측정하고 개선하는 방법: 하이브리드 검색을 통해 0.43에서 0.75로 향상하기]]></title>
    <description><![CDATA[BM25 어휘 검색과 Jina AI 벡터 임베딩을 결합하여 Elasticsearch에서 검색 회상률을 측정하고 개선하는 방법을 알아보고, rank_eval API를 사용해 실제 숫자로 개선 효과를 검증하세요.]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/docs/solutions/search/full-text">어휘 검색</a>은 <a href="https://www.elastic.co/blog/practical-bm25-part-1-how-shards-affect-relevance-scoring-in-elasticsearch">BM25 순위 알고리즘</a>을 사용하여 저렴하고, 빠르며, 다양한 쿼리에 매우 효과적입니다. 하지만 문서와 토큰을 공유하지 않는 쿼리는 사각지대에 빠집니다. 이 글에서는 BM25의 부족한 부분을 정확히 측정해 보겠습니다. Elasticsearch의 <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval">순위 평가 API</a>(<code>rank_eval</code>)를 사용하고, <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a>(EIS)를 통해 <a href="https://www.elastic.co/search-labs/es/blog/jina-embeddings-v3-elastic-inference-service">Jina AI 임베딩</a>을 추가하여 격차를 좁힐 것입니다. 리콜 점수가 <code>0.43</code>에서 <code>0.75</code> 로 올라가는 것을 보면 그 이유를 확인하실 수 있습니다.</p><h2>리콜이란 무엇입니까?</h2><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-recall">리콜</a> 척도는 사용자가 실제로 원하는 문서가 검색 결과 어딘가에 나타나는 정도를 <code>0</code>에서 <code>1</code>까지의 범위로 측정합니다. 만약 쿼리에 3개의 제품이 표시되어야 하는데 검색 결과 상위 10개 제품 중 2개만 표시되는 경우, 해당 쿼리에 대해 <code>recall@10 = 0.67</code>을 입력합니다. 이는 집합 기반 메트릭이므로 <em>k</em> 결과 내에서 관련 문서의 위치는 중요하지 않습니다. 10번 위치에 있는 관련 문서는 1번 위치에 있는 문서와 동일하게 계산됩니다. 리콜률이 높다는 것은 관련성 있는 결과를 놓치지 않고 있다는 뜻입니다.</p><p>
</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5ffd147b13705680/6a170a6fe8fbce11a539fc22/b13af2a5d0ca055535d8bfe3dfe4b3d1093ee6da-1457x796.png" alt="모든 관련 문서와 BM25에서 검색한 상위 10개 결과 간의 중복을 보여주어 Recall@10이 어떻게 계산되는지 설명하며, 그 결과 Recall@10 점수가 0.40임을 보여주는 벤 다이어그램." /><p>다이어그램은 모든 관련 문서(왼쪽)와 BM25가 실제로 검색한 문서(상위 10개, 오른쪽)의 두 가지 세트를 보여줍니다. 오직 교차점만이 리콜에 포함되며, <code>prod_1</code>과 <code>prod_2</code>는 발견되었지만, <code>prod_3</code>, <code>prod_4</code>, <code>prod_6</code>은 완전히 누락되었습니다. 결과: <code>Recall@10 = 2/5 = </code><strong><code>0.40</code></strong>.</p><h2>필수 구성 요소</h2><p>리콜이 어떻게 작동하는지에 대해 자세히 알아보겠습니다. 이 데모에서는 Python을 사용합니다. 제공된 노트북(<a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/relevance-tuning-improving-recall-adding-vectors/notebook.ipynb">notebook.ipynb</a>)을 통해 따라 해보실 수 있습니다. 모든 코드 블록은 실행할 준비가 된 셀입니다.</p><p>제공된 코드는 다음을 사용합니다.</p><ul><li><p>Elasticsearch 9.3+</p></li><li><p>Python 3.10+</p></li></ul>pip install elasticsearch pandas plotly python-dotenv<ul><li><p>Elasticsearch 자격 증명이 포함된 <code>.env</code> 파일</p></li></ul>ELASTICSEARCH_URL=https://your-cluster-url
ELASTICSEARCH_API_KEY=your-api-key<h2>데이터 세트</h2><p>신발, 전자제품, 공구 등의 카테고리를 아우르는 1,000개의 제품 카탈로그.</p><p>각 문서에는 네 개의 필드가 있습니다.</p><p>필드</p><p>유형</p><p>`title`</p><p>텍스트</p><p>`설명`</p><p>텍스트</p><p>`브랜드`</p><p>키워드</p><p>'카테고리`</p><p>키워드</p><p>데이터 세트는 <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/relevance-tuning-improving-recall-adding-vectors/dataset.csv"><code>dataset.csv</code></a>에서 로드되었습니다.</p><h2>어휘 검색의 힘과 한계</h2><p>BM25는 Elasticsearch 및 대부분의 검색 엔진에서 기본 순위 알고리즘으로 사용됩니다. 쿼리 용어가 문서에 나타나는 빈도에 따라 점수를 매기며, 이는 문서 길이와 전체 인덱스에서 이러한 용어의 빈도에 따라 조정됩니다. 소문자 정규화, 어간 제거, 중단어 제거 등의 <a href="https://www.elastic.co/docs/reference/text-analysis/analyzer-reference">분석</a> 기능을 상단에서 사용할 수 있습니다. '러닝화'를 검색하면 '러닝화'와 '실행'이 모두 검색 결과에 나타날 가능성이 높습니다.</p><p>이는 대규모 쿼리 클래스에 적합합니다.</p><ul><li><p>'러닝화'를 입력하면 제목에 토큰이 정확히 일치하는 제품이 즉시 검색됩니다.</p></li><li><p>'블루투스 스피커'는 토큰이 문자 그대로 나타나기 때문에 휴대용 오디오 제품을 표시합니다.</p></li></ul><p>결과는 결정론적이고 설명 가능합니다. 문서가 높은 순위를 차지한 이유는 쿼리 용어가 문서에 포함되어 있기 때문입니다. 디버깅 관련성은 간단합니다.</p><h3>부족한 부분</h3><p>이제 동일한 카탈로그에 대해 다음 쿼리를 실행해 보겠습니다.</p><ul><li><p><strong>'스킨케어 루틴':</strong> '루틴'이라는 단어는 제품 제목에 나타나지 않습니다. BM25는 '스킨케어'와 부분적으로 일치시킬 수 있지만, 페이스 세럼, 바디 오일, 보습제 등은 '비타민 C', '레티놀', 또는 '브라이트닝' 등 검색어와 겹치지 않는 용어를 사용하여 설명됩니다. 완전한 스킨케어 루틴을 구성하는 제품들은 인덱스에 흩어져 있으며, 이를 연결할 공유 토큰이 없습니다.</p></li></ul>ID: B06XX6DS3P, Score: 9.0552, Title: Replenix Retinol Smooth + Tighten Body Lotion - Collagen-Boosting, Regenerating Anti-Aging Body Cream, Reduces Appearance of Stretch Marks, 6.7 oz.

  ID: B08XMPKJ1L, Score: 5.2699, Title: Bio-Oil Skincare Body Oil (Natural) Serum for Scars and Stretchmarks, Face and Body Moisturizer Hydrates Skin, with Organic Jojoba Oil and Vitamin E, For All Skin Types, 6.7 oz

  ID: B01CY764KQ, Score: 5.0057, Title: Nike Up Or Down Men Deodorant - Pack of 2 | Long-Lasting Fragrance, Body Spray Combo for Men | Deodorant for Active Living | Nike Men's Deo Set | Ultimate Odor Protection | Grooming Essentials | Signature Nike Scent | High-Performance Men's Deodorant<ul><li><p><strong>'반려동물 여행용 액세서리':</strong> 이는 제품 카테고리가 아닌 사용 사례 그룹입니다. 반려견 슬링 캐리어, 반려동물 카시트, 여행용 케이지는 모두 관련성이 있지만 '여행용 액세서리'보다는 휴대성, 안전성, 편안함에 대한 설명이 더 많습니다. BM25는 '반려동물'을 광범위하게 매칭하지만, 여행 관련 제품을 나머지 반려동물 카탈로그와 구분하는 신호가 없습니다.</p></li></ul>ID: B0BVV7BKTW, Score: 7.4371, Title: Large Foldable Travel Duffel Bag with Shoes Compartment

ID: B07TNPHYNV, Score: 6.6455, Title: 40 Pieces Christmas Bronze Jingle Bells Craft Small Bells

ID: B08R8FRW53, Score: 6.6335, Title: CUBY Dog and Cat Sling Carrier
ID: B08QMCQYGM, Score: 6.5259, Title: YTFGGY Whiteboard Pinstripe Tape 6 Rolls 1/8"
ID: B0CP3LQSWM, Score: 6.2994, Title: Portable Dog Water Bottle 32 Oz<p>이것은 <strong>리콜 문제</strong>입니다. 관련 문서가 인덱스에 존재합니다. 그러나 사용자의 입력 내용과 문서의 내용이 충분히 일치하지 않기 때문에 BM25가 해당 내용을 찾지 못합니다.</p><p>동의어를 추가하면 알려진 경우에 도움이 됩니다. 하지만 사용자가 의사를 표현하는 모든 방법을 열거할 수는 없습니다. 이것이 벡터가 중요한 이유입니다.</p><h2>리콜을 측정해야 하는 이유</h2><p>문제를 해결하기 전에 그것을 정량화해야 합니다.</p><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-recall"><strong>Recall@k</strong></a>는 사용자가 실제로 원하는 문서가 검색 결과 위치에 관계없이 얼마나 많이 나타나는지를 측정합니다. 공식적으로</p>Recall@k = (relevant documents found in top k) / (total relevant documents)<p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-precision"><strong>Precision@k</strong></a>는 상위 k개의 결과와 실제 연관성이 있는 결과의 수를 측정합니다.</p>Precision@k = (relevant documents in top k) / k<p>정확도가 높다는 것은 반환되는 결과가 좋다는 것을 의미합니다. 전자상거래에서 관련 제품을 놓치는 것(낮은 리콜)은 약간 불완전한 결과(낮은 정확도)를 보여주는 것보다 더 나쁠 수 있습니다. 제품이 드러나지 않으면 판매 손실로 연결되기 때문입니다.</p><p>Elasticsearch의 <code>rank_eval</code> API를 사용하면 두 가지를 체계적으로 측정할 수 있습니다. 사용자가 각각 등급이 매겨진 문서 세트가 포함된 쿼리 목록을 제공하면 Elasticsearch가 모든 쿼리에 대해 메트릭을 계산합니다.</p><h2>평가 설정</h2><p><code>rank_eval</code> API에는 관련성 등급(0 = 관련성 없음, 1 = 관련성 있음, 2 = 매우 관련성 있음)과 함께 각 쿼리에 대한 관련 문서 매핑인 <strong>평가 데이터 세트</strong>가 필요합니다.</p><p>노트북에서 이것은 <a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr#learning-to-rank-judgement-list">심사 목록입니다</a>:</p>judgments = [
    # Query 1: "running shoes" BM25 handles well (tokens appear in product titles) 
    {"query_id": "q1", "doc_id": "B09NQJFRW6", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B08JMD4LMM", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B08VRJ6F2Q", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B07S8NRRWR", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B01HD620I8", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B07DX86321", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B0968YVLQ8", "grade": 1, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B093QJ39ZS", "grade": 1, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B096FGSC39", "grade": 1, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B01GVQWVV2", "grade": 1, "query": "running shoes"},

    # Query 2: "skincare routine" intent-based, "routine" never appears in product titles
    {"query_id": "q2", "doc_id": "B08XMPKJ1L", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B0BN3WQB92", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B0BT7B7P5T", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B00NPA2WEY", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B06XX6DS3P", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B07PDRD1KT", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B074J7869B", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B08JV31QW4", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B00K3TVJMQ", "grade": 1, "query": "skincare routine"},

    # Query 3: "study desk setup" intent-based, products are desks/stands/organizers
    {"query_id": "q3", "doc_id": "B08CS35J2T", "grade": 2, "query": "study desk setup"},
    {"query_id": "q3", "doc_id": "B09B3LFDXJ", "grade": 2, "query": "study desk setup"},
    {"query_id": "q3", "doc_id": "B07W58LMND", "grade": 1, "query": "study desk setup"},
    {"query_id": "q3", "doc_id": "B0CHYDX91L", "grade": 1, "query": "study desk setup"},

    # Query 4: "pet travel accessories" use-case grouping, products are carriers/crates/seats
    {"query_id": "q4", "doc_id": "B08R8FRW53", "grade": 2, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B01MYUYX33", "grade": 2, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B003C5RKE4", "grade": 2, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B09GF8GBF6", "grade": 1, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B0CP3LQSWM", "grade": 1, "query": "pet travel accessories"},
]<p>이 조합은 의도적으로 구성된 것입니다.<code>q1</code>은 BM25가 잘 처리하는 쿼리(제품 제목의 정확한 토큰 포함)이고, <code>q2</code>, <code>q3</code>, <code>q4</code>는 사용자의 의도를 특정 제품 키워드가 아닌 개념으로 표현하는 의도 기반 쿼리입니다.</p><h2>BM25 기준 리콜 측정</h2><p>먼저, Elasticsearch 클라이언트를 설정하고 원시 텍스트 데이터를 색인합니다.</p>import os
import json
import pandas as pd
import plotly.graph_objects as go
from elasticsearch import Elasticsearch, helpers
from dotenv import load_dotenv

load_dotenv()

es = Elasticsearch(
    os.getenv("ELASTICSEARCH_URL"),
    api_key=os.getenv("ELASTICSEARCH_API_KEY")
)

INDEX_NAME = "ecommerce-products"<p>이제 BM25에 대한 <code>rank_eval</code> 요청을 생성합니다. 목록의 각 요청은 쿼리와 그 등급을 결합합니다.</p>judgments_df = pd.DataFrame(judgments)

bm25_requests = []
for query_id, query_text in (
    judgments_df[["query_id", "query"]].drop_duplicates().values
):
    relevant_docs = judgments_df[judgments_df["query_id"] == query_id]
    ratings = [
        {"_index": INDEX_NAME, "_id": row["doc_id"], "rating": row["grade"]}
        for _, row in relevant_docs.iterrows()
    ]

    bm25_requests.append({
        "id": query_id,
        "request": {
            "query": {
                "multi_match": {
                    "query": query_text,
                    "fields": ["title", "description"]
                }
            }
        },
        "ratings": ratings,
    })

bm25_eval = {
    "requests": bm25_requests,
    "metric": {"recall": {"k": 10, "relevant_rating_threshold": 1}},
}

bm25_result = es.rank_eval(index=INDEX_NAME, body=bm25_eval)
print("BM25 Recall@10:", bm25_result.body["metric_score"])<p>결과:</p>BM25 Recall@10: 0.43<p><code>0.43</code> 이는 네 개의 쿼리 모두에서 BM25가 찾아야 하는 문서 중 43%만을 찾았다는 의미입니다. 부족한 부분은 의도 기반 쿼리에 집중되어 있습니다. '스킨케어 루틴'은 제품 제목에 '루틴'이 포함되지 않는 페이스 세럼과 바디 오일을 놓치고, '여행용 반려동물 액세서리'는 주제와 다른 반려동물 제품을 검색하는 반면, 휴대성과 안전성 측면을 강조해서 설명하는 캐리어와 케이지는 정작 빠져 있습니다.</p><p>여기가 기준이 됩니다. 이제 넘어야 할 목표가 생겼습니다.</p><h2>Jina 임베딩으로 벡터 검색 추가</h2><p><a href="https://www.elastic.co/docs/solutions/search/vector"><code>Vector search</code></a> 문서와 쿼리를 고차원 벡터로 인코딩합니다. 고차원 벡터는 수백에서 수천 개의 수치 값으로 구성되며, 각 값은 해당 데이터의 특정한 특징을 인코딩합니다. 비슷한 의미를 가진 문서들은 단어가 공유되지 않더라도 벡터 공간에서 서로 가깝게 배치됩니다. '헬스 기구'와 '덤벨 세트'는 개념이 관련되어 있기 때문에 가까이에 배치될 것입니다. 하이브리드 검색을 지원하여 의미론적 이해와 키워드 정확도를 모두 제공하는 Elasticsearch를 벡터 데이터베이스로 선택했습니다.</p><p><a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">EIS</a>는 <a href="https://www.elastic.co/docs/api/doc/elasticsearch/group/endpoint-inference">추론 API</a>를 통해 모델 임베딩을 즉시 지원하는 기능을 포함하고 있습니다.</p><h3>1단계: Jina 임베딩 v5를 추론 엔드포인트로 사용</h3>INFERENCE_ENDPOINT_ID = ".jina-embeddings-v5-text-small"<p>클러스터에 GPU 리소스가 있는 경우(Elastic Cloud 및 Elasticsearch 9.3 이상에서 사용 가능), 임베딩이 GPU에서 생성되어 CPU 추론보다 훨씬 빠르며, 과거에 대규모 벡터 처리를 비용 부담으로 만들었던 성능 트레이드오프 문제를 해소합니다.</p><p>왜 Jina 임베딩을 사용할까요? <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text">jina-embeddings-v5-text</a>는 32,000개 이상의 토큰 컨텍스트 창을 가진 다국어 모델(119개 이상의 언어)로, 작업별 <a href="https://arxiv.org/abs/2106.09685">LoRA(Low-Rank Adaptation) 어댑터</a>를 지원합니다. 바로 사용할 수 있는 짧은 제품 설명에 적합합니다. <code>jina-embeddings-v5-text</code> 모델에 대한 자세한 내용은 <a href="https://huggingface.co/jinaai/jina-embeddings-v5-text-small">여기</a>에서 확인하실 수 있습니다.</p><h3>2단계: 시맨틱 필드로 인덱스 생성</h3>index_mappings = {
    "mappings": {
        "properties": {
            "title": {"type": "text", "copy_to": "semantic_field"},
            "description": {"type": "text", "copy_to": "semantic_field"},
            "brand": {"type": "keyword"},
            "category": {"type": "keyword"},
            "semantic_field": {
                "type": "semantic_text",
                "inference_id": INFERENCE_ENDPOINT_ID,
            },
        }
    }
}

if not es.indices.exists(index=INDEX_NAME):
    es.indices.create(index=INDEX_NAME, body=index_mappings)
    print(f"Created index: {INDEX_NAME}")<p>여기서는 <a href="https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text"><code>semantic_text</code></a> 필드 유형이 핵심입니다. <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector"><code>dense_vector</code></a>보다 더 높은 수준의 추상화입니다. 추론 엔드포인트를 가리키면 Elasticsearch가 자동으로 임베딩 생성을 처리합니다.</p><p><code>title</code>과 <code>description</code>의 <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/copy-to"><code>copy_to</code></a> 속성은 두 필드의 콘텐츠가 <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_field</code></a>로 흘러들어가 임베딩되므로, 단일 벡터가 전체 제품 표현을 캡처합니다.</p><h3>3단계: 제품 인덱스 생성</h3>def bulk_index(products, index_name):
    actions = []
    for product in products:
        doc_id = product.get("_id")
        source = {k: v for k, v in product.items() if k != "_id"}
        action = {"_index": index_name, "_source": source}
        if doc_id:
            action["_id"] = doc_id
        actions.append(action)

    success, failed = helpers.bulk(es, actions, raise_on_error=False)
    if failed:
        for error in failed:
            print(f"Error: {error}")
    else:
        print(f"Successfully indexed {success} documents")

bulk_index(products, INDEX_NAME)<p>색인 시점에 Elasticsearch는 각 문서에 대해 추론 엔드포인트를 호출하고, 결과 임베딩을 <code>semantic_field</code>에 저장합니다. 사용자 측에서 추가 코드를 작성할 필요가 없습니다.</p><h2>하이브리드 검색: BM25 및 벡터와 RRF의 결합</h2><p>벡터를 추가하면 리콜이 향상되지만, 벡터만 사용하면 정확한 일치 쿼리에서 정확도를 잃을 위험이 있습니다. '러닝화'와 같은 용어는 여전히 문자 자체가 일치하는 결과가 우선 순위에 표시되어야 합니다. 하이브리드 검색은 정확성을 보장하기 위해 어휘 기반 검색 요소를 유지합니다.</p><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">상호 순위 결합</a>(RRF)을 사용한 하이브리드 검색은 두 가지 장점을 모두 유지합니다.</p><ul><li><p>BM25는 정확하거나 거의 정확한 쿼리를 높은 정확도로 처리합니다.</p></li><li><p>의미 검색은 의도 기반 및 다국어 쿼리를 높은 리콜로 처리합니다.</p></li><li><p>RRF는 두 개의 순위 목록을 단일 순위로 결합합니다.</p></li></ul><p>RRF 공식은 각 문서의 결과 목록 순위에 따라 점수를 부여합니다.</p>score = sum(1 / (rank_constant + rank))<p>두 목록 모두에서 높은 순위를 차지한 문서가 더 높은 합산 점수를 받습니다. <code>rank_constant</code>는 하위 등급의 문서가 받는 가중치를 조절합니다.</p>hybrid_requests = []

for query_id, query_text in (
    judgments_df[["query_id", "query"]].drop_duplicates().values
):
    relevant_docs = judgments_df[judgments_df["query_id"] == query_id]
    ratings = [
        {"_index": INDEX_NAME, "_id": row["doc_id"], "rating": row["grade"]}
        for _, row in relevant_docs.iterrows()
    ]

    hybrid_requests.append({
        "id": query_id,
        "request": {
            "retriever": {
                "rrf": {
                    "retrievers": [
                        {
                            "standard": {
                                "query": {
                                    "multi_match": {
                                        "query": query_text,
                                        "fields": ["title", "description"],
                                    }
                                }
                            }
                        },
                        {
                            "standard": {
                                "query": {
                                    "match": {
                                        "semantic_field": {"query": query_text}
                                    }
                                }
                            }
                        },
                    ],
                    "rank_window_size": 50,
                    "rank_constant": 5,
                }
            }
        },
        "ratings": ratings,
    })

hybrid_eval = {
    "requests": hybrid_requests,
    "metric": {"recall": {"k": 10, "relevant_rating_threshold": 1}},
}

hybrid_result = es.rank_eval(index=INDEX_NAME, body=hybrid_eval)
print("Hybrid Recall@10:", hybrid_result.body["metric_score"])<p>결과:</p>Hybrid Recall@10: 0.75<p>하이브리드는 BM25(<code>0.43</code>)에 비해 성능이 크게 향상되었으며, '러닝화'와 같은 정확한 일치 쿼리에 대한 정확확도를 유지합니다.</p><h2>결과: 전후 비교</h2><p>세 가지 접근 방식을 모두 비교한 전체 내용은 다음과 같습니다.</p>methods = {
    "BM25 (Lexical)": bm25_requests,
    "Hybrid (BM25 + Vectors)": hybrid_requests,
}

recall_metric = {"recall": {"k": 10, "relevant_rating_threshold": 1}}

comparison_data = []
for method_name, requests in methods.items():
    result = es.rank_eval(
        index=INDEX_NAME,
        body={"requests": requests, "metric": recall_metric}
    )
    comparison_data.append({
        "method": method_name,
        "recall@10": result.body["metric_score"]
    })

comparison_df = pd.DataFrame(comparison_data)
print(comparison_df.to_string(index=False))<p>결과:</p><p>메서드</p><p>Recall@10</p><p>BM25(어휘 검색)</p><p>0.43</p><p>하이브리드(BM25 + 벡터)</p><p>0.75</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5a1d72b57056fe64/6a170a71c1e8a56c58f882ab/e49f6c10516b0a48a0ad75962c6590ee07311407-700x500.png" alt="BM25 어휘 검색과 BM25와 벡터를 결합한 하이브리드 검색의 Recall@10을 비교한 막대 차트에서 하이브리드 검색이 훨씬 더 높은 리콜률을 달성했음을 확인할 수 있습니다." /><p>쿼리별로 분석해 보겠습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt871347f754c866d0/6a170a73839dfa40abdcfeb4/40e36dcb7b34cbf4649c512bcb60cef60f1778a6-700x500.png" alt="4개의 제품 쿼리에서 BM25 어휘 검색과 하이브리드 검색의 Recall@10을 비교한 그룹화된 막대 차트는 각 쿼리에서 하이브리드 검색이 어휘 검색보다 일관되게 우수한 성능을 보였음을 증명합니다." /><h2>결론</h2><p>이 포스팅에서는 사용자가 정확한 쿼리를 입력할 때 BM25 어휘 검색이 신뢰할 수 있지만, 키워드보다는 의도에 따라 검색할 때 리콜이 떨어진다는 것을 보았습니다. <code>rank_eval</code>을 사용하여 재현 가능한 기준선을 설정하고 실제 숫자로 그 차이를 측정했습니다. 그 후, Jina 임베딩을 기반으로 하는 <code>semantic_text</code> 필드를 추가하고 평가를 다시 실행했습니다. 결과적으로, 하이브리드 검색은 리콜을 <code>0.43</code>에서 <code>0.75</code>로 높이면서도 정확한 일치 쿼리의 정확도를 유지했지만, 실제 마진은 쿼리 구성에 따라 달라집니다.</p><p>이 패턴은 이 예제 이상으로 확장됩니다. 사용자의 실제 쿼리에서 판단을 수집하고 <code>rank_eval</code>을 기준선으로 실행한 다음 <code>semantic_text</code>를 추가하고 다시 측정하세요. 어떤 부분이 얼마나 개선되었는지 정확히 알 수 있습니다.</p><h2>다음 단계</h2><ul><li><p>리콜과 벡터 검색에 대해 자세히 알아보기: Jeff Vestal의 <a href="https://www.elastic.co/search-labs/blog/recall-vector-search-quantization">리콜 및 벡터 검색 양자화</a></p></li><li><p>상위 결과의 정확도를 높이기 위해 재순위 추가</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html">Elasticsearch 하이브리드 검색 문서</a> 살펴보기</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html"><code>rank_eval</code></a><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html">API</a>에 대해 자세히 알아보기</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-relevance-tuning-improve-recall</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-relevance-tuning-improve-recall</guid>
    <category><![CDATA[하이브리드 검색]]></category>
    <category><![CDATA[벡터 데이터베이스]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt37c9d2971b5a2db3/6a170a75cf4f254223b2d149/492c9b5432a2b9e40cebb3b60f0df019a8c7bf6d-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[TypeScript로 Elasticsearch MCP 서버 생성]]></title>
    <description><![CDATA[TypeScript와 Claude Desktop을 사용하여 Elasticsearch MCP 서버를 생성하는 방법을 알아보세요.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch에서 대규모 지식 기반을 다룰 때, 정보를 찾아내는 것은 첫 관문을 넘긴 것에 불과합니다. 엔지니어는 종종 여러 문서에서 결과를 종합하고, 요약을 작성하며, 답변을 출처까지 추적해야 합니다. 모델 컨텍스트 프로토콜(MCP)은 이를 달성하기 위해 Elasticsearch를 거대 언어 모델(LLM) 기반 애플리케이션과 연결하는 표준화된 방법을 제공합니다. Elastic은 Elastic Agent Builder(기능 중 <a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server">MCP 엔드포인트</a> 포함)와 같은 공식 솔루션을 제공하지만, 사용자 지정 MCP 서버를 구축하면 검색 논리, 결과 형식, 검색된 콘텐츠가 종합, 요약, 인용을 위해 LLM에 전달되는 방식을 완전히 제어할 수 있습니다.</p><p>이 글에서는 사용자 지정 Elasticsearch MCP 서버 구축의 장점을 살펴보고, Elasticsearch를 LLM 기반 애플리케이션에 연결하는 TypeScript로 서버를 생성하는 방법을 보여드리겠습니다.</p><h2>사용자 지정 Elasticsearch MCP 서버를 구축해야 하는 이유는 무엇입니까?</h2><p>Elastic은 <a href="https://www.elastic.co/docs/solutions/search/mcp">MCP 서버</a>에 대한 몇 가지 대안을 제공합니다.</p><ul><li><p><a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server">Elasticsearch 9.2 이상 버전용 Elastic Agent Builder MCP 서버</a></p></li><li><p><a href="https://github.com/elastic/mcp-server-elasticsearch?tab=readme-ov-file#elasticsearch-mcp-server">구버전용 Elasticsearch MCP 서버(Python)</a></p></li></ul><p>MCP 서버가 Elasticsearch와 상호 작용하는 방식을 더 세밀하게 제어하고 싶다면, 직접 사용자 지정 서버를 구축하여 요구 사항에 딱 맞게 최적화할 수 있는 유연성을 확보할 수 있습니다. 예를 들어, Agent Builder의 MCP 엔드포인트는 Elasticsearch 쿼리 언어(ES|QL) 쿼리로 제한되지만, 사용자 지정 서버를 사용하면 전체 쿼리 DSL을 사용할 수 있습니다. 또한 결과를 LLM으로 전달되기 전에 결과의 서식을 지정하는 방법을 제어할 수 있으며, 이번 튜토리얼에서 다룰 OpenAI 기반 요약 기능과 같은 추가적인 처리 단계를 통합할 수도 있습니다.</p><p>이 글을 마칠 때쯤이면, Elasticsearch 인덱스에 저장된 정보를 검색하고, 요약하며, 인용을 제공하는 TypeScript로 된 MCP 서버를 갖게 됩니다. 검색에는 Elasticsearch를, 요약 및 인용 생성에는 OpenAI <code>gpt-4o-mini</code> 모델을 사용하며, 사용자 쿼리를 받고 응답을 제공하는 MCP 클라이언트와 UI로는 Claude Desktop을 사용할 것입니다. 최종적으로 엔지니어가 조직 내 기술 문서 전반에서 모범 사례를 발견하고 종합할 수 있도록 돕는 내부 지식 어시스턴트를 구축하게 됩니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltad9133cb083ad352/6a170c19b0367d411e72bd5b/ec5771a874cf9740d4cac6888622cbe8cd6aede7-1999x1133.png" alt="TypeScript와 Claude Desktop을 사용하여 Elastic MCP 서버를 생성합니다." /><h2>필수 구성 요소:</h2><ul><li><p>Node.js 20+</p></li><li><p>Elasticsearch</p></li><li><p>OpenAI API 키</p></li><li><p>Claude Desktop</p></li></ul><h3>MCP란 무엇입니까?</h3><p><a href="https://www.elastic.co/what-is/mcp">MCP</a>는 <a href="https://www.anthropic.com/news/model-context-protocol">Anthropic</a>에서 만든 오픈 표준으로, LLM과 Elasticsearch와 같은 외부 시스템 간에 안전한 양방향 연결을 제공합니다. MCP의 현황에 대한 자세한 내용은 <a href="https://www.elastic.co/search-labs/blog/mcp-current-state">이 글</a>에서 확인할 수 있습니다.</p><p>MCP 환경은 광범위한 사용 사례를 지원하는 서버들이 등장하며 <a href="https://www.elastic.co/search-labs/blog/mcp-current-state#mcp-project-updates:-transport,-elicitation,-and-structured-tooling">매일 진화</a>하고 있습니다. 게다가, 이 글에서 보여 드릴 것처럼 자신만의 맞춤형 MCP 서버를 구축하는 것도 매우 쉽습니다.</p><h3>MCP 클라이언트</h3><p><a href="https://modelcontextprotocol.io/clients">사용 가능한 MCP 클라이언트 목록</a>은 매우 방대하며, 각 클라이언트에는 저마다의 특징과 제한 사항이 있습니다. 단순함과 대중성을 고려하여 <a href="https://claude.ai/download">Claude Desktop</a>을 MCP 클라이언트로 사용하겠습니다. Claude Desktop은 사용자가 자연어로 질문을 던지는 채팅 인터페이스 역할을 하며, MCP 서버에 노출된 도구를 자동으로 호출하여 문서를 검색하고 요약을 생성합니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt06fd7a02042094e1/6a170c1b14b2700024e3c651/66eb0b11473347b6cf2d85718251eeac38d6249d-1999x1491.png" alt="‘커피 한 잔과 함께하는 Claude 타임인가요? 오늘은 어떻게 도와드릴까요?' 라는 문구가 적힌 Claude 4.5 Sonnet 페이지입니다." /><h2>Elasticsearch MCP 서버 생성하기</h2><p><a href="https://github.com/modelcontextprotocol/typescript-sdk">TypeScript SDK</a>를 사용하면, 사용자 쿼리 입력을 기반으로 Elasticsearch 데이터를 쿼리하는 방법을 이해하는 서버를 쉽게 만들 수 있습니다.</p><p>이 글에서는 Elasticsearch MCP 서버를 Claude Desktop 클라이언트와 통합하는 단계를 설명합니다.</p><ol><li><p><a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude#configure-mcp-server-for-elasticsearch">Elasticsearch용 MCP 서버를 구성합니다.</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude#load-the-mcp-server-into-claude-desktop">MCP 서버를 Claude Desktop에 로드합니다.</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude#test-it-out">테스트해 보세요.</a></p></li></ol><h3>Elasticsearch용 MCP 서버 구성</h3><p>시작하려면 Node 애플리케이션을 초기화하십시오:</p>npm init -y<p>이렇게 하면 <code>package.json</code> 파일이 생성되며, 이를 통해 이 애플리케이션에 필요한 의존성을 설치하기 시작할 수 있습니다.</p>npm install @elastic/elasticsearch @modelcontextprotocol/sdk openai zod &amp;&amp; npm install --save-dev ts-node @types/node typescript<ul><li><p><strong>@elastic/elasticsearch</strong> 패키지를 통해 Elasticsearch Node.js 라이브러리에 액세스할 수 있습니다.</p></li><li><p><strong>@modelcontextprotocol/sdk</strong>는 MCP 서버 생성 및 관리, 도구 등록, MCP 클라이언트와의 통신 처리를 위한 핵심 도구를 제공합니다.</p></li><li><p><strong>openai</strong>를 사용하면 OpenAI 모델과 상호 작용하여 요약이나 자연어 응답을 생성할 수 있습니다.</p></li><li><p><a href="https://zod.dev/"><strong>zod</strong></a>는각 도구의 입력 및 출력 데이터에 대해 구조화된 스키마를 정의하고 검증하는 것을 돕습니다.</p></li></ul><p><code>ts-node</code>, <code>@types/node</code>, <code>typescript</code> 는 개발 중에 코드의 타입을 지정하고 스크립트를 컴파일하는 데 사용됩니다.</p><h4>데이터셋 설정</h4><p>Claude Desktop이 MCP 서버를 통해 쿼리할 수 있는 데이터를 제공하기 위해, 가상의 <a href="https://github.com/Delacrobix/typescript-elasticsearch-mcp/blob/main/dataset.json">내부 지식 기반 데이터 세트</a>를 사용하겠습니다. 이 데이터 세트의 문서는 다음과 같습니다.</p>{
    "id": 5,
    "title": "Logging Standards for Microservices",
    "content": "Consistent logging across microservices helps with debugging and tracing. Use structured JSON logs and include request IDs and timestamps. Avoid logging sensitive information. Centralize logs in Elasticsearch or a similar system. Configure log rotation to prevent storage issues and ensure logs are searchable for at least 30 days.",
    "tags": ["logging", "microservices", "standards"]
}<p>데이터를 수집하기 위해, Elasticsearch에 인덱스를 생성하고 데이터 세트를 로드하는 스크립트를 준비했습니다. <a href="https://github.com/Delacrobix/typescript-elasticsearch-mcp/blob/main/setup.ts">여기서</a> 확인하실 수 있습니다.</p><h4>MCP 서버</h4><p><a href="https://github.com/Delacrobix/typescript-elasticsearch-mcp/blob/main/index.ts"><code>index.ts</code></a>(이)라는 이름의 파일을 생성하고, 의존성을 가져오고 환경 변수를 처리하기 위해 다음 코드를 추가하세요.</p>// index.ts
import { z } from "zod";
import { Client } from "@elastic/elasticsearch";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import OpenAI from "openai";

const ELASTICSEARCH_ENDPOINT =
  process.env.ELASTICSEARCH_ENDPOINT ?? "http://localhost:9200";
const ELASTICSEARCH_API_KEY = process.env.ELASTICSEARCH_API_KEY ?? "";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
const INDEX = "documents";<p>또한, Elasticsearch와 OpenAI 호출을 처리할 클라이언트들을 초기화해 보겠습니다.</p>const openai = new OpenAI({
  apiKey: OPENAI_API_KEY,
});

const _client = new Client({
  node: ELASTICSEARCH_ENDPOINT,
  auth: {
    apiKey: ELASTICSEARCH_API_KEY,
  },
});<p>구현을 더 견고하게 만들고 입력 및 출력 데이터의 구조를 보장하기 위해, <a href="https://zod.dev/"><code>zod</code></a>(을)를 사용하여 스키마를 정의하겠습니다. 이를 통해 런타임에 데이터를 검증하고, 오류를 조기에 발견하며, 도구의 응답을 프로그램 방식으로 더 쉽게 처리할 수 있습니다.</p>const DocumentSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()),
});

const SearchResultSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()),
  score: z.number(),
});

type Document = z.infer&lt;typeof DocumentSchema&gt;;
type SearchResult = z.infer&lt;typeof SearchResultSchema&gt;;<p>구조화된 출력을 자세히 알아보려면 <a href="https://www.elastic.co/search-labs/blog/structured-outputs-elasticsearch-guide">여기</a>를 참조하세요.</p><p>이제 MCP 서버를 초기화해 보겠습니다.</p>const server = new McpServer({
  name: "Elasticsearch RAG MCP",
  description:
    "A RAG server using Elasticsearch. Provides tools for document search, result summarization, and source citation.",
  version: "1.0.0",
});<h4>MCP 도구 정의</h4><p>모든 구성이 완료되면, MCP 서버가 외부에 제공할 도구를 작성하기 시작할 수 있습니다. 이 서버는 두 가지 도구를 외부에 제공합니다.</p><ul><li><p><strong><code>search_docs</code></strong><strong>: </strong>전체 텍스트 검색을 사용하여 Elasticsearch에서 문서를 검색합니다.</p></li><li><p><strong><code>summarize_and_cite</code></strong><strong>:</strong> 사용자의 질문에 답하기 위해, 이전에 검색된 문서들로부터 정보를 요약하고 종합합니다. 이 도구는 또한 출처 문서를 참조하는 인용 정보를 추가합니다.</p></li></ul><p>이 도구들은 함께 작동하여 간단한 '검색 후 요약' 워크플로우를 형성합니다. 하나의 도구가 관련 문서를 가져오면, 다른 도구가 해당 문서들을 바탕으로 인용구가 포함된 요약 응답을 생성하는 방식입니다.</p><h4>도구 응답 형식</h4><p>각 도구는 임의의 입력 매개변수를 허용할 수 있지만, 다음과 같은 구조로 응답해야 합니다.</p><ul><li><p><strong>내용:</strong> 비정형 형식으로 된 도구의 응답입니다. 이 필드는 일반적으로 텍스트, 이미지, 오디오, 링크 또는 임베딩을 반환하는 데 사용됩니다. 이 애플리케이션의 경우 도구가 생성한 정보를 포함한 서식 있는 텍스트를 반환하는 데 사용됩니다.</p></li><li><p><strong>structuredContent: </strong>각 도구의 결과를 구조화된 형식으로 제공하기 위해 사용되는 선택적 반환 값입니다. 이는 프로그램 방식의 처리에 유용합니다. 비록 이 MCP 서버에서는 사용되지 않지만, 다른 도구를 개발하거나 결과를 프로그램 방식으로 처리하고자 할 때 유용하게 활용될 수 있습니다.</p></li></ul><p>그 구조를 염두에 두고, 각 도구에 대해 자세히 살펴보겠습니다.</p><h4>Search_docs 도구</h4><p>이 도구는 사용자의 쿼리를 기반으로 가장 관련성 높은 문서들을 검색하기 위해 Elasticsearch 인덱스에서 <a href="https://www.elastic.co/docs/solutions/search/full-text">전체 텍스트 검색</a>을 수행합니다. 또한 주요 일치 항목을 강조하고, 연관성 점수와 함께 빠른 개요를 제공합니다.</p>server.registerTool(
  "search_docs",
  {
    title: "Search Documents",
    description:
      "Search for documents in Elasticsearch using full-text search. Returns the most relevant documents with their content, title, tags, and relevance score.",
    inputSchema: {
      query: z
        .string()
        .describe("The search query terms to find relevant documents"),
      max_results: z
        .number()
        .optional()
        .default(5)
        .describe("Maximum number of results to return"),
    },
    outputSchema: {
      results: z.array(SearchResultSchema),
      total: z.number(),
    },
  },
  async ({ query, max_results }) =&gt; {
    if (!query) {
      return {
        content: [
          {
            type: "text",
            text: "Query parameter is required",
          },
        ],
        isError: true,
      };
    }

    try {
      const response = await _client.search({
        index: INDEX,
        size: max_results,
        query: {
          bool: {
            must: [
              {
                multi_match: {
                  query: query,
                  fields: ["title^2", "content", "tags"],
                  fuzziness: "AUTO",
                },
              },
            ],
            should: [
              {
                match_phrase: {
                  title: {
                    query: query,
                    boost: 2,
                  },
                },
              },
            ],
          },
        },
        highlight: {
          fields: {
            title: {},
            content: {},
          },
        },
      });

      const results: SearchResult[] = response.hits.hits.map((hit: any) =&gt; {
        const source = hit._source as Document;

        return {
          id: source.id,
          title: source.title,
          content: source.content,
          tags: source.tags,
          score: hit._score ?? 0,
        };
      });

      const contentText = results
        .map(
          (r, i) =&gt;
            `[${i + 1}] ${r.title} (score: ${r.score.toFixed(
              2,
            )})\n${r.content.substring(0, 200)}...`,
        )
        .join("\n\n");

      const totalHits =
        typeof response.hits.total === "number"
          ? response.hits.total
          : (response.hits.total?.value ?? 0);

      return {
        content: [
          {
            type: "text",
            text: `Found ${results.length} relevant documents:\n\n${contentText}`,
          },
        ],
        structuredContent: {
          results: results,
          total: totalHits,
        },
      };
    } catch (error: any) {
      console.log("Error during search:", error);

      return {
        content: [
          {
            type: "text",
            text: `Error searching documents: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }
);<p><em>We configure </em><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-fuzzy-query"><em><code>fuzziness</code></em></a><em><code>: “AUTO”</code></em><em> to have a variable typo tolerance based on the length of the token that’s being analyzed. We also set </em><em><code>title^2</code></em><em> to increase the score of the documents where the match happens on the title 필드.</em></p><h4>summarize_and_cite 도구</h4><p>이 도구는 이전 검색에서 가져온 문서들을 바탕으로 요약을 생성합니다. 사용자의 질문에 답하기 위해 OpenAI의 <code>gpt-4o-mini</code> 모델을 사용하여 가장 관련성 높은 정보를 종합하며, 검색 결과에서 직접 도출된 응답을 제공합니다. 요약과 더불어, 사용된 출처 문서들에 대한 인용 메타데이터도 함께 반환합니다.</p>server.registerTool(
  "summarize_and_cite",
  {
    title: "Summarize and Cite",
    description:
      "Summarize the provided search results to answer a question and return citation metadata for the sources used.",
    inputSchema: {
      results: z
        .array(SearchResultSchema)
        .describe("Array of search results from search_docs"),
      question: z.string().describe("The question to answer"),
      max_length: z
        .number()
        .optional()
        .default(500)
        .describe("Maximum length of the summary in characters"),
      max_docs: z
        .number()
        .optional()
        .default(5)
        .describe("Maximum number of documents to include in the context"),
    },
    outputSchema: {
      summary: z.string(),
      sources_used: z.number(),
      citations: z.array(
        z.object({
          id: z.number(),
          title: z.string(),
          tags: z.array(z.string()),
          relevance_score: z.number(),
        })
      ),
    },
  },
  async ({ results, question, max_length, max_docs }) =&gt; {
    if (!results || results.length === 0 || !question) {
      return {
        content: [
          {
            type: "text",
            text: "Both results and question parameters are required, and results must not be empty",
          },
        ],
        isError: true,
      };
    }

    try {
      const used = results.slice(0, max_docs);

      const context = used
        .map(
          (r: SearchResult, i: number) =&gt;
            `[Document ${i + 1}: ${r.title}]\\n${r.content}`
        )
        .join("\n\n---\n\n");

      // Generate summary with OpenAI
      const completion = await openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [
          {
            role: "system",
            content:
              "You are a helpful assistant that answers questions based on provided documents. Synthesize information from the documents to answer the user's question accurately and concisely. If the documents don't contain relevant information, say so.",
          },
          {
            role: "user",
            content: `Question: ${question}\\n\\nRelevant Documents:\\n${context}`,
          },
        ],
        max_tokens: Math.min(Math.ceil(max_length / 4), 1000),
        temperature: 0.3,
      });

      const summaryText =
        completion.choices[0]?.message?.content ?? "No summary generated.";

      const citations = used.map((r: SearchResult) =&gt; ({
        id: r.id,
        title: r.title,
        tags: r.tags,
        relevance_score: r.score,
      }));

      const citationText = citations
        .map(
          (c: any, i: number) =&gt;
            `[${i + 1}] ID: ${c.id}, Title: "${c.title}", Tags: ${c.tags.join(
              ", ",
            )}, Score: ${c.relevance_score.toFixed(2)}`,
        )
        .join("\n");

      const combinedText = `Summary:\\n\\n${summaryText}\\n\\nSources used (${citations.length}):\\n\\n${citationText}`;

      return {
        content: [
          {
            type: "text",
            text: combinedText,
          },
        ],
        structuredContent: {
          summary: summaryText,
          sources_used: citations.length,
          citations: citations,
        },
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error generating summary and citations: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }
);<p>마지막으로, <a href="https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#stdio">stdio</a>를 사용하여 서버를 시작해야 합니다. 이는 MCP 클라이언트가 서버의 표준 입력과 표준 출력 스트림을 읽고 씀으로써 통신하게 된다는 것을 의미합니다. stdio는 가장 단순한 전송 옵션이며, 클라이언트를 통해 하위 프로세스로 실행되는 로컬 MCP 서버에 적합합니다. 파일 끝에 다음 코드를 추가합니다.</p>const transport = new StdioServerTransport();
server.connect(transport);<p>이제 다음 명령어를 사용하여 프로젝트를 컴파일하십시오:</p>npx tsc index.ts --target ES2022 --module node16 --moduleResolution node16 --outDir ./dist --strict --esModuleInterop<p>이렇게 하면 <code>dist</code> 폴더가 생성되고, 그 안에 <code>index.js</code> 파일이 생성됩니다.</p><h3>MCP 서버를 Claude Desktop에 로드</h3><p>Claude Desktop에서 MCP 서버를 구성하려면 <a href="https://modelcontextprotocol.io/docs/develop/connect-local-servers">이 가이드</a>를 따르세요. Claude 구성 파일에서 다음 값들을 설정해야 합니다.</p>{
  "mcpServers": {
    "elasticsearch-rag-mcp": {
      "command": "node",
      "args": [   "/Users/user-name/app-dir/dist/index.js"
      ],
      "env": {
        "ELASTICSEARCH_ENDPOINT": "your-endpoint-here",
        "ELASTICSEARCH_API_KEY": "your-api-key-here",
        "OPENAI_API_KEY": "your-openai-key-here"
      }
    }
  }
}<p><code>args</code> 값은 <code>dist</code> 폴더 안에 있는 컴파일된 파일을 가리켜야 합니다. 또한 코드에 정의된 것과 똑같은 이름으로 구성 파일 내에 환경 변수를 설정해야 합니다.</p><h3>테스트해 보기</h3><p>각 도구를 실행하기 전에, <strong>검색 및 도구</strong>를 클릭하여 도구들이 활성화되어 있는지 확인하세요. 여기에서 각 도구를 개별적으로 활성화하거나 비활성화할 수도 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt395a7337021f9820/6a170c1c67045bb74d45c228/172981c2a54adabc70d5819013c3007670935605-1999x1002.png" alt="‘좋은 오후입니다, 제프. 오늘은 어떻게 도와드릴까요?’ 라는 문구가 적힌 Claude 4.5 Sonnet 페이지입니다." /><p>마지막으로 Claude Desktop 채팅에서 MCP 서버를 테스트하고 질문을 시작하십시오:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf4ac458dc0206271/6a170c1e66c4f91328f8c072/03654c0f8c53c714f801fba8b25747071179209b-1999x1353.png" alt="인증 방식 및 역할 기반 액세스 제어에 관한 문서를 찾는 Claude Desktop 채팅창의 사용자 검색 요청과 그에 대한 Claude의 응답입니다." /><p>'<strong>인증 방법 및 역할 기반 액세스 제어에 관한 문서를 검색해 줘</strong>'라는 질문에 대해, <code>search_docs</code> 도구가 실행되어 다음과 같은 결과를 반환합니다.</p>Most Relevant Documents:
Access Control and Role Management (highest relevance) - This document covers role-based access control (RBAC) principles, including ensuring users only have necessary permissions, regular auditing of user roles, revoking inactive accounts, and implementing just-in-time access for sensitive operations.
User Authentication with OAuth 2.0 - This document explains OAuth 2.0 authentication, which enables secure delegated access without credential sharing. It covers configuring identity providers, token management with limited scope and lifetime, and secure storage of refresh tokens.
Container Security Guidelines - While primarily about container security, this document touches on access control aspects like running containers as non-root users and avoiding embedded credentials.
Incident Response Playbook - This mentions role assignment during incidents (incident commander, communications lead, etc.), which relates to access control in emergency scenarios.
Logging Standards for Microservices - This document includes guidance on avoiding logging sensitive information, which is relevant to authentication security.<p>응답 내용은 다음과 같습니다. '좋습니다! 인증 방식 및 역할 기반 액세스 제어에 관한 관련 문서 5개를 찾았습니다. 검색된 내용은 다음과 같습니다.'</p><p>도구 호출은 응답 페이로드의 일부로 소스 문서들을 반환하며, 이 문서들은 나중에 인용구를 생성하는 데 사용됩니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbaf48a9468770ce2/6a170c21509168bffee1bb14/25ff4c7e9563d99752f95540dafdc7fd211a66e3-800x530.gif" alt="인증 방식 및 역할 기반 액세스 제어에 관한 5개의 관련 문서를 포함하는 스크롤형 응답의 Claude 4.5 Sonnet 페이지입니다." /><p>한 번의 상호 작용 내에서 여러 도구를 연결하여 사용할 수도 있습니다. 이 경우, Claude Desktop은 사용자의 질문을 분석한 뒤, 관련 문서를 가져오기 위해 먼저 <code>search_docs</code>(을)를 호출하고, 그 결과를 <code>summarize_and_cite</code>에 전달하여 최종 답변을 생성해야 한다고 판단합니다. 이 모든 과정은 별도의 사용자 프롬프트 없이 이루어집니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta46ff45274e64192/6a170c230c4857a91501aac1/e6a8a46acb4236e77058f18bcd2f0737b5882c05-1999x1101.png" alt="'제프가 돌아왔네요.'라는 문구와 함께, '우리 시스템 전반의 인증 및 액세스 제어를 개선하기 위한 핵심 권장 사항이 뭐야? 참고 문헌 포함해 줘.'라는 사용자의 새로운 질문이 표시된 Claude Desktop 채팅 화면입니다." /><p>이 경우, '<strong>우리 시스템 전반의 인증 및 액세스 제어를 개선하기 위한 핵심 권장 사항이 뭐야? 참고 문헌 포함해 줘.</strong>'라는 쿼리에 대한 답변입니다. 다음과 같은 결과를 얻었습니다.</p>Based on the documentation, here are the main recommendations to improve authentication and access control across your systems:

Key Recommendations

1. Implement Role-Based Access Control (RBAC)
Ensure users have only the permissions necessary for their job functions. This principle of least privilege minimizes security risks by limiting access to what's truly needed. [1]

2. Regular Access Audits
Conduct regular audits of user roles and promptly revoke access for inactive accounts. This helps prevent unauthorized access from dormant or compromised accounts. [1]

3. Just-in-Time (JIT) Access
Implement just-in-time access for sensitive operations, where permissions are granted only when needed and for a limited duration. This reduces the attack surface by minimizing standing privileges. [1]

4. OAuth 2.0 for Secure Authentication
Use OAuth 2.0 to enable secure delegated access without sharing user credentials. This protocol allows third-party applications to access resources on behalf of users without exposing passwords. [2]

5. Token Security and Management
Configure your identity provider to issue tokens with limited scope and lifetime

Store refresh tokens securely
Validate access tokens consistently to maintain security [2]
References

Access Control and Role Management (Tags: security, access-control)
User Authentication with OAuth 2.0 (Tags: authentication, oauth)
These recommendations work together to create a defense-in-depth approach, where multiple security layers protect your systems from unauthorized access.<p>이전 단계와 마찬가지로, 이 질문에 대한 각 도구의 응답을 확인할 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8f633c518e708a99/6a170c25ab7f082991db9ed6/cb606d356b2f7d5e4878a5eff71bc881869ac0ee-800x585.gif" alt="'우리 시스템 전반의 인증 및 액세스 제어를 개선하기 위한 핵심 권장 사항이 뭐야? 참고 문헌 포함해 줘'라는 질문에 대한 각 도구의 응답을 포함하는 스크롤형 텍스트의 Claude Desktop 채팅 페이지입니다." /><p><em>참고: 각 도구 사용 승인 여부를 묻는 하위 메뉴가 나타나면 </em><em><strong>항상 허용</strong></em><em> 또는 </em><em><strong>한 번 허용</strong></em><em>을 선택하십시오.</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6627ee0bff1862df/6a170c266f7f040f6f91488c/aea942ba9b0037526ea215bec65690f1a5c3099c-1522x250.png" alt="사용자가 선택할 수 있는 '항상 허용' 및 '한 번만 허용' 옵션이 표시된 Claude Desktop 화면입니다." /><h2>결론</h2><p>MCP 서버는 로컬 및 원격 애플리케이션 모두를 위한 LLM 도구 표준화를 향한 중요한 진전을 의미합니다. 완전한 호환성을 구현하기 위해 아직 작업 중이지만, 이를 향해 빠르게 나아가고 있습니다.</p><p>이 글에서 Elasticsearch를 LLM 기반 애플리케이션에 연결하는 사용자 지정 MCP 서버를 TypeScript로 구축하는 방법을 배웠습니다. 서버는 두 가지 도구를 제공합니다. Query DSL을 사용하여 관련 문서를 가져오는 <code>search_docs</code>(와)과, OpenAI 모델을 통해 인용구가 포함된 요약을 생성하고 Claude Desktop을 클라이언트 UI로 사용하는 <code>summarize_and_cite</code>입니다.</p><p>다양한 클라이언트와 서버 제공 업체 간의 호환성 미래는 매우 유망해 보입니다. 다음 단계로는 에이전트에 더 많은 기능과 유연성을 추가하는 과정이 포함됩니다. 검색 템플릿을 사용하여 쿼리를 매개변수화함으로써 정확도와 유연성을 얻는 방법에 대한 실용적인 <a href="https://www.elastic.co/search-labs/blog/llm-functions-elasticsearch-intelligent-query">글</a>을 읽어 보실 수 있습니다.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude</guid>
    <category><![CDATA[에이전틱 AI]]></category>
    <category><![CDATA[통합]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5600198cb47666a5/6a170c28509168ce3ae1bb18/0bb24c05fff391f42070c2883182ea6fe9cb9680-1280x720.png" length="0" type="image/png"/>
    <pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch 추론 API와 Hugging Face 모델 함께 사용하기]]></title>
    <description><![CDATA[추론 엔드포인트를 사용하여 Elasticsearch를 Hugging Face 모델에 연결하고, 시맨틱 검색 및 채팅 완성을 갖춘 다국어 블로그 추천 시스템을 구축하는 방법을 알아보세요.]]></description>
    <content:encoded><![CDATA[<p>최근 업데이트에서 Elasticsearch는 <a href="https://endpoints.huggingface.co/">Hugging Face Inference Service</a>에 호스팅된 모델과 연결할 수 있는 네이티브 통합 기능을 도입했습니다. 이 게시물에서는 대규모 언어 모델(LLM)을 사용하여 간단한 API 호출을 통해 이 통합을 구성하고 추론을 수행하는 방법을 살펴보겠습니다. 리소스 사용량과 답변 품질 간의 균형이 잘 잡힌 경량 범용 모델인 <a href="https://huggingface.co/HuggingFaceTB/SmolLM3-3B">SmolLM3-3B</a>를 사용하겠습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9094997548bd70f8/6a170d6a839dfa0ad6dcff54/7ddadf1976421a860a7d62087239adb9150d808b-1999x1388.png" alt="산점도는 모델 크기(매개변수 십억 단위)를 x-축으로, 승률(백분율)을 y-축으로 하여 여러 소규모 생성형 언어 모델을 나타냅니다. SmolLM3-3B는 비슷한 크기의 다른 모델보다 높은 승률로 효율성 추세의 최상위권에 자리하고 있습니다." /><h2>필수 구성 요소</h2><ul><li><p><strong>Elasticsearch 9.3 또는 Elastic Cloud Serverless: </strong> <a href="https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud">이 지침</a>을 따라 클라우드 배포를 생성하거나, <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart#local-dev-quick-start"><code>start-local</code></a> 퀵스타트를 사용할 수 있습니다.</p></li><li><p><strong>Python 3.12: </strong>Python을 <a href="https://www.python.org/">여기</a>에서 다운로드하세요.</p></li><li><p><strong>Hugging Face </strong><a href="https://huggingface.co/docs/hub/en/security-tokens">액세스 토큰</a>.</p></li></ul><h2>Hugging Face 추론 엔드포인트를 사용하여 채팅 완료 수행하기</h2><p>먼저, Elasticsearch를 Hugging Face <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put">엔드포인트</a>에 연결하여 블로그 게시물 모음에서 AI 기반 추천을 생성하는 실용적인 예제를 구축할 것입니다. 앱 지식 기반 시스템을 위해, 회사 블로그 기사의 데이터 세트를 사용할 것입니다. 이 데이터 세트에는 귀중하지만 종종 탐색하기 어려운 정보가 포함되어 있습니다.</p><p>이 엔드포인트를 사용하면 <a href="https://www.elastic.co/docs/solutions/search/semantic-search">시맨틱 검색</a>을 통해 주어진 쿼리에 가장 적합한 문서를 검색할 수 있으며, Hugging Face LLM이 해당 결과를 바탕으로 문맥에 맞는 짧은 추천 결과를 생성합니다.</p><p>구축할 정보 흐름에 대한 개괄적인 내용을 살펴보겠습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf217b7b7db4e1e6c/6a170d6ca929cf8022ae0a3b/1dfbc2323438feaaa42e13ab242dd1f7166f74aa-1200x676.png" alt="시맨틱 검색 결과를 추론 엔드포인트에 공급하여 문서 추천을 반환하는 Elasticsearch 인덱스를 보여주는 흐름도." /><p>이 기사에서는 <strong>SmolLM3-3B</strong>의 컴팩트한 크기와 강력한 다국어 추론 및 도구 호출 기능을 결합하는 능력을 테스트할 것입니다. 검색 쿼리를 기반으로 일치하는 모든 콘텐츠(영어 및 스페인어)를 LLM으로 전송하고, 검색 쿼리와 결과를 바탕으로 맞춤형 설명이 포함된 추천 기사 목록을 생성합니다.</p><p>AI 추천 생성 시스템이 포함된 기사 사이트의 UI는 다음과 같을 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt20e69b9a06fecd65/6a170d6e839dfa6f97dcff58/8d3b86b212f28ff279f2da67a33e6134039f0e4e-1999x949.png" alt="AI 추천 생성 시스템이 포함된 기사 사이트의 UI로, 세 가지 예시가 나열되어 있으며, 텍스트는 영어로, 제목은 영어 또는 스페인어로 되어 있습니다." /><p>이 애플리케이션의 전체 구현은 연결된 <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-inference-api-and-hugging-face/notebook.ipynb">노트북</a>에서 확인하실 수 있습니다.</p><h3>Elasticsearch 추론 엔드포인트 구성하기</h3><p>Elasticsearch <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-hugging-face">Hugging Face 추론 엔드포인트</a>를 사용하려면 Hugging Face API 키와 실행 중인 Hugging Face 엔드포인트 URL라는 두 가지 중요한 요소가 필요합니다. 다음과 같이 보여야 합니다.</p>PUT _inference/chat_completions/hugging-face-smollm3-3b
{
    "service": "hugging_face",
    "service_settings": {
        "api_key": "hugging-face-access-token", 
        "url": "url-endpoint" 
    }
}<p>Elasticsearch의 Hugging Face 추론 엔드포인트는 <code>text_embedding</code>, <code>completion</code>, <code>chat_completion</code>, <code>rerank</code> 등 다양한 작업 유형을 지원합니다. 이 블로그 글에서는 검색 결과와 시스템 프롬프트를 바탕으로 대화형 추천을 생성하는 모델이 필요하기 때문에 <code>chat_completion</code>를 사용합니다. 이 엔드포인트를 통해 Elasticsearch API를 사용하여 Elasticsearch에서 직접 채팅 완료를 간단하게 수행할 수 있습니다.</p>POST _inference/chat_completion/hugging-face-smollm3-3b/_stream
{
  "messages": [
      { "role": "user", "content": "&lt;user prompt&gt;" }
  ]
}<p>이것은 애플리케이션의 핵심 역할을 하며, 프롬프트와 모델을 통과할 검색 결과를 받습니다. 이론을 다뤘으니 이제 애플리케이션 구현을 시작해 보겠습니다.</p><h4>Hugging Face에서 추론 엔드포인트 설정하기</h4><p>Hugging Face 모델을 배포하기 위해 <a href="https://huggingface.co/inference-endpoints/dedicated">Hugging Face 원클릭 배포</a>를 사용할 것입니다. 이는 모델 엔드포인트를 배포하기 위한 쉽고 빠른 서비스입니다. 이 서비스는 유료 서비스이므로 이용 시 추가 비용이 발생할 수 있습니다. 이 단계에서는 기사 추천을 생성하는 데 사용될 모델 인스턴스를 생성합니다.</p><p>원클릭 카탈로그에서 모델을 선택할 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta7bdfa43d6766324/6a170d6fb339d59e5476a039/b816e9fba1fe172687bf58f5143fb1f838c1077f-549x331.png" alt="'smoll3'로 필터링된 모델 카탈로그의 인터페이스 화면으로, 텍스트 생성, vLLM, GPU 1× NVIDIA L4, 가격 $0.8의 'smollm3-3b'라는 모델 1개와 모든 Hugging Face 모델로 검색을 확장할 것을 제안하는 메모가 표시되어 있습니다." /><p><strong>SmolLM3-3B</strong> 모델을 선택합니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdb0a2e6ffd7deb20/6a170d710c48574b7401aafc/610d3aba0429f3666c2df3616d513eb6a4397c0c-502x478.png" alt="SmolLM3‑3B 모델의 엔드포인트를 생성하기 위한 인터페이스로, 모델 이름, &quot;Hugging Face에서 확인됨&quot; 메모, 엔드포인트 이름 필드, 실행 중인 복제본당 시간당 $0.80의 비용, cURL 옵션 및 &quot;엔드포인트 생성&quot; 버튼을 표시합니다." /><p>여기에서 Hugging Face 엔드포인트 URL을 가져옵니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt25714021711ed6ff/6a170d72c1e8a54853f88336/025094ddb2cfbd1f0f216a5ec4e119b0f4fa2c42-646x328.png" alt="&quot;smollm3‑3b‑pnz&quot;라는 이름의 Hugging Face 추론 엔드포인트의 대시보드 화면으로, 녹색으로 표시된 실행 중 상태, 활성 복제본 1개, 지난 1시간 동안 요청 0건, 탐색 탭 및 표시된 엔드포인트 URL이 나타납니다." /><p>Elasticsearch <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-hugging-face">Hugging Face 추론 엔드포인트 설명서</a>에서 언급했듯이, 텍스트 생성에는 OpenAI API와 호환되는 모델이 필요합니다. 그러므로 Hugging Face 엔드포인트 URL에 <code>/v1/chat/completions</code> 하위 경로를 추가해야 합니다. 최종 결과는 다음과 같습니다.</p>https://j2g31h0futopfkli.us-east-1.aws.endpoints.huggingface.cloud/v1/chat/completions<p>이렇게 준비되면 Python 노트북에서 코딩을 시작할 수 있습니다.</p><h4>Hugging Face API 키 생성하기</h4><p><a href="https://huggingface.co/join">Hugging Face 계정</a>을 만들고 <a href="https://huggingface.co/docs/hub/en/security-tokens#user-access-tokens">다음 안내</a>에 따라 API 토큰을 받습니다. <em>세분화</em>(특정 리소스에만 액세스를 제공하므로 프로덕션에 권장), 읽기(<em>읽기</em> 전용 액세스), <em>쓰기</em>(읽기 및 쓰기 액세스용)의 세 가지 토큰 유형 중 선택할 수 있습니다. 이 튜토리얼에서는 추론 엔드포인트만 호출하면 되므로 읽기 토큰으로 충분합니다. 다음 단계를 위해 이 키를 저장해 두세요.</p><h4>Elasticsearch 추론 엔드포인트 설정</h4><p>먼저, Elasticsearch Python 클라이언트를 선언해 보겠습니다.</p>os.environ["ELASTICSEARCH_API_KEY"] = "your-elasticsearch-api-key"
os.environ["ELASTICSEARCH_URL"] = "https://xxxx.us-central1.gcp.cloud.es.io:443"

es_client = Elasticsearch(
    os.environ["ELASTICSEARCH_URL"], api_key=os.environ["ELASTICSEARCH_API_KEY"]
)<p>다음으로, Hugging Face 모델을 사용하는 Elasticsearch 추론 엔드포인트를 생성해 보겠습니다. 이 엔드포인트를 통해 블로그 게시물과 모델에 전달된 프롬프트를 기반으로 응답을 생성할 수 있습니다.</p>INFERENCE_ENDPOINT_ID = "smollm3-3b-pnz"

os.environ["HUGGING_FACE_INFERENCE_ENDPOINT_URL"] = (
 "https://j2g31h0futopfkli.us-east-1.aws.endpoints.huggingface.cloud/v1/chat/completions"
)
os.environ["HUGGING_FACE_API_KEY"] = "hf_xxxxx"

resp = es_client.inference.put(
        task_type="chat_completion",
        inference_id=INFERENCE_ENDPOINT_ID,
        body={
            "service": "hugging_face",
            "service_settings": {
                "api_key": os.environ["HUGGING_FACE_API_KEY"],
                "url": os.environ["HUGGING_FACE_INFERENCE_ENDPOINT_URL"],
            },
        },
    )<h3>데이터 세트</h3><p>데이터 세트에는 전체 워크플로우에서 사용되는 다국어 콘텐츠 세트를 나타내는 쿼리될 <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-inference-api-and-hugging-face/dataset.json">블로그 게시물</a>이 포함되어 있습니다.</p>// Articles dataset document example: 
{
    "id": "6",
    "title": "Complete guide to the new API: Endpoints and examples",
    "author": "Tomas Hernandez",
    "date": "2025-11-06",
    "category": "tutorial",
    "content": "This guide describes in detail all endpoints of the new API v2. It includes code examples in Python, JavaScript, and cURL for each endpoint. We cover authentication, resource creation, queries, updates, and deletion. We also explain error handling, rate limiting, and best practices. Complete documentation is available on our developer portal."
  }<h4>Elasticsearch 매핑</h4><p>데이터 세트가 정의되었으므로, 이제 블로그 게시물 구조에 적합한 데이터 스키마를 생성해야 합니다. 다음 <a href="https://www.elastic.co/docs/manage-data/data-store/mapping">인덱스 매핑</a>은 Elasticsearch에 데이터를 저장하는 데 사용됩니다.</p>INDEX_NAME = "blog-posts"

mapping = {
    "mappings": {
        "properties": {
            "id": {"type": "keyword"},
            "title": {
                "type": "object",
                "properties": {
                    "original": {
                        "type": "text",
                        "copy_to": "semantic_field",
                        "fields": {"keyword": {"type": "keyword"}},
                    },
                    "translated_title": {
                        "type": "text",
                        "fields": {"keyword": {"type": "keyword"}},
                    },
                },
            },
            "author": {"type": "keyword", "copy_to": "semantic_field"},
            "category": {"type": "keyword", "copy_to": "semantic_field"},
            "content": {"type": "text", "copy_to": "semantic_field"},
            "date": {"type": "date"},
            "semantic_field": {"type": "semantic_text"},
        }
    }
}


es_client.indices.create(index=INDEX_NAME, body=mapping)<p>여기에서 데이터가 어떻게 구성되어 있는지 더욱 명확하게 확인할 수 있습니다. 자연어를 기반으로 결과를 검색하는 데 시맨틱 검색을 사용하고, <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/copy-to"><code>copy_to</code></a> 속성을 사용하여 필드 내용을 <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> 필드로 복사합니다. 또한 <code>title</code> 필드에는 두 개의 하위 필드가 있습니다. <code>original</code> 하위 필드는 기사의 원래 언어에 따라 영어 또는 스페인어로 제목을 저장하며, <code>translated_title</code> 하위 필드는 스페인어 기사에만 존재하고 원래 제목의 영어 번역을 포함합니다.</p><h3>데이터 수집</h3><p>다음 코드 스니펫은 <a href="https://www.elastic.co/docs/reference/elasticsearch/clients/javascript/bulk_examples">벌크 API</a>를 사용하여 블로그 게시물 데이터 세트를 Elasticsearch로 수집합니다.</p>def build_data(json_file, index_name):
    with open(json_file, "r") as f:
        data = json.load(f)

    for doc in data:
        action = {"_index": index_name, "_source": doc}
        yield action


try:
    success, failed = helpers.bulk(
        es_client,
        build_data("dataset.json", INDEX_NAME),
    )
    print(f"{success} documents indexed successfully")

    if failed:
        print(f"Errors: {failed}")
except Exception as e:
    print(f"Error: {str(e)}")<p>이제 기사들이 Elasticsearch에 수집되었으니, <code>semantic_text</code> 필드에 대해 검색할 수 있는 함수를 만들어야 합니다.</p>def perform_semantic_search(query_text, index_name=INDEX_NAME, size=5):
    try:
        query = {
            "query": {
                "match": {
                    "semantic_field": {
                        "query": query_text,
                    }
                }
            },
            "size": size,
        }

        response = es_client.search(index=index_name, body=query)
        hits = response["hits"]["hits"]

        return hits
    except Exception as e:
        print(f"Semantic search error: {str(e)}")
        return []<p>추론 엔드포인트를 호출하는 함수도 필요합니다. 이 경우 <strong><code>chat_completion</code></strong>작업 유형을 사용하여 엔드포인트를 호출하여 스트리밍 응답을 받습니다.</p>def stream_chat_completion(messages: list, inference_id: str = INFERENCE_ENDPOINT_ID):
    url = f"{ELASTICSEARCH_URL}/_inference/chat_completion/{inference_id}/_stream"
    payload = {"messages": messages}
    headers = {
        "Authorization": f"ApiKey {ELASTICSEARCH_API_KEY}",
        "Content-Type": "application/json",
    }

    try:
        response = requests.post(url, json=payload, headers=headers, stream=True)
        response.raise_for_status()

        for line in response.iter_lines(decode_unicode=True):
            if line:
                line = line.strip()

                if line.startswith("event:"):
                    continue

                if line.startswith("data: "):
                    data_content = line[6:]

                    if not data_content.strip() or data_content.strip() == "[DONE]":
                        continue

                    try:
                        chunk_data = json.loads(data_content)

                        if "choices" in chunk_data and len(chunk_data["choices"]) &gt; 0:
                            choice = chunk_data["choices"][0]
                            if "delta" in choice and "content" in choice["delta"]:
                                content = choice["delta"]["content"]
                                if content:
                                    yield content

                    except json.JSONDecodeError as json_err:
                        print(f"\nJSON decode error: {json_err}")
                        print(f"Problematic data: {data_content}")
                        continue

    except requests.exceptions.RequestException as e:
        yield f"Error: {str(e)}"<p>이제 의미 탐색 함수와 <code>chat_completions</code> 추론 엔드포인트, 추천 엔드포인트를 호출하여 카드에 할당될 데이터를 생성할 수 있습니다.</p>def recommend_articles(search_query, index_name=INDEX_NAME, max_articles=5):
    print(f"\n{'='*80}")
    print(f"🔍 Search Query: {search_query}")
    print(f"{'='*80}\n")

    articles = perform_semantic_search(search_query, index_name, size=max_articles)

    if not articles:
        print("❌ No relevant articles found.")
        return None, None

    print(f"✅ Found {len(articles)} relevant articles\n")

    # Build context with found articles
    context = "Available blog articles:\n\n"
    for i, article in enumerate(articles, 1):
        source = article.get("_source", article)
        context += f"Article {i}:\n"
        context += f"- Title: {source.get('title', 'N/A')}\n"
        context += f"- Author: {source.get('author', 'N/A')}\n"
        context += f"- Category: {source.get('category', 'N/A')}\n"
        context += f"- Date: {source.get('date', 'N/A')}\n"
        context += f"- Content: {source.get('content', 'N/A')}\n\n"

    system_prompt = """You are an expert content curator that recommends blog articles.

    Write recommendations in a conversational style starting with phrases like:
    - "If you're interested in [topic], this article..."
    - "This post complements your search with..."
    - "For those looking into [topic], this article provides..."


    FORMAT REQUIREMENTS:
    - Return ONLY a JSON array
    - Each element must have EXACTLY these three fields: "article_number", "title", "recommendation"
    - If the original title is in spanish, use the "translated_title" subfield in the "title" field

    Keep each recommendation concise (2-3 sentences max) and focused on VALUE to the reader.

    EXAMPLE OF CORRECT FORMAT:
    [
        {"article_number": 1, "title": "Article title in english", "recommendation": "If you are interested in [topic], this article provides..."},
        {"article_number": 2, "title": "Article title in english", "recommendation": " for those looking into [topic], this article provides..."}
    ]

    Return ONLY the JSON array following this exact structure."""

    user_prompt = f"""Search query: "{search_query}"

    Generate recommendations for the following articles: {context}
    """

    messages = [
        {"role": "system", "content": "/no_think"},
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt},
    ]

    # LLM generation
    print(f"{'='*80}")
    print("🤖 Generating personalized recommendations...\n")

    full_response = ""

    for chunk in stream_chat_completion(messages):
        print(chunk, end="", flush=True)
        full_response += chunk

    return context, articles, full_response<p>마지막으로, 정보를 추출하여 인쇄할 수 있도록 서식을 지정해야 합니다.</p>def display_recommendation_cards(articles, recommendations_text):
    print("\n" + "=" * 100)
    print("📇 RECOMMENDED ARTICLES".center(100))
    print("=" * 100 + "\n")

    # Parse JSON recommendations - clean tags and extract JSON
    recommendations_list = []
    try:

        # Clean up &lt;think&gt; tags
        cleaned_text = re.sub(
            r"&lt;think&gt;.*?&lt;/think&gt;", "", recommendations_text, flags=re.DOTALL
        )
        # Remove markdown code blocks ( ... ``` or ``` ... ```)
        cleaned_text = re.sub(r"```(?:json)?", "", cleaned_text)
        cleaned_text = cleaned_text.strip()

        parsed = json.loads(cleaned_text)

        # Extract recommendations from list format
        for item in parsed:
            article_number = item.get("article_number")
            title = item.get("title", "")
            rec_text = item.get("recommendation", "")

            if article_number and rec_text:
                recommendations_list.append(
                    {
                        "article_number": article_number,
                        "title": title,
                        "recommendation": rec_text,
                    }
                )
    except json.JSONDecodeError as e:
        print(f"⚠️  Could not parse recommendations as JSON: {e}")
        return

    for i, article in enumerate(articles, 1):
        source = article.get("_source", article)

        # Card border
        print("┌" + "─" * 98 + "┐")

        # Find recommendation and title for this article number
        recommendation = None
        title = None
        for rec in recommendations_list:
            if rec.get("article_number") == i:
                recommendation = rec.get("recommendation")
                title = rec.get("title")
                break

        # Print title
        title_lines = textwrap.wrap(f"📌 {title}", width=94)
        for line in title_lines:
            print(f"│  {line}".ljust(99) + "│")

        # Card border
        print("├" + "─" * 98 + "┤")

        # Print recommendation
        if recommendation:
            recommendation_lines = textwrap.wrap(recommendation, width=94)
            for line in recommendation_lines:
                print(f"│  {line}".ljust(99) + "│")

        # Card bottom
        print("└" + "─" * 98 + "┘")<p>보안 블로그 게시물에 대해 질문하여 이를 테스트해 보겠습니다.</p>search_query = "Security and vulnerabilities"

context, articles, recommendations = recommend_articles(search_query)

print("\nElasticsearch context:\n", context)

# Display visual cards
display_recommendation_cards(articles, recommendations)<p>여기서 워크플로우가 생성한 콘솔의 카드를 볼 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4aa221a08a51aeb3/6a170d7460084be1413c45d6/730d35212594bb3db30447c3ea7e2a92857287b7-1999x1515.png" alt="&quot;추천 기사&quot;라는 제목의 섹션은 인증 시스템 취약점, 마이그레이션 위험, REST API v2 성능 및 인증 개선, 알림 시스템 변경, 그리고 새로운 API에 대한 완전한 가이드를 포함한 다섯 개의 상자형 기사 요약을 보여줍니다." /><p><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-inference-api-and-hugging-face/results.md">이 파일에서</a> 모든 히트와 LLM 응답을 포함한 전체 결과를 확인하실 수 있습니다.</p><p>"보안 및 취약점"과 관련된 기사를 찾고 있습니다. 이 질문은 Elasticsearch에 저장된 문서에 대한 검색 쿼리로 사용됩니다. 검색된 결과는 모델로 전달되어 해당 콘텐츠를 기반으로 추천을 생성합니다. 보시다시피, 이 모델은 독자가 클릭하도록 동기를 부여할 수 있는 매력적인 짧은 텍스트를 훌륭하게 생성했습니다.</p><h2>결론</h2><p>이 예시는 Elasticsearch와 Hugging Face를 결합하여 AI 애플리케이션을 위한 빠르고 효율적인 중앙 집중식 시스템을 만드는 방법을 보여줍니다. 이 접근 방식은 수동 작업을 줄이고 Hugging Face의 광범위한 모델 카탈로그 덕분에 유연성을 제공합니다. SmolLM3-3B를 사용하면 특히 소형 다국어 모델이 시맨틱 검색과 결합될 때 여전히 의미 있는 추론과 콘텐츠 생성을 제공할 수 있음을 보여줍니다. 이러한 도구들을 함께 사용하면 지능형 콘텐츠 분석 및 다국어 애플리케이션 구축을 위한 확장성 있는 효과적인 기반을 마련할 수 있습니다.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/hugging-face-elasticsearch-inference-api</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/hugging-face-elasticsearch-inference-api</guid>
    <category><![CDATA[에이전틱 AI]]></category>
    <category><![CDATA[통합]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5f961af4cb26ec97/6a170d767d8d6790c770e790/1417d6ff033712206c9bd4bcc22074ee3437ce96-1999x1125.png" length="0" type="image/png"/>
    <pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[LangGraph.js와 Elasticsearch를 사용하여 금융 AI 검색 워크플로우 구축]]></title>
    <description><![CDATA[LangGraph.js와 Elasticsearch를 사용하여 자연어 쿼리를 투자 및 시장 분석을 위한 동적인 조건부 필터로 전환하는 AI 기반 금융 검색 워크플로우를 구축하는 방법을 알아보세요.]]></description>
    <content:encoded><![CDATA[<p>AI 검색 애플리케이션을 구축할 때는 여러 작업, 데이터 검색 및 데이터 추출을 원활한 워크플로우로 조정하는 것이 중요합니다. LangGraph는 개발자가 노드 기반 구조를 통해 AI 에이전트를 오케스트레이션하여 이 프로세스를 간소화합니다. 이 포스트에서는 <a href="https://langchain-ai.github.io/langgraphjs/">LangGraph.js</a>를 사용하여 금융 솔루션을 구축해 보겠습니다.</p><h2>LangGraph란?</h2><p><a href="https://langchain-ai.github.io/langgraphjs/">LangGraph</a>는 AI 에이전트를 구축하고 워크플로우에서 이를 오케스트레이션하여 AI 지원 애플리케이션을 생성하는 프레임워크입니다. LangGraph에는 작업을 나타내는 함수를 선언하고 워크플로우의 노드로 할당할 수 있는 노드 아키텍처가 있습니다. 여러 노드가 상호작용을 한 결과는 그래프가 됩니다. LangGraph는 모듈형 및 조합 가능한 AI 시스템을 구축하기 위한 도구를 제공하는 광범위한 <a href="https://js.langchain.com/docs/introduction/">LangChain</a> 에코시스템의 일부입니다.</p><p>LangGraph가 유용한 이유를 더 잘 이해하기 위해 LangGraph를 사용하여 문제 상황을 해결해 보겠습니다.</p><h2>솔루션 개요</h2><p>벤처 캐피털 회사에서는 투자자들이 다양한 필터링 옵션을 갖춘 방대한 데이터베이스를 활용할 수 있지만, 기준을 결합하려고 하면 어렵고 시간이 오래 걸립니다. 이로 인해 일부 관련 스타트업이 투자 대상에서 제외될 수 있습니다. 결국 최적의 후보자를 찾는 데 많은 시간을 허비하거나, 심지어 기회를 놓치게 됩니다.</p><p>LangGraph와 Elasticsearch를 사용하면 자연어로 Elasticsearch 검색을 수행할 수 있어 사용자가 수십 개의 필터로 복잡한 요청을 수동으로 작성할 필요가 없습니다. 유연성을 높이기 위해 워크플로우는 사용자 입력에 따라 두 가지 쿼리 유형 중 하나를 자동으로 선택합니다.</p><ul><li><p><strong>투자 중심 쿼리</strong>: <a href="https://www.investopedia.com/articles/personal-finance/102015/series-b-c-funding-what-it-all-means-and-how-it-works.asp">펀딩 라운드</a>, 가치 평가 또는 <a href="https://www.investopedia.com/terms/r/revenue.asp">수익</a>과 같은 스타트업의 금융 및 자금 조달 측면을 대상으로 합니다. <em>예시:</em> "800만~2500만 달러의 시리즈 A 또는 시리즈 B 펀딩을 받고 월 매출이 50만 달러 이상인 스타트업을 찾아주세요."</p></li><li><p><strong>시장 중심 쿼리</strong>: 이는 <a href="https://en.wikipedia.org/wiki/Vertical_market">산업 분야</a>, <a href="https://en.wikipedia.org/wiki/Target_market">지리적 시장</a> 또는 <a href="https://www.investopedia.com/terms/b/businessmodel.asp">비즈니스 모델</a>에 집중하여 특정 부문이나 지역의 기회를 식별하는 데 도움을 줍니다. <em>예시:</em> "샌프란시스코, 뉴욕 또는 보스턴에서 핀테크 및 헬스케어 스타트업을 찾으주세요."</p></li></ul><p>쿼리를 견고하게 유지하기 위해 LLM이 <a href="https://www.elastic.co/docs/solutions/search/search-templates">검색 템플릿</a>을 작성하도록 하고, 전체 <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/querydsl">DSL 쿼리</a> 대신 사용합니다. 이렇게 하면 항상 원하는 쿼리를 얻을 수 있으며, LLM은 매번 필요한 쿼리를 작성할 필요 없이 빈칸만 채우면 됩니다.</p><h2>시작에 필요한 사항</h2><ul><li><p>Elasticsearch APIKey</p></li><li><p>OpenAPI APIKey</p></li><li><p>Node 18 이상 버전</p></li></ul><h2>단계별 지침</h2><p>이 섹션에서는 앱이 어떻게 보일지 살펴보겠습니다. 이를 위해 <a href="https://www.typescriptlang.org/">TypeScript</a>를 사용하겠습니다. TypeScript는 JavaScript의 상위 집합으로, 정적 타입을 추가하여 코드의 안정성, 유지관리 용이성, 안전성을 높이고 기존 JavaScript와의 완벽한 호환성을 유지하면서도 오류를 조기에 발견할 수 있도록 합니다.</p><p>노드의 흐름은 다음과 같습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt90db8f03f372608c/6a170986dc55de6e16e00d93/b47d7f238c4964a6febc0de7fe5e68b186f539c3-363x555.png" alt="" /><p>위 이미지는 LangGraph에서 생성되었으며, 노드 간의 실행 순서와 조건부 로직을 정의하는 워크플로우를 나타냅니다.</p><ul><li><p><strong>decideStrategy: </strong>LLM을 사용하여 사용자의 쿼리를 분석하고 두 가지 전문 검색 전략, 즉 투자 중심 또는 시장 중심 중 하나를 결정합니다.</p></li><li><p><strong>prepareInvestmentSearch: </strong>쿼리에서 필터 값을 추출하고 금융 및 자금 조달 관련 매개변수를 강조하는 사전 정의된 템플릿을 작성합니다.</p></li><li><p><strong>prepareMarketSearch</strong>: 필터 값을 추출할 뿐만 아니라 시장, 산업 및 지리적 컨텍스트를 강조하는 매개변수를 동적으로 구축합니다.</p></li><li><p><strong>executeSearch: </strong>구성된 쿼리를 검색 템플릿을 사용하여 Elasticsearch로 전송하고 일치하는 스타트업 문서를 검색합니다.</p></li><li><p><strong>visualizeResults: </strong>최종 결과를 자금 조달, 산업, 수익과 같은 주요 스타트업 속성을 보여주는 명확하고 읽기 쉬운 요약 형식으로 구성합니다.</p></li></ul><p>이 흐름에는 사용자의 입력에 따라 투자 또는 시장 검색 경로를 선택하는 'if' 문 역할을 하는 <a href="https://langchain-ai.github.io/langgraphjs/how-tos/branching/?h=conditional#how-to-create-branches-for-parallel-node-execution">조건부 분기</a>가 포함되어 있습니다. LLM에 의해 구동되는 이러한 결정 로직은 워크플로우를 적응적이고 컨텍스트에 맞게 만들어줍니다. 다음 섹션에서 이 메커니즘에 대해 더 자세히 살펴보겠습니다.</p><h3>LangGraph 상태</h3><p>각 노드를 개별적으로 보기 전에 노드가 어떻게 통신하고 데이터를 공유하는지 이해해야 합니다. 이를 위해 LangGraph를 사용하여 워크플로우 상태를 정의할 수 있습니다. 이는 노드 간에 전달될 공유 상태를 정의합니다.</p><p>상태는 워크플로우 전반에 걸쳐 중간 데이터를 저장하는 공유 컨테이너 역할을 합니다. 사용자의 자연어 쿼리로 시작하여 선택한 검색 전략, Elasticsearch에 준비된 매개변수, 검색 결과, 마지막으로 형식화된 출력을 유지합니다.</p><p>이 구조는 모든 노드가 상태를 읽고 업데이트할 수 있도록 하여 사용자 입력에서 최종 시각화까지 정보의 일관된 흐름을 보장합니다.</p>const VCState = Annotation.Root({
  input: Annotation&lt;string&gt;(), // User's natural language query
  searchStrategy: Annotation&lt;string&gt;(), // Search strategy chosen by LLM
  searchParams: Annotation&lt;any&gt;(), // Prepared search parameters
  results: Annotation&lt;any[]&gt;(), // Search results
  final: Annotation&lt;string&gt;(), // Final formatted response
});<h3>애플리케이션 설정</h3><p>이 섹션의 모든 코드는 <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch">elasticsearch-labs 리포지토리</a>에서 확인할 수 있습니다.</p><p>앱이 위치할 폴더에서 터미널을 열고 다음 명령어로 Node.js 애플리케이션을 초기화합니다.</p>npm init -y<p>이제 이 프로젝트에 필요한 필수 종속성을 설치할 수 있습니다.</p>npm install @elastic/elasticsearch @langchain/langgraph @langchain/openai @langchain/core dotenv zod &amp;&amp; npm install --save-dev @types/node tsx typescript<ul><li><p><strong><code>@elastic/elasticsearch</code></strong>: 데이터 수집 및 검색과 같은 Elasticsearch 요청을 처리하는 데 도움이 됩니다.</p></li><li><p><strong><code>@langchain/langgraph</code></strong>: 모든 LangGraph 도구를 제공하기 위한 JS 종속성입니다.</p></li><li><p><strong><code>@langchain/openai</code></strong>: LangChain을 위한 OpenAI LLM 클라이언트.</p></li><li><p>@langchain/core: 프롬프트 템플릿 등 LangChain 앱을 위한 기본 구성 요소를 제공합니다.</p></li><li><p><strong><code>dotenv</code></strong>: JavaScript에서 환경 변수를 사용하기 위한 필수 종속성입니다.</p></li><li><p><strong><code>zod</code></strong>: 유형 데이터에 대한 종속성입니다.</p></li></ul><p><code>@types/node</code> <code>tsx</code> <code>typescript</code> TypeScript 코드를 작성하고 실행할 수 있게 해줍니다.</p><p>이제 다음 파일을 생성합니다.</p><ul><li><p><code>elasticsearchSetup</code><a href="http://ingest.ts/"><code>.ts</code></a>: 인덱스 매핑을 생성하고 JSON 파일에서 데이터 세트를 로드한 후 Elasticsearch에 데이터를 수집합니다.</p></li><li><p><a href="http://main.ts/"><code>main.ts</code></a>: LangGraph 애플리케이션을 포함합니다.</p></li><li><p><code>.env</code>: 환경 변수를 저장하기 위한 파일</p></li></ul><p><code>.env</code> 파일에 다음 환경 변수를 추가합시다:</p>ELASTICSEARCH_ENDPOINT="your-endpoint-here"
ELASTICSEARCH_API_KEY="your-key-here"
OPENAI_API_KEY="your-key-here"<p>OpenAPI APIKey는 코드에서 직접 사용되지 않으며, 대신 라이브러리 <code>@langchain/openai</code>에서 내부적으로 사용됩니다.</p><p>모든 매핑 생성, 검색 템플릿 생성 및 데이터 세트 수집과 관련된 로직은 <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/elasticsearchSetup.ts"><code>elasticsearchSetup.ts</code></a> 파일에서 확인할 수 있습니다. 다음 단계에서는 <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/main.ts"><code>main.ts</code></a> 파일에 초점을 맞추겠습니다. 또한 데이터 세트를 확인하여 <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/dataset.json"><code>dataset.json</code></a> 에서 데이터가 어떻게 보이는지 더 잘 이해할 수 있습니다.</p><h3>LangGraph 앱</h3><p><code>main.ts</code> 파일에서 LangGraph 애플리케이션을 통합하기 위해 필요한 몇 가지 종속성을 가져오겠습니다. 이 파일에는 노드 함수와 상태 선언도 포함해야 합니다. 그래프 선언은 다음 단계에서 <code>main</code> 메서드로 진행됩니다. <code>elasticsearchSetup.ts</code> 파일에는 향후 단계에서 노드 내에서 사용할 Elasticsearch 도우미가 포함됩니다.</p>import { writeFileSync } from "node:fs";
import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";
import {
  esClient,
  ingestDocuments,
  createSearchTemplates,
  INDEX_NAME,
  INVESTMENT_FOCUSED_TEMPLATE,
  MARKET_FOCUSED_TEMPLATE,
  createIndex,
} from "./elasticsearchSetup.js";

const llm = new ChatOpenAI({ model: "gpt-4o-mini" });<p>앞서 언급한 바와 같이 LLM 클라이언트는 사용자의 질문에 따라 Elasticsearch 검색 템플릿 매개변수를 생성하는 데 사용됩니다.</p>async function saveGraphImage(app: any): Promise&lt;void&gt; {
  try {
    const drawableGraph = app.getGraph();
    const image = await drawableGraph.drawMermaidPng();
    const arrayBuffer = await image.arrayBuffer();

    const filePath = "./workflow_graph.png";
    writeFileSync(filePath, new Uint8Array(arrayBuffer));
    console.log(`📊 Workflow graph saved as: ${filePath}`);
  } catch (error: any) {
    console.log("⚠️  Could not save graph image:", error.message);
  }
}<p>해당 메서드는 그래프 이미지를 png 형식으로 생성하고 <a href="https://mermaid.ink/">Mermaid.INK API</a>를 백그라운드에서 사용합니다. 이는 앱 노드가 스타일이 지정된 시각화와 어떻게 상호작용을 하는지 확인하려는 경우에 유용합니다.</p><h3>LangGraph 노드</h3><p>이제 각 노드를 자세히 살펴보겠습니다.</p><h3>decideSearchStrategy 노드</h3><p><code>decideSearchStrategy</code> 노드는 사용자 입력을 분석하여 투자 중심 검색을 수행할지 시장 중심 검색을 수행할지 결정합니다. 이 노드는 정형 출력 스키마(Zod로 정의됨)가 있는 LLM을 사용해 쿼리 유형을 분류합니다. 결정을 내리기 전에 집계를 사용하여 인덱스에서 사용 가능한 필터를 검색하여 모델이 산업, 위치 및 자금 조달 데이터에 대한 최신 컨텍스트를 갖출 수 있도록 합니다.</p><p>필터 가능한 값을 추출하여 LLM으로 전송하기 위해 <a href="https://www.elastic.co/docs/explore-analyze/query-filter/aggregations">집계</a> 쿼리를 사용하여 Elasticsearch 인덱스에서 직접 검색해 보겠습니다. 이 로직은 <code>getAvailableFilters</code> 이라는 메서드에 할당되어 있습니다.</p>async function getAvailableFilters() {
  try {
    const response = await esClient.search({
      index: INDEX_NAME,
      size: 0,
      aggs: {
        industries: {
          terms: { field: "industry", size: 100 },
        },
        locations: {
          terms: { field: "location", size: 100 },
        },
        funding_stages: {
          terms: { field: "funding_stage", size: 20 },
        },
        business_models: {
          terms: { field: "business_model", size: 10 },
        },
        lead_investors: {
          terms: { field: "lead_investor", size: 100 },
        },
        funding_amount_stats: {
          stats: { field: "funding_amount" },
        },
      },
    });

    return response.aggregations;
  } catch (error) {
    console.error("❌ Error getting available filters:", error);
    return {};
  }
}<p>위의 집계 쿼리로 다음과 같은 결과를 얻었습니다.</p>{
  "industries": {
    "doc_count_error_upper_bound": 0,
    "sum_other_doc_count": 0,
    "buckets": [
      {
        "key": "logistics",
        "doc_count": 5
      },
      ...
    ]
  },
  "locations": {
    "doc_count_error_upper_bound": 0,
    "sum_other_doc_count": 0,
    "buckets": [
      {
        "key": "San Francisco, CA",
        "doc_count": 4
      },
      {
        "key": "New York, NY",
        "doc_count": 3
      },
      ...
    ]
  },
  "funding_stages": {
    "doc_count_error_upper_bound": 0,
    "sum_other_doc_count": 0,
    "buckets": [
      {
        "key": "Series A",
        "doc_count": 8
      },
      ...
    ]
  },
  "business_models": {
    "doc_count_error_upper_bound": 0,
    "sum_other_doc_count": 0,
    "buckets": [
      {
        "key": "B2B",
        "doc_count": 13
      },
      ...
    ]
  },
  "lead_investors": {
    "doc_count_error_upper_bound": 0,
    "sum_other_doc_count": 0,
    "buckets": [
      {
        "key": "Battery Ventures",
        "doc_count": 1
      },
      {
        "key": "Benchmark Capital",
        "doc_count": 1
      },
      ...
    ]
  },
  "funding_amount_stats": {
    "count": 20,
    "min": 4500000,
    "max": 35000000,
    "avg": 14075000,
    "sum": 281500000
  }
}<p><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/responses/aggregationsResponse.json">여기</a>에서 모든 결과를 확인하세요.</p><p>두 가지 전략 모두 하이브리드 검색을 사용하여 질문의 정형적인 부분(필터)과 보다 주관적인 부분(시맨틱)을 모두 탐지합니다. 다음은 <a href="https://www.elastic.co/docs/solutions/search/search-templates">검색 템플릿</a>을 사용한 두 쿼리의 예입니다.</p>await esClient.putScript({
      id: INVESTMENT_FOCUSED_TEMPLATE,
      script: {
        lang: "mustache",
        source: `{
          "size": 5,
          "retriever": {
            "rrf": {
              "retrievers": [
                {
                  "standard": {
                    "query": {
                      "semantic": {
                        "field": "semantic_field",
                        "query": "{{query_text}}"
                      }
                    }
                  }
                },
                {
                  "standard": {
                    "query": {
                      "bool": {
                        "filter": [
                          {"terms": {"funding_stage": {{#join}}{{#toJson}}funding_stage{{/toJson}}{{/join}}}},
                          {"range": {"funding_amount": {"gte": {{funding_amount_gte}}{{#funding_amount_lte}},"lte": {{funding_amount_lte}}{{/funding_amount_lte}}}}},
                          {"terms": {"lead_investor": {{#join}}{{#toJson}}lead_investor{{/toJson}}{{/join}}}},
                          {"range": {"monthly_revenue": {"gte": {{monthly_revenue_gte}}{{#monthly_revenue_lte}},"lte": {{monthly_revenue_lte}}{{/monthly_revenue_lte}}}}}
                        ]
                      }
                    }
                  }
                }
              ],
              "rank_window_size": 100,
              "rank_constant": 20
            }
          }
        }`,
      },
    });<p>자세히 설명된 쿼리는 <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/elasticsearchSetup.ts#L119"><code>elasticsearchSetup.ts</code></a> 파일에서 확인하세요. 다음 노드에서는 두 쿼리 중 어떤 쿼리를 사용할지 결정합니다.</p>// Node 1: Decide search strategy using LLM
async function decideSearchStrategy(state: typeof VCState.State) {
  // Zod schema for specialized search strategy decision
  const SearchDecisionSchema = z.object({
    search_type: z
      .enum(["investment_focused", "market_focused"])
      .describe("Type of specialized search strategy to use"),
    reasoning: z
      .string()
      .describe("Brief explanation of why this search strategy was chosen"),
  });

  const decisionLLM = llm.withStructuredOutput(SearchDecisionSchema);

  // Get dynamic filters from Elasticsearch
  const availableFilters = await getAvailableFilters();

  const prompt = `Query: "${state.input}"
    Available filters: ${JSON.stringify(availableFilters, null, 2)}

    Choose between two specialized search strategies:
    
    - investment_focused: For queries about funding stages, funding amounts, monthly revenue, lead investors, financial performance
    
    - market_focused: For queries about industries, locations, business models, market segments, geographic markets
    
    Analyze the query intent and choose the most appropriate strategy.
  `;

  try {
    const result = await decisionLLM.invoke(prompt);
    console.log(
      `🤔 Search strategy: ${result.search_type} - ${result.reasoning}`
    );

    return {
      searchStrategy: result.search_type,
    };
  } catch (error: any) {
    console.error("❌ Error in decideSearchStrategy:", error.message);
    return {
      searchStrategy: "investment_focused",
    };
  }
}<h3>prepareInvestmentSearch 및 prepareMarketSearch 노드</h3><p>두 노드 모두 <code>extractFilterValues</code>이라는 공유 도우미 함수를 사용하는데, 이 함수는 LLM을 활용하여 사용자 입력에 언급된 관련 필터(산업, 위치, 자금 조달 단계, 비즈니스 모델 등)를 식별합니다. 이 스키마를 사용하여 <a href="https://www.elastic.co/docs/solutions/search/search-templates">검색 템플릿</a>을 작성하고 있습니다.</p>// Extract all possible filter values from user input
async function extractFilterValues(input: string) {
  const FilterValuesSchema = z.object({
    // Investment-focused filters
    funding_stage: z
      .array(z.string())
      .default([])
      .describe("Funding stage values mentioned in query"),
    funding_amount_gte: z
      .number()
      .default(0)
      .describe("Minimum funding amount in USD"),
    funding_amount_lte: z
      .number()
      .default(100000000)
      .describe("Maximum funding amount in USD"),
    lead_investor: z
      .array(z.string())
      .default([])
      .describe("Lead investor values mentioned in query"),
    monthly_revenue_gte: z
      .number()
      .default(0)
      .describe("Minimum monthly revenue in USD"),
    monthly_revenue_lte: z
      .number()
      .default(10000000)
      .describe("Maximum monthly revenue in USD"),
    industry: z
      .array(z.string())
      .default([])
      .describe("Industry values mentioned in query"),
    location: z
      .array(z.string())
      .default([])
      .describe("Location values mentioned in query"),
    business_model: z
      .array(z.string())
      .default([])
      .describe("Business model values mentioned in query"),
  });

  const extractorLLM = llm.withStructuredOutput(FilterValuesSchema);
  const availableFilters = await getAvailableFilters();

  const extractPrompt = `Extract ALL relevant filter values from: "${input}"
    Available options: ${JSON.stringify(availableFilters, null, 2)}
    Extract only values explicitly mentioned in the query. Leave fields empty if not mentioned.`;

  return await extractorLLM.invoke(extractPrompt);
}<p>탐지된 의도에 따라 워크플로우는 다음 두 가지 경로 중 하나를 선택합니다.</p><p><strong>prepareInvestmentSearch:</strong> 자금 조달 단계, 자금 조달 금액, 투자자 및 갱신 정보 등 금융 지향적 검색 매개변수를 구축합니다. 전체 쿼리 템플릿은 <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/elasticsearchSetup.ts"><code>elasticsearchSetup.ts</code></a> 파일에서 확인할 수 있습니다.</p>// Node 2A: Prepare Investment-Focused Search Parameters 
async function prepareInvestmentSearch(state: typeof VCState.State) {
  console.log(
    "💰 Preparing INVESTMENT-FOCUSED search parameters with financial emphasis..."
  );

  try {
    // Extract all filter values from input
    const values = await extractFilterValues(state.input);

    let searchParams: any = {
      template_id: INVESTMENT_FOCUSED_TEMPLATE,
      query_text: state.input,
      ...values,
    };

    return { searchParams };
  } catch (error) {
    console.error("❌ Error preparing investment-focused params:", error);
    return {
      searchParams: {},
    };
  }
}<p><strong>prepareMarketSearch:</strong> 산업, 지역, 비즈니스 모델에 초점을 맞춘 시장 기반 매개변수를 생성합니다. 전체 쿼리는 <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/elasticsearchSetup.ts"><code>elasticsearchSetup.ts</code></a> 에서 확인하세요.</p>// Node 2B: Prepare Market-Focused Search Parameters
async function prepareMarketSearch(state: typeof VCState.State) {
  console.log(
    "🔍 Preparing MARKET-FOCUSED search parameters with market emphasis..."
  );

  try {
    // Extract all filter values from input
    const values = await extractFilterValues(state.input);

    let searchParams: any = {
      template_id: MARKET_FOCUSED_TEMPLATE,
      query_text: state.input,
      ...values,
    };

    return { searchParams };
  } catch (error) {
    console.error("❌ Error preparing market-focused params:", error);
    return {};
  }
}<h3>executeSearch 노드</h3><p>이 node는 상태에서 생성된 검색 매개변수를 가져와 먼저 <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-render-search-template">_render API</a> 를 사용하여 디버깅 목적으로 쿼리를 시각화한 후, 결과를 검색하기 위해 Elasticsearch로 요청을 보냅니다.</p>// Node 3: Execute Search
async function executeSearch(state: typeof VCState.State) {
  const { searchParams } = state;

  try {
    // getting formed query from template for debugging
    const renderedTemplate = await esClient.renderSearchTemplate({
      id: searchParams.template_id,
      params: searchParams,
    });

    console.log(
      "📋 Complete query:",
      JSON.stringify(renderedTemplate.template_output, null, 2)
    );

    const results = await esClient.searchTemplate({
      index: INDEX_NAME,
      id: searchParams.template_id,
      params: searchParams,
    });

    return {
      results: results.hits.hits.map((hit: any) =&gt; hit._source),
    };
  } catch (error: any) {
    console.error(`❌ ${state.searchParams.search_type} search error:`, error);
    return { results: [] };
  }
}<h3>visualizeResults 노드</h3><p>마지막으로, 이 node는 Elasticsearch 결과를 표시합니다.</p>// Node 4: Visualize results
async function visualizeResults(state: typeof VCState.State) {
  const results = state.results || [];

  let formattedResults = `🎯 Found ${results.length} startups matching your criteria:\n\n`;

  results.forEach((startup: any, index: number) =&gt; {
    formattedResults += `${index + 1}. **${startup.company_name}**\n`;
    formattedResults += `   📍 ${startup.location} | 🏢 ${startup.industry} | 💼 ${startup.business_model}\n`;
    formattedResults += `   💰 ${startup.funding_stage} - $${(
      startup.funding_amount / 1000000
    ).toFixed(1)}M\n`;
    formattedResults += `   👥 ${startup.employee_count} employees | 📈 $${(
      startup.monthly_revenue / 1000
    ).toFixed(0)}K MRR\n`;
    formattedResults += `   🏦 Lead: ${startup.lead_investor}\n`;
    formattedResults += `   📝 ${startup.description}\n\n`;
  });

  return {
    final: formattedResults,
  };
}<p>프로그래밍 방식으로 전체 그래프는 다음과 같습니다.</p>  const workflow = new StateGraph(VCState)
    // Register nodes - these are the processing functions
    .addNode("decideStrategy", decideSearchStrategy)
    .addNode("prepareInvestment", prepareInvestmentSearch)
    .addNode("prepareMarket", prepareMarketSearch)
    .addNode("executeSearch", executeSearch)
    .addNode("visualizeResults", visualizeResults)
    // Define execution flow with conditional branching
    .addEdge(START, "decideStrategy") // Start with strategy decision
    .addConditionalEdges(
      "decideStrategy",
      (state: typeof VCState.State) =&gt; state.searchStrategy, // Conditional function
      {
        investment_focused: "prepareInvestment", // If investment focused -&gt; RRF template preparation
        market_focused: "prepareMarket", // If market focused -&gt; dynamic query preparation
      }
    )
    .addEdge("prepareInvestment", "executeSearch") // Investment prep -&gt; execute
    .addEdge("prepareMarket", "executeSearch") // Market prep -&gt; execute
    .addEdge("executeSearch", "visualizeResults") // Execute -&gt; visualize
    .addEdge("visualizeResults", END); // End workflow<p>보시다시피 앱이 다음에 실행할 '경로' 또는 '노드'를 결정하는 조건부 엣지가 있습니다. 이 기능은 워크플로우에서 여러 도구 중 하나를 선택하거나 인간이 개입하는 단계를 포함하는 등 분기 로직이 필요할 때 유용합니다.</p><p>LangGraph의 핵심 기능을 이해하면 다음과 같이 코드가 실행될 애플리케이션을 설정할 수 있습니다.</p><p><code>main</code> 메서드에 모든 것을 통합합니다. 여기서는 변수 워크플로우 아래의 모든 요소를 포함하는 그래프를 선언합니다.</p>async function main() {
  await createIndex();
  await createSearchTemplates();
  await ingestDocuments();

  // Create the workflow graph with shared state
  const workflow = new StateGraph(VCState)
    // Register nodes - these are the processing functions
    .addNode("decideStrategy", decideSearchStrategy)
    .addNode("prepareInvestment", prepareInvestmentSearch)
    .addNode("prepareMarket", prepareMarketSearch)
    .addNode("executeSearch", executeSearch)
    .addNode("visualizeResults", visualizeResults)
    // Define execution flow with conditional branching
    .addEdge(START, "decideStrategy") // Start with strategy decision
    .addConditionalEdges(
      "decideStrategy",
      (state: typeof VCState.State) =&gt; state.searchStrategy, // Conditional function
      {
        investment_focused: "prepareInvestment", // If investment focused -&gt; RRF template preparation
        market_focused: "prepareMarket", // If market focused -&gt; dynamic query preparation
      }
    )
    .addEdge("prepareInvestment", "executeSearch") // Investment prep -&gt; execute
    .addEdge("prepareMarket", "executeSearch") // Market prep -&gt; execute
    .addEdge("executeSearch", "visualizeResults") // Execute -&gt; visualize
    .addEdge("visualizeResults", END); // End workflow


  const app = workflow.compile();

  await saveGraphImage(app);

  const query =
    "Find startups with Series A or Series B funding between $8M-$25M and monthly revenue above $500K";

  const marketResult = await app.invoke({ input: query });
  console.log(marketResult.final);
}<p>쿼리 변수는 가상의 검색창에 입력된 사용자 입력을 시뮬레이션합니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltba7189d5f4e63403/6a1709880e2e49cc3041a076/e8d76909eb2bc1bb62f3ca9a8b3e4b85fcec2893-1600x164.png" alt="" /><p>"800만~2500만 달러의 시리즈 A 또는 시리즈 B 펀딩을 받고 월 매출이 50만 달러 이상인 스타트업을 찾아주세요"라는 자연어 문구에서 모든 필터가 추출됩니다.</p><p>마지막으로 다음과 같이 메인 메서드를 호출합니다.</p>main().catch(console.error);<h3>결과</h3>🔍 Checking if index exists...
🏗️ Creating index...
✅ Index created successfully!
Ingesting documents...
✅ Documents ingested successfully!
✅ Investment-focused template created successfully!
✅ Market-focused template created successfully!

📊 Workflow graph saved as: ./workflow_graph.png

🔍 Query: "Find startups with Series A or Series B funding between $8M-$25M and monthly revenue above $500K"

🤔 Search strategy: investment_focused - The query specifically seeks profitable fintech startups with defined funding amounts and high monthly revenue, which aligns closely with financial performance metrics and investment-related criteria.

💰 Preparing INVESTMENT-FOCUSED search parameters with financial emphasis...

📋 Complete query: {
  "size": 5,
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "semantic_field",
                "query": "Find startups with Series A or Series B funding between $8M-$25M and monthly revenue above $500K"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "bool": {
                "filter": [
                  {
                    "terms": {
                      "funding_stage": [
                        "Series A",
                        "Series B"
                      ]
                    }
                  },
                  {
                    "range": {
                      "funding_amount": {
                        "gte": 8000000,
                        "lte": 25000000
                      }
                    }
                  },
                  {
                    "terms": {
                      "lead_investor": []
                    }
                  },
                  {
                    "range": {
                      "monthly_revenue": {
                        "gte": 500000,
                        "lte": 0
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ],
      "rank_window_size": 100,
      "rank_constant": 20
    }
  }
}
🎯 Found 5 startups matching your criteria:

1. **TechFlow**
   📍 San Francisco, CA | 🏢 logistics | 💼 B2B
   💰 Series A - $8.0M
   👥 45 employees | 📈 $500K MRR
   🏦 Lead: Sequoia Capital
   📝 TechFlow optimizes supply chain operations using AI-powered route optimization and real-time tracking. Founded in 2023, shows remarkable growth with $500K monthly revenue.

2. **DataViz**
   📍 New York, NY | 🏢 enterprise software | 💼 B2B
   💰 Series A - $10.0M
   👥 42 employees | 📈 $450K MRR
   🏦 Lead: Battery Ventures
   📝 DataViz creates intuitive data visualization tools for enterprise customers. No-code platform allows business users to create dashboards without technical expertise.

3. **FinanceAI**
   📍 San Francisco, CA | 🏢 fintech | 💼 B2C
   💰 Series C - $25.0M
   👥 120 employees | 📈 $1200K MRR
   🏦 Lead: Tiger Global Management
   📝 FinanceAI provides AI-powered investment advisory services to retail investors. Uses machine learning to analyze market trends with over 100,000 active users.

4. **UrbanMobility**
   📍 New York, NY | 🏢 logistics | 💼 B2B2C
   💰 Series B - $15.0M
   👥 78 employees | 📈 $750K MRR
   🏦 Lead: Kleiner Perkins
   📝 UrbanMobility revolutionizes urban transportation through autonomous delivery drones and smart logistics hubs. Partners with major retailers for same-day delivery across Manhattan and Brooklyn.

5. **HealthTech Solutions**
   📍 Boston, MA | 🏢 healthcare | 💼 B2B
   💰 Series B - $18.0M
   👥 95 employees | 📈 $900K MRR
   🏦 Lead: General Catalyst
   📝 HealthTech Solutions develops medical devices and software for remote patient monitoring. Comprehensive telehealth platform reducing hospital readmissions by 30%.

✨  Done in 18.80s.<p>전송된 입력에 대해 애플리케이션은 <strong>투자 중심</strong> 경로를 선택합니다. 그 결과, LangGraph 워크플로우에서 생성된 Elasticsearch 쿼리를 확인할 수 있으며, 이 쿼리는 사용자 입력에서 값과 범위를 추출합니다. 추출된 값이 적용된 쿼리가 Elasticsearch로 전송된 것을 볼 수 있으며, 마지막으로 <code>visualizeResults</code> 노드에 의해 형식이 지정된 결과를 확인할 수 있습니다.</p><p>이제 “샌프란시스코, 뉴욕 또는 보스턴에서 핀테크 및 헬스케어 스타트업을 찾아주세요”라는 쿼리를 사용하여 <strong>시장 중심</strong> 노드를 테스트해 보겠습니다.</p>...

🔍 Query: Find fintech and healthcare startups in San Francisco, New York, or Boston

🤔 Search strategy: market_focused - The query is focused on finding fintech startups in San Francisco that are disrupting traditional banking and payment systems, which pertains to specific industries (fintech) and locations (San Francisco). Thus, a market-focused strategy is more appropriate.

🔍 Preparing MARKET-FOCUSED search parameters with market emphasis...

📋 Complete query: {
  "size": 5,
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "semantic_field",
                "query": "Find fintech and healthcare startups in San Francisco, New York, or Boston"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "bool": {
                "filter": [
                  {
                    "terms": {
                      "industry": [
                        "fintech",
                        "healthcare"
                      ]
                    }
                  },
                  {
                    "terms": {
                      "location": [
                        "San Francisco, CA",
                        "New York, NY",
                        "Boston, MA"
                      ]
                    }
                  },
                  {
                    "terms": {
                      "business_model": []
                    }
                  }
                ]
              }
            }
          }
        }
      ],
      "rank_window_size": 50,
      "rank_constant": 10
    }
  }
}
🎯 Found 5 startups matching your criteria:

1. **FinanceAI**
   📍 San Francisco, CA | 🏢 fintech | 💼 B2C
   💰 Series C - $25.0M
   👥 120 employees | 📈 $1200K MRR
   🏦 Lead: Tiger Global Management
   📝 FinanceAI provides AI-powered investment advisory services to retail investors. Uses machine learning to analyze market trends with over 100,000 active users.

2. **CryptoWallet**
   📍 Miami, FL | 🏢 fintech | 💼 B2C
   💰 Series B - $16.0M
   👥 73 employees | 📈 $820K MRR
   🏦 Lead: Coinbase Ventures
   📝 CryptoWallet provides secure digital wallet solutions for cryptocurrency trading and storage. Multi-chain support with enterprise-grade security features.

...

✨  Done in 7.41s.<h2>학습</h2><p>글을 쓰는 과정에서 배운 점은 다음과 같습니다.</p><ul><li><p>LLM에 필터의 정확한 값을 표시해야 합니다. 그렇지 않으면 사용자가 정확한 값을 입력해야 합니다. 카디널리티가 낮을 때는 이 방법이 괜찮지만, 카디널리티가 높을 때는 결과를 필터링할 메커니즘이 필요합니다.</p></li><li><p>검색 템플릿을 사용하면 LLM이 Elasticsearch 쿼리를 작성하게 하는 것보다 훨씬 더 일관되고 더 빠른 결과를 얻을 수 있습니다.</p></li><li><p>조건부 엣지는 여러 변형과 분기 경로를 갖춘 애플리케이션을 구축하는 강력한 메커니즘입니다.</p></li><li><p>정형 출력은 예측 가능하고 형식에 안전한 응답을 적용하므로 LLM으로 정보를 생성할 때 매우 유용합니다. 이렇게 하면 안정성이 향상되고 즉각적인 오해를 줄일 수 있습니다.</p></li></ul><p>하이브리드 검색을 통해 시맨틱 및 정형 검색을 결합하면 정확도와 컨텍스트 이해의 균형을 유지하면서 더 정확하고 관련성 높은 결과를 얻을 수 있습니다.</p><h2>결론</h2><p>이 예시에서는 LangGraph.js를 Elasticsearch와 결합하여 자연어 쿼리를 해석하고 금융 또는 시장 중심의 검색 전략을 선택할 수 있는 동적 워크플로우를 만듭니다. 이러한 접근 방식은 수동 쿼리의 복잡성을 줄이고 벤처 캐피털 분석가의 유연성과 정확성을 향상합니다.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-agent-workflow-finance-langgraph-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-agent-workflow-finance-langgraph-elasticsearch</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[에이전틱 AI]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt013eba5d152f11f3/6a1709892b835f6784f4b1a6/12b6057d84c6356267cd178a3c6c1a5c61123ece-2000x1256.png" length="0" type="image/png"/>
    <pubDate>Fri, 05 Dec 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[AI 기반 대시보드: 비전에서 Kibana까지]]></title>
    <description><![CDATA[이미지를 처리하기 위해 LLM을 사용해 대시보드를 생성하고 이를 Kibana 대시보드로 전환합니다.
]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/kibana/kibana-lens">Kibana Lens를</a> 사용하면 대시보드를 매우 간단하게 끌어서 놓을 수 있지만, 수십 개의 패널이 필요한 경우에는 클릭 수가 늘어납니다. 대시보드를 스케치하고 스크린샷을 찍어 LLM이 전체 프로세스를 완료하도록 할 수 있다면 어떨까요?</p><p>이 글에서는 이를 실현하는 방법에 대해 설명합니다. 대시보드의 이미지를 가져와서 매핑을 분석한 다음 Kibana를 전혀 건드리지 않고도 대시보드를 생성하는 애플리케이션을 만들어 보겠습니다!</p><p><strong>단계</strong>:</p><ol><li><p><a href="https://www.elastic.co/search-labs/blog/ai-powered-dashboards#background-&amp;-application-workflow">배경 &amp; 애플리케이션 워크플로</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/ai-powered-dashboards#prepare-data">데이터 준비</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/ai-powered-dashboards#llm-configuration">LLM 구성</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/ai-powered-dashboards#application-functions">애플리케이션 기능</a></p></li></ol><h2>배경 &amp; 애플리케이션 워크플로</h2><p>가장 먼저 떠오른 생각은 LLM이 전체 NDJSON 형식의 Kibana <a href="https://www.elastic.co/docs/explore-analyze/find-and-organize/saved-objects">저장 개체를</a> 생성한 다음 Kibana로 가져오도록 하는 것이었습니다.</p><p>몇 가지 모델을 사용해 보았습니다:</p><ul><li><p>Gemini 2.5 프로</p></li><li><p>GPT o3 / o4-미니 하이 / 4.1</p></li><li><p>클로드 4 소네트</p></li><li><p>Grok 3</p></li><li><p>딥씽크(딥씽크 R1)</p></li></ul><p>그리고 프롬프트는 간단하게 시작했습니다:</p>You are an Elasticsearch Saved-Object generator (Kibana 9.0).
INPUTS
=====
1. PNG screenshot of a 4-panel dashboard (attached).
2. Index mapping (below) – trimmed down to only the fields present in the screenshot.
3. Example NDJSON of *one* metric visualization (below) for reference.

TASK
====
Return **only** a valid NDJSON array that recreates the dashboard exactly:
* 2 metric panels (Visits, Unique Visitors)
* 1 pie chart (Most used OS)
* 1 vertical bar chart (State Geo Dest)
* Use index pattern `kibana_sample_data_logs`.
* Preserve roughly the same layout (2×2 grid).
* Use `panelIndex` values 1-4 and random `id` strings.
* Kibana version: 9.0<p>각 비주얼리제이션을 작성하는 방법에 대한 <a href="https://www.elastic.co/search-labs/blog/function-calling-with-elastic#:~:text=Few%2Dshot%20prompting%20involves%20providing%20examples%20of%20the%20types%20of%20queries%20you%20want%20it%20to%20return%2C%20which%20helps%20in%20increasing%20consistency.">몇 가지 예시와</a> 자세한 설명을 살펴봤지만 운이 없었습니다. 이 실험에 관심이 있으시다면 <a href="https://gist.github.com/TomasMurua/a78dc283e115624731beffc98984b70b">여기에서</a> 자세한 내용을 확인할 수 있습니다.</p><p>이 접근 방식을 사용한 결과, LLM에서 생성된 파일을 Kibana에 업로드하려고 할 때 이러한 메시지가 표시되었습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9ea005966a783057/6a1707d266c4f90e4ef8bf88/2b599443b5613c9f0fc3235581614add5b4b3900-891x98.png" alt="" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5e5632d6d95b998c/6a1707d3a6c2b9441de79661/d87ccfc033bc00ee8188c5cae18043fbca22784c-741x233.png" alt="" /><p>이는 생성된 JSON이 유효하지 않거나 형식이 잘못되었음을 의미합니다. 가장 흔한 문제는 불완전한 NDJSON을 생성하거나, 매개변수를 착각하거나, 아무리 강제 적용을 시도해도 NDJSON 대신 일반 JSON을 반환하는 LLM이었습니다.</p><p><a href="https://www.elastic.co/docs/solutions/search/search-templates">검색 템플릿이</a> LLM 프리스타일보다 더 효과적이었다는 <a href="https://www.elastic.co/search-labs/blog/llm-functions-elasticsearch-intelligent-query">이 글에서 영감을</a> 받아 전체 NDJSON 파일을 생성하도록 요청하는 대신 템플릿을 LLM에 제공하고 코드에서 LLM이 제공한 매개변수를 사용하여 적절한 시각화를 만들기로 결정했습니다. 이 접근 방식은 실망스럽지 않았고 예측 가능하고 확장 가능하며 이제 코드가 무거운 작업을 수행하므로 LLM이 아닌 코드가 작업을 수행하게 되었습니다.</p><p>애플리케이션 워크플로우는 다음과 같습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9f7738a4c7ddd0cd/6a1707d52b835f0a25f4b166/52c587cf0cf3517fdd4ee7ab95581dd4f2bce030-725x668.png" alt="" /><p></p><p><em>간단하게 설명하기 위해 일부 코드는 생략하지만, 전체 애플리케이션의 작업 코드는 </em><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/from-image-idea-to-kibana-dashboard-using-ai/from-image-idea-to-kibana-dashboard-using-ai.ipynb"><em><strong>이</strong></em></a><em> 노트북에서</em>찾을 수 있습니다.</p><h2>필수 구성 요소</h2><p>개발을 시작하기 전에 다음이 필요합니다:</p><ol><li><p>Python 3.8 이상</p></li><li><p><a href="https://docs.python.org/3/library/venv.html">Venv</a> Python 환경</p></li><li><p>엔드포인트 및 API 키와 함께 실행 중인 Elasticsearch 인스턴스</p></li><li><p>환경 변수 이름 OPENAI_API_KEY에 저장된 OpenAI API 키입니다:</p></li></ol>export OPENAI_API_KEY="your-openai-api-key"<h2>데이터 준비</h2><p>데이터의 경우, 간단하게 유지하면서 Elastic 샘플 웹 로그를 사용하겠습니다. <a href="https://www.elastic.co/docs/manage-data/ingest/sample-data#add-sample-data-sets">여기에서</a> 해당 데이터를 클러스터로 가져오는 방법을 알아보세요.</p><p>각 문서에는 애플리케이션에 요청을 보낸 호스트에 대한 세부 정보와 함께 요청 자체 및 응답 상태에 대한 정보가 포함되어 있습니다. 아래는 문서 예시입니다:</p>{
    "agent": "Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24",
    "bytes": 8509,
    "clientip": "70.133.115.149",
    "extension": "css",
    "geo": {
        "srcdest": "US:IT",
        "src": "US",
        "dest": "IT",
        "coordinates": {
            "lat": 38.05134111,
            "lon": -103.5106908
        }
    },
    "host": "cdn.elastic-elastic-elastic.org",
    "index": "kibana_sample_data_logs",
    "ip": "70.133.115.149",
    "machine": {
        "ram": 5368709120,
        "os": "osx"
    },
    "memory": null,
    "message": "70.133.115.149 - - [2018-08-30T23:35:31.492Z] \"GET /styles/semantic-ui.css HTTP/1.1\" 200 8509 \"-\" \"Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\"",
    "phpmemory": null,
    "referer": "http://twitter.com/error/john-phillips",
    "request": "/styles/semantic-ui.css",
    "response": 200,
    "tags": [
        "success",
        "info"
    ],
    "@timestamp": "2025-07-03T23:35:31.492Z",
    "url": "https://cdn.elastic-elastic-elastic.org/styles/semantic-ui.css",
    "utc_time": "2025-07-03T23:35:31.492Z",
    "event": {
        "dataset": "sample_web_logs"
    },
    "bytes_gauge": 8509,
    "bytes_counter": 51201128
}<p>이제 방금 로드한 인덱스( <code>kibana_sample_data_logs</code>)의 매핑을 가져와 보겠습니다:</p>INDEX_NAME = "kibana_sample_data_logs"

es_client = Elasticsearch(
    [os.getenv("ELASTICSEARCH_URL")],
    api_key=os.getenv("ELASTICSEARCH_API_KEY"),
)

result = es_client.indices.get_mapping(index=INDEX_NAME)
index_mappings = result[list(result.keys())[0]]["mappings"]["properties"]<p>나중에 로드할 이미지와 함께 매핑을 전달하겠습니다.</p><h2>LLM 구성</h2><p><a href="https://python.langchain.com/docs/concepts/structured_outputs/">구조화된 출력을</a> 사용하여 이미지를 입력하고 함수에 전달해야 하는 정보가 포함된 JSON을 수신하여 JSON 객체를 생성하도록 LLM을 구성해 보겠습니다.</p><p>종속성을 설치합니다:</p>pip install elasticsearch pydantic langchain langchain-openai -q<p>Elasticsearch는 <a href="https://www.elastic.co/docs/manage-data/data-store/mapping">인덱스 매핑을</a> 검색하는 데 도움이 됩니다. Pydantic을 사용하면 파이썬으로 스키마를 정의한 다음 LLM에 따르도록 요청할 수 있으며, <a href="https://www.elastic.co/search-labs/integrations/langchain">LangChain은</a> LLM과 AI 도구를 더 쉽게 호출할 수 있도록 도와주는 프레임워크입니다.</p><p>LLM에서 원하는 출력을 정의하기 위해 Pydantic 스키마를 생성합니다. 이미지에서 알아야 할 것은 차트 유형, 필드, 비주얼리제이션 제목 및 대시보드 제목입니다:</p>class Visualization(BaseModel):
    title: str = Field(description="The dashboard title")
    type: List[Literal["pie", "bar", "metric"]]
    field: str = Field(
        description="The field that this visualization use based on the provided mappings"
    )


class Dashboard(BaseModel):
    title: str = Field(description="The dashboard title")
    visualizations: List[Visualization]<p>이미지 입력은 제가 방금 그린 대시보드를 보내드리겠습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7870f6421986d11d/6a1707d78b73cb3408189fa3/36441d7b5dc1f3ff2ac2a30710208d57ad41c716-1600x898.jpg" alt="" /><p>이제 LLM 모델 호출과 이미지 로딩을 선언합니다. 이 함수는 생성하려는 대시보드의 이미지와 Elasticsearch 인덱스의 매핑을 수신합니다.</p><p><code>with_structured_output</code> 을 사용하면 Pydantic <code>Dashboard</code> 스키마를 LLM이 생성할 응답 객체로 사용할 수 있습니다. <a href="https://docs.pydantic.dev/latest/">Pydantic을</a> 사용하면 유효성 검사를 통해 데이터 모델을 정의할 수 있으므로 LLM 출력이 예상 구조와 일치하는지 확인할 수 있습니다.</p><p>이미지를 base64로 변환하여 입력으로 보내려면 <a href="https://www.base64-image.de/">온라인 변환기를</a> 사용하거나 <a href="https://www.geeksforgeeks.org/python-convert-image-to-string-and-vice-versa/">코드로</a> 변환할 수 있습니다.</p>prompt = f"""
    You are an expert in analyzing Kibana dashboards from images for the version 9.0.0 of Kibana.

    You will be given a dashboard image and an Elasticsearch index mapping.

    Below are the index mappings for the index that the dashboard is based on.
    Use this to help you understand the data and the fields that are available.

    Index Mappings:
    {index_mappings}

    Only include the fields that are relevant for each visualization, based on what is visible in the image.
    """

message = [
    {
        "role": "user",
        "content": [
            {"type": "text", "text": prompt},
            {
                "type": "image",
                "source_type": "base64",
                "data": image_base64,
                "mime_type": "image/png",
            },
        ],
    }
]


try:
    llm = init_chat_model("gpt-4.1-mini")
    llm = llm.with_structured_output(Dashboard)
    dashboard_values = llm.invoke(message)

    print("Dashboard values generated by the LLM successfully")
    print(dashboard_values)
except Exception as e:
    print(f"Failed to analyze image and match fields: {str(e)}")<p>LLM에는 이미 Kibana 대시보드에 대한 컨텍스트가 있으므로 프롬프트에서 모든 것을 설명할 필요는 없으며, Elasticsearch 및 Kibana와 함께 작동한다는 것을 잊지 않도록 하기 위한 몇 가지 세부 사항만 설명하면 됩니다.</p><p>프롬프트를 자세히 살펴 보겠습니다:</p><p>섹션</p><p>이유</p><p>귀하는 Kibana 버전 9.0.0의 이미지에서 Kibana 대시보드를 분석하는 전문가입니다.</p><p>이를 강화하는 것이 바로 Elasticsearch이며, Elasticsearch 버전은 LLM이 오래되거나 유효하지 않은 매개변수를 착각할 가능성을 줄여줍니다.</p><p>대시보드 이미지와 Elasticsearch 인덱스 매핑이 제공됩니다.</p><p>LLM의 잘못된 해석을 피하기 위해 이미지가 대시보드에 관한 것이라고 설명합니다.</p><p>다음은 대시보드의 기반이 되는 인덱스의 인덱스 매핑입니다. 이를 사용하여 데이터와 사용 가능한 필드를 이해하는 데 도움이 됩니다. 인덱스 매핑: {index_mappings}</p><p>LLM이 유효한 필드를 동적으로 선택할 수 있도록 매핑을 제공하는 것이 중요합니다. 그렇지 않으면 여기에 매핑을 하드 코딩하여 너무 딱딱하게 만들거나 올바른 필드 이름이 포함된 이미지에 의존할 수 있는데, 이는 신뢰할 수 없습니다.</p><p>이미지에 표시되는 내용에 따라 각 시각화와 관련된 필드만 포함합니다.</p><p>가끔 이미지와 관련이 없는 필드를 추가하려고 시도하기 때문에 이 기능을 추가해야 했습니다.</p><p>그러면 표시할 시각화 배열이 있는 객체가 반환됩니다:</p>"Dashboard values generated by the LLM successfully
title=""Client, Extension, OS, and Response Keyword Analysis""visualizations="[
   "Visualization(title=""Count of Client IP",
   "type="[
      "metric"
   ],
   "field=""clientip"")",
   "Visualization(title=""Extension Keyword Distribution",
   "type="[
      "pie"
   ],
   "field=""extension.keyword"")",
   "Visualization(title=""Most Used OS",
   "type="[
      "bar"
   ],
   "field=""machine.os.keyword"")",
   "Visualization(title=""Response Keyword Distribution",
   "type="[
      "bar"
   ],
   "field=""response.keyword"")"
]<h2>LLM 응답 처리</h2><p>에서 샘플 2x2 패널 대시보드를 만든 다음 <a href="https://www.elastic.co/docs/api/doc/kibana/operation/operation-get-dashboards-dashboard">대시보드 가져오기 API를</a> 사용하여 JSON으로 내보낸 다음, 패널을 시각화 템플릿(파이, 막대, 메트릭)으로 저장하여 일부 매개 변수를 교체하여 질문에 따라 다른 필드로 새로운 시각화를 만들 수 있습니다.</p><p>템플릿 JSON 파일은 <a href="https://github.com/Delacrobix/elasticsearch-labs/tree/supporting-blog-content/from-image-idea-to-kibana-dashboard-using-ai/supporting-blog-content/from-image-idea-to-kibana-dashboard-using-ai/templates"><strong>여기에서</strong></a> 확인할 수 있습니다. 나중에 대체할 개체 값을 {<code>variable_name</code>}로 변경한 방법에 유의하세요.
</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc55d69d84a08e668/6a1707d8a2929903acd00fb8/ec7e1ac0cd8b470df13e60940162b56778acb386-315x234.png" alt="" /><p>LLM이 제공한 정보를 바탕으로 어떤 템플릿을 사용하고 어떤 값을 대체할지 결정할 수 있습니다.</p><p><code>fill_template_with_analysis</code> 는 시각화의 JSON 템플릿, 제목, 필드, 그리드에 있는 시각화의 좌표 등 단일 패널에 대한 매개변수를 받습니다.</p><p>그런 다음 템플릿의 값을 바꾸고 최종 JSON 시각화를 반환합니다.</p>def fill_template_with_analysis(
    template: Dict[str, Any],
    visualization: Visualization,
    grid_data: Dict[str, Any],
):
    template_str = json.dumps(template)
    replacements = {
	 "{visualization_id}": str(uuid.uuid4()),
        "{title}": visualization.title,
        "{x}": grid_data["x"],
        "{y}": grid_data["y"],
    }

    if visualization.field:
        replacements["{field}"] = visualization.field

    for placeholder, value in replacements.items():
        template_str = template_str.replace(placeholder, str(value))

    return json.loads(template_str)<p>간단하게 하기 위해 LLM이 생성하기로 결정한 패널에 정적 좌표를 할당하고 위 이미지와 같은 2x2 그리드 대시보드를 생성합니다.</p># Filling templates fields
panels = []    
grid_data = [
    {"x": 0, "y": 0},
    {"x": 12, "y": 0},
    {"x": 0, "y": 12},
    {"x": 12, "y": 12},
]


i = 0

for vis in dashboard_values.visualizations:
    for vis_type in vis.type:
        template = templates.get(vis_type, templates.get("bar", {}))
        filled_panel = fill_template_with_analysis(template, vis, grid_data[i])
        panels.append(filled_panel)
        i += 1<p>LLM에서 결정한 시각화 유형에 따라 JSON 파일 템플릿을 선택하고 <code>fill_template_with_analysis</code> 을 사용하여 관련 정보를 바꾼 다음 새 패널을 나중에 대시보드를 만드는 데 사용할 배열에 추가합니다.</p><p>대시보드가 준비되면, <a href="https://www.elastic.co/docs/api/doc/kibana/operation/operation-post-dashboards-dashboard-id">대시보드 만들기 API를</a> 사용해 새 JSON 파일을 Kibana로 푸시하여 대시보드를 생성합니다:
</p>try:
    dashboard_id = str(uuid.uuid4())

    # post request to create the dashboard endpoint
    url = f"{os.getenv('KIBANA_URL')}/api/dashboards/dashboard/{dashboard_id}"

    dashboard_config = {
        "attributes": {
            "title": dashboard_values.title,
            "description": "Generated by AI",
            "timeRestore": True,
            "panels": panels,  # Visualizations with the values generated by the LLM
            "timeFrom": "now-7d/d",
            "timeTo": "now",
        },
    }

    headers = {
        "Content-Type": "application/json",
        "kbn-xsrf": "true",
        "Authorization": f"ApiKey {os.getenv('ELASTICSEARCH_API_KEY')}",
    }

    requests.post(
        url,
        headers=headers,
        json=dashboard_config,
    )

    # Url to the generated dashboard
    dashboard_url = f"{os.getenv('KIBANA_URL')}/app/dashboards#/view/{dashboard_id}"

    print("Dashboard URL: ", dashboard_url)
    print("Dashboard ID: ", dashboard_id)

except Exception as e:
    print(f"Failed to create dashboard: {str(e)}")<p>스크립트를 실행하고 대시보드를 생성하려면 콘솔에서 다음 명령을 실행합니다:</p>python &lt;file_name&gt;.py<p>최종 결과는 다음과 같습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5ceffed004153a4f/6a1707d9a929cf9147ae0901/e909afbf0e47d9a6e0f7bd07dfb2efcfa5cf06ac-921x715.png" alt="" /><h2>결론</h2><p>LLM은 텍스트를 코드로 변환하거나 이미지를 코드로 변환할 때 강력한 시각적 기능을 발휘합니다. 또한 대시보드 API를 사용하면 JSON 파일을 대시보드로 전환할 수 있으며, LLM과 몇 가지 코드를 사용하면 이미지를 Kibana 대시보드로 전환할 수 있습니다.</p><p>다음 단계는 다양한 그리드 설정, 대시보드 크기 및 위치를 사용하여 대시보드 시각적 요소의 유연성을 개선하는 것입니다. 또한 더 복잡한 시각화 및 시각화 유형에 대한 지원을 제공하면 이 애플리케이션에 유용한 추가 기능이 될 것입니다.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-powered-dashboards</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-powered-dashboards</guid>
    <category><![CDATA[Kibana]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo,Tomás Murúa]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt41727cbee6155a68/6a1707dbb0367dd2fd72bc86/eb60ceb2fbc3941745b21ae3357cbb6ea8fab18c-1443x811.png" length="0" type="image/png"/>
    <pubDate>Wed, 16 Jul 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[올바른 방법으로 자바스크립트에서 Elasticsearch, 2부]]></title>
    <description><![CDATA[서버리스 환경에서 Elasticsearch Node.js 클라이언트를 실행하는 방법과 프로덕션 모범 사례를 학습하여 코딩 오류를 줄이는 방법을 알아보세요. ]]></description>
    <content:encoded><![CDATA[<p>이번 글은 자바스크립트에서의 Elasticsearch 시리즈의 두 번째 파트입니다. <a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i"> 첫 번째 파트에서는</a> 환경을 올바르게 설정하고, Node.js 클라이언트를 구성하고, 데이터를 색인하고, 검색하는 방법을 배웠습니다. 이 두 번째 파트에서는 프로덕션 모범 사례를 구현하고 서버리스 환경에서 Elasticsearch <a href="http://node.js">Node.js</a> 클라이언트를 실행하는 방법에 대해 알아보세요.</p><p>검토하겠습니다:</p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-ii#production-best-practices">프로덕션 모범 사례</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-ii#error-handling">오류 처리</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-ii#testing">테스팅</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-ii#serverless-environments">서버리스 환경</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-ii#running-the-client-on-elastic-serverless">Elastic 서버리스에서 클라이언트 실행</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-ii#running-the-client-on-function-as-a-service-environment">서비스형 기능 환경에서 클라이언트 실행하기</a></p></li></ul></li></ul><p><em>여기에서 예제와 함께 소스 코드를 확인할 수 </em><a href="https://github.com/Delacrobix/JS-client-best-practices_article"><em></em></a><em><strong>있습니다.</strong></em></p><h2>프로덕션 모범 사례</h2><h3>Elasticsearch의 오류 처리</h3><p>Node.js의 Elasticsearch 클라이언트의 유용한 기능은 Elasticsearch에서 발생할 수 있는 오류에 대한 객체를 노출하여 다양한 방식으로 오류의 유효성을 검사하고 처리할 수 있다는 점입니다.</p><p><a href="https://www.elastic.co/docs/reference/elasticsearch/clients/javascript/connecting#client-error-handling">모두 보려면</a> 이 링크를 실행하세요: </p>const { errors } = require('@elastic/elasticsearch')
console.log(errors)<p>검색 예제로 돌아가서 몇 가지 가능한 오류를 처리해 보겠습니다:</p>app.get("/search/lexic", async (req, res) =&gt; {
 ....
  } catch (error) {
    if (error instanceof errors.ResponseError) {
      let errorMessage =
        "Response error!, query malformed or server down, contact the administrator!";

      if (error.body.error.type === "parsing_exception") {
        errorMessage = "Query malformed, make sure mappings are set correctly";
      }

      res.status(error.meta.statusCode).json({
        erroStatus: error.meta.statusCode,
        success: false,
        results: null,
        error: errorMessage,
      });
    }

    res.status(500).json({
      success: false,
      results: null,
      error: error.message,
    });
  }
});<p><code>ResponseError</code> 특히 응답이 <code>4xx</code> 또는 <code>5xx</code> 인 경우 요청이 잘못되었거나 서버를 사용할 수 없음을 의미합니다.</p><p><strong>텍스트 유형 필드에 용어 쿼리를</strong>시도하는 등 잘못된 쿼리를 생성하여 이러한 유형의 오류를 테스트할 수 있습니다:</p><p>기본 오류입니다:</p> {
    "success": false,
    "results": null,
    "error": "parsing_exception\n\tRoot causes:\n\t\tparsing_exception: [terms] query does not support [visit_details]"
}<p>사용자 지정 오류: </p>{
    "erroStatus": 400,
    "success": false,
    "results": null,
    "error": "Response error!, query malformed or server down; contact the administrator!"
}<p>또한 각 오류 유형을 특정 방식으로 캡처하고 처리할 수도 있습니다. 예를 들어 <code>TimeoutError</code> 에 재시도 로직을 추가할 수 있습니다.</p>app.get("/search/semantic", async (req, res) =&gt; {
    try {
  ...
  } catch (error) {
    if (error instanceof errors.TimeoutError) {


     // Retry logic...

      res.status(error.meta.statusCode).json({
        erroStatus: error.meta.statusCode,
        success: false,
        results: null,
        error:
          "The request took more than 10s after 3 retries. Try again later.",
      });
    }
  }
});<h3>테스팅</h3><p>테스트는 앱의 안정성을 보장하기 위한 핵심 요소입니다. Elasticsearch와 분리된 방식으로 코드를 테스트하려면 클러스터를 생성할 때 <a href="https://github.com/elastic/elasticsearch-js-mock">elasticsearch-js-mock</a> 라이브러리를 사용할 수 있습니다.</p><p>이 라이브러리를 사용하면 실제 클라이언트와 매우 유사하지만 클라이언트의 HTTP 계층만 모의 계층으로 바꾸고 나머지는 원본과 동일하게 유지하여 구성에 응답하는 클라이언트를 인스턴스화할 수 있습니다.</p><p>자동화된 테스트를 위해 모의 라이브러리와 <a href="https://github.com/avajs/ava">AVA를</a> 설치합니다.</p><p><code>npm install @elastic/elasticsearch-mock</code></p><p><code>npm install --save-dev ava</code></p><p>테스트를 실행하도록 <code>package.json</code> 파일을 구성하겠습니다. 이렇게 표시되는지 확인합니다:</p>"type": "module",
	"scripts": {
		"test": "ava"
	},
	"devDependencies": {
		"ava": "^5.0.0"
	}<p>이제 <code>test.js</code> 파일을 생성하고 모의 클라이언트를 설치해 보겠습니다:</p>const { Client } = require('@elastic/elasticsearch')
const Mock = require('@elastic/elasticsearch-mock')

const mock = new Mock()
const client = new Client({
  node: 'http://localhost:9200',
  Connection: mock.getConnection()
})<p>이제 시맨틱 검색을 위한 모형을 추가합니다:</p>function createSemanticSearchMock(query, indexName) {
  mock.add(
    {
      method: "POST",
      path: `/${indexName}/_search`,
      body: {
        query: {
          semantic: {
            field: "semantic_field",
            query: query,
          },
        },
      },
    },
    () =&gt; {
      return {
        hits: {
          total: { value: 2, relation: "eq" },
          hits: [
            {
              _id: "1",
              _score: 0.9,
              _source: {
                owner_name: "Alice Johnson",
                pet_name: "Buddy",
                species: "Dog",
                breed: "Golden Retriever",
                vaccination_history: ["Rabies", "Parvovirus", "Distemper"],
                visit_details:
                  "Annual check-up and nail trimming. Healthy and active.",
              },
            },
            {
              _id: "2",
              _score: 0.7,
              _source: {
                owner_name: "Daniel Kim",
                pet_name: "Mochi",
                species: "Rabbit",
                breed: "Mixed",
                vaccination_history: [],
                visit_details:
                  "Nail trimming and general health check. No issues.",
              },
            },
          ],
        },
      };
    }
  );
}<p>이제 코드에 대한 테스트를 생성하여 Elasticsearch 부분이 항상 동일한 결과를 반환하는지 확인할 수 있습니다:</p>import test from 'ava';

test("performSemanticSearch must return formatted results correctly", async (t) =&gt; {
  const indexName = "vet-visits";
  const query = "Which pets had nail trimming?";

  createSemanticSearchMock(query, indexName);

  async function performSemanticSearch(esClient, q, indexName = "vet-visits") {
    try {
      const result = await esClient.search({
        index: indexName,
        body: {
          query: {
            semantic: {
              field: "semantic_field",
              query: q,
            },
          },
        },
      });

      return {
        success: true,
        results: result.hits.hits,
      };
    } catch (error) {
      if (error instanceof errors.TimeoutError) {
        return {
          success: false,
          results: null,
          error: error.body.error.reason,
        };
      }

      return {
        success: false,
        results: null,
        error: error.message,
      };
    }
  }

  const result = await performSemanticSearch(esClient, query, indexName);

  t.true(result.success, "The search must be successful");
  t.true(Array.isArray(result.results), "The results must be an array");

  if (result.results.length &gt; 0) {
    t.true(
      "_source" in result.results[0],
      "Each result must have a _source property"
    );
    t.true(
      "pet_name" in result.results[0]._source,
      "Results must include the pet_name field"
    );
    t.true(
      "visit_details" in result.results[0]._source,
      "Results must include the visit_details field"
    );
  }
});<p>테스트를 실행해 보겠습니다.</p><p><code>npm run test</code></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt36304e286146f362/6a170559d7c02237b2de638f/42feae845ae8eae03c37ad7ad114e8db35984812-1186x302.png" alt="" /><p>완료! 이제부터는 외부 요소가 아닌 코드에 100 % 초점을 맞춰 앱을 테스트할 수 있습니다.</p><h2>서버리스 환경</h2><h3>Elastic Serverless에서 클라이언트를 실행하는 방법</h3><p>클라우드 또는 온프레미스에서 Elasticsearch를 실행하는 방법에 대해 설명했지만, Node.js 클라이언트는 <a href="https://www.elastic.co/guide/en/serverless/current/intro.html">Elastic Cloud 서버리스에</a> 대한 연결도 지원합니다.</p><p>Elastic Cloud Serverless를 사용하면 Elastic이 내부적으로 처리하므로 인프라에 대해 걱정할 필요가 없는 프로젝트를 만들 수 있으며, 사용자는 색인하려는 데이터와 액세스 기간에 대해서만 걱정하면 됩니다.</p><p>사용 관점에서 서버리스는 스토리지에서 컴퓨팅을 분리하여 <a href="https://www.elastic.co/search-labs/blog/elasticsearch-serverless-tier-autoscaling">검색과</a> <a href="https://www.elastic.co/search-labs/blog/elasticsearch-ingest-autoscaling">인덱싱</a> 모두에 자동 확장 기능을 제공합니다. 이를 통해 실제로 필요한 리소스만 성장시킬 수 있습니다.</p><p>클라이언트는 서버리스에 연결하기 위해 다음과 같이 조정합니다:</p><ul><li><p>스니핑을 끄고 모든 스니핑 관련 옵션을 무시합니다.</p></li><li><p>첫 번째 노드를 제외한 구성에서 전달된 모든 노드를 무시하고 모든 노드 필터링 및 선택 옵션을 무시합니다.</p></li><li><p>압축 및 `TLSv1_2_method`를 활성화합니다(Elastic Cloud에 대해 구성할 때와 동일).</p></li><li><p>모든 요청에 `elastic-api-version` HTTP 헤더를 추가합니다.</p></li><li><p>기본적으로 '가중치 연결 풀' 대신 '클라우드 연결 풀'을 사용합니다.</p></li><li><p>표준 MIME 유형을 위해 공급업체의 `content-type` 및 `accept` 헤더를 끕니다.</p></li></ul><p>서버리스 프로젝트를 연결하려면 서버모드: 서버리스 매개변수를 사용해야 합니다.</p>const { Client } = require('@elastic/elasticsearch')
const client = new Client({
  node: 'ELASTICSEARCH_ENDPOINT',
  auth: { apiKey: 'ELASTICSEARCH_API_KEY' },
  serverMode: "serverless",
});<h3>서비스형 함수(FaaS) 환경에서 클라이언트를 실행하는 방법</h3><p>이 예제에서는 Node.js 서버를 사용했지만 AWS 람다, GCP 실행 등과 같은 기능을 갖춘 서비스형 기능 환경을 사용하여 연결할 수도 있습니다.</p>'use strict'

const { Client } = require('@elastic/elasticsearch')

const client = new Client({
  // client initialisation
})

exports.handler = async function (event, context) {
  // use the client
}<p>또 다른 예는 서버리스 서비스인 Vercel과 같은 서비스에 연결하는 것입니다. 이 <a href="https://github.com/elastic/elasticsearch-js/blob/main/docs/examples/proxy/README.md">전체 예제에서</a> 이를 수행하는 방법을 확인할 수 있지만 <a href="https://github.com/elastic/elasticsearch-js/blob/main/docs/examples/proxy/api/search.js">검색 엔드포인트의</a> 가장 관련성이 높은 부분은 다음과 같습니다:</p>const response = await client.search(
  {
    index: INDEX,
    // You could directly send from the browser
    // the Elasticsearch's query DSL, but it will
    // expose you to the risk that a malicious user
    // could overload your cluster by crafting
    // expensive queries.
    query: {
      match: { field: req.body.text },
    },
  },
  {
    headers: {
      Authorization: `ApiKey ${token}`,
    },
  }
);<p>이 엔드포인트는 /api 폴더에 있으며 서버 측에서 실행되므로 클라이언트는 검색어에 해당하는 'text' 매개변수만 제어할 수 있습니다.</p><p>함수형 서비스 사용의 의미는 24시간 연중무휴로 실행되는 서버와 달리 함수는 해당 기능을 실행하는 컴퓨터만 불러오고, 작업이 끝나면 컴퓨터가 휴식 모드로 전환되어 리소스를 덜 소비한다는 것입니다.</p><p>이 구성은 애플리케이션에 요청이 너무 많지 않은 경우 편리할 수 있지만, 그렇지 않은 경우 비용이 많이 들 수 있습니다. 또한 <a href="https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtime-environment.html">함수의 수명 주기와</a> 실행 시간(경우에 따라 몇 초에 불과할 수도 있음)도 고려해야 합니다.</p><h2>결론</h2><p>이 글에서는 프로덕션 환경에서 매우 중요한 오류를 처리하는 방법에 대해 알아보았습니다. 또한 클러스터의 상태에 관계없이 안정적인 테스트를 제공하고 코드에 집중할 수 있게 해주는 Elasticsearch 서비스를 모의하면서 애플리케이션을 테스트하는 방법도 다루었습니다.</p><p>마지막으로, Elastic Cloud 서버리스와 Vercel 애플리케이션을 모두 프로비저닝하여 완전한 서버리스 스택을 스핀업하는 방법을 시연했습니다.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-ii</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-ii</guid>
    <category><![CDATA[Javascript]]></category>
    <category><![CDATA[기본]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc58be329ffebcd60/6a17043e47d49c0bc62d88ab/70fb0ff949f6db9ac9b8a28ecb4329ab915ebf46-720x420.png" length="0" type="image/png"/>
    <pubDate>Mon, 19 May 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[올바른 방법으로 자바스크립트에서 Elasticsearch, 파트 1]]></title>
    <description><![CDATA[자바스크립트로 프로덕션에 사용할 수 있는 Elasticsearch 백엔드를 만드는 방법을 설명합니다.  

JavaScript와 함께 Elasticsearch를 사용하는 방법을 살펴보세요. 클라이언트/서버 모범 사례를 따르면서 Elasticsearch 문서를 쿼리하는 다양한 검색 엔드포인트를 가진 서버를 만드는 방법을 소개합니다.]]></description>
    <content:encoded><![CDATA[<p>이 글은 JavaScript와 함께 Elasticsearch를 사용하는 방법을 다루는 시리즈의 첫 번째 글입니다. 이 시리즈에서는 JavaScript 환경에서 Elasticsearch를 사용하는 방법에 대한 기본 사항을 알아보고 검색 앱을 만드는 데 가장 적합한 기능과 모범 사례를 검토합니다. 끝으로 JavaScript를 사용하여 Elasticsearch를 실행하는 데 필요한 모든 것을 알게 될 것입니다.</p><p>이 첫 번째 파트에서는 검토할 내용을 살펴봅니다:</p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#environment">환경</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#frontend,-backend,-or-serverless?">프론트엔드, 백엔드 또는 서버리스?</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#connecting-the-client">클라이언트 연결하기</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#indexing-documents">문서 색인 생성</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#elasticsearch-client">Elasticsearch 클라이언트</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#semantic-mappings">시맨틱 매핑</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#bulk-helper">대량 도우미</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#searching-data">데이터 검색</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#lexical-query-(/search/lexic?q=%3Cquery-term%3E)">어휘 쿼리</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#semantic-query-(/search/semantic?q=%3Cquery-term%3E)">시맨틱 쿼리</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i#hybrid-query-(/search/hybrid?q=%3Cquery-term%3E)">하이브리드 쿼리</a></p></li></ul></li></ul><p><em>여기에서 예제와 함께 소스 코드를 확인할 수 </em><a href="https://github.com/Delacrobix/JS-client-best-practices_article"><em></em></a><em><strong>있습니다.</strong></em></p><h3>Elasticsearch Node.js 클라이언트란 무엇인가요?</h3><p><a href="https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html">Elasticsearch Node.js 클라이언트는</a> Elasticsearch API의 HTTP REST 호출을 JavaScript로 변환하는 JavaScript 라이브러리입니다. 이렇게 하면 문서를 일괄 색인하는 것과 같은 작업을 간소화하는 도우미를 더 쉽게 처리하고 사용할 수 있습니다.</p><h2>환경</h2><h3>프론트엔드, 백엔드 또는 서버리스?</h3><p>JavaScript 클라이언트를 사용하여 검색 앱을 생성하려면 최소한 두 가지 구성 요소, 즉 Elasticsearch 클러스터와 클라이언트를 실행하기 위한 JavaScript 런타임이 필요합니다.</p><p>JavaScript 클라이언트는 모든 Elasticsearch 솔루션(클라우드, 온프레미스, 서버리스)을 지원하며, 클라이언트가 내부적으로 모든 변형을 처리하므로 어떤 것을 사용할지 고민할 필요가 없습니다.</p><p>하지만 자바스크립트 런타임은 <strong>브라우저에서</strong> 직접 <strong>실행하는 것이 아니라 서버에서 실행해야 합니다.</strong></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd3ec469c83e3a71a/6a17e3d5445de91da44d00b6/92ce6cfd923c8008fa44f617a58193642d9d5879-661x410.png" alt="JavaScript 환경에서의 Elasticsearch." /><p>이는 브라우저에서 Elasticsearch를 호출할 때 사용자가 클러스터 API 키, 호스트 또는 쿼리 자체와 같은 민감한 정보를 얻을 수 있기 때문입니다. Elasticsearch는 <strong>클러스터를 인터넷에 직접 노출하지 않고 </strong>사용자가 매개변수만 볼 수 있도록 이 모든 정보를 추상화하는 중간 계층을 사용할 것을 권장합니다. 이 주제에 대한 자세한 내용은 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/es-security-principles.html#security-protect-cluster-traffic">여기에서</a> 확인할 수 있습니다.</p><p>다음과 같은 스키마를 사용하는 것이 좋습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4d7f215f2e70230a/6a17e3d6fbc5f83de6491a13/a08769f08ec73fe57bf2e961cfdfbb1cdd57919d-972x429.png" alt="Elasticsearch Node.js 클라이언트 설정하기." /><p>이 경우 클라이언트는 서버에 대한 검색어와 인증 키만 전송하고 서버는 쿼리 및 Elasticsearch와의 통신을 완전히 제어합니다.</p><h3>클라이언트 연결하기</h3><p><a href="https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud">다음 단계에</a> 따라 API 키를 생성하여 시작하세요.</p><p>이전 예제에 따라 간단한 Express 서버를 생성하고 Node.JS 서버의 클라이언트를 사용하여 서버에 연결하겠습니다.</p><p>NPM으로 프로젝트를 초기화하고 Elasticsearch 클라이언트와 <a href="https://expressjs.com/">Express를</a> 설치하겠습니다. 후자는 Node.js에서 서버를 불러오는 라이브러리입니다. Express를 사용하면 HTTP를 통해 백엔드와 상호 작용할 수 있습니다.</p><p>프로젝트를 초기화해 보겠습니다:</p><p><code>npm init -y</code></p><p>종속성을 설치합니다:</p><p><code>npm install @elastic/elasticsearch express split2 dotenv</code></p><p>자세히 설명해 드리겠습니다:</p><ul><li><p><a href="https://www.npmjs.com/package/@elastic/elasticsearch"><em><strong>elastic/elasticsearch</strong></em></a>: 공식 Node.js 클라이언트입니다.</p></li><li><p><a href="https://www.npmjs.com/package/express"><em><strong>익스프레스</strong></em></a>: 경량 노드js 서버를 회전시켜 Elasticsearch를 노출할 수 있습니다.</p></li><li><p><a href="https://www.npmjs.com/package/split2"><em><strong>split2</strong></em></a>: 텍스트 줄을 스트림으로 분할합니다. 한 번에 한 줄씩 ndjson 파일을 처리하는 데 유용합니다.</p></li><li><p><a href="https://www.npmjs.com/package/dotenv"><em><strong>dotenv</strong></em></a>: .env를 사용하여 환경 변수를 관리할 수 있습니다. 파일</p></li></ul><p>.env 파일 만들기 파일을 열고 다음 줄을 추가합니다:</p>ELASTICSEARCH_ENDPOINT="Your Elasticsearch endpoint"
ELASTICSEARCH_API_KEY="Your Elasticssearch API"<p>이렇게 하면 <code>dotenv</code> 패키지를 사용하여 해당 변수를 가져올 수 있습니다.</p><p><code>server.js</code> 파일을 만듭니다:</p>const express = require("express");
const bodyParser = require("body-parser");
const { Client } = require("@elastic/elasticsearch");
 
require("dotenv").config(); //environment variables setup

const ELASTICSEARCH_ENDPOINT = process.env.ELASTICSEARCH_ENDPOINT;
const ELASTICSEARCH_API_KEY = process.env.ELASTICSEARCH_API_KEY;
const PORT = 3000;


const app = express();

app.listen(PORT, () =&gt; {
  console.log("Server running on port", PORT);
});
app.use(bodyParser.json());


let esClient = new Client({
  node: ELASTICSEARCH_ENDPOINT,
  auth: { apiKey: ELASTICSEARCH_API_KEY },  
});

app.get("/ping", async (req, res) =&gt; {
  try {
    const result = await esClient.info();

    res.status(200).json({
      success: true,
      clusterInfo: result,
    });
  } catch (error) {
    console.error("Error getting Elasticsearch info:", error);

    res.status(500).json({
      success: false,
      clusterInfo: null,
      error: error.message,
    });
  }
});<p>이 코드는 포트 3000에서 수신 대기하고 인증을 위해 API 키를 사용하여 Elasticsearch 클러스터에 연결하는 기본 Express.js 서버를 설정합니다. 여기에는 GET 요청을 통해 액세스할 때 Elasticsearch 클라이언트의 <code>.info()</code> 메서드를 사용하여 기본 정보를 위해 Elasticsearch 클러스터에 쿼리하는 /ping 엔드포인트가 포함되어 있습니다. </p><p>쿼리가 성공하면 클러스터 정보를 JSON 형식으로 반환하고, 그렇지 않으면 오류 메시지를 반환합니다. 또한 서버는 본문 파서 미들웨어를 사용하여 JSON 요청 본문을 처리합니다.</p><p>파일을 실행하여 서버를 불러옵니다:</p><p><code>node server.js</code></p><p>답은 다음과 같이 표시되어야 합니다:</p>Server running on port 3000<p>이제 엔드포인트 <code>/ping</code> 를 참조하여 Elasticsearch 클러스터의 상태를 확인해 보겠습니다.</p>curl http://localhost:3000/ping
{
    "success": true,
    "clusterInfo": {
        "name": "instance-0000000000",
        "cluster_name": "61b7e19eec204d59855f5e019acd2689",
        "cluster_uuid": "BIfvfLM0RJWRK_bDCY5ldg",
        "version": {
            "number": "9.0.0",
            "build_flavor": "default",
            "build_type": "docker",
            "build_hash": "112859b85d50de2a7e63f73c8fc70b99eea24291",
            "build_date": "2025-04-08T15:13:46.049795831Z",
            "build_snapshot": false,
            "lucene_version": "10.1.0",
            "minimum_wire_compatibility_version": "8.18.0",
            "minimum_index_compatibility_version": "8.0.0"
        },
        "tagline": "You Know, for Search"
    }
}<h2>문서 색인 생성</h2><p>연결되면 시맨틱 검색을 위한 <a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text">semantic_text와</a> 전체 텍스트 쿼리를 위한 텍스트와 같은 매핑을 사용하여 문서를 색인할 수 있습니다. 이 두 가지 필드 유형을 사용하면 <a href="https://www.elastic.co/what-is/hybrid-search">하이브리드 검색도</a> 수행할 수 있습니다.</p><p>매핑을 생성하고 문서를 업로드하기 위해 <code>load.js</code> 파일을 새로 만들겠습니다.</p><h3>Elasticsearch 클라이언트</h3><p>먼저 클라이언트를 인스턴스화하고 인증해야 합니다:</p>const { Client } = require("@elastic/elasticsearch");

const ELASTICSEARCH_ENDPOINT = "cluster/project_endpoint";
const ELASTICSEARCH_API_KEY = "apiKey";

const esClient = new Client({
  node: ELASTICSEARCH_ENDPOINT,
  auth: { apiKey: ELASTICSEARCH_API_KEY },
});<h3>시맨틱 매핑</h3><p>동물 병원에 대한 데이터로 인덱스를 만들겠습니다. 주인과 반려동물의 정보, 방문 세부 정보를 저장합니다.</p><p>이름과 설명 등 전체 텍스트 검색을 실행하려는 데이터는 텍스트로 저장됩니다. 동물의 종이나 품종과 같은 카테고리의 데이터는 키워드로 저장됩니다.</p><p>또한 모든 필드의 값을 semantic_text 필드에 복사하여 해당 정보에 대해서도 의미론적 검색을 실행할 수 있도록 합니다.</p>const INDEX_NAME = "vet-visits";

const createMappings = async (indexName, mapping) =&gt; {
  try {
    const body = await esClient.indices.create({
      index: indexName,
      body: {
        mappings: mapping,
      },
    });

    console.log("Index created successfully:", body);
  } catch (error) {
    console.error("Error creating mapping:", error);
  }
};

await createMappings(INDEX_NAME, {
  properties: {
    owner_name: {
      type: "text",
      copy_to: "semantic_field",
    },
    pet_name: {
      type: "text",
      copy_to: "semantic_field",
    },
    species: {
      type: "keyword",
      copy_to: "semantic_field",
    },
    breed: {
      type: "keyword",
      copy_to: "semantic_field",
    },
    vaccination_history: {
      type: "keyword",
      copy_to: "semantic_field",
    },
    visit_details: {
      type: "text",
      copy_to: "semantic_field",
    },
    semantic_field: {
      type: "semantic_text",
    },
  },
});<h3>대량 도우미</h3><p>클라이언트의 또 다른 장점은 일괄 <a href="https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/client-helpers.html#bulk-helper">도우미를</a> 사용하여 일괄 색인을 생성할 수 있다는 점입니다. 대량 도우미를 사용하면 동의, 재시도 및 기능을 통과하여 성공 또는 실패한 각 문서에 대해 수행할 작업과 같은 작업을 쉽게 처리할 수 있습니다.</p><p>이 도우미의 매력적인 기능은 스트림으로 작업할 수 있다는 점입니다. 이 기능을 사용하면 전체 파일을 메모리에 저장하고 한 번에 Elasticsearch로 전송하는 대신 파일을 한 줄씩 전송할 수 있습니다.</p><p>Elasticsearch에 데이터를 업로드하려면 프로젝트의 루트에 data.ndjson이라는 파일을 생성하고 아래 정보를 추가하세요(또는 <a href="https://github.com/Delacrobix/JS-client-best-practices_article/blob/main/data.ndjson">여기에서</a> 데이터 세트가 포함된 파일을 다운로드할 수 있습니다):</p>{"owner_name":"Alice Johnson","pet_name":"Buddy","species":"Dog","breed":"Golden Retriever","vaccination_history":["Rabies","Parvovirus","Distemper"],"visit_details":"Annual check-up and nail trimming. Healthy and active."}
{"owner_name":"Marco Rivera","pet_name":"Milo","species":"Cat","breed":"Siamese","vaccination_history":["Rabies","Feline Leukemia"],"visit_details":"Slight eye irritation, prescribed eye drops."}
{"owner_name":"Sandra Lee","pet_name":"Pickles","species":"Guinea Pig","breed":"Mixed","vaccination_history":[],"visit_details":"Loss of appetite, recommended dietary changes."}
{"owner_name":"Jake Thompson","pet_name":"Luna","species":"Dog","breed":"Labrador Mix","vaccination_history":["Rabies","Bordetella"],"visit_details":"Mild ear infection, cleaning and antibiotics given."}
{"owner_name":"Emily Chen","pet_name":"Ziggy","species":"Cat","breed":"Mixed","vaccination_history":["Rabies","Feline Calicivirus"],"visit_details":"Vaccination update and routine physical."}
{"owner_name":"Tomás Herrera","pet_name":"Rex","species":"Dog","breed":"German Shepherd","vaccination_history":["Rabies","Parvovirus","Leptospirosis"],"visit_details":"Follow-up for previous leg strain, improving well."}
{"owner_name":"Nina Park","pet_name":"Coco","species":"Ferret","breed":"Mixed","vaccination_history":["Rabies"],"visit_details":"Slight weight loss; advised new diet."}
{"owner_name":"Leo Martínez","pet_name":"Simba","species":"Cat","breed":"Maine Coon","vaccination_history":["Rabies","Feline Panleukopenia"],"visit_details":"Dental cleaning. Minor tartar buildup removed."}
{"owner_name":"Rachel Green","pet_name":"Rocky","species":"Dog","breed":"Bulldog Mix","vaccination_history":["Rabies","Parvovirus"],"visit_details":"Skin rash, antihistamines prescribed."}
{"owner_name":"Daniel Kim","pet_name":"Mochi","species":"Rabbit","breed":"Mixed","vaccination_history":[],"visit_details":"Nail trimming and general health check. No issues."}<p>split2를 사용해 파일 줄을 스트리밍하는 동시에 대량 도우미가 이를 Elasticsearch로 전송합니다.</p>const { createReadStream } = require("fs");
const split = require("split2");
 
const indexData = async (filePath, indexName) =&gt; {
  try {
    console.log(`Indexing data from ${filePath} into ${indexName}...`);

    const result = await esClient.helpers.bulk({
      datasource: createReadStream(filePath).pipe(split()),

      onDocument: () =&gt; {
        return {
          index: { _index: indexName },
        };
      },
      onDrop(doc) {
        console.error("Error processing document:", doc);
      },
    });

    console.log("Bulk indexing successful elements:", result.items.length);
  } catch (error) {
    console.error("Error indexing data:", error);
    throw error;
  }
};

await indexData("./data.ndjson", INDEX_NAME);<p>위의 코드는 .ndjson 파일을 한 줄씩 읽고 <code>helpers.bulk</code> 메서드를 사용하여 각 JSON 객체를 지정된 Elasticsearch 인덱스로 일괄 색인합니다. <code>createReadStream</code> 및 <code>split2</code> 을 사용하여 파일을 스트리밍하고 각 문서에 대한 인덱싱 메타데이터를 설정하며 처리하지 못한 모든 문서를 기록합니다. 완료되면 성공적으로 색인된 항목의 수를 기록합니다.</p><p><code>indexData</code> 기능 대신, Kibana를 사용하여 <a href="https://www.elastic.co/docs/manage-data/ingest/upload-data-files">데이터 파일 업로드 UI를</a>통해 직접 파일을 업로드할 수 있습니다.</p><p>파일을 실행하여 Elasticsearch 클러스터에 문서를 업로드합니다.</p><p><code>node load.js</code></p>Creating mappings for index vet-visits...
Index created successfully: { acknowledged: true, shards_acknowledged: true, index: 'vet-visits' }
Indexing data from ./data.ndjson into vet-visits...
Bulk indexing completed. Total documents: 10, Failed: 0<h2>Elasticsearch에서 데이터 검색</h2><p><code>server.js</code> 파일로 돌아가서 어휘, 시맨틱 또는 하이브리드 검색을 수행하기 위해 다양한 엔드포인트를 만들어 보겠습니다.</p><p>간단히 말해, 이러한 유형의 검색은 상호 배타적인 것이 아니라 답변해야 하는 질문의 종류에 따라 달라집니다.</p><p>쿼리 유형</p><p>사용 사례</p><p>질문 예시</p><p>어휘 쿼리</p><p>문제의 단어 또는 단어 어근이 색인 문서에 표시될 가능성이 높습니다. 질문과 문서 간의 토큰 유사성.</p><p>파란색 스포츠 티셔츠를 찾고 있습니다.</p><p>시맨틱 쿼리</p><p>질문의 단어는 문서에 표시되지 않을 가능성이 높습니다. 질문과 문서 간의 개념적 유사성.</p><p>추운 날씨에 입을 옷을 찾고 있습니다.</p><p>하이브리드 검색</p><p>문제에는 어휘 및/또는 의미 구성 요소가 포함되어 있습니다. 질문과 문서 간의 토큰 및 의미적 유사성.</p><p>해변 결혼식에 입을 S 사이즈 드레스를 찾고 있습니다.</p><p>문제의 <em><strong>어휘 </strong></em>부분은 제목과 설명 또는 카테고리 이름의 일부일 가능성이 높으며, <em><strong>의미 </strong></em>부분은 해당 필드와 관련된 개념입니다. <em><strong>파란색은</strong></em> 카테고리 이름 또는 설명의 일부일 수 있으며, <em><strong>해변 결혼식은</strong></em> 그렇지 않을 가능성이 높지만 의미상 린넨 의류와 관련이 있을 수 있습니다.</p><h3>어휘 쿼리(/search/lexic?q=&lt;query_term&gt;)</h3><p>전체 텍스트 검색이라고도 하는 어휘 검색은 토큰 유사성을 기반으로 검색하는 것을 의미합니다. 즉, 분석 후 검색에 토큰이 포함된 문서가 반환됩니다.</p><p>어휘 검색 실습 튜토리얼은 <a href="https://www.elastic.co/demo-gallery/lexical-search">여기에서</a> 확인할 수 있습니다.</p>app.get("/search/lexic", async (req, res) =&gt; {
  const { q } = req.query;

  const INDEX_NAME = "vet-visits";

  try {
    const result = await esClient.search({
      index: INDEX_NAME,
      size: 5,
      body: {
        query: {
          multi_match: {
            query: q,
            fields: ["owner_name", "pet_name", "visit_details"],
          },
        },
      },
    });

    res.status(200).json({
      success: true,
      results: result.hits.hits
    });
  } catch (error) {
    console.error("Error performing search:", error);

    res.status(500).json({
      success: false,
      results: null,
      error: error.message,
    });
  }
});<p>테스트 대상: <em><strong>손톱 다듬기</strong></em></p>curl http://localhost:3000/search/lexic?q=nail%20trimming<p>답변:</p>{
    "success": true,
    "results": [
        {
            "_index": "vet-visits",
            "_id": "-RY6RJYBLe2GoFQ6-9n9",
            "_score": 2.7075968,
            "_source": {
                "pet_name": "Mochi",
                "owner_name": "Daniel Kim",
                "species": "Rabbit",
                "visit_details": "Nail trimming and general health check. No issues.",
                "breed": "Mixed",
                "vaccination_history": []
            }
        },
        {
            "_index": "vet-visits",
            "_id": "8BY6RJYBLe2GoFQ6-9n9",
            "_score": 2.560356,
            "_source": {
                "pet_name": "Buddy",
                "owner_name": "Alice Johnson",
                "species": "Dog",
                "visit_details": "Annual check-up and nail trimming. Healthy and active.",
                "breed": "Golden Retriever",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus",
                    "Distemper"
                ]
            }
        }
    ]
}<h3>시맨틱 쿼리(/search/semantic?q=&lt;query_term&gt;)</h3><p>시맨틱 검색은 어휘 검색과 달리 벡터 검색을 통해 검색어의 의미와 유사한 결과를 찾아냅니다.</p><p>시맨틱 검색 실습 튜토리얼은 <a href="https://www.elastic.co/demo-gallery/semantic-search">여기에서</a> 확인할 수 있습니다.</p>app.get("/search/semantic", async (req, res) =&gt; {
  const { q } = req.query;

  const INDEX_NAME = "vet-visits";

  try {
    const result = await esClient.search({
      index: INDEX_NAME,
      size: 5,
      body: {
        query: {
          semantic: {
            field: "semantic_field",
            query: q
          },
        },
      },
    });

    res.status(200).json({
      success: true,
      results: result.hits.hits,
    });
  } catch (error) {
    console.error("Error performing search:", error);

    res.status(500).json({
      success: false,
      results: null,
      error: error.message,
    });
  }
});<p>테스트 대상은 다음과 같습니다: <em><strong>누가 페디큐어를 받았나요?</strong></em></p>curl http://localhost:3000/search/semantic?q=Who%20got%20a%20pedicure?<p>답변:</p>{
    "success": true,
    "results": [
        {
            "_index": "vet-visits",
            "_id": "-RY6RJYBLe2GoFQ6-9n9",
            "_score": 4.861466,
            "_source": {
                "owner_name": "Daniel Kim",
                "pet_name": "Mochi",
                "species": "Rabbit",
                "breed": "Mixed",
                "vaccination_history": [],
                "visit_details": "Nail trimming and general health check. No issues."
            }
        },
        {
            "_index": "vet-visits",
            "_id": "8BY6RJYBLe2GoFQ6-9n9",
            "_score": 4.7152824,
            "_source": {
                "pet_name": "Buddy",
                "owner_name": "Alice Johnson",
                "species": "Dog",
                "visit_details": "Annual check-up and nail trimming. Healthy and active.",
                "breed": "Golden Retriever",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus",
                    "Distemper"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "9RY6RJYBLe2GoFQ6-9n9",
            "_score": 1.6717153,
            "_source": {
                "pet_name": "Rex",
                "owner_name": "Tomás Herrera",
                "species": "Dog",
                "visit_details": "Follow-up for previous leg strain, improving well.",
                "breed": "German Shepherd",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus",
                    "Leptospirosis"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "9xY6RJYBLe2GoFQ6-9n9",
            "_score": 1.5600781,
            "_source": {
                "pet_name": "Simba",
                "owner_name": "Leo Martínez",
                "species": "Cat",
                "visit_details": "Dental cleaning. Minor tartar buildup removed.",
                "breed": "Maine Coon",
                "vaccination_history": [
                    "Rabies",
                    "Feline Panleukopenia"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "-BY6RJYBLe2GoFQ6-9n9",
            "_score": 1.2696637,
            "_source": {
                "pet_name": "Rocky",
                "owner_name": "Rachel Green",
                "species": "Dog",
                "visit_details": "Skin rash, antihistamines prescribed.",
                "breed": "Bulldog Mix",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus"
                ]
            }
        }
    ]
}<h3>하이브리드&lt;query_term&gt; 쿼리(/검색/하이브리드?q=)</h3><p>하이브리드 검색을 사용하면 시맨틱 검색과 어휘 검색을 결합하여 토큰별 검색의 정확성과 시맨틱 검색의 의미 근접성을 모두 얻을 수 있습니다.</p>app.get("/search/hybrid", async (req, res) =&gt; {
  const { q } = req.query;

  const INDEX_NAME = "vet-visits";

  try {
    const result = await esClient.search({
      index: INDEX_NAME,
      body: {
        retriever: {
          rrf: {
            retrievers: [
              {
                standard: {
                  query: {
                    bool: {
                      must: {
                         multi_match: {
             query: q,
            fields: ["owner_name", "pet_name", "visit_details"],
          },
                      },
                    },
                  },
                },
              },
              {
                standard: {
                  query: {
                    bool: {
                      must: {
                        semantic: {
                          field: "semantic_field",
                          query: q,
                        },
                      },
                    },
                  },
                },
              },
            ],
          },
        },
        size: 5,
      },
    });

    res.status(200).json({
      success: true,
      results: result.hits.hits,
    });
  } catch (error) {
    console.error("Error performing search:", error);

    res.status(500).json({
      success: false,
      results: null,
      error: error.message,
    });
  }
});<p>"<em><strong>누가 페디큐어나 치과 치료를 받았나요?</strong></em>"로 테스트합니다."</p>curl http://localhost:3000/search/hybrid?q=who%20got%20a%20pedicure%20or%20dental%20treatment<p>대응:</p>{
    "success": true,
    "results": [
        {
            "_index": "vet-visits",
            "_id": "9xY6RJYBLe2GoFQ6-9n9",
            "_score": 0.032522473,
            "_source": {
                "pet_name": "Simba",
                "owner_name": "Leo Martínez",
                "species": "Cat",
                "visit_details": "Dental cleaning. Minor tartar buildup removed.",
                "breed": "Maine Coon",
                "vaccination_history": [
                    "Rabies",
                    "Feline Panleukopenia"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "-RY6RJYBLe2GoFQ6-9n9",
            "_score": 0.016393442,
            "_source": {
                "pet_name": "Mochi",
                "owner_name": "Daniel Kim",
                "species": "Rabbit",
                "visit_details": "Nail trimming and general health check. No issues.",
                "breed": "Mixed",
                "vaccination_history": []
            }
        },
        {
            "_index": "vet-visits",
            "_id": "8BY6RJYBLe2GoFQ6-9n9",
            "_score": 0.015873017,
            "_source": {
                "pet_name": "Buddy",
                "owner_name": "Alice Johnson",
                "species": "Dog",
                "visit_details": "Annual check-up and nail trimming. Healthy and active.",
                "breed": "Golden Retriever",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus",
                    "Distemper"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "9RY6RJYBLe2GoFQ6-9n9",
            "_score": 0.015625,
            "_source": {
                "pet_name": "Rex",
                "owner_name": "Tomás Herrera",
                "species": "Dog",
                "visit_details": "Follow-up for previous leg strain, improving well.",
                "breed": "German Shepherd",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus",
                    "Leptospirosis"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "8xY6RJYBLe2GoFQ6-9n9",
            "_score": 0.015384615,
            "_source": {
                "pet_name": "Luna",
                "owner_name": "Jake Thompson",
                "species": "Dog",
                "visit_details": "Mild ear infection, cleaning and antibiotics given.",
                "breed": "Labrador Mix",
                "vaccination_history": [
                    "Rabies",
                    "Bordetella"
                ]
            }
        }
    ]
}<h2>결론</h2><p>이 시리즈의 첫 번째 파트에서는 클라이언트/서버 모범 사례에 따라 환경을 설정하고 다양한 검색 엔드포인트를 가진 서버를 생성하여 Elasticsearch 문서를 쿼리하는 방법을 설명했습니다. 시리즈 <a href="https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i">2부에서</a> 프로덕션 모범 사례와 서버리스 환경에서 Elasticsearch Node.js 클라이언트를 실행하는 방법에 대해 알아보세요.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/how-to-use-elasticsearch-in-javascript-part-i</guid>
    <category><![CDATA[Javascript]]></category>
    <category><![CDATA[기본]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt16d00c8a548b32e8/6a17e3d8fbc5f8c740491a19/72200540ed258779d87e53a72ea189f8a138540c-1600x901.png" length="0" type="image/png"/>
    <pubDate>Thu, 15 May 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[추론 API와 함께 올라마 사용]]></title>
    <description><![CDATA[추론 API를 사용해 Ollama를 Elasticsearch와 통합하는 방법을 알아보세요.]]></description>
    <content:encoded><![CDATA[<p>이 문서에서는 Ollama를 사용해 로컬 모델을 Elasticsearch 추론 모델에 연결한 다음 Playground를 사용해 문서에 질문하는 방법을 알아보세요.</p><p>Elasticsearch는 사용자가 Open <a href="https://www.elastic.co/kr/guide/en/elasticsearch/reference/current/inference-apis.html">Inference API를</a> 사용하여 LLM에 연결할 수 있도록 지원하며, Amazon Bedrock, Cohere, Google AI, Azure AI Studio, HuggingFace 등 서비스형 제공업체를 지원합니다.</p><p><a href="https://ollama.com">Ollama는</a> 자체 인프라(로컬 머신/서버)를 사용하여 LLM 모델을 다운로드하고 실행할 수 있는 도구입니다. <a href="https://ollama.com/library">여기에서</a> Ollama와 호환되는 사용 가능한 모델 목록을 확인할 수 있습니다.</p><p>각 모델을 설정해야 하는 다양한 방법이나 모델 기능에 액세스하기 위한 API를 만드는 방법에 대해 걱정할 필요 없이 다양한 오픈 소스 모델을 호스팅하고 테스트하려는 경우 Ollama가 모든 것을 처리하므로 훌륭한 옵션이 될 수 있습니다.</p><p>Ollama API는 OpenAI API와 호환되므로 추론 모델을 쉽게 통합하고 Playground를 사용하여 RAG 애플리케이션을 만들 수 있습니다.</p><h2>필수 구성 요소</h2><ol><li><p>Elasticsearch 8.17</p></li><li><p>Kibana 8.17</p></li><li><p>Python</p></li></ol><h2>단계</h2><ol><li><p><a href="https://www.elastic.co/kr/search-labs/blog/ollama-with-inference-api#setting-up-ollama-llm-server">올라마 LLM 서버 설정</a></p></li><li><p><a href="https://www.elastic.co/kr/search-labs/blog/ollama-with-inference-api#creating-mappings">매핑 만들기</a></p></li><li><p><a href="https://www.elastic.co/kr/search-labs/blog/ollama-with-inference-api#indexing-data">데이터 인덱싱</a></p></li><li><p><a href="https://www.elastic.co/kr/search-labs/blog/ollama-with-inference-api#asking-questions-using-playground">Playground를 사용하여 질문하기</a></p></li></ol><h2>올라마 LLM 서버 설정</h2><p>LLM 서버를 설정하여 Ollama를 사용하여 Playground 인스턴스에 연결하겠습니다. 그래야 합니다:</p><ul><li><p>올라마를 다운로드하여 실행합니다.</p></li><li><p>ngrok을 사용하여 인터넷을 통해 Ollama를 호스팅하는 로컬 웹 서버에 액세스합니다.</p></li></ul><h3>Ollama 다운로드 및 실행</h3><p>Ollama를 사용하려면 먼저 <a href="https://ollama.com/download">다운로드해야</a> 합니다. Ollama는 Linux, Windows, macOS를 지원하므로 <a href="https://ollama.com/download">여기에서</a> 사용 중인 OS와 호환되는 Ollama 버전을 다운로드하세요. Ollama가 설치되면 지원되는 LLM <a href="https://ollama.com/library">목록에서</a> 모델을 선택할 수 있습니다. 이 예에서는 일반적인 다국어 모델인 <a href="https://ollama.com/library/llama3.2">llama3.2</a> 모델을 사용하겠습니다. 설정 과정에서 Ollama용 명령줄 도구를 활성화합니다. 다운로드가 완료되면 다음 줄을 실행하면 됩니다:</p>ollama pull llama3.2<p>출력됩니다:</p>pulling manifest
pulling dde5aa3fc5ff... 100% ▕█████████████████████████████████████████████████████████████████████████████████████████▏ 2.0 GB
pulling 966de95ca8a6... 100% ▕█████████████████████████████████████████████████████████████████████████████████████████▏ 1.4 KB
pulling fcc5a6bec9da... 100% ▕█████████████████████████████████████████████████████████████████████████████████████████▏ 7.7 KB
pulling a70ff7e570d9... 100% ▕█████████████████████████████████████████████████████████████████████████████████████████▏ 6.0 KB
pulling 56bb8bd477a5... 100% ▕█████████████████████████████████████████████████████████████████████████████████████████▏   96 B
pulling 34bb5ab01051... 100% ▕█████████████████████████████████████████████████████████████████████████████████████████▏  561 B
verifying sha256 digest
writing manifest
success<p>설치가 완료되면 다음 명령으로 테스트할 수 있습니다:</p>ollama run llama3.2<p>질문해 보겠습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc12f240920e897f5/6a17f39425daab32a508a367/ad1eff81c1b04d2a747c3afd0ecbc215e5bd96fd-800x501.gif" alt="올라마를 실행하고 질문하기" /><p>모델이 실행되면 Ollama는 포트 "11434" 에서 기본적으로 실행되는 API를 활성화합니다. <a href="https://github.com/ollama/ollama/blob/main/docs/api.md">공식 문서에</a> 따라 해당 API를 요청해 보겠습니다:</p>curl http://localhost:11434/api/generate -d '{                                          
  "model": "llama3.2",               
  "prompt": "What is the capital of France?"
}' <p>이것이 저희가 받은 답변입니다:</p>{"model":"llama3.2","created_at":"2024-11-28T21:48:42.152817532Z","response":"The","done":false}
{"model":"llama3.2","created_at":"2024-11-28T21:48:42.251884485Z","response":" capital","done":false}
{"model":"llama3.2","created_at":"2024-11-28T21:48:42.347365913Z","response":" of","done":false}
{"model":"llama3.2","created_at":"2024-11-28T21:48:42.446837322Z","response":" France","done":false}
{"model":"llama3.2","created_at":"2024-11-28T21:48:42.542367394Z","response":" is","done":false}
{"model":"llama3.2","created_at":"2024-11-28T21:48:42.644580384Z","response":" Paris","done":false}
{"model":"llama3.2","created_at":"2024-11-28T21:48:42.739865362Z","response":".","done":false}
{"model":"llama3.2","created_at":"2024-11-28T21:48:42.834347518Z","response":"","done":true,"done_reason":"stop","context":[128006,9125,128007,271,38766,1303,33025,2696,25,6790,220,2366,18,271,128009,128006,882,128007,271,3923,374,279,6864,315,9822,30,128009,128006,78191,128007,271,791,6864,315,9822,374,12366,13],"total_duration":6948567145,"load_duration":4386106503,"prompt_eval_count":32,"prompt_eval_duration":1872000000,"eval_count":8,"eval_duration":684000000}<p><em>이 엔드포인트의 구체적인 응답은 스트리밍이라는 점에 유의하세요.</em></p><h3>ngrok을 사용하여 엔드포인트를 인터넷에 노출하기</h3><p>엔드포인트는 로컬 환경에서 작동하기 때문에 인터넷을 통해 다른 지점(예: Elastic Cloud 인스턴스)에서 액세스할 수 없습니다. <a href="https://ngrok.com">ngrok을</a> 사용하면 공인 IP를 제공하는 포트를 노출할 수 있습니다. ngrok에서 계정을 만들고 공식 <a href="https://dashboard.ngrok.com/get-started/setup">설정 가이드를</a> 따르세요.</p><p>ngrok 에이전트가 설치 및 구성되면 Ollama가 사용 중인 포트를 노출할 수 있습니다:</p>ngrok http 11434 --host-header="localhost:11434"<p><em>참고: </em><em><code>--host-header="localhost:11434"</code></em>헤더는<em> 요청의 "호스트" 헤더가 "localhost:11434와 일치하는지 확인합니다."</em></p><p>이 명령을 실행하면 ngrok과 Ollama 서버가 로컬에서 실행되는 한 작동하는 공개 링크가 반환됩니다.</p>Session Status                online                                                                                                                                                                              
Account                       xxxx@yourEmailProvider.com (Plan: Free)                                                                                                                                             
Version                       3.18.4                                                                                                                                                                              
Region                        United States (us)                                                                                                                                                                  
Latency                       561ms                                                                                                                                                                               
Web Interface                 http://127.0.0.1:4040                                                                                                                                                               
Forwarding                    https://your-ngrok-url.ngrok-free.app -&gt; http://localhost:11434                                                                                                                   


Connections                   ttl     opn     rt1     rt5     p50     p90                                                                                                                                         
                              0       0       0.00    0.00    0.00    0.00                                                ```<p>"포워딩" 에서 ngrok이 URL을 생성한 것을 볼 수 있습니다. 나중에 사용할 수 있도록 저장하세요.</p><p>이제 ngrok에서 생성한 URL을 사용하여 엔드포인트에 HTTP 요청을 다시 해보겠습니다:</p>curl https://your-ngrok-endpoint.ngrok-free.app/api/generate -d '{                                          
  "model": "llama3.2",               
  "prompt": "What is the capital of France?"
}'<p>응답은 이전 응답과 비슷해야 합니다.</p><h2>매핑 만들기</h2><h3>ELSER 엔드포인트</h3><p>이 예제에서는 <a href="https://www.elastic.co/kr/guide/en/elasticsearch/reference/current/put-inference-api.html">Elasticsearch 추론 API를 사용하여 추론 엔드포인트를 생성하겠습니다</a>. 또한 임베딩을 생성하는 데 <a href="https://www.elastic.co/kr/guide/en/machine-learning/current/ml-nlp-elser.html">ELSER를</a> 사용합니다.</p>PUT _inference/sparse_embedding/medicines-inference
{
  "service": "elasticsearch",
  "service_settings": {
    "num_allocations": 1,
    "num_threads": 1,
    "model_id": ".elser_model_2_linux-x86_64"
  }
}<p>이 예제에서는 두 가지 종류의 약을 판매하는 약국이 있다고 가정해 보겠습니다:</p><ul><li><p>처방전이 필요한 의약품.</p></li><li><p>처방전이 필요하지 않은 의약품.</p></li></ul><p>이 정보는 각 약품의 설명 필드에 포함됩니다.</p><p>LLM은 이 필드를 해석해야 하므로 이것이 우리가 사용할 데이터 매핑입니다:</p>PUT medicines
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "copy_to": "semantic_field"
      },
      "semantic_field": {
        "type": "semantic_text",
        "inference_id": "medicines-inference"
      },
      "text_description": {
        "type": "text",
        "copy_to": "semantic_field"
      }
    }
  }
}<p><code>text_description</code> 필드에는 설명의 일반 텍스트가 저장되고 <a href="https://www.elastic.co/kr/guide/en/elasticsearch/reference/current/semantic-text.html">semantic_text</a> 필드 유형인 <code>semantic_field</code> 필드에는 ELSER에서 생성한 임베딩이 저장됩니다.</p><p><a href="https://www.elastic.co/kr/guide/en/elasticsearch/reference/current/copy-to.html">copy_to</a> 속성은 필드 이름 및 <code>text_description</code> 의 콘텐츠를 시맨틱 필드에 복사하여 해당 필드에 대한 임베딩을 생성합니다.</p><h2>데이터 인덱싱</h2><p>이제 <a href="https://www.elastic.co/kr/guide/en/elasticsearch/reference/current/docs-bulk.html">_bulk API를</a> 사용하여 데이터를 색인해 보겠습니다.</p>POST _bulk
{"index":{"_index":"medicines"}}
{"id":1,"name":"Paracetamol","text_description":"An analgesic and antipyretic that does NOT require a prescription."}
{"index":{"_index":"medicines"}}
{"id":2,"name":"Ibuprofen","text_description":"A nonsteroidal anti-inflammatory drug (NSAID) available WITHOUT a prescription."}
{"index":{"_index":"medicines"}}
{"id":3,"name":"Amoxicillin","text_description":"An antibiotic that requires a prescription."}
{"index":{"_index":"medicines"}}
{"id":4,"name":"Lorazepam","text_description":"An anxiolytic medication that strictly requires a prescription."}
{"index":{"_index":"medicines"}}
{"id":5,"name":"Omeprazole","text_description":"A medication for stomach acidity that does NOT require a prescription."}
{"index":{"_index":"medicines"}}
{"id":6,"name":"Insulin","text_description":"A hormone used in diabetes treatment that requires a prescription."}
{"index":{"_index":"medicines"}}
{"id":7,"name":"Cold Medicine","text_description":"A compound formula to relieve flu symptoms available WITHOUT a prescription."}
{"index":{"_index":"medicines"}}
{"id":8,"name":"Clonazepam","text_description":"An antiepileptic medication that requires a prescription."}
{"index":{"_index":"medicines"}}
{"id":9,"name":"Vitamin C","text_description":"A dietary supplement that does NOT require a prescription."}
{"index":{"_index":"medicines"}}
{"id":10,"name":"Metformin","text_description":"A medication used for type 2 diabetes that requires a prescription."}<p>대응:</p>{
   "errors": false,
   "took": 34732020848,
   "items": [
 	{
   	"index": {
     	"_index": "medicines",
     	"_id": "mYoeMpQBF7lnCNFTfdn2",
     	"_version": 1,
     	"result": "created",
     	"_shards": {
       	"total": 2,
       	"successful": 2,
       	"failed": 0
     	},
     	"_seq_no": 0,
     	"_primary_term": 1,
     	"status": 201
   	}
 	},
 	{
   	"index": {
     	"_index": "medicines",
     	"_id": "mooeMpQBF7lnCNFTfdn2",
     	"_version": 1,
     	"result": "created",
     	"_shards": {
       	"total": 2,
       	"successful": 2,
       	"failed": 0
     	},
     	"_seq_no": 1,
     	"_primary_term": 1,
     	"status": 201
   	}
 	},
 	{
   	"index": {
     	"_index": "medicines",
     	"_id": "m4oeMpQBF7lnCNFTfdn2",
     	"_version": 1,
     	"result": "created",
     	"_shards": {
       	"total": 2,
       	"successful": 2,
       	"failed": 0
     	},
     	"_seq_no": 2,
     	"_primary_term": 1,
     	"status": 201
   	}
 	},
 	{
   	"index": {
     	"_index": "medicines",
     	"_id": "nIoeMpQBF7lnCNFTfdn2",
     	"_version": 1,
     	"result": "created",
     	"_shards": {
       	"total": 2,
       	"successful": 2,
       	"failed": 0
     	},
     	"_seq_no": 3,
     	"_primary_term": 1,
     	"status": 201
   	}
 	},
 	{
   	"index": {
     	"_index": "medicines",
     	"_id": "nYoeMpQBF7lnCNFTfdn2",
     	"_version": 1,
     	"result": "created",
     	"_shards": {
       	"total": 2,
       	"successful": 2,
       	"failed": 0
     	},
     	"_seq_no": 4,
     	"_primary_term": 1,
     	"status": 201
   	}
 	},
 	{
   	"index": {
     	"_index": "medicines",
     	"_id": "nooeMpQBF7lnCNFTfdn2",
     	"_version": 1,
     	"result": "created",
     	"_shards": {
       	"total": 2,
       	"successful": 2,
       	"failed": 0
     	},
     	"_seq_no": 5,
     	"_primary_term": 1,
     	"status": 201
   	}
 	},
 	{
   	"index": {
     	"_index": "medicines",
     	"_id": "n4oeMpQBF7lnCNFTfdn2",
     	"_version": 1,
     	"result": "created",
     	"_shards": {
       	"total": 2,
       	"successful": 2,
       	"failed": 0
     	},
     	"_seq_no": 6,
     	"_primary_term": 1,
     	"status": 201
   	}
 	},
 	{
   	"index": {
     	"_index": "medicines",
     	"_id": "oIoeMpQBF7lnCNFTfdn2",
     	"_version": 1,
     	"result": "created",
     	"_shards": {
       	"total": 2,
       	"successful": 2,
       	"failed": 0
     	},
     	"_seq_no": 7,
     	"_primary_term": 1,
     	"status": 201
   	}
 	},
 	{
   	"index": {
     	"_index": "medicines",
     	"_id": "oYoeMpQBF7lnCNFTfdn2",
     	"_version": 1,
     	"result": "created",
     	"_shards": {
       	"total": 2,
       	"successful": 2,
       	"failed": 0
     	},
     	"_seq_no": 8,
     	"_primary_term": 1,
     	"status": 201
   	}
 	},
 	{
   	"index": {
     	"_index": "medicines",
     	"_id": "oooeMpQBF7lnCNFTfdn2",
     	"_version": 1,
     	"result": "created",
     	"_shards": {
       	"total": 2,
       	"successful": 2,
       	"failed": 0
     	},
     	"_seq_no": 9,
     	"_primary_term": 1,
     	"status": 201
   	}
 	}
   ]
 }<h2>Playground를 사용하여 질문하기</h2><p><a href="https://www.elastic.co/kr/guide/en/kibana/current/playground.html">Playground는</a> Elasticsearch 인덱스와 LLM 공급자를 사용해 RAG 시스템을 빠르게 생성할 수 있는 Kibana 도구입니다. 자세한 내용은 이 <a href="https://www.elastic.co/kr/search-labs/blog/playground-connectors-data-chat">도움말을</a> 참조하세요.</p><h3>로컬 LLM을 플레이그라운드에 연결</h3><p>먼저 방금 만든 공개 URL을 사용하는 커넥터를 만들어야 합니다. Kibana에서 <strong>&gt;플레이그라운드를 검색한</strong> 다음 "LLM에 연결" 을 클릭합니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt22148eabfabf6d3f/6a17f3963e9e459f97ba15c6/1854f0808f8150e359fe62ba5d901d32a88d477c-1600x867.png" alt="로컬 LLM을 올라마 플레이그라운드에 연결하기" /><p>이 작업을 수행하면 Kibana 인터페이스의 왼쪽에 메뉴가 표시됩니다. 거기에서 "OpenAI" 을 클릭합니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6e28194d9012f141/6a17f39725daab500a08a36b/c83d3c4d7035a518124ad7d22b38764db57b6800-933x1007.png" alt="커넥터를 선택합니다: AI 올라마 열기" /><p>이제 OpenAI 커넥터 구성을 시작할 수 있습니다.</p><p>"커넥터 설정" 으로 이동하여 OpenAI 제공업체의 경우 "기타(OpenAI 호환 서비스)" 를 선택합니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9984dce6f78a7c08/6a17f3990b0bed0b7add36c4/ecfcdc4b575c309bd55b4e61ca0ddb348aa84f64-917x268.png" alt="추론 API로 올라마를 사용하기 위한 커넥터 설정하기" /><p>이제 다른 필드를 구성해 보겠습니다. 이 예에서는 모델 이름을 "medicines-llm" 으로 지정합니다. URL 필드에는 ngrok에서 생성한 URL(/v1/chat/completions)을 사용합니다. "기본 모델" 필드에서 "llama3.2" 을 선택합니다. API 키를 사용하지 않으므로 임의의 텍스트를 입력하면 계속 진행할 수 있습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8a24b93a39d380fb/6a17f39b96142a15c7eb1c3c/5d3b5027c8096cbe49fb740d70aa24e849611a9d-916x688.png" alt="설정 추가" /><p>"저장" 을 클릭하고 "데이터 소스 추가" 를 클릭하여 인덱스 의약품을 추가합니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4f107a54d5be25f9/6a17f39d4b055deb9d432338/525113da59e902c8235f62bde8fb62371a63e11b-1579x753.png" alt="Playground를 사용하여 문서에 질문할 데이터 소스 추가하기" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt03fb36fe05dcdbf5/6a17f39ebe608602f40048be/96138de0bbe2c2ac619f64889d3487df62739ca4-466x805.png" alt="쿼리 데이터 추가" /><p>좋아요! 이제 로컬에서 실행 중인 LLM을 RAG 엔진으로 사용하여 Playground에 액세스할 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb1b27580107259b3/6a17f3a096142abefceb1c40/cfb48b33c70f4534ab77eb01f58008237f65e6f4-1600x851.png" alt="Playground에서 모델 설정 선택" /><p>테스트하기 전에 상담원에게 더 구체적인 지침을 추가하고 모델에 전송되는 문서 수를 10개로 늘려서 답변에 사용 가능한 문서가 가장 많이 포함되도록 해 보겠습니다. 컨텍스트 필드는 <code>semantic_field</code> 이며, 여기에는 copy_to 속성 덕분에 약품의 이름과 설명이 포함됩니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4fbc97dc87c6fc62/6a17f3a1e8fbce052c3a1aa0/0c57c9c0e1a0e7b58fffdd3ef81d67d41e2990c4-580x806.png" alt="Elastic Playground의 Moel 설정" /><p>이제 질문을 해보겠습니다: <em><strong>처방전 없이 클로나제팜을 구입할 수 있나요?</strong></em> 를 클릭하고 어떤 일이 일어나는지 확인하세요:</p><p>예상대로 정답을 맞혔습니다.</p><h3>다음 단계</h3><p>다음 단계는 나만의 애플리케이션을 만드는 것입니다! Playground는 컴퓨터에서 실행하고 필요에 맞게 사용자 지정할 수 있는 Python 코드 스크립트를 제공합니다. 예를 들어, <a href="https://fastapi.tiangolo.com/">FastAPI</a> 서버 뒤에 배치하여 UI에서 소비되는 QA 의약품 챗봇을 만들 수 있습니다.</p><p>이 코드는 Playground의 오른쪽 상단에 있는 <em><strong>코드 보기</strong></em> 버튼을 클릭하면 찾을 수 있습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt42fe193b8aa08830/6a17f3a33e9e4569e8ba15ca/816bfd0e5f936ad65dbe719d5df10714e550a40b-380x121.png" alt="코드 보기 버튼" /><p>그리고 <em><strong>엔드포인트 &amp; API 키를</strong></em> 사용하여 코드에 필요한 <code>ES_API_KEY</code> 환경 변수를 생성합니다.</p><p>이 특정 예제의 코드는 다음과 같습니다:</p>## Install the required packages
## pip install -qU elasticsearch openai
import os
from elasticsearch import Elasticsearch
from openai import OpenAI
es_client = Elasticsearch(
    "https://your-deployment.us-central1.gcp.cloud.es.io:443",
    api_key=os.environ["ES_API_KEY"]
)
openai_client = OpenAI(
    api_key=os.environ["OPENAI_API_KEY"],
)
index_source_fields = {
    "medicines": [
        "semantic_field"
    ]
}
def get_elasticsearch_results():
    es_query = {
        "retriever": {
            "standard": {
                "query": {
                    "nested": {
                        "path": "semantic_field.inference.chunks",
                        "query": {
                            "sparse_vector": {
                                "inference_id": "medicines-inference",
                                "field": "semantic_field.inference.chunks.embeddings",
                                "query": query
                            }
                        },
                        "inner_hits": {
                            "size": 2,
                            "name": "medicines.semantic_field",
                            "_source": [
                                "semantic_field.inference.chunks.text"
                            ]
                        }
                    }
                }
            }
        },
        "size": 3
    }
    result = es_client.search(index="medicines", body=es_query)
    return result["hits"]["hits"]
def create_openai_prompt(results):
    context = ""
    for hit in results:
        inner_hit_path = f"{hit['_index']}.{index_source_fields.get(hit['_index'])[0]}"
        ## For semantic_text matches, we need to extract the text from the inner_hits
        if 'inner_hits' in hit and inner_hit_path in hit['inner_hits']:
            context += '\n --- \n'.join(inner_hit['_source']['text'] for inner_hit in hit['inner_hits'][inner_hit_path]['hits']['hits'])
        else:
            source_field = index_source_fields.get(hit["_index"])[0]
            hit_context = hit["_source"][source_field]
            context += f"{hit_context}\n"
    prompt = f"""
  Instructions:
  - You are an assistant specializing in answering questions about the sale of medicines.
  - Answer questions truthfully and factually using only the context presented.
  - If you don't know the answer, just say that you don't know, don't make up an answer.
  - You must always cite the document where the answer was extracted using inline academic citation style [], using the position.
  - Use markdown format for code examples.
  - You are correct, factual, precise, and reliable.
  Context:
  {context}
  """
    return prompt
def generate_openai_completion(user_prompt, question):
    response = openai_client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "system", "content": user_prompt},
            {"role": "user", "content": question},
        ]
    )
    return response.choices[0].message.content
if __name__ == "__main__":
    question = "my question"
    elasticsearch_results = get_elasticsearch_results()
    context_prompt = create_openai_prompt(elasticsearch_results)
    openai_completion = generate_openai_completion(context_prompt, question)
    print(openai_completion)<p>Ollama에서 작동하게 하려면 OpenAI 서버 대신 Ollama 서버에 연결하도록 OpenAI 클라이언트를 변경해야 합니다. 여기에서 OpenAI 예제 및 호환되는 엔드포인트의 전체 목록을 확인할 수 있습니다.</p>openai_client = OpenAI(
    # you can use http://localhost:11434/v1/ if running this code locally.
    base_url='https://your-ngrok-url.ngrok-free.app/v1/',
    # required but ignored
    api_key='ollama',
)<p>또한 완성 메서드를 호출할 때 모델을 llama3.2로 변경합니다:</p>def generate_openai_completion(user_prompt, question):
    response = openai_client.chat.completions.create(
        model="llama3.2",
        messages=[
            {"role": "system", "content": user_prompt},
            {"role": "user", "content": question},
        ]
    )
    return response.choices[0].message.content<p>질문을 추가해 보겠습니다: <em><strong>처방전 없이 클로나제팜을 구입할 수 있나요? </strong></em>Elasticsearch 쿼리로 이동합니다:</p>def get_elasticsearch_results():
    es_query = {
        "retriever": {
            "standard": {
                "query": {
                    "nested": {
                        "path": "semantic_field.inference.chunks",
                        "query": {
                            "sparse_vector": {
                                "inference_id": "medicines-inference",
                                "field": "semantic_field.inference.chunks.embeddings",
                                "query": "Can I buy Clonazepam without a prescription?"
                            }
                        },
                        "inner_hits": {
                            "size": 2,
                            "name": "medicines.semantic_field",
                            "_source": [
                                "semantic_field.inference.chunks.text"
                            ]
                        }
                    }
                }
            }
        },
        "size": 3
    }
    result = es_client.search(index="medicines", body=es_query)
    return result["hits"]["hits"]<p>또한 몇 번의 인쇄를 통해 완료 호출에도 Elasticsearch 결과를 질문 컨텍스트의 일부로 전송하고 있음을 확인할 수 있습니다:</p>if __name__ == "__main__":
    question = "Can I buy Clonazepam without a prescription?"
    elasticsearch_results = get_elasticsearch_results()
    context_prompt = create_openai_prompt(elasticsearch_results)
    print("========== Context Prompt START ==========")
    print(context_prompt)
    print("========== Context Prompt END ==========")
    print("========== Ollama Completion START ==========")
    openai_completion = generate_openai_completion(context_prompt, question)
    print(openai_completion)
    print("========== Ollama Completion END ==========")<p>이제 다음 명령을 실행해 보겠습니다.</p><p><code>pip install -qU elasticsearch openai</code></p><p><code>python main.py</code></p><p>다음과 같은 내용이 표시될 것입니다:</p>========== Context Prompt START ==========
  Instructions:
  - You are an assistant specializing in answering questions about the sale of medicines.
  - Answer questions truthfully and factually using only the context presented.
  - If you don't know the answer, just say that you don't know, don't make up an answer.
  - You must always cite the document where the answer was extracted using inline academic citation style [], using the position.
  - Use markdown format for code examples.
  - You are correct, factual, precise, and reliable.
  Context:
  Clonazepam
 ---
An antiepileptic medication that requires a prescription.A nonsteroidal anti-inflammatory drug (NSAID) available WITHOUT a prescription.
 ---
IbuprofenAn anxiolytic medication that strictly requires a prescription.
 ---
Lorazepam


========== Context Prompt END ==========
========== Ollama Completion START ==========
No, you cannot buy Clonazepam over-the-counter (OTC) without a prescription [1]. It is classified as a controlled substance in the United States due to its potential for dependence and abuse. Therefore, it can only be obtained from a licensed healthcare provider who will issue a prescription for this medication.
========== Ollama Completion END ==========<h2>결론</h2><p>이 글에서는 Ollama와 같은 도구를 Elasticsearch 추론 API 및 Playground와 함께 사용할 때 그 힘과 다양성을 확인할 수 있습니다.</p><p>몇 가지 간단한 단계를 거친 후, 자체 인프라에서 실행되는 LLM을 사용하는 채팅이 포함된 RAG 애플리케이션을 무료로 사용할 수 있게 되었습니다. 이를 통해 리소스와 민감한 정보를 더 잘 제어할 수 있을 뿐만 아니라 다양한 작업에 대한 다양한 모델에 액세스할 수 있습니다.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ollama-with-inference-api</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ollama-with-inference-api</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd9c8eb0fc946920e/6a17f3a46864a4b2fbb688f0/399b9ef527be633845fb6505b68132cc03bc9e09-1150x628.png" length="0" type="image/png"/>
    <pubDate>Fri, 14 Feb 2025 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>