langchain-elasticsearch 1.0.0 실전 가이드
Store와 Retriever, 공식 Elasticsearch 클라이언트의 역할을 나누고, 적재부터 analyzer·유사어·Nori·bool query·BM25·벡터 검색·RRF까지 Elasticsearch 8.14.3에서 직접 검증했다.
핵심: Store는 적재와 일반 검색, Retriever는 복잡한 조회 DSL, 공식 클라이언트는 스키마와 운영 제어를 담당한다.
이 비교를 한 이유
이 글의 목적은 Elasticsearch 기능을 나열하는 데 있지 않다. Elasticsearch 공식 Python 클라이언트를 직접 사용할 때와 langchain-elasticsearch==1.0.0을 사용할 때, 같은 기능을 어디까지 같은 방식으로 구현할 수 있는지 비교하는 것이 목적이다.
따라서 모든 실험은 같은 인덱스와 같은 문서를 대상으로 다음 순서로 확인했다.
- 공식
Elasticsearch클라이언트에서는 어떻게 작성하는가? ElasticsearchStore1.0.0의 공개 API만으로 같은 기능을 쓸 수 있는가?- Store로 부족하다면
ElasticsearchRetriever의body_func로 같은 DSL과 결과를 만들 수 있는가? - LangChain에서 직접 지원하지 않는다면 공식 클라이언트, Elasticsearch 서버 설정, 애플리케이션 코드 중 어디에서 보완해야 하는가?
| 비교 질문 | 이 글의 확인 방법 |
|---|---|
| 공식 클라이언트와 LangChain이 같은 결과를 내는가? | 같은 데이터·질의·필터로 문서 ID, 순서, 점수를 비교 |
| LangChain 공개 옵션으로 바로 지원하는가? | Store와 Retriever의 생성자·검색 메서드로 실제 호출 |
| 공개 옵션이 없더라도 Elasticsearch DSL을 전달할 수 있는가? | Store의 custom_query, Retriever의 body_func로 검증 |
| LangChain만으로는 할 수 없는가? | 관리 API, 노드 플러그인, 파일 사전, 라이선스 제한을 별도로 구분 |
30초 결론
핵심은 간단하다. LangChain을 쓴다고 Elasticsearch 기능이 새로 생기거나 사라지는 것은 아니다. 달라지는 것은 어디까지 편의 기능에 맡기고, 어디서부터 Elasticsearch DSL과 운영 API를 직접 다루느냐다.
같은 검색 결과가 필요하다면 단순 BM25·필터·벡터 검색은 Store의 공개 API로 옮길 수 있고, 멀티필드·복잡한 bool query는 Retriever에 Elasticsearch DSL을 옮기면 된다. 그러나 스키마·분석기·플러그인·유사어 세트·alias·reindex 같은 운영 기능은 langchain-elasticsearch 1.0.0만으로 대체할 수 없다.
| 요구사항 | 추천 API |
|---|---|
| 단일 본문 BM25·벡터 검색·간단한 metadata 필터 | ElasticsearchStore |
| 제목·본문·태그 멀티필드, 필드 boost, 복잡한 bool query | ElasticsearchRetriever |
| analyzer·mapping·유사어·alias·원시 응답 제어 | 공식 Elasticsearch 클라이언트 |
| 적재와 복잡한 조회를 한 프로젝트에서 함께 사용 | Store로 적재하고 Retriever로 조회 |
| Nori·사용자 사전 | Elasticsearch 이미지 또는 모든 노드에 먼저 배포 |
| Basic 라이선스에서 RRF | 서버 RRF 대신 애플리케이션 측 RRF 사용 |
Store·Retriever·공식 클라이언트의 역할
세 API는 경쟁 관계라기보다 담당 영역이 다르다. 실제 프로젝트에서는 하나만 고르기보다 같은 Elasticsearch 연결을 공유하면서 역할을 나누는 편이 자연스럽다.
| API | 가장 잘하는 일 | 인덱스·settings·mapping | 문서 적재 | 검색 결과 |
|---|---|---|---|---|
공식 Elasticsearch 클라이언트 | 관리 API와 전체 검색 DSL 제어 | 전체 지원 | index, bulk, update, reindex | Elasticsearch 응답 원형 |
ElasticsearchStore 1.0.0 | 일반적인 RAG 적재와 검색 | 자동 생성과 일부 생성 옵션 | add_documents, add_texts | LangChain Document |
ElasticsearchRetriever 1.0.0 | 기존 인덱스에 사용자 정의 검색 DSL 연결 | 지원하지 않음 | 지원하지 않음 | LangChain Document |
ElasticsearchStore는 가장 간단하지만 추상화 범위가 좁다. ElasticsearchRetriever는 LangChain의 Document와 Runnable 인터페이스를 유지하면서 검색 DSL을 직접 작성하는 중간 지점이다. 공식 클라이언트는 제약이 가장 적고 Elasticsearch 응답도 그대로 보존한다.
ElasticsearchRetriever는 이름 그대로 조회 전용 어댑터다. 1.0.0 클래스에는 invoke()가 있지만 add_documents()와 from_documents()는 없다. analyzer, mapping, 유사어 세트, Nori 플러그인도 Retriever가 생성하거나 설치하지 않는다.
다만 해당 기능을 Retriever로 조회할 수 없다는 뜻은 아니다. 공식 클라이언트나 Store가 인덱스를 준비해 두면 body_func의 match·multi_match에도 필드의 analyzer, Nori, synonym filter가 그대로 적용된다.
먼저 알아둘 용어
| 용어 | 쉽게 말하면 | 이 글에서 확인할 내용 |
|---|---|---|
| mapping | 필드 자료형과 검색 방식을 정하는 스키마 | text, keyword, integer, dense_vector, _meta |
| analyzer | 문장과 질의를 검색 가능한 토큰으로 나누는 규칙 | lowercase, asciifolding, Nori, synonym filter |
| Nori | 한국어를 형태소 단위로 분석하는 Elasticsearch 플러그인 | 플러그인 설치, 인라인·파일 사용자 사전 |
| RRF | BM25 순위와 벡터 순위를 결합하는 방식 | 서버 RRF와 애플리케이션 측 RRF |
5분 빠른 시작
복잡한 설정이 없다면 다음 순서만 기억하면 된다.
| 순서 | 할 일 | 사용할 API | 대표 메서드 |
|---|---|---|---|
| 1 | Elasticsearch 연결 만들기 | 공식 클라이언트 | Elasticsearch(...) |
| 2 | 문서와 metadata 넣기 | Store | add_documents() |
| 3 | 단일 본문 BM25와 간단한 필터 조회 | Store | similarity_search() |
| 4 | 제목+본문, boost, 복잡한 필터 조회 | Retriever | invoke() |
| 5 | mapping·analyzer·유사어·운영 설정 변경 | 공식 클라이언트 | indices.*, synonyms.* |
핵심: 하나의 공식 클라이언트 연결을 공유하면서 Store와 Retriever를 목적에 맞게 함께 쓰는 구성이 가장 현실적이다.
아래 예제는 현재 인프라의 http://localhost:9200에 연결해 문서를 적재하고, Store와 Retriever로 각각 조회하는 최소 구성이다.
from elasticsearch import Elasticsearch
from langchain_core.documents import Document
from langchain_elasticsearch import (
BM25Strategy,
ElasticsearchRetriever,
ElasticsearchStore,
)
client = Elasticsearch("http://localhost:9200")
index_name = "dev-es-quick-start"
# 1. Store로 인덱스를 만들고 문서를 적재한다.
store = ElasticsearchStore(
index_name=index_name,
client=client,
query_field="text",
strategy=BM25Strategy(),
metadata_mappings={
"title": {"type": "text"},
"tenant": {"type": "keyword"},
"year": {"type": "integer"},
},
)
store.add_documents(
[
Document(
page_content="Elasticsearch 필터와 BM25 사용법",
metadata={
"title": "검색 개발 가이드",
"tenant": "alpha",
"year": 2026,
},
)
],
ids=["guide-1"],
)
# 2. 단일 본문 검색은 Store가 가장 간단하다.
store_docs = store.similarity_search(
"BM25 사용법",
k=5,
filter=[{"term": {"metadata.tenant": "alpha"}}],
)
# 3. 제목과 본문을 함께 검색하려면 Retriever가 명확하다.
retriever = ElasticsearchRetriever(
index_name=index_name,
client=client,
content_field="text",
body_func=lambda query: {
"size": 5,
"query": {
"bool": {
"must": [{
"multi_match": {
"query": query,
"fields": ["metadata.title^3", "text"],
}
}],
"filter": [{"term": {"metadata.tenant": "alpha"}}],
}
},
},
)
retriever_docs = retriever.invoke("검색 가이드")
이 코드에서 헷갈리기 쉬운 인자는 다음처럼 구분한다.
| 인자 | 사용 위치 | 의미 |
|---|---|---|
query_field="text" | Store | Store의 기본 텍스트 검색 대상 필드 |
content_field="text" | Retriever | hit에서 Document.page_content로 옮길 필드. 검색 대상 필드라는 뜻은 아님 |
body_func | Retriever | 실제 Elasticsearch 검색 DSL을 만드는 함수 |
filter | Store 또는 DSL | 점수에는 영향을 주지 않고 반드시 만족해야 하는 조건 |
metadata_mappings | Store 생성 시 | metadata.* 필드 타입을 미리 지정하는 설정 |
document_mapper | Retriever 선택 사항 | _id, _score, highlight 등을 Document.metadata에 옮기는 함수 |
이후 테스트는 가능한 한 공식 클라이언트, Store, Retriever 세 경로를 모두 실행하고 결과가 같은지 확인했다. 지원 범위 표에서는 공개 인자로 가능한지, 사용자 정의 DSL이 필요한지, 서버 설정이 먼저 필요한지까지 구분했다.
테스트 환경
이번 실험은 저장소의 docker-compose-infra.yml에 정의된 Elasticsearch를 그대로 사용했다.
Elasticsearch server 8.14.3
License Basic
elasticsearch (Python) 8.19.3
langchain-elasticsearch 1.0.0
Endpoint http://localhost:9200
Nori validation endpoint http://localhost:19200 (disposable)
Nori plugin analysis-nori 8.14.3
이 글은 사용자 요구에 따라 langchain-elasticsearch를 1.0.0으로 고정했다. 공식 Python 클라이언트 문서는 8.x 클라이언트와 8.x 서버의 호환을 표시하지만, 기능 동등성을 의미하지는 않는다고 설명한다. 이번 8.19.3 클라이언트와 8.14.3 서버 조합에서는 연결과 검색이 동작했지만, 8.19에 추가된 서버 기능까지 8.14에서 사용할 수 있는 것은 아니다.
참고 자료:
재현 가능한 테스트 데이터
외부 임베딩 API나 키에 따라 결과가 달라지지 않도록, 실험 코드에는 개념 키워드를 5차원으로 매핑하는 결정적 임베딩을 넣었다. 이 임베딩은 API 흐름을 재현하기 위한 것이며 검색 품질 평가용은 아니다.
인덱스는 ElasticsearchStore가 만들고 문서를 적재한다. 이후 공식 클라이언트와 LangChain이 모두 같은 인덱스를 조회한다.
store = ElasticsearchStore(
index_name="dev-es-langchain-1-0-comparison",
client=client,
embedding=DemoEmbeddings(),
query_field="text",
vector_query_field="vector",
strategy=DenseVectorStrategy(),
metadata_mappings={
"title": {"type": "text"},
"tenant": {"type": "keyword"},
"year": {"type": "integer"},
},
)
store.add_documents(documents, ids=document_ids)
실행 코드는 experiment.py, 검증은 test_experiment.py에 있다.
문서 적재와 같은 ID 갱신
ElasticsearchStore.add_documents()는 여러 번 호출할 수 있다. 인덱스가 없으면 첫 호출 때 인덱스를 만들고, 이미 있으면 그대로 bulk 적재한다.
ElasticsearchRetriever에는 적재 메서드가 없다. 따라서 LangChain Elasticsearch만 사용하더라도 적재는 Store, 복잡한 조회는 Retriever로 역할을 나누는 구성이 자연스럽다.
store.add_documents(
[Document(page_content="첫 번째 문서", metadata={"tenant": "alpha"})],
ids=["doc-1"],
)
store.add_documents(
[Document(page_content="추가 문서", metadata={"tenant": "alpha"})],
ids=["doc-2"],
)
이번 테스트에서는 첫 호출에 same-id를 넣고, 두 번째 호출에서 new-id와 same-id를 다시 넣었다. 최종 문서 수는 2개였다. 같은 ID를 다시 넣으면 merge가 아니라 기존 _source 전체가 새 문서로 교체됐다.
first ids = [same-id]
second ids = [new-id, same-id]
count = 2
same-id = {text: "덮어쓴 문서", metadata: {tenant: "beta", ...}}
일부 필드만 수정하거나 없으면 생성하는 upsert가 필요하면 Store보다 공식 클라이언트가 명확하다.
client.update(
index=index_name,
id="doc-1",
doc={"metadata": {"status": "published"}},
doc_as_upsert=True,
)
analyzer와 mapping은 적재 전에 정한다
결론부터 말하면 가능하면 문서를 넣기 전에 정한다. ElasticsearchStore가 인덱스를 자동 생성하게 둘 수도 있고, 공식 클라이언트로 settings와 mappings를 먼저 만든 뒤 Store는 적재·조회에만 사용할 수도 있다.
핵심: analyzer나 사용자 사전이 바뀌어 토큰이 달라지면 기존 문서에는 자동 반영되지 않는다. 새 인덱스를 만들고 재색인해야 한다.
Store로 기본 analyzer 지정하기
langchain-elasticsearch==1.0.0의 ElasticsearchStore에는 custom_index_settings가 있다. 인덱스가 없는 상태에서 첫 add_documents()가 실행될 때 settings에 병합된다.
store = ElasticsearchStore(
index_name=index_name,
client=client,
query_field="text",
strategy=BM25Strategy(),
custom_index_settings={
"analysis": {
"analyzer": {
"default": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding"],
}
}
}
},
metadata_mappings={
"doc_id": {"type": "keyword"},
"title": {"type": "text"},
"tenant": {"type": "keyword"},
"year": {"type": "integer"},
},
)
실제 _analyze 결과 Café는 cafe가 됐고, cafe 질의로 Cafe 추가 문서가 조회됐다. metadata_mappings의 타입도 생성된 mapping에서 확인했다.
이 방식은 Store가 만드는 기본 text 필드에 공통 default analyzer를 적용할 때 편하다. 하지만 제목과 본문에 서로 다른 analyzer를 쓰거나 dynamic: strict, mapping _meta, dynamic template까지 관리하려면 인덱스를 먼저 만드는 편이 낫다.
필드별 analyzer는 인덱스를 먼저 만든다
client.indices.create(
index=index_name,
settings={
"analysis": {
"analyzer": {
"body_index": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding"],
},
"body_search": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding"],
},
}
}
},
mappings={
"_meta": {"schema_owner": "search-team", "schema_version": 1},
"dynamic": "strict",
"properties": {
"text": {
"type": "text",
"analyzer": "body_index",
"search_analyzer": "body_search",
},
"metadata": {
"properties": {
"title": {
"type": "text",
"analyzer": "body_index",
"search_analyzer": "body_search",
"fields": {"keyword": {"type": "keyword"}},
},
"tenant": {"type": "keyword"},
"year": {"type": "integer"},
}
},
},
},
)
store = ElasticsearchStore(
index_name=index_name,
client=client,
query_field="text",
strategy=BM25Strategy(),
)
store.add_documents(
documents,
ids=document_ids,
create_index_if_not_exists=False,
)
이 인덱스에서 metadata.title^3 + text 멀티필드 질의와 tenant=alpha 필터를 함께 실행해 analyzer-guide가 조회되는 것을 확인했다.
response = client.search(
index=index_name,
query={
"bool": {
"must": [{
"multi_match": {
"query": "cafe analyzer",
"fields": ["metadata.title^3", "text"],
}
}],
"filter": [{"term": {"metadata.tenant": "alpha"}}],
}
},
)
같은 인덱스를 ElasticsearchRetriever로 조회할 때는 analyzer 설정을 다시 적지 않는다. Retriever는 body_func가 만든 DSL을 기존 인덱스에 보내며, metadata.title과 text mapping에 연결된 analyzer는 Elasticsearch가 실행한다.
from langchain_core.documents import Document
from langchain_elasticsearch import ElasticsearchRetriever
def body_func(query: str) -> dict:
return {
"size": 5,
"query": {
"bool": {
"must": [{
"multi_match": {
"query": query,
"fields": ["metadata.title^3", "text"],
}
}],
"filter": [{"term": {"metadata.tenant": "alpha"}}],
}
},
}
def document_mapper(hit: dict) -> Document:
source = hit["_source"]
return Document(
page_content=source["text"],
metadata={
**source.get("metadata", {}),
"_id": hit["_id"],
"_score": hit.get("_score"),
},
)
retriever = ElasticsearchRetriever(
index_name=index_name,
client=client,
body_func=body_func,
document_mapper=document_mapper,
)
documents = retriever.invoke("cafe analyzer")
공식 클라이언트와 Retriever가 모두 _id=analyzer-guide를 반환했다. 즉 멀티필드·boost·필터는 Retriever에서 가능하고 analyzer도 적용되지만, 인덱스 생성과 analyzer 정의는 Retriever의 역할이 아니다.
생성 후 무엇을 바꿀 수 있나
| 항목 | 기존 인덱스에서 변경 | 실측 결과 |
|---|---|---|
| 새 문서·새 ID 적재 | 가능 | 반복 add_documents() 성공 |
| 같은 ID 전체 교체 | 가능 | 최종 문서 수 유지, _source 교체 |
| 새 mapping 필드 추가 | 가능 | metadata.department=keyword 추가 성공 |
mapping _meta | 가능 | schema version 1 → 2 성공 |
refresh_interval | 가능 | 1s → 5s 성공 |
number_of_shards | 일반 변경 불가 | 열린 인덱스에서 400 |
기존 필드의 index-time analyzer | 불가능 | body_index → standard 변경 400 |
기존 필드의 search_analyzer | 가능 | standard, 이후 late_analyzer로 변경 성공 |
| 열린 인덱스에 새 analyzer 정의 | 불가능 | 400 |
| 닫힌 인덱스에 새 analyzer 정의 | 가능 | close → settings 추가 → open 성공 |
닫힌 인덱스에 analyzer 정의를 추가할 수는 있지만, 이미 색인된 토큰은 바뀌지 않는다. 기존 필드의 index-time analyzer 자체도 교체할 수 없다. 형태소 분석이나 사전을 바꿔 기존 문서의 토큰을 다시 만들려면 새 인덱스를 만들고 _reindex한 뒤 alias를 전환하는 방식이 안전하다.
Store와 Retriever는 변경된 서버 설정을 다음 조회부터 사용하지만 설정 변경 기능을 제공하는 관리 API는 아니다. 특히 Retriever는 기존 인덱스를 조회하기만 하므로, 위 표의 변경 작업은 공식 클라이언트의 indices.* API로 수행했다.
metadata_mappings와 mapping _meta는 이름이 비슷하지만 목적이 다르다.
metadata_mappings: LangChain 문서의_source.metadata.*필드 타입- mapping
_meta: schema version, owner 같은 설명용 정보. 검색 점수나 토큰에는 영향을 주지 않음
관련 문서: Analyzer mapping parameter, Search analyzer, Index settings 변경, Reindex API
유사어는 검색 시점에 관리한다
유사어에는 두 가지 시점이 있다.
- 인덱싱 시점 유사어: 문서를 넣을 때 토큰을 확장한다. 규칙을 바꾸면 기존 문서를 재색인해야 한다.
- 검색 시점 유사어: 질의를 분석할 때 확장한다.
synonym_graph와 Synonyms API를 사용하면 재색인 없이 규칙을 갱신할 수 있다.
이번 Elasticsearch 8.14.3 Basic 클러스터에서는 Synonyms API가 동작했다. 먼저 유사어 세트를 만든다. 세트는 인덱스가 참조하기 전에 존재해야 한다.
client.synonyms.put_synonym(
id="search-synonyms",
synonyms_set=[
{"id": "meeting", "synonyms": "회의, 미팅"},
],
)
Store의 custom_index_settings에 default_search analyzer를 넣을 수 있다.
store = ElasticsearchStore(
index_name=index_name,
client=client,
query_field="text",
strategy=BM25Strategy(),
custom_index_settings={
"analysis": {
"filter": {
"meeting_synonyms": {
"type": "synonym_graph",
"synonyms_set": "search-synonyms",
"updateable": True,
}
},
"analyzer": {
"default": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase"],
},
"default_search": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "meeting_synonyms"],
},
},
}
},
)
회의 일정 문서를 넣은 뒤 미팅으로 검색해 같은 문서가 조회됐다. 이후 세트만 갱신했다.
client.synonyms.put_synonym(
id="search-synonyms",
synonyms_set=[
{"id": "meeting", "synonyms": "회의, 미팅, conference"},
],
)
응답에는 default_search analyzer가 자동 reload됐다는 정보가 들어왔고, 재색인 없이 conference 질의도 같은 문서를 찾았다.
기존 인덱스를 Retriever로 조회하는 코드는 단순하다. 유사어 설정을 body_func에 반복하지 않아도 text 필드의 default_search analyzer가 적용된다.
retriever = ElasticsearchRetriever(
index_name=index_name,
client=client,
body_func=lambda query: {
"size": 3,
"query": {"match": {"text": query}},
},
content_field="text",
)
retriever.invoke("미팅")
retriever.invoke("conference")
| 질의 | ElasticsearchStore | ElasticsearchRetriever |
|---|---|---|
미팅 | meeting-doc | meeting-doc |
conference 세트 갱신 후 | meeting-doc | meeting-doc |
LangChain은 유사어 세트 CRUD를 별도로 추상화하지 않는다. 세트 관리는 공식 client.synonyms.*, 검색은 Store나 Retriever로 나누는 구성이 자연스럽다. inline synonyms는 테스트에는 편하지만 운영에서는 Synonyms API 또는 모든 노드에 배포한 파일을 사용하는 편이 낫다.
관련 문서: Elasticsearch 유사어 구성, Synonyms API
Nori와 사용자 사전은 서버 준비가 먼저다
유사어 사전과 Nori 사용자 사전은 역할이 다르다.
| 종류 | 목적 | 변경 영향 |
|---|---|---|
| Synonym set | 회의, 미팅처럼 서로 다른 표현을 같은 의미로 확장 | 검색 시점 구성은 재색인 없이 갱신 가능 |
| Nori 기본 사전 | 한국어 형태소 분석 | analysis-nori 플러그인에 포함 |
| Nori 사용자 사전 | 프로젝트로카, 제품명, 도메인 복합어의 분해 방식을 지정 | 새 분석 결과를 기존 문서에 적용하려면 재색인 필요 |
현재 docker-compose-infra.yml은 다음 공식 이미지를 직접 사용한다.
image: docker.elastic.co/elasticsearch/elasticsearch:8.14.3
현재 컨테이너의 _cat/plugins는 빈 배열이고 analyzer: nori 호출은 400이었다. 즉 Python 패키지나 LangChain 설정만으로 Nori를 켤 수 없다. Elasticsearch 노드에 버전이 맞는 analysis-nori를 설치하고 재시작해야 한다.
재현용 이미지는 다음처럼 만들었다.
FROM docker.elastic.co/elasticsearch/elasticsearch:8.14.3
RUN bin/elasticsearch-plugin install --batch analysis-nori
COPY experiments/elasticsearch_langchain_1_0/userdict_ko.txt \
/usr/share/elasticsearch/config/userdict_ko.txt
기존 인프라는 그대로 두고 이 이미지를 19200 포트에 별도로 실행했다. 플러그인 버전은 서버와 같은 8.14.3으로 확인됐다.
인라인 사용자 사전
배포 파일 없이 index settings에 사전 규칙을 넣을 수 있다.
"tokenizer": {
"ko_inline_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "mixed",
"user_dictionary_rules": [
"세종",
"세종시 세종 시",
"프로젝트로카 프로젝트 로카",
],
}
}
mixed는 복합어 원형과 분해 토큰을 함께 보존한다. 실제 토큰은 다음과 같았다.
입력 프로젝트로카 세종시 회의
index tokens 프로젝트로카, 프로젝트, 로카, 세종시, 세종, 시, 회의
search tokens 프로젝트로카, 프로젝트, 로카, 세종시, 세종, 시, 미팅, 회의
검색 analyzer에는 회의, 미팅 synonym filter를 추가했기 때문에 검색 토큰에 둘 다 나타났다.
파일 사용자 사전
user_dictionary는 Elasticsearch config 디렉터리를 기준으로 파일을 읽는다.
c++
C쁠쁠
세종
세종시 세종 시
프로젝트로카 프로젝트 로카
"ko_file_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "mixed",
"user_dictionary": "userdict_ko.txt",
"lenient": True,
}
파일 사전 실험에서도 프로젝트로카, 프로젝트, 로카, 세종시, 세종, 시가 생성됐고 C쁠쁠은 하나의 사용자 명사로 보존됐다. 파일은 모든 Elasticsearch 노드의 같은 config 경로에 있어야 한다.
실제 조회 결과
retriever = ElasticsearchRetriever(
index_name="dev-es-nori-inline",
client=nori_client,
body_func=lambda query: {
"size": 3,
"query": {"match": {"text": query}},
},
content_field="text",
)
retriever.invoke("미팅")
retriever.invoke("프로젝트")
| 질의 | API | 분석·검색 방식 | 결과 |
|---|---|---|---|
미팅 | Store | Nori + 검색 시점 synonym | 프로젝트로카 회의 안내 |
미팅 | Retriever | 같은 필드의 match | Store와 동일 |
프로젝트 | Store | Nori 사용자 사전 분해 토큰 | 프로젝트로카 회의 안내 |
프로젝트 | Retriever | 같은 필드의 match | Store와 동일 |
세종 프로젝트 | 공식 클라이언트 | metadata.title^3 + text, tenant=alpha | _id=meeting |
세종 프로젝트 | Retriever | body_func의 multi_match + filter | 공식 클라이언트와 동일 |
이 테스트로 “Nori 설치 가능 여부”, “인라인/파일 사전”, “유사어와 결합”, “LangChain Store 적재”, “Store/Retriever 실제 BM25 조회”까지 확인했다. Nori와 사전은 Elasticsearch에 설치·설정하고, Retriever에는 별도 Nori 옵션 없이 조회 DSL만 전달한다. 재현 코드는 nori_experiment.py, 이미지는 Dockerfile.elasticsearch-nori에 있다.
관련 문서: Nori analysis plugin, Nori tokenizer와 사용자 사전, 플러그인 설치와 재시작
기능별 지원 범위 한눈에 보기
아래 표의 “가능”은 이번 환경에서 실제 호출까지 성공했다는 뜻이다. 서버 설정이 필요한 항목은 LangChain 자체 기능은 아니지만, 설정 완료 후 Store와 Retriever 조회에서 효과가 적용되는 것까지 확인했다.
표는 각 기능을 공식 클라이언트에서 쓰는 방법 → Store 지원 여부 → Retriever 지원 여부 → 같은 기능을 구현하는 현실적인 방법 순서로 비교한다.
판정은 다음 네 가지로 읽으면 된다.
| 판정 | 뜻 |
|---|---|
| 가능 | 해당 LangChain API의 공개 기능으로 구현하고 실제 호출 성공 |
| 제한 | 구현은 가능하지만 custom DSL 또는 생성 시점 제약이 있음 |
| 서버 설정 후 가능 | LangChain 밖에서 설정한 뒤 Store/Retriever 조회에 적용 가능 |
| 불가 | 해당 API가 생성·변경·실행 기능을 제공하지 않음 |
인덱스 생성·적재·분석 설정
| 하고 싶은 일 | 공식 클라이언트 | Store 1.0.0 | Retriever 1.0.0 | 같은 기능을 쓰는 방법 |
|---|---|---|---|---|
| 새 인덱스 생성 | 가능: indices.create() | 가능: 첫 적재 때 자동 생성 | 불가 | 간단한 인덱스는 Store, 엄격한 스키마는 공식 클라이언트 |
| 문서를 새로 또는 반복 적재 | 가능: index, bulk | 가능: add_documents() | 불가 | LangChain 문서라면 Store 사용 |
| 같은 ID 문서를 다시 적재 | 가능 | 가능 | 불가 | Store는 부분 수정이 아니라 문서 전체를 교체 |
| 일부 필드만 update/upsert | 가능: update() | 불가 | 불가 | 공식 client.update() 사용 |
| metadata 필드 타입 지정 | 가능: mapping | 가능: metadata_mappings | 불가 | 단순 타입은 Store, 전체 mapping은 공식 클라이언트 |
기본 text analyzer 정의 | 가능 | 제한: custom_index_settings | 불가 | 인덱스 생성 시 설정해야 함 |
필드별 analyzer·strict mapping·_meta | 가능 | 직접 관리 불가 | 직접 관리 불가 | 공식 클라이언트로 인덱스를 먼저 생성한 뒤 연결 |
| Synonyms API 세트 생성·수정 | 가능: client.synonyms.* | 불가 | 불가 | 공식 Synonyms API 사용 |
| Nori 플러그인·파일 사전 설치 | 노드 작업 필요 | 불가 | 불가 | Elasticsearch 이미지 또는 모든 노드에 설치 |
| 기존 index-time analyzer 교체 | 새 인덱스 필요 | 불가 | 불가 | 새 인덱스 생성 → _reindex → alias 전환 |
검색 기능
| 하고 싶은 검색 | 공식 클라이언트 | Store 1.0.0 | Retriever 1.0.0 | 같은 결과를 만드는 방법 |
|---|---|---|---|---|
| 단일 본문 BM25 | 가능: match | 가능 | 가능 | Store가 가장 간단함 |
| 멀티필드 BM25·필드 boost | 가능: multi_match | 제한: custom_query | 가능: body_func | Retriever에 같은 multi_match DSL 전달 |
metadata term·range 필터 | 가능 | 가능: filter | 가능: body_func | 단순 필터는 Store, 복합 조건은 Retriever |
must·should·must_not 조합 | 가능: bool | 제한: custom_query | 가능: body_func | Retriever에 같은 bool DSL 전달 |
| 기존 analyzer 적용 | 가능 | 서버 설정 후 가능 | 서버 설정 후 가능 | 인덱스에 설정하면 세 API 모두 같은 분석 결과 사용 |
| 동적 유사어 적용 | 가능 | 서버 설정 후 가능 | 서버 설정 후 가능 | Synonyms API는 공식 클라이언트, 검색은 세 API 가능 |
| Nori 적용 필드 조회 | 가능 | 서버 설정 후 가능 | 서버 설정 후 가능 | 플러그인·사전을 서버에 먼저 설치 |
| 벡터 kNN 검색 | 가능: knn | 가능 | 가능: DSL 직접 작성 | Store 전략 또는 Retriever DSL 사용 |
| BM25 + kNN hybrid | 가능 | 가능 | 가능: DSL 직접 작성 | 현재 환경에서는 rrf=False 방식 성공 |
| 서버 RRF 요청 작성 | 가능 | 가능 | 가능 | 요청은 같지만 현재 Basic 서버는 403 |
| 애플리케이션 측 RRF | 별도 Python 코드 | 별도 Python 코드 | 별도 Python 코드 | BM25와 kNN을 각각 조회한 뒤 순위를 결합 |
bool query 실전: must, should, filter, must_not
Elasticsearch의 bool query는 여러 검색 조건을 AND, OR, NOT처럼 조합하는 기본 도구다. 가장 중요한 구분은 문서를 통과시킬 조건인지와 관련도 점수에도 반영할 조건인지다.
| 절 | 문서가 만족해야 하나 | _score 반영 | 주로 쓰는 경우 |
|---|---|---|---|
must | 모두 만족해야 함 | 반영함 | 사용자가 입력한 검색어, 제목·본문의 핵심 질의 |
filter | 모두 만족해야 함 | 반영하지 않음 | tenant, 권한, 상태, 날짜, category 같은 정확한 제한 |
should | 기본값에 따라 다름 | 일치하면 가점 | 선호 키워드, 제목 일치, 최신 문서 등 순위 boost |
must_not | 하나라도 일치하면 제외 | 반영하지 않음 | 삭제·보관 문서, 차단 상태, 제외 category |
핵심: must와 should는 관련도 점수에 영향을 줄 수 있고, filter와 must_not은 문서 통과 여부만 결정한다.
쉽게 기억하면 must=검색 AND, should=가점 또는 OR, filter=점수 없는 AND, must_not=NOT이다.
한 번에 조합하는 기본 예제
다음 검색은 네 가지 절을 모두 사용한다.
- 본문에
elasticsearch가 있어야 한다. - tenant는 반드시
alpha여야 하지만 tenant 일치 여부로 점수를 올리지는 않는다. - 본문에
bm25가 있으면 점수를 더 준다. archived문서는 제외한다.
bool_query = {
"bool": {
"must": [
{"match": {"text": "elasticsearch"}},
],
"should": [
{"match": {"text": "bm25"}},
],
"filter": [
{"term": {"metadata.tenant": "alpha"}},
],
"must_not": [
{"term": {"metadata.status": "archived"}},
],
}
}
response = client.search(
index=index_name,
query=bool_query,
size=10,
)
match는 analyzer를 거치는 전문 검색에 사용하고, term은 keyword처럼 분석하지 않은 정확한 값에 사용한다. 따라서 본문은 match, tenant와 status는 term으로 작성했다.
should는 언제 선택이고 언제 필수인가
should에서 가장 자주 발생하는 실수는 항상 OR 조건이라고 생각하는 것이다.
| bool query 구성 | 기본 minimum_should_match | 의미 |
|---|---|---|
should만 있고 must·filter가 없음 | 1 | should 중 하나 이상 반드시 일치 |
must 또는 filter가 있음 | 0 | should는 선택 조건이며 일치할수록 점수만 상승 |
minimum_should_match=1을 직접 지정 | 1 | 다른 절이 있어도 should 하나 이상 필수 |
앞의 검색에서는 must와 filter가 있으므로 bm25 should는 선택 조건이다. bm25가 없는 문서도 검색되지만 점수가 낮다. bm25가 반드시 있어야 한다면 다음처럼 명시한다.
bool_query["bool"]["minimum_should_match"] = 1
조건이 여러 개라면 정수뿐 아니라 백분율도 사용할 수 있다. 예를 들어 should 네 개 중 세 개 이상을 요구하려면 minimum_should_match=3을 사용한다. 운영 코드에서는 기본값을 암묵적으로 기대하기보다 의도를 명시하는 편이 안전하다.
실제 테스트 결과
테스트 문서 네 개를 넣고 공식 클라이언트, Store, Retriever에서 같은 bool query를 실행했다.
| 문서 ID | tenant | status | 본문 특징 | 예상 동작 |
|---|---|---|---|---|
boosted-bm25 | alpha | published | elasticsearch, bm25 포함 | 통과하고 should 가점 |
must-only | alpha | published | elasticsearch만 포함 | should가 선택이면 통과 |
wrong-tenant | beta | published | 두 검색어 모두 포함 | filter에서 제외 |
archived-guide | alpha | archived | 두 검색어 모두 포함 | must_not에서 제외 |
| 테스트 | 공식 클라이언트 | Store | Retriever | 확인 결과 |
|---|---|---|---|---|
should만 사용, 기본값 1 | boosted-bm25, wrong-tenant | 동일 | 동일 | should 불일치 문서는 제외 |
should 선택, 기본값 0 | boosted-bm25, must-only | 동일 | 동일 | should 불일치 문서도 통과 |
minimum_should_match=1 | boosted-bm25 | 동일 | 동일 | should 일치를 필수로 변경 |
tenant filter | beta 문서 제외 | 동일 | 동일 | 조건은 적용되고 점수에는 미반영 |
status must_not | archived 문서 제외 | 동일 | 동일 | 제외 조건 성공 |
should가 선택 조건일 때 공식 클라이언트 점수는 다음과 같았다. 절대 점수는 데이터와 analyzer에 따라 달라질 수 있으므로 숫자 자체보다 순서와 상대 차이를 본다.
| 문서 | should 일치 | _score |
|---|---|---|
boosted-bm25 | bm25 일치 | 0.4523 |
must-only | 불일치 | 0.1241 |
tenant 조건을 filter에 둘 때와 must에 둘 때 결과 문서는 같았지만 점수는 달랐다.
| 문서 | tenant를 filter에 둔 점수 | tenant를 must에 둔 점수 |
|---|---|---|
must-only | 0.1241 | 0.4807 |
boosted-bm25 | 0.1031 | 0.4598 |
tenant·상태·권한처럼 검색 관련도가 아닌 조건을 must에 넣으면 의미 없는 점수가 추가된다. 이런 조건은 filter에 두는 것이 의도도 분명하고 반복 필터 캐시도 활용할 수 있다.
Store와 Retriever에서 작성하는 방법
Store가 기본 제공하는 metadata filter만 필요하면 가장 짧게 작성할 수 있다.
filters = [
{"term": {"metadata.tenant": "alpha"}},
{"range": {"metadata.year": {"gte": 2025}}},
]
docs = store.similarity_search(
"elasticsearch",
k=5,
filter=filters,
)
Store에서 should, must_not, minimum_should_match까지 제어하려면 custom_query로 생성된 query를 교체한다.
def custom_query(body: dict, query: str | None) -> dict:
body["query"] = {
"bool": {
"must": [{"match": {"text": query or ""}}],
"should": [{"match": {"text": "bm25"}}],
"filter": [{"term": {"metadata.tenant": "alpha"}}],
"must_not": [{"term": {"metadata.status": "archived"}}],
"minimum_should_match": 1,
}
}
return body
docs = store.similarity_search(
"elasticsearch",
k=10,
custom_query=custom_query,
)
Retriever에서는 body_func가 완성된 검색 body를 반환한다.
def body_func(query: str) -> dict:
return {
"size": 10,
"query": {
"bool": {
"must": [{"match": {"text": query}}],
"should": [{"match": {"text": "bm25"}}],
"filter": [{"term": {"metadata.tenant": "alpha"}}],
"must_not": [
{"term": {"metadata.status": "archived"}}
],
"minimum_should_match": 1,
}
},
}
retriever = ElasticsearchRetriever(
index_name=index_name,
client=client,
body_func=body_func,
content_field="text",
)
documents = retriever.invoke("elasticsearch")
정리하면 간단한 metadata 제한은 Store의 filter, 복잡한 bool query는 Retriever의 body_func가 가장 읽기 쉽다. Store의 custom_query도 가능하지만 생성된 body의 규칙을 알고 수정해야 한다.
관련 문서: Query와 filter context, Boolean query, Minimum should match
BM25: 단일 필드는 Store, 멀티필드는 Retriever가 자연스럽다
단일 필드 BM25
ElasticsearchStore의 BM25Strategy는 지정한 query_field 한 곳에 match query를 만든다.
store = ElasticsearchStore(
index_name=index_name,
client=client,
query_field="text",
strategy=BM25Strategy(),
)
results = store.similarity_search_with_score(
"hybrid search Elasticsearch",
k=5,
filter=filters,
)
k1과 b도 지정할 수 있다.
BM25Strategy(k1=1.5, b=0.6)
단, 이 값은 새 인덱스를 만들 때 similarity settings에 들어간다. 이미 생성된 인덱스에 다른 BM25Strategy 객체를 연결해도 기존 필드의 similarity는 바뀌지 않는다. 테스트에서는 별도 인덱스를 생성해 custom_bm25.k1=1.5, b=0.6이 저장되는 것을 확인했다.
멀티필드 BM25
제목, 본문, 태그를 한 번에 검색하고 제목에 더 높은 가중치를 주려면 multi_match가 적합하다.
multi_match = {
"multi_match": {
"query": query,
"fields": ["metadata.title^3", "text", "metadata.tags^2"],
"type": "best_fields",
"tie_breaker": 0.2,
}
}
ElasticsearchStore의 query_field는 문자열 하나이고 BM25Strategy도 match 하나를 생성한다. 따라서 멀티필드는 1급 옵션으로 지원되지 않는다. 다만 custom_query에서 생성된 DSL을 바꾸는 방식으로는 가능하다.
def custom_query(body: dict, query: str | None) -> dict:
body["query"]["bool"]["must"] = [{
"multi_match": {
"query": query,
"fields": ["metadata.title^3", "text", "metadata.tags^2"],
}
}]
return body
results = store.similarity_search(
query,
filter=filters,
custom_query=custom_query,
)
가능은 하지만 DSL 구조를 알아야 하고 strategy가 생성하는 내부 body 구조에 의존한다. 멀티필드가 핵심 요구사항이라면 ElasticsearchRetriever가 더 명확하다.
retriever = ElasticsearchRetriever(
index_name=index_name,
client=client,
content_field="text",
body_func=lambda query: {
"size": 5,
"query": {
"bool": {
"must": [{
"multi_match": {
"query": query,
"fields": ["metadata.title^3", "text", "metadata.tags^2"],
}
}],
"filter": filters,
}
},
},
)
documents = retriever.invoke("hybrid search Elasticsearch")
여기서 content_field는 “검색할 필드 목록”이 아니라 Elasticsearch hit 중 어떤 필드를 LangChain Document.page_content로 옮길지를 뜻한다. 실제 검색 필드는 body_func의 DSL로 정한다.
실험에서는 공식 클라이언트와 ElasticsearchRetriever의 멀티필드 BM25 결과 순서가 완전히 같았다.
관련 문서: Multi-match query와 필드 boost
벡터 검색: kNN과 metadata 필터
공식 클라이언트는 kNN body를 직접 작성한다.
response = client.search(
index=index_name,
knn={
"field": "vector",
"query_vector": embedding.embed_query(query),
"k": 5,
"num_candidates": 50,
"filter": filters,
},
)
ElasticsearchStore에서는 DenseVectorStrategy가 같은 구조를 만든다.
store = ElasticsearchStore(
index_name=index_name,
client=client,
embedding=embedding,
vector_query_field="vector",
strategy=DenseVectorStrategy(),
)
docs = store.similarity_search(query, k=5, fetch_k=50, filter=filters)
이번 실험에서 공식 클라이언트의 kNN과 Store kNN의 결과 순서는 동일했고 tenant=beta 문서는 필터로 제외됐다.
RRF: API보다 먼저 라이선스를 확인해야 한다
RRF는 BM25와 벡터 점수처럼 서로 척도가 다른 랭킹을 원점수 정규화 없이 순위로 합치는 방법이다.
핵심: BM25는 키워드 일치, kNN은 의미 유사도, RRF는 두 결과의 원점수가 아니라 순위를 결합한다. 현재 Basic 클러스터의 서버 RRF는 403으로 거부됐다.
RRF score(d) = Σ 1 / (rank_constant + rank(d))
Elasticsearch 8.14에는 retriever API와 RRF retriever가 도입됐고, retriever API는 8.16에서 GA가 됐다. DSL은 다음 형태다.
body = {
"retriever": {
"rrf": {
"retrievers": [
{"standard": {"query": bm25_query}},
{"knn": knn_query},
],
"rank_constant": 60,
"rank_window_size": 50,
}
}
}
ElasticsearchStore 1.0.0에서도 다음처럼 표현할 수 있다.
strategy = DenseVectorStrategy(
hybrid=True,
rrf={"rank_constant": 60, "rank_window_size": 50},
text_field="text",
)
그러나 현재 인프라는 Basic 라이선스다. 공식 클라이언트와 LangChain 양쪽 모두 실제 호출에서 동일한 403을 반환했다.
current license is non-compliant for [Reciprocal Rank Fusion (RRF)]
RRF는 “코드를 작성할 수 있는가”와 “현재 서버가 실행해 주는가”를 나눠 봐야 한다.
| 확인 항목 | 판정 | 설명 |
|---|---|---|
| 공식 클라이언트로 RRF DSL 작성 | 가능 | retriever.rrf 요청 body 작성 가능 |
langchain-elasticsearch에서 RRF 전략 구성 | 가능 | DenseVectorStrategy(hybrid=True, rrf=...) 사용 |
| 현재 Basic 클러스터에서 서버 RRF 실행 | 불가능 | 실제 요청에서 403 라이선스 오류 |
| RRF 기능이 활성화된 클러스터에서 실행 | 가능 | 서버 버전과 구독 기능을 함께 확인해야 함 |
| 애플리케이션 측 RRF | 가능 | BM25와 kNN을 따로 조회한 뒤 Python에서 순위 결합 |
관련 문서: Elasticsearch RRF, Retriever API 개요, Elastic 구독 기능표
Basic 라이선스의 대안 1: rrf=False
DenseVectorStrategy(hybrid=True, rrf=False, text_field="text")
이 방식은 현재 Basic 클러스터에서 성공했다. 다만 RRF가 아니다. LangChain 1.0.0은 top-level query와 knn을 함께 보내고 Elasticsearch가 두 점수를 결합하게 한다. BM25와 벡터 점수의 스케일 차이에 결과가 민감할 수 있다.
Basic 라이선스의 대안 2: 애플리케이션 측 RRF
BM25와 kNN을 각각 실행한 뒤 문서 ID별 순위를 합친다.
for rank, hit in enumerate(hits, start=1):
scores[hit["_id"]] += 1.0 / (60 + rank)
이번 데이터에서는 BM25 1위가 rrf-guide, kNN 1위가 filter-guide였다. 두 문서는 동일 가중치 RRF에서 동점을 기록했다. RRF가 원점수 크기를 무시하고 순위만 사용한다는 성질을 잘 보여준다.
멀티필드와 하이브리드는 어디까지 조합할 수 있나
| 조합 | 판정 | 구현 방법 |
|---|---|---|
| 공식 클라이언트 멀티필드 BM25 | 가능 | multi_match |
ElasticsearchRetriever 멀티필드 BM25 | 가능 | body_func |
ElasticsearchStore 멀티필드 BM25 | 제한적 가능 | custom_query 필요 |
| 공식 클라이언트의 멀티필드 BM25 + kNN + RRF | DSL 가능 | RRF retriever의 standard 안에 multi_match |
| Store 멀티필드 BM25 + kNN + RRF | 제한적 가능 | hybrid body를 custom_query로 수정 |
| 현재 Basic 서버에서 위 RRF 실행 | 불가능 | 403 라이선스 오류 |
| 멀티필드 BM25 + kNN + 앱 RRF | 가능 | 두 검색 후 Python에서 fusion |
여러 필드가 제품 검색 품질의 핵심이라면 ElasticsearchRetriever 또는 공식 클라이언트를 추천한다. Store의 custom_query도 동작하지만, 추상화의 장점이 줄고 업그레이드 때 내부 query body 변화를 확인해야 한다.
langchain-elasticsearch 1.0.0 마이그레이션 주의사항
저장소의 기존 예제 일부에는 다음 패턴이 남아 있었다.
ElasticsearchRetriever.from_es_params(...)
1.0.0에서 이 메서드는 존재하지 않는다. 생성자를 직접 호출해야 한다.
retriever = ElasticsearchRetriever(
index_name=index_name,
body_func=body_func,
content_field="text",
client=client,
)
그리고 검색은 get_relevant_documents() 대신 Runnable 인터페이스인 invoke() 또는 ainvoke()를 사용한다.
또 하나의 제한은 hybrid mode에서 ElasticsearchStore.similarity_search_with_score()가 점수 반환을 지원하지 않는다는 점이다. 1.0.0 코드가 Elasticsearch를 호출하기 전에 ValueError를 낸다. 하이브리드 hit의 _score, _rank, 원본 응답이 필요하면 ElasticsearchRetriever에서 custom document mapper를 쓰거나 공식 클라이언트를 사용해야 한다.
결론: LangChain Elasticsearch 1.0.0에서 직접 지원하지 않는 것
langchain-elasticsearch는 Elasticsearch의 모든 기능을 감싼 관리 SDK가 아니다. Store와 Retriever는 LangChain 문서를 적재하고 검색 파이프라인에 연결하는 어댑터다. 다음 기능은 1.0.0에서 직접 지원하지 않거나, LangChain 밖의 준비가 반드시 필요하다.
| 기능 | Store 1.0.0 | Retriever 1.0.0 | 대안 |
|---|---|---|---|
전체 mapping·strict mapping·dynamic template·mapping _meta 관리 | 직접 지원하지 않음 | 지원하지 않음 | 공식 indices.create, put_mapping 사용 |
| index settings 변경·alias·reindex·운영 API | 지원하지 않음 | 지원하지 않음 | 공식 indices.*, reindex 사용 |
| 문서 일부 필드 update/upsert | 지원하지 않음 | 지원하지 않음 | 공식 client.update() 사용 |
| Synonyms API 세트 생성·수정·삭제 | 지원하지 않음 | 지원하지 않음 | 공식 client.synonyms.* 사용 |
| Nori 플러그인 설치·노드 파일 사전 배포 | 지원하지 않음 | 지원하지 않음 | Docker 이미지·노드 설정에서 처리 |
| 문서 적재 | 지원함 | 지원하지 않음 | Store 또는 공식 bulk 사용 |
| 멀티필드 BM25의 전용 고수준 옵션 | 없음: custom_query 필요 | body_func로 가능 | Retriever에 multi_match DSL 작성 |
| 복잡한 bool query의 전용 고수준 옵션 | 없음: custom_query 필요 | body_func로 가능 | Retriever에 bool DSL 작성 |
| aggregation·highlight·search template·원시 hit 전체 보존 | 제한적 | 변환 로직 필요 | 원형 응답이 중요하면 공식 클라이언트 사용 |
| hybrid 검색의 점수 반환 | similarity_search_with_score() 미지원 | mapper로 일부 처리 가능 | Retriever mapper 또는 공식 클라이언트 사용 |
| 현재 Basic 서버의 RRF 실행 | 요청 작성은 가능, 실행은 403 | 요청 작성은 가능, 실행은 403 | 애플리케이션 측 RRF 또는 라이선스 변경 |
여기서 “직접 지원하지 않음”이 항상 “구현 불가능”을 뜻하지는 않는다. 멀티필드와 bool query는 Retriever에 같은 Elasticsearch DSL을 전달하면 구현할 수 있다. 반대로 플러그인 설치, 스키마 운영, 유사어 세트 관리, alias와 reindex는 LangChain의 책임 범위가 아니므로 공식 클라이언트나 인프라 작업이 필요하다. 현재 Basic 라이선스의 서버 RRF는 DSL 전달 방법과 무관하게 서버가 거부하므로 애플리케이션 측 결합이 필요하다.
최종 선택 가이드
핵심: 하나만 고집할 필요는 없다. 단순 검색은 Store, 복잡한 DSL은 Retriever, 스키마·운영은 공식 클라이언트가 맡는다.
| 이런 상황이라면 | 우선 선택 | 이유 |
|---|---|---|
| 문서를 넣고 한 개의 본문 필드를 BM25로 검색한다 | ElasticsearchStore | 코드가 가장 짧고 Document 변환이 자동임 |
| 임베딩을 저장하고 kNN 검색을 한다 | ElasticsearchStore | DenseVectorStrategy로 구성하기 쉬움 |
metadata term·range 필터가 필요하다 | ElasticsearchStore | filter 인자로 바로 전달 가능 |
| 제목·본문·태그를 함께 검색하고 boost를 준다 | ElasticsearchRetriever | body_func에 multi_match를 명확하게 작성 가능 |
| 복잡한 bool query 또는 자체 검색 DSL이 있다 | ElasticsearchRetriever | DSL 제어와 LangChain Runnable 인터페이스를 함께 유지 |
| 기존 Elasticsearch 인덱스를 LangChain chain에 연결한다 | ElasticsearchRetriever | 인덱스를 만들지 않고 조회만 연결하기 좋음 |
| analyzer·mapping·유사어·alias를 관리한다 | 공식 클라이언트 | Store/Retriever는 운영 관리 API가 아님 |
aggregation·highlight·원시 _score가 중요하다 | 공식 클라이언트 | Elasticsearch 응답을 변환 없이 보존 |
| 적재와 복잡한 조회가 모두 필요하다 | Store + Retriever | Store로 적재하고 Retriever로 조회 역할 분리 |
| 운영 설정과 LangChain 검색을 함께 쓴다 | client + Store/Retriever | 하나의 client 연결을 세 도구가 공유 가능 |
실무에서는 셋 중 하나만 고집할 필요가 없다. 공식 클라이언트 하나를 연결 객체로 만들고, 단순 벡터 검색에는 ElasticsearchStore(client=client), 복잡한 검색에는 ElasticsearchRetriever(client=client), 운영·관리 API에는 client.indices.*를 쓰는 조합이 가장 현실적이다.
실무에서 가장 추천하는 역할 분담
| 역할 | 담당 도구 | 예시 |
|---|---|---|
| 인프라와 스키마 준비 | 공식 클라이언트·Docker image | analyzer, mapping, Nori, synonym set, alias |
| 문서 적재 | ElasticsearchStore | add_documents() |
| 일반 RAG 조회 | ElasticsearchStore | BM25, kNN, 간단한 metadata filter |
| 복잡한 검색 조회 | ElasticsearchRetriever | 멀티필드, boost, bool query, 사용자 정의 DSL |
| 운영·관찰 | 공식 클라이언트 | count, mapping/settings 확인, aggregation, raw hit 점검 |
직접 다시 실행하기
python experiments/elasticsearch_langchain_1_0/experiment.py
python -m pytest experiments/elasticsearch_langchain_1_0/test_experiment.py -q
실험 결과 스냅샷은 같은 실험 폴더의 RESULTS.md에 정리했다.
'관심있는 주제 > 탐구 노트' 카테고리의 다른 글
| 콜 녹음에서 비즈니스 인사이트까지: STT·NER·LLM 요약과 대규모 운영 설계 (0) | 2026.10.31 |
|---|---|
| Airflow 기반 STT 배치 파이프라인 설계: GPU 병렬 처리와 모델 서빙 (0) | 2026.10.28 |
| Codex 쓰다가 PC가 멈췄다: 범인은 2,172개의 Git 프로세스 (1) | 2026.10.20 |
| Elasticsearch Filter 완전 정복: 한국어 검색부터 벡터·하이브리드 검색 성능 최적화까지 (0) | 2026.10.19 |
| AI 에이전트에게 신분증과 격리실이 필요한 이유: MCP 보안 오픈소스 5종 지도 (0) | 2026.10.19 |
