Elasticsearch SQR 예제

JunMyung Lee·2024년 8월 2일
0

Elasticsearch

목록 보기
40/41

위키독스를 보면 chromadb를 사용하여 sqr을 사용하는 예제를 보여준다.
Elasticsearch를 이용한 예제로 변경해서 진행하려고 했더니 발생한 이슈와 해결책, 그리고 적용된 예제를 공유한다.

SQR?

SQRSelfQueryRetriever의 약자로 자체적으로 질문을 생성하고 해결할 수 있는 기능을 갖춘 검색 도구라고 한다.
사용자의 질의에서 필터를 추출하고, 필터를 실행해서 관련된 문서를 찾을 수 있다.

'5박 6일 일본 상품 찾아줘' 라는 검색어가 있다면,
검색어 : 일본
조건-날짜 : 5박 6일
조건-타입 : 상품

지원하는 SQR 리스트는 해당 페이지에서 확인이 가능하다
LangChain SQR List

시작하기에 앞서

LangChainElasticsearch의 KNN[^1] 검색을 시도한다. Elasticsearch버전이 7은 dense_vector검색시 ANN[^2] 검색을 시도한다.
즉, LangChain을 사용하기 위해서는 버전이 8이상인 Elasticsearch가 필요하다!

Docker를 통한 Elasticsearch 8.5.2버전을 설치하는 방법은 예전에 작성한 https://velog.io/@mertyn88/Docker-ES-Kibana페이지를 보면 된다.


예제 - SelfQueryRetriever

예제는 python3.10이며 jupyter로 진행한다.
셀 단위로 코드를 실행하고 보여준다

from langchain.vectorstores import ElasticsearchStore
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from elasticsearch import Elasticsearch
from langchain.chains.query_constructor.base import AttributeInfo
from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain_openai import ChatOpenAI
import os
########################################################
# Setting
########################################################

# OpenAI API 키 설정
openai_api_key = 'sk-'
os.environ["OPENAI_API_KEY"] = openai_api_key
# Elasticsearch url 설정
es_url = 'http://localhost:9200'
# Elasticsearch index 설정
index_name = "movies-test"
# Elasticsearch Mappings 설정
# 인덱스 매핑 설정
index_mappings = {
    "mappings": {
        "properties": {
            "page_content": {"type": "text"},
            "metadata": {"type": "object"},
            "vector": {
                "type": "dense_vector",
                "dims": 512,
                "index": True,
                "similarity": "l2_norm"
            } 
        }
    }
}
  • similarity: kNN 검색에 사용할 벡터 유사성 측정 항목.
    • l2_norm: 벡터 간의 L2 거리 (유클리드 거리)
    • dot_product: 두 벡터의 내적을 계산.
      * 추천 시스템에서 사용자가 얼마나 강력하게 선호하는지를 반영하고자 할 때 유용
    • cosine: 두 벡터 간의 코사인 각도를 계산.
      * 문서 유사도 비교, 텍스트 마이닝 등에서 주로 사용

doc_product와 cosine 둘다 벡터 계산을 하지만 차이가 있다고 한다. 수식같은건 잘 모르겠고 다음과 같은 차이가 있다고 한다.

  • Dot Product는 벡터의 크기와 방향 모두를 고려하여 유사도를 계산하며, 벡터의 크기가 클수록 값이 커진다
  • Cosine Similarity는 벡터의 크기를 무시하고 방향만을 고려하여 유사도를 계산한다. 크기가 다른 벡터라도 동일한 방향을 가리키면 유사도가 1에 가깝다
# 문서 데이터 설정
docs = [
    Document(
        page_content="A bunch of scientists bring back dinosaurs and mayhem breaks loose",
        metadata={"year": 1993, "rating": 7.7, "genre": "science fiction"},
    ),
    Document(
        page_content="Leo DiCaprio gets lost in a dream within a dream within a dream within a ...",
        metadata={"year": 2010, "director": "Christopher Nolan", "rating": 8.2},
    ),
    Document(
        page_content="A psychologist / detective gets lost in a series of dreams within dreams within dreams and Inception reused the idea",
        metadata={"year": 2006, "director": "Satoshi Kon", "rating": 8.6},
    ),
    Document(
        page_content="A bunch of normal-sized women are supremely wholesome and some men pine after them",
        metadata={"year": 2019, "director": "Greta Gerwig", "rating": 8.3},
    ),
    Document(
        page_content="Toys come alive and have a blast doing so",
        metadata={"year": 1995, "genre": "animated"},
    ),
    Document(
        page_content="Three men walk into the Zone, three men walk out of the Zone",
        metadata={
            "year": 1979,
            "director": "Andrei Tarkovsky",
            "genre": "thriller",
            "rating": 9.9},
    ),
]

metadata_field_info = [
    AttributeInfo(
        name="genre",
        description="The genre of the movie. One of ['science fiction', 'comedy', 'drama', 'thriller', 'romance', 'action', 'animated']",
        type="string",
    ),
    AttributeInfo(
        name="year",
        description="The year the movie was released",
        type="integer",
    ),
    AttributeInfo(
        name="director",
        description="The name of the movie director",
        type="string",
    ),
    AttributeInfo(
        name="rating", description="A 1-10 rating for the movie", type="float"
    ),
########################################################
# Client variable
########################################################

# Elasticsearch
es_client = Elasticsearch(hosts=es_url)
# 인덱스 생성
if es_client.indices.exists(index=index_name):
    es_client.indices.delete(index=index_name)
    es_client.indices.create(index=index_name, body=index_mappings)
    

# Openai
embedding_client = OpenAIEmbeddings(api_key=openai_api_key, model="text-embedding-3-large", dimensions=512)
# Vectorstore
vectorstore_client = ElasticsearchStore.from_documents(
    documents=docs,
    embedding=embedding_client,
    index_name=index_name,
    query_field='page_content',
    vector_query_field='vector',
    es_url=es_url,
    strategy=ElasticsearchStore.ApproxRetrievalStrategy()
)

# SelfQueryRetriever 생성
retriever = SelfQueryRetriever.from_llm(
    llm=ChatOpenAI(model="gpt-4-turbo-preview", temperature=0),
    vectorstore=vectorstore_client,
    document_contents="Brief summary of a movie",
    metadata_field_info=metadata_field_info,
)
########################################################
# Similar search test & Embedding test
########################################################

query = "What did the president say about Ketanji Brown Jackson"
results = vectorstore_client.similarity_search(query)

for result in results:
    print(result)

"""
page_content='A psychologist / detective gets lost in a series of dreams within dreams within dreams and Inception reused the idea' metadata={'year': 2006, 'director': 'Satoshi Kon', 'rating': 8.6}
page_content='A bunch of scientists bring back dinosaurs and mayhem breaks loose' metadata={'year': 1993, 'rating': 7.7, 'genre': 'science fiction'}
page_content='Toys come alive and have a blast doing so' metadata={'year': 1995, 'genre': 'animated'}
page_content='Three men walk into the Zone, three men walk out of the Zone' metadata={'year': 1979, 'director': 'Andrei Tarkovsky', 'genre': 'thriller', 'rating': 9.9}
"""

print(embedding_client.embed_query(query)[0])
# 0.0575622133910656
# 8.5 이상의 평점을 받은 영화를 보고 싶다는 필터만 지정합니다.
retriever.invoke("I want to watch a movie rated higher than 8.5")

"""
[Document(metadata={'year': 1979, 'director': 'Andrei Tarkovsky', 'genre': 'thriller', 'rating': 9.9}, page_content='Three men walk into the Zone, three men walk out of the Zone')]
"""
# Greta Gerwig가 여성에 관한 영화를 연출한 적이 있는지 질의합니다.
retriever.invoke("Has Greta Gerwig directed any movies about women")

"""
[Document(metadata={'year': 2019, 'director': 'Greta Gerwig', 'rating': 8.3}, page_content='A bunch of normal-sized women are supremely wholesome and some men pine after them')]
"""
retriever.invoke(
    # 1990년 이후 2005년 이전에 제작된 장난감에 관한 영화를 검색하며, 애니메이션 영화를 선호한다는 쿼리와 복합 필터를 지정합니다.
    "What's a movie after 1990 but before 2005 that's all about toys, and preferably is animated"
)
"""
[Document(metadata={'year': 1995, 'genre': 'animated'}, page_content='Toys come alive and have a blast doing so')]
"""

예제 - Query Constructor

from langchain.chains.query_constructor.base import (
    StructuredQueryOutputParser,
    get_query_constructor_prompt,
)
# 문서 내용 설명과 메타데이터 필드 정보를 사용하여 쿼리 생성기 프롬프트를 가져옵니다.
prompt = get_query_constructor_prompt(
    document_contents="Brief summary of a movie",
    attribute_info=metadata_field_info,
)

# 구성 요소에서 구조화된 쿼리 출력 파서를 생성합니다.
output_parser = StructuredQueryOutputParser.from_components()

# 프롬프트, 언어 모델, 출력 파서를 연결하여 쿼리 생성기를 만듭니다.
query_constructor = prompt | ChatOpenAI(model="gpt-4-turbo-preview", temperature=0) | output_parser
query_constructor
print(prompt.format(query="{query}"))

"""
Your goal is to structure the user's query to match the request schema provided below.

<< Structured Request Schema >>
When responding use a markdown code snippet with a JSON object formatted in the following schema:

\`\`\`json
{
    "query": string \ text string to compare to document contents
    "filter": string \ logical condition statement for filtering documents
}
\`\`\`

The query string should contain only text that is expected to match the contents of documents. Any conditions in the filter should not be mentioned in the query as well.

A logical condition statement is composed of one or more comparison and logical operation statements.

A comparison statement takes the form: \`comp(attr, val)\`:
- \`comp\` (eq | ne | gt | gte | lt | lte | contain | like | in | nin): comparator
- \`attr\` (string):  name of attribute to apply the comparison to
- \`val\` (string): is the comparison value

A logical operation statement takes the form \`op(statement1, statement2, ...)\`:
- \`op\` (and | or | not): logical operator
- \`statement1\`, \`statement2\`, ... (comparison statements or logical operation statements): one or more statements to apply the operation to

Make sure that you only use the comparators and logical operators listed above and no others.
Make sure that filters only refer to attributes that exist in the data source.
Make sure that filters only use the attributed names with its function names if there are functions applied on them.
Make sure that filters only use format \`YYYY-MM-DD\` when handling date data typed values.
Make sure that filters take into account the descriptions of attributes and only make comparisons that are feasible given the type of data being stored.
Make sure that filters are only used as needed. If there are no filters that should be applied return "NO_FILTER" for the filter value.

<< Example 1. >>
Data Source:
\`\`\`json
{
    "content": "Lyrics of a song",
    "attributes": {
        "artist": {
            "type": "string",
            "description": "Name of the song artist"
        },
        "length": {
            "type": "integer",
            "description": "Length of the song in seconds"
        },
        "genre": {
            "type": "string",
            "description": "The song genre, one of "pop", "rock" or "rap""
        }
    }
}
\`\`\`

User Query:
What are songs by Taylor Swift or Katy Perry about teenage romance under 3 minutes long in the dance pop genre

Structured Request:
\`\`\`json
{
    "query": "teenager love",
    "filter": "and(or(eq(\"artist\", \"Taylor Swift\"), eq(\"artist\", \"Katy Perry\")), lt(\"length\", 180), eq(\"genre\", \"pop\"))"
}
\`\`\`


<< Example 2. >>
Data Source:
\`\`\`json
{
    "content": "Lyrics of a song",
    "attributes": {
        "artist": {
            "type": "string",
            "description": "Name of the song artist"
        },
        "length": {
            "type": "integer",
            "description": "Length of the song in seconds"
        },
        "genre": {
            "type": "string",
            "description": "The song genre, one of "pop", "rock" or "rap""
        }
    }
}
\`\`\`

User Query:
What are songs that were not published on Spotify

Structured Request:
\`\`\`json
{
    "query": "",
    "filter": "NO_FILTER"
}
\`\`\`


<< Example 3. >>
Data Source:
\`\`\`json
{
    "content": "Brief summary of a movie",
    "attributes": {
    "genre": {
        "description": "The genre of the movie. One of ['science fiction', 'comedy', 'drama', 'thriller', 'romance', 'action', 'animated']",
        "type": "string"
    },
    "year": {
        "description": "The year the movie was released",
        "type": "integer"
    },
    "director": {
        "description": "The name of the movie director",
        "type": "string"
    },
    "rating": {
        "description": "A 1-10 rating for the movie",
        "type": "float"
    }
}
}
\`\`\`

User Query:
{query}

Structured Request:
"""
# 쿼리 생성기를 호출하여 주어진 질문에 대한 쿼리를 생성
query_constructor.invoke(
    {
	    "query": "What are some sci-fi movies from the 90's directed by Luc Besson about taxi drivers"
    }
)
query_constructor.invoke(
    # 1990년 이후 2005년 이전에 제작된 장난감에 관한 영화를 검색하며, 애니메이션 영화가 선호됩니다.
    "What's a movie after 1990 but before 2005 that's all about toys, and preferably is animated"
)
"""
StructuredQuery(query='toys', filter=Operation(operator=<Operator.AND: 'and'>, arguments=[Comparison(comparator=<Comparator.GT: 'gt'>, attribute='year', value=1990), Comparison(comparator=<Comparator.LT: 'lt'>, attribute='year', value=2005), Comparison(comparator=<Comparator.EQ: 'eq'>, attribute='genre', value='animated')]), limit=None)
"""

이슈

이슈 케이스

예제 문서를 색인하려고 하는데 bulk request fail이 발생

원인

Elasticsearch의 knn검색시 벡터 차원이 1024를 초과할 수 없다.
OpenAI의 text embedding은 기본 차원이 1536이여서 발생.

ANN과 다르게 KNN은 속도가 느리기 때문에 제한을 두는것 같다.

Elasticsearch 8.10 미만 버전

https://www.elastic.co/guide/en/elasticsearch/reference/8.9/dense-vector.html

dims 설명
(Required, integer) Number of vector dimensions. Can’t exceed 1024 for indexed vectors ("index": true), or 2048 for non-indexed vectors.

벡터 차원의 수. 인덱스가 있는 벡터의 경우 1024("index": true)를 초과할 수 없고 인덱스가 없는 벡터의 경우 2048을 초과할 수 없습니다.

Elasticsearch 8.10 이상 버전

https://www.elastic.co/guide/en/elasticsearch/reference/8.10/dense-vector.html

dims 설명
(Required, integer) Number of vector dimensions. Can’t exceed 2048.
벡터 차원의 수. 2048을 초과할 수 없습니다.

[preview] This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.The number of dimensions for indexed vectors can be extended up to 2048.

이 기능은 기술 미리 보기 상태이며 향후 릴리스에서 변경되거나 제거될 수 있습니다. Elastic은 모든 문제를 해결하기 위해 노력할 것이지만 기술 미리 보기의 기능에는 공식 GA 기능의 지원 SLA가 적용되지 않습니다. 인덱스된 벡터의 차원 수는 최대 2048개까지 확장될 수 있습니다.

해결

Elasticsearch 버전을 8.10이상으로 하던지, 차원의 개수를 1024이하로 줄여야 한다. 여기서는 차원의 개수를 1536 → 512로 변경해서 진행한다.



### requirements.txt
```text
aiohappyeyeballs==2.3.4
aiohttp==3.10.0
aiosignal==1.3.1
annotated-types==0.7.0
anyio==4.4.0
appnope==0.1.4
argon2-cffi==23.1.0
argon2-cffi-bindings==21.2.0
arrow==1.3.0
asttokens==2.4.1
async-lru==2.0.4
async-timeout==4.0.3
attrs==23.2.0
Babel==2.15.0
beautifulsoup4==4.12.3
bleach==6.1.0
certifi==2024.7.4
cffi==1.16.0
charset-normalizer==3.3.2
comm==0.2.2
dataclasses-json==0.6.7
debugpy==1.8.2
decorator==5.1.1
defusedxml==0.7.1
distro==1.9.0
elastic-transport==8.13.1
elasticsearch==8.5.2
exceptiongroup==1.2.2
executing==2.0.1
fastjsonschema==2.20.0
fqdn==1.5.1
frozenlist==1.4.1
greenlet==3.0.3
h11==0.14.0
httpcore==1.0.5
httpx==0.27.0
idna==3.7
ipykernel==6.29.5
ipython==8.26.0
ipywidgets==8.1.3
isoduration==20.11.0
jedi==0.19.1
Jinja2==3.1.4
json5==0.9.25
jsonpatch==1.33
jsonpointer==3.0.0
jsonschema==4.23.0
jsonschema-specifications==2023.12.1
jupyter==1.0.0
jupyter-console==6.6.3
jupyter-events==0.10.0
jupyter-lsp==2.2.5
jupyter_client==8.6.2
jupyter_core==5.7.2
jupyter_server==2.14.2
jupyter_server_terminals==0.5.3
jupyterlab==4.2.4
jupyterlab_pygments==0.3.0
jupyterlab_server==2.27.3
jupyterlab_widgets==3.0.11
langchain==0.2.11
langchain-community==0.2.10
langchain-core==0.2.26
langchain-openai==0.1.20
langchain-text-splitters==0.2.2
langsmith==0.1.95
lark==1.1.9
MarkupSafe==2.1.5
marshmallow==3.21.3
matplotlib-inline==0.1.7
mistune==3.0.2
multidict==6.0.5
mypy-extensions==1.0.0
nbclient==0.10.0
nbconvert==7.16.4
nbformat==5.10.4
nest-asyncio==1.6.0
notebook==7.2.1
notebook_shim==0.2.4
numpy==1.26.4
openai==1.37.1
orjson==3.10.6
overrides==7.7.0
packaging==24.1
pandocfilters==1.5.1
parso==0.8.4
pexpect==4.9.0
platformdirs==4.2.2
prometheus_client==0.20.0
prompt_toolkit==3.0.47
psutil==6.0.0
ptyprocess==0.7.0
pure_eval==0.2.3
pycparser==2.22
pydantic==2.8.2
pydantic_core==2.20.1
Pygments==2.18.0
python-dateutil==2.9.0.post0
python-json-logger==2.0.7
PyYAML==6.0.1
pyzmq==26.0.3
qtconsole==5.5.2
QtPy==2.4.1
referencing==0.35.1
regex==2024.7.24
requests==2.32.3
rfc3339-validator==0.1.4
rfc3986-validator==0.1.1
rpds-py==0.19.1
Send2Trash==1.8.3
six==1.16.0
sniffio==1.3.1
soupsieve==2.5
SQLAlchemy==2.0.31
stack-data==0.6.3
tenacity==8.5.0
terminado==0.18.1
tiktoken==0.7.0
tinycss2==1.3.0
tomli==2.0.1
tornado==6.4.1
tqdm==4.66.4
traitlets==5.14.3
types-python-dateutil==2.9.0.20240316
typing-inspect==0.9.0
typing_extensions==4.12.2
uri-template==1.3.0
urllib3==1.26.19
wcwidth==0.2.13
webcolors==24.6.0
webencodings==0.5.1
websocket-client==1.8.0
widgetsnbextension==4.0.11
yarl==1.9.4

[^1]: KNN(k-Nearest Neighbors): 특정 벡터와 가장 가까운 k개의 벡터를 찾는 방법. Elasticsearch의 k-NN 검색은 벡터 간의 유사도를 측정하여 가장 가까운 이웃을 찾는 방식

[^2]: ANN(Approximate Nearest Neighbors): 근사적 이웃을 찾는 방법으로, 대규모 데이터셋에서도 빠른 검색이 가능. 대규모 데이터셋에서 효율적으로 유사성을 검색

profile
11년차 검색개발자 입니다. 여러 지식과 함께 실제 서비스를 운영 하면서 발생한 이슈에 대해서 정리하고 공유하고자 합니다.

0개의 댓글