상품 검색에 자연어 검색을 적용하기

Posted on June 12, 2024
상품 검색에 자연어 검색을 적용하기

들어가기

대부분의 서비스는 데이터 저장과 함께 검색이 필요합니다. 데이터의 저장 방식과 검색 시스템 설계는 서비스의 형태에 따라 다르지만, 비슷한 과정을 거칩니다. 원본 데이터를 수집하고 가공/정제하여 데이터의 형태를 결정한 후, 검색을 위해 색인을 구성합니다. 자연어 혹은 동의어 등에 강인한 검색을 만들기 위해서는 벡터 검색의 도움이 필요합니다. 일반적으로 이러한 시스템을 구축하는 데는 DBMS, Vector DB 등 하나 이상의 서비스가 필요하며, 서비스 간 동기화 및 개별적인 운영으로 인해 복잡도가 증가하기 쉽습니다.

위 과정을 Aeca 데이터베이스 하나로 Full-Text Search와 Vector Search를 통합하여 웹 서비스로 제공하는 방법을 알아보겠습니다. 살펴 볼 검색 데모는 상품(고양이 사료) 검색 기능을 구현한 것이며, 수집된 데이터는 정형 데이터와 비정형 데이터가 혼합되어 있습니다. 이런 환경에서 사용자가 “단백질이 많이 들어있는 사료 중에 콩이 없는 사료는?"이라는 질문을 했을 때, 성분 정보가 저장된 필드에서 적절한 범위 값을 포함하여 검색하는 내용을 포함합니다.

고양이 사료 데이터로 만든 Aeca 검색 데모에서 제품, 성분량, 성분명, 가격 등을 대상으로 검색할 수 있습니다.

자연어 검색을 위한 Aeca 데이터베이스와 검색 특징 요약

Aeca는 데이터베이스 자체에 다양한 데이터 모델(Document, Vector 등)과 검색 기능을 갖추고 있어 별도의 제품 없이 Aeca 제품 하나로 자연어 검색 개발을 구현할 수 있습니다.

  • Nested field 지원을 통해 추가적인 데이터베이스 정규화 과정 없이 저장과 색인 지정이 가능합니다.
  • 하나 이상의 임베딩 모델을 하나의 컬렉션에서 사용 가능하며 임베딩 모델을 실시간으로 가중치 조절이 가능합니다.
  • ML Model Serving 기능을 내재하고 있어 별도의 모델 서빙을 준비할 필요가 없습니다.
  • Full-Text Search 또는 Vector Search로 하나의 쿼리를 통해 Aeca에서 통합된 점수로 랭킹하여 결과 값을 반환할 수 있습니다.
  • Vector Search는 양자화를 지원하고 양자화 학습을 위한 최소한의 샘플을 충족하기 전에도 중단없이 서비스 할 수 있습니다.

Aeca 제품으로 자연어 검색 개발 구현

크게 총 3단계로 (1) 데이터 수집 및 전처리 → (2) 색인 → (3) 데모 검색 개발 순으로 구분하였습니다.

데이터 전처리 단계인 1번 단계를 거쳐 2 ~ 3번 단계에서 Aeca 제품으로 데이터 저장부터 검색까지 실제 구현 과정을 확인할 수 있습니다.

1. 데이터 수집 및 전처리

데모를 구성하기 위해 상품 데이터(고양이 사료)를 직접 수집했습니다. 일반적으로 이러한 데이터 수급은 크롤링을 하거나 직접 수집하거나 등 여러 과정을 통해 입수하게 됩니다.

데이터 구성은 회사, 브랜드, 제품명, 성분, 성분량, 가격 등의 정보를 포함하고 있습니다. 해당 데이터는 여기에서 직접 확인하실 수 있습니다.

raw data

1.1. 데이터 추출

성분량은 수집 과정에서 가공되지 않고 다음과 같은 형태로 수집되었습니다.

조단백질 37.00% 이상 조지방 14.00% 이상 칼슘 1.00% 이상 0.80% 이상 수분 12.00% 이하 조회분 9.00% 이하 조섬유 6.00% 이하 칼로리 3,750 kcal/kg

이 형태에서는 단백질 30% 이상인 사료를 검색할 수 없기 때문에 LLM을 통해 다음과 같이 데이터를 추출했습니다. 아래 코드와 같이 공개된 한국어에 맞게 튜닝되지 않은 Llama3-8B를 사용했습니다. 성능 개선을 위해 더 큰 모델을 선택할 수도 있습니다. 추출 과정에서 큰 오류는 확인되지 않아 진행상 편의를 위해 별도로 검증하진 않았습니다.

from llama_cpp import Llama llm = Llama( model_path="./data/models/Meta-Llama-3-8B-Instruct.Q5_K_M.gguf", n_gpu_layers=-1, seed=1337, n_ctx=4096, chat_format="llama-3", verbose=True ) _INGREDIENT_COMPOSITION_PROMPT = """Convert the following data to JSON format. Ensures that numbers and units are converted separately. Doesn't generate any code, just returns the converted result. Outputs the JSON block directly without any commentary. example: { "조단백질": {"value": 31.00, "unit": "%", "condition": "이상"}, "칼로리": {"value": 2680, "unit": "kcal/kg"} }""" def extract_ingredient_composition(llm, text): response = llm.create_chat_completion( messages=[ {"role": "system", "content": _INGREDIENT_COMPOSITION_PROMPT}, {"role": "user", "content": text}, ], ) result = response["choices"][0]["message"]["content"] return result df["ingredient_composition"] = df["등록성분량"].apply(lambda x: extract_ingredient_composition(llm, x)) df.to_csv("data/food.csv", index=False)

이 과정을 통해 추출된 데이터는 다음과 같은 JSON 형태를 가지게 됩니다. 이런 형태의 데이터는 색인 과정에서 설명하겠지만, Aeca에서는 nested field를 지원하기 때문에 이런 형식으로 저장된 데이터를 별다른 정규화 과정을 거치지 않고 저장과 색인 지정이 가능합니다.

{ "조단백질": { "value": 33, "unit": "%", "condition": "이상" }, "조지방": { "value": 20, "unit": "%", "condition": "이상" }, "조회분": { "value": 9.5, "unit": "%", "condition": "이하" }, "조섬유": { "value": 2, "unit": "%", "condition": "이하" }, "인": { "value": 1.3, "unit": "%", "condition": "이상" }, "칼슘": { "value": 1.6, "unit": "%", "condition": "이상" }, "수분": { "value": 7, "unit": "%", "condition": "이하" }, "칼로리": { "value": 3700, "unit": "kcal/kg" } }

성분도 유사하게 처리합니다.

∙원재료 국문: 가수분해 참치, 해바라기씨박, 현미, 어분, 아마씨, 사탕무박(무 섬유소), 어유, 베타글루칸, 비타민제합제, 미네랄제합제, 유카추출물, DL-메치오닌, 녹두, 천일염, 어골칼슘, 타우린, 프락토올리고당, 염화칼륨, 가수분해 초록입홍합복합물, 알긴산나트륨, 씨벅턴열매, 달맞이꽃종자, 고수, 당근, 시금치, 글루코사민, SPM OMEGA-3, 유익균합제, 아스코르빈산 ∙원재료 영문: 데이터 없음

추출된 예시는 다음과 같습니다.

{ "ko": [ "연어", "건조 연어", "고구마", "완두", "이집트콩", "사과", "미량광물질-[아미노산킬레이트(아연, 철분, 망간, 구리), 요오드산칼슘]", "셀룰로오스", "타피오카", "알팔파", "크랜베리", "배", ... "마리골드", "아니스", "호로파", "계피", "비타민합제[Vitamin A/D3/ E]" ], "en": [ "Salmon", "Dried Salmon", "Sweet Potato", "Peas", "Chickpeas", "Apple", ... "Copper", "Iodine" ] }

1.2. 데이터 가공

일반적으로 데이터 전처리에 포함되는 내용은 다음과 같습니다. 이제 이와 유사한 작업들을 진행합니다.

  • 데이터 타입 정규화
    • 데이터 필드가 가능한 동일한 타입을 가지도록 수정
    • None, NaN, NaT, [] 등 타입에 맞는 Null값을 가지는지 확인
  • Categorical 타입에 대한 정규화
    • 오메가-3, 오메가-3 지방산처럼 동일한 의미의 값이 다른 값을 가지지 않도록 처리
  • 데이터 변환, 추출과정의 오류 수정

성분량 중에서 지배적인 비중으로 출현하는 필드를 찾습니다. 이는 성분량의 하위 필드 값인 성분량.조지방.value의 형태로 검색할 예정인데, 모든 필드를 색인하는 것이 비효율적이기 때문에 주요한 특성만 검색 가능하도록 제한했습니다. 101개의 데이터 중 조지방조섬유는 항상 포함하고 있고, 오메가-3까지 정도면 대부분의 성분을 포함할 수 있게 됩니다.

  • 데이터 전처리에는 오메가-3, 오메가-3 Fatty Acids 등 유사하나 다른 이름을 가진 데이터는 편의상 정규화하지 않음
{ '조지방': 101, '조섬유': 101, '수분': 100, '칼슘': 100, '인': 100, '조단백질': 97, '조회분': 97, '칼로리': 81, '타우린': 20, '마그네슘': 16, 'DHA': 16, 'EPA': 11, '오메가-6': 11, '오메가-3': 10, 'Chondroitin Sulphate': 5, '비타민E': 5, '콘드로이틴 황산': 4, '오메가 3 지방산': 4, '조단백': 4, 'EHA': 3, '오메가 6지방산': 3, '오메가-6 지방산': 3, '오메가-3 지방산': 3, '클루코사민': 2, '글루코사민': 2, 'DHA/EPA': 2, '비타민 E': 2, '오메가-3 Fatty Acids': 2, '오메가-6 Fatty Acids': 2, ... }

따라서 선택된 필드는 다음과 같습니다.

_INGREDIENT_FIELDS = [ "조지방", "조섬유", "수분", "칼슘", "인", "조단백질", "조회분", "칼로리", "타우린", "마그네슘", "DHA", "EPA", "오메가-6", "오메가-3", ]

편의상 다음과 같이 한글 칼럼을 영문 필드명으로 변경합니다.

_NAME_MAP = { "회사": "company", "브랜드": "brand", "제품명": "product_name", "가격정보": "price_info", "가격": "price", "가격 기준": "price_basis", "1kg당 가격": "price_per_1kg", "가격정보_text": "price_info_text", "기타정보": "other_info", "기타정보_text": "other_info_text", "등록성분량": "ingredient_amounts", "등록성분량_text": "ingredient_amounts_text", "성분": "ingredients", "성분_text": "ingredients_text", "첨가제_text": "additives_text", }

이러한 정의를 모아 다음과 같이 정제 후 데이터를 저장합니다.

  • 첨가물의 데이터 없음을 None값으로 변환
  • 성분량의 통계량 계산
    • LLM을 이용한 쿼리 변환시 평균/많이/적게 들어 있는 등 추상적인 표현을 위함
import json from json import JSONDecodeError import numpy as np import pandas as pd # 중략 _JSON_FIELDS = [ "price_info", "other_info", "ingredients", "ingredient_amounts", ] def _parse_json(text): if not isinstance(text, str): return {} try: data = json.loads(text) return data except JSONDecodeError: return {} def main(): df = pd.read_csv("data/food.csv") df = df.rename(columns=_NAME_MAP) df[_JSON_FIELDS] = df[_JSON_FIELDS].map(_parse_json) df.price_info = df.price_info.apply( lambda x: {_NAME_MAP.get(k, k): v for k, v in x.items()} ) df["ingredient_amounts"] = df["ingredient_amounts"].apply( lambda x: { ingredient: {k: v for k, v in values.items() if v} for ingredient, values in x.items() }, ) df["additives_text"] = df.additives_text.apply( lambda x: None if x == "데이터 없음" else x ) df_ingredient = df.ingredient_amounts.apply(pd.Series).map( lambda x: x.get("value") if isinstance(x, dict) else np.nan ) df["company_alias"] = df.company.apply(lambda x: _COMPANY_ALIAS.get(x)) df_ingredient[_INGREDIENT_FIELDS].describe().loc[ ["mean", "25%", "75%"] ].to_csv("data/food_ingredient.csv", float_format="%.1f") df_price_info = ( df.price_info.apply(pd.Series) .price.apply(lambda x: pd.to_numeric(x.get("value"))) .describe() ) print(df_price_info) df.to_pickle("data/food.pk", compression="gzip")

이 과정을 거친 가공된 데이터는 다음과 같은 형식을 가집니다.

  • JSON으로 추출된 ingredient_amountsingredient_amounts_text의 같이 원본 text 데이터 필드도 유지함
    • 추출된 숫자 타입의 데이터는 범위 검색을 위해서 사용
    • _text 데이터는 키워드, 자연어 검색에 활용

최종적으로 저장된 데이터의 형식은 다음과 같습니다.

proprocessed data

1.3. 데이터 인코딩

이제 텍스트 데이터를 벡터 데이터로 변환합니다. JSON으로 추출하기 전 원본 텍스트 데이터는 _text 형태의 접미사를 가지도록 지정했습니다. 이제 _text_embed로 변환합니다. 변환에는 sentence_transformers를 사용하고 모델은 intfloat/multilingual-e5-large를 사용했습니다. 선정된 모델은 모델의 차원을 알고 있어야 합니다. 이는 이후 색인 과정에서 변수로 활용되기 때문입니다.

대부분의 다국어 모델은 한국어 성능이 좋지 않기 때문에 적절한 모델을 선정하는 것이 좋습니다. 예를 들어 sentence_transformers에서 추천하는 다국어 모델은 한국어 성능이 낮거나 구어체 문장에 취약한 경우가 많습니다. 임베딩은 대부분 대량의 문서를 대상으로 한 번에 인코딩하는 경우가 많기 때문에 임베딩 API를 활용하는 것이 편리하지만 비용이 증가할 수 있습니다. 따라서 현재 가진 자원(GPU 등)과 예산을 고려하여 사용 여부를 결정하는 것이 좋습니다.

만약 하나 이상의 임베딩 모델 결과를 저장하여 선택 후 검색에 반영하거나, 둘의 결과를 가중치로 조절하여 취합하고 싶다면 별도의 필드명(_embed_e5, _embed_minilm 등)으로 저장 후 색인이 가능합니다. 이렇게 저장된 색인은 쿼리를 통해 선택 혹은 통합하여 검색할 수 있습니다.

변환에 사용된 코드는 다음과 같이 간단히 인코딩 후 저장하는 내용입니다.

from pathlib import Path import pandas as pd from sentence_transformers import SentenceTransformer from tqdm import tqdm _MODEL_PATH = "intfloat/multilingual-e5-large" # 1024, 560M _CHUNK_SIZE = 32 _BATCH_SIZE = 16 _POOL_SIZE = 2 def _chunkify(df: pd.DataFrame, chunk_size: int): return [df.iloc[i : i + chunk_size] for i in range(0, len(df), chunk_size)] def _encode( model: SentenceTransformer, pool: dict[str, any], df: pd.DataFrame, output_file: Path, ): dfs = [] target_columns = [ ("product_name", "product_name_embed"), ] + [(x, x.replace("_text", "_embed")) for x in df.columns if "_text" in x] for name, embed_name in target_columns: column = df[name] column = column[column.notnull()] if column.empty: item = pd.Series([], name=embed_name) dfs.append(item) else: embed = model.encode_multi_process( column.tolist(), pool, batch_size=min(len(column), _BATCH_SIZE) ) item = pd.Series(list(embed), index=column.index, name=embed_name) dfs.append(item) df = pd.concat([df, *dfs], axis=1) df.to_pickle(output_file, compression="gzip") def main(): model = SentenceTransformer(_MODEL_PATH, device="mps") print(f"Dims: {model.get_sentence_embedding_dimension()}") model_name = _MODEL_PATH.rsplit("/", maxsplit=1)[-1] output_path = Path(f"data/food/{model_name}") if output_path.exists(): print("Output path already exists: ", output_path) return print(f"Output path: {output_path}") output_path.mkdir(exist_ok=True, parents=True) pool = model.start_multi_process_pool( [f"mps:{id}" for id in range(_POOL_SIZE)] ) df = pd.read_pickle("data/food.pk", compression="gzip") chunks = _chunkify(df, _CHUNK_SIZE) for i, chunk in enumerate(tqdm(chunks)): output_file = output_path / f"food_{i:03d}.pk" _encode(model, pool, chunk, output_file)

여기에서 추가적으로 다음을 고려할 수 있습니다.

  • 임베딩할 대상 필드의 선정
    • 인코딩과 저장에 비용이 발생하기 때문에 길이가 길고 의미상 일치여부가 중요한 필드를 선택
  • 인코딩의 단위
    • 문장, chunk
  • 분할 기준
    • 문장, 고정 길이, 의미 단위, 중첩 여부, 계층 포함 여부

2. 색인

이제 데이터를 Aeca에 입력하고 색인을 생성합니다. 색인 구성은 다음과 같이 company, brand, product_name를 primary key로 정의되어 있습니다.

indexes

전체적으로 데이터 입력과 색인 생성되는 부분으로 나누어져 있습니다. Aeca는 데이터 삽입 이후에 색인 생성을 권장하며, 이 경우 더 빠르게 데이터 입력과 색인 생성할 수 있습니다.

from pathlib import Path from pprint import pprint import numpy as np import pandas as pd from aeca import Channel, DocumentDB from tqdm import tqdm _COLLECTION_NAME = "demo.cat_food" _DATA_PATH = "data/food/multilingual-e5-large" _AECA_HOST = "localhost" _AECA_PORT = 10080 def main(): collection_name = _COLLECTION_NAME channel = Channel(_AECA_HOST, _AECA_PORT) docdb = DocumentDB(channel) data_path = Path(_DATA_PATH) _insert(docdb, collection_name, data_path) _create_index(docdb, collection_name)

데이터 입력 코드에서는 primary key를 정의하고 위에서 전처리된 데이터를 로딩하여 약간의 타입 변환 이후 dict로 변환 이후 DocumentDB에 입력합니다.

def _convert_dtype(item: np.ndarray | float): result = item if isinstance(item, np.ndarray): result = item.tolist() if isinstance(item, float): if np.isnan(item): result = None return result def _insert(docdb: DocumentDB, collection_name: str, data_path: Path): if collection_name in docdb.list_collections(): docdb.drop_collection(collection_name) indexes = [ { "fields": ["company", "brand", "product_name"], "unique": True, "index_type": "kPrimaryKey", }, ] docdb.create_collection(collection=collection_name, indexes=indexes) files = sorted(data_path.glob("*.pk")) for file in tqdm(files): df = pd.read_pickle(file, compression="gzip") columns = [x for x in df.columns if "_embed" in x] df[columns] = df[columns].map(_convert_dtype) docdb.insert(collection_name, df.to_dict(orient="records"))

이후 색인을 생성합니다.

_HNSW_OPTIONS = { "index_type": "HNSW", "dims": 1024, "m": 64, "ef_construction": 100, "ef_search": 32, "metric": "inner_product", "normalize": True, "shards": 1, } def _create_index(docdb: DocumentDB, collection_name: str): default_analyzer = { "analyzer": { "type": "standard_cjk", "options": {"ngram_filter": {"min_size": 1, "max_size": 4}}, }, "index_options": "offsets", } int_analyzer = {"analyzer": {"type": "int64"}, "index_options": "doc_freqs"} float_analyzer = { "analyzer": {"type": "float64"}, "index_options": "doc_freqs", } dense_vector_analyzer = { "analyzer": { "type": "DenseVectorAnalyzer", "options": _HNSW_OPTIONS, }, "index_options": "doc_freqs", } ingredient_fields = ( pd.read_csv("data/food_ingredient.csv").columns[1:].to_list() ) ingredient_options = { f"ingredient_amounts.{x}.value": float_analyzer for x in ingredient_fields } common_options = { "company": default_analyzer, "company_alias": default_analyzer, "brand": default_analyzer, "product_name": default_analyzer, "product_name_embed": dense_vector_analyzer, "price_info.price.value": int_analyzer, "price_info.price_per_1kg.value": float_analyzer, "additives_text": default_analyzer, "additives_embed": dense_vector_analyzer, "ingredients.ko": default_analyzer, "ingredients_embed": dense_vector_analyzer, "ingredient_amounts_text": default_analyzer, "ingredient_amounts_embed": dense_vector_analyzer, "other_info_text": default_analyzer, "other_info_embed": dense_vector_analyzer, } index = { "index_name": "sk_fts", "index_type": "kFullTextSearchIndex", "unique": False, "options": {**common_options, **ingredient_options}, } index["fields"] = list(index["options"].keys()) pprint(index) docdb.create_index(collection_name, **index)

일반적인 텍스트 필드(*_text)는 standard_cjk analyzer를 선택하여 n-gram으로 토큰을 분리하여 저장합니다. 과 같은 한 글자의 성분을 다루기 위해 ngram_filter.min_size는 1로 지정되어 있습니다. 한국어의 경우 MecabTokenizer도 좋은 선택이 될 수 있습니다. n-gram은 일관된 단위로 토큰을 나누기 때문에 구어체, 신조어 등에 유리하지만, 중복 토큰이 많이 생길 수 있어 이는 저장 용량 증가에 영향을 줄 수 있습니다. 반대로 MecabTokenizer는 형태소 단위로 분할하기 때문에 의미 단위로 분할될 가능성이 높고 토큰의 수가 줄어듭니다. 그러나 형태소 분석기의 오류가 포함될 가능성이 있습니다.

다음으로 임베딩 벡터가 저장된 필드(*_embed)는 dense analyzer가 선택되었고 HNSW를 선택했습니다. 여기서 차원(dims)은 임베딩 모델이 출력한 차원 수와 일치시킵니다. 추가적으로 Aeca는 양자화를 지원하고 있으며 약간의 recall 저하를 감수할 수 있다면 속도와 색인 용량 감소를 위한 좋은 옵션이 될 수 있습니다. 양자화를 위해서는 학습에 필요한 최소 샘플을 충족해야 하는데, 만약 최소 샘플을 만족하지 못한 경우 양자화되지 않은 HNSW 색인을 사용하고 이후 샘플 수가 충족된 경우 전환되도록 설계되어 서비스 중단 없이 색인을 사용할 수 있습니다.

nested field 색인을 살펴보면 price_info 필드에 저장된 price.value 값은 price_info.price.valuefloat64 analyzer로 색인이 지정된 것을 확인할 수 있습니다.

price info

이렇게 생성된 FTS 색인의 옵션값은 다음과 같습니다.

{ "company": { "analyzer": { "type": "standard_cjk", "options": { "ngram_filter": { "min_size": 1, "max_size": 4 } } }, "index_options": "offsets" }, "company_alias": { "analyzer": { "type": "standard_cjk", "options": { "ngram_filter": { "min_size": 1, "max_size": 4 } } }, "index_options": "offsets" }, "brand": { "analyzer": { "type": "standard_cjk", "options": { "ngram_filter": { "min_size": 1, "max_size": 4 } } }, "index_options": "offsets" }, "product_name": { "analyzer": { "type": "standard_cjk", "options": { "ngram_filter": { "min_size": 1, "max_size": 4 } } }, "index_options": "offsets" }, "product_name_embed": { "analyzer": { "type": "DenseVectorAnalyzer", "options": { "index_type": "HNSW", "dims": 1024, "m": 64, "ef_construction": 100, "ef_search": 32, "metric": "inner_product", "normalize": true, "shards": 1 } }, "index_options": "doc_freqs" }, "price_info.price.value": { "analyzer": { "type": "int64" }, "index_options": "doc_freqs" }, "price_info.price_per_1kg.value": { "analyzer": { "type": "float64" }, "index_options": "doc_freqs" }, "additives_text": { "analyzer": { "type": "standard_cjk", "options": { "ngram_filter": { "min_size": 1, "max_size": 4 } } }, "index_options": "offsets" }, "additives_embed": { "analyzer": { "type": "DenseVectorAnalyzer", "options": { "index_type": "HNSW", "dims": 1024, "m": 64, "ef_construction": 100, "ef_search": 32, "metric": "inner_product", "normalize": true, "shards": 1 } }, "index_options": "doc_freqs" }, "ingredients.ko": { "analyzer": { "type": "standard_cjk", "options": { "ngram_filter": { "min_size": 1, "max_size": 4 } } }, "index_options": "offsets" }, "ingredients_embed": { "analyzer": { "type": "DenseVectorAnalyzer", "options": { "index_type": "HNSW", "dims": 1024, "m": 64, "ef_construction": 100, "ef_search": 32, "metric": "inner_product", "normalize": true, "shards": 1 } }, "index_options": "doc_freqs" }, "ingredient_amounts_text": { "analyzer": { "type": "standard_cjk", "options": { "ngram_filter": { "min_size": 1, "max_size": 4 } } }, "index_options": "offsets" }, "ingredient_amounts_embed": { "analyzer": { "type": "DenseVectorAnalyzer", "options": { "index_type": "HNSW", "dims": 1024, "m": 64, "ef_construction": 100, "ef_search": 32, "metric": "inner_product", "normalize": true, "shards": 1 } }, "index_options": "doc_freqs" }, // 중략 "ingredient_amounts.조지방.value": { "analyzer": { "type": "float64" }, "index_options": "doc_freqs" }, "ingredient_amounts.조섬유.value": { "analyzer": { "type": "float64" }, "index_options": "doc_freqs" }, "ingredient_amounts.수분.value": { "analyzer": { "type": "float64" }, "index_options": "doc_freqs" }, "ingredient_amounts.칼슘.value": { "analyzer": { "type": "float64" }, "index_options": "doc_freqs" }, "ingredient_amounts.인.value": { "analyzer": { "type": "float64" }, "index_options": "doc_freqs" }, "ingredient_amounts.조단백질.value": { "analyzer": { "type": "float64" }, "index_options": "doc_freqs" }, "ingredient_amounts.조회분.value": { "analyzer": { "type": "float64" }, "index_options": "doc_freqs" }, "ingredient_amounts.칼로리.value": { "analyzer": { "type": "float64" }, "index_options": "doc_freqs" }, // 중략 "ingredient_amounts.오메가-3.value": { "analyzer": { "type": "float64" }, "index_options": "doc_freqs" } }

이제 데이터가 저장 되었고 검색을 통해 데이터를 조회할 수 있는 준비가 되었습니다.

catfood collection

필요에 따라 $group를 이용하여 다음과 같이 통계량 계산에 활용할 수 있습니다. 다음 예시는 브랜드별 평균 가격을 계산한 결과입니다.

$group result

3. 데모 개발

데모는 Next.js를 통해 구현되었습니다. Next.js는 Node.js를 이용한 backend와 React로 구성된 front가 유기적으로 결합된 형태입니다. 데모는 Node.js 상에서 Aeca Javascript SDK를 통해 Aeca와 통신하도록 구현되었습니다. 이로써 Aeca와 웹서버 둘로 구성된 최소한의 서비스로 검색, RAG를 비롯한 대부분의 서비스를 구현할 수 있다는 뜻이 됩니다.

demo archtecture

backend를 위해 구현된 코드는 하나의 API로 구성되어 있으며 내용은 다음과 같습니다.

import { StatusCode, statusSuccess } from "@_api/_lib/status" import { convertToSearchQuery } from "@_api/catfood/_lib/chat" import { SearchResult, } from "@_app/api/catfood/_lib/document" import config from "@_app/config" import { parseJSONStringsInObject } from "@_lib/transform" import { Channel, DocumentDB, SentenceTransformer } from "@aeca/client" import { NextResponse } from "next/server" import { NextRequest } from "next/server" const _COLLECTION_GOODS = "demo.cat_food" const _RESULT_FILTER_PATTERN = /^.+_embed/ const _FOOD_EMBED_COLUMNS = [ "product_name_embed", "ingredients_embed", "ingredient_amounts_embed", "additives_embed", "other_info_embed", ] export async function GET( request: NextRequest, ): Promise<NextResponse<SearchResult>> { const channel = new Channel(config.host, config.port) const docdb = new DocumentDB(channel) const model = new SentenceTransformer(channel, config.model) const searchParams = request.nextUrl.searchParams let query = searchParams.get("query") if (!query) { return NextResponse.json( { code: StatusCode.INVALID_ARGUMENT, message: "query is empty" }, { status: 500 }, ) } let convertedQuery: string | null = query let convertElapsedTime if (config.queryConversion) { if (query && query.startsWith(">")) { query = query.substring(1).trim() convertedQuery = query } else { const convertStartTime = performance.now() convertedQuery = await convertToSearchQuery(query) convertElapsedTime = performance.now() - convertStartTime } } const queryEmbed = await model.encode([query]) let productQueryString if (queryEmbed.length > 0) { const embedQueryString = queryEmbed[0].data.join(",") const embedFieldsQuery = _FOOD_EMBED_COLUMNS .map((x) => `${x}:[${embedQueryString}]`) .join(" OR ") productQueryString = `(${convertedQuery}) AND (${embedFieldsQuery})^0.5` } else { productQueryString = convertedQuery } const stopwords = ["사료"] const productQuery = { $search: { query: productQueryString, custom_stop_words: stopwords, highlight: true, limit: 12, }, } const findStartTime = performance.now() const productTable = await docdb.find(_COLLECTION_GOODS, productQuery) const findElapsedTime = performance.now() - findStartTime const productDataRaw = productTable?.data.toArray() const productData = parseJSONStringsInObject( productDataRaw, _RESULT_FILTER_PATTERN, ) return NextResponse.json({ ...statusSuccess, product: productData, info: { query: query, convertedQuery: convertedQuery, convertElapsedTime: convertElapsedTime, findElapsedTime: findElapsedTime, }, }) }

주요 코드를 하나씩 살펴보면

  • convertedQuery = await convertToSearchQuery(query)
    • 자연어인 사용자 쿼리(query)를 LLM을 통해 Aeca 검색 쿼리로 변환
  • const queryEmbed = await model.encode([query])
  • productQueryString = (${convertedQuery}) AND (${embedFieldsQuery})^0.5
    • 변환된 쿼리와 임베딩 쿼리를 취합한 Aeca 검색 쿼리를 작성

쿼리 변환(convertToSearchQuery) 과정을 살펴보면 GPT-4o 모델에 다음과 같은 프롬프트를 전달하고 그 응답을 받습니다. 전처리 과정에서 계산된 성분량의 통계량(food_ingredient.csv)을 프롬프트에 포함하여 "많은", "적은", "평균" 같은 추상적인 표현에 대응하도록 했습니다.

입력된 자연어 쿼리를 Lucene의 검색어로 바꿉니다. 검색 대상 필드는 다음과 같습니다: company brand price_info.price.value price_info.price_per_1kg.value ingredient_amounts.조지방.value ingredient_amounts.조섬유.value ingredient_amounts.수분.value ingredient_amounts.칼슘.value ingredient_amounts.인.value ingredient_amounts.조단백질.value ingredient_amounts.조회분.value ingredient_amounts.칼로리.value ingredient_amounts.타우린.value ingredient_amounts.마그네슘.value ingredient_amounts.DHA.value ingredient_amounts.EPA.value ingredient_amounts.오메가-6.value ingredient_amounts.오메가-3.value 이 중 `price_info`(금액: 원)와 `ingredient_amounts`(백분율 값: %)은 숫자 타입으로, 사용자는 이 필드를 기준으로 범위를 검색할 수 있습니다. 필드를 검색할 때는 키워드 목록과 AND 연산자로 묶어야 합니다. 다음 내용은 각 필드에 대한 통계량입니다. 예를 들어 사용자가 "많이 들어 있는"과 같은 추상적인 표현을 할 때 75% percentile을 사용할 수 있습니다. 그러나 "타우린이 들어 있는" 같이 표현을 위해 `ingredient_amounts.타우린.value:[* TO *]`와 같이 전체 범위로 변환하지 않습니다. 이 경우 `타우린`이란 키워드로 변환합니다. ,조지방,조섬유,수분,칼슘,인,조단백질,조회분,칼로리,타우린,마그네슘,DHA,EPA,오메가-6,오메가-3 mean,15.5,5.1,11.9,1.1,0.8,34.1,10.6,3563.8,0.7,0.1,1.1,0.4,2.6,1.1 25%,12.0,4.0,8.0,0.9,0.7,31.0,8.0,3460.0,0.1,0.1,0.3,0.2,2.2,0.8 75%,19.0,6.0,12.0,1.2,0.9,36.5,9.5,4050.0,0.2,0.1,0.8,0.5,3.1,1.0 다음은 `price_info.price.value`에 대한 통계량입니다. mean,40512 25%,28900 75%,47000 다음은 brand에 저장된 데이터입니다. 로얄캐닌, 이즈칸, 네추럴코어, 웰츠, N&D, 아카나, 지위픽, 샘스필드, 내추럴발란스, 뉴트로, 네츄럴랩, 헤일로, 오리젠, ANF, 마이펫닥터, 아투, 네츄럴랩, 정관장 지니펫 다음은 company에 저장된 데이터입니다. Royal Canin,Champion Petfoods,네추럴코어,Farmina Pet Foods,굿데이,ZIWI Limited,VAFO Production.sro,Natural Balance,Mars,Halo Pets,AATU Pet Food,KGC라이브앤진 주의사항: - company, brand, price_info, ingredient_amounts 외에 필드 검색을 하지 않고 키워드로 나열 - 변환된 내용에 대한 짧은 답변하며 추가 내용은 출력되지 않음 예시: 로얄캐닌 추천사료는? 로얄캐닌 추천사료 돌보는 길냥이 사료로 로얄캐닌 어떤거 주면 좋을까요? 길냥이 사료 로얄캐닌 콩이 들어 있지 않은 사료는? 사료 -콩 단백질 함량이 높으면서 대두가 빠진 사료는? 단백질 -대두 사료 조단백질이 30%이상인 사료는? (ingredient_amounts.조단백질.value:[30 TO *]) AND (사료)

이 프롬프트를 통해 변환되는 예시는 다음과 같습니다.

입력변환
콩이 없는 ANF 사료(brand:ANF) AND (-콩)
칼로리가 낮은 사료중에 가격이 저렴한 사료는?(ingredient_amounts.칼로리.value:[* TO 3460]) AND (price_info.price.value:[* TO 28900]) AND (사료)
단백질이 많이 들어 있는 사료중에 콩이 없는 사료(ingredient_amounts.조단백질.value:[36.5 TO *]) AND (사료 -콩)

종합하여 위의 코드를 통해 생성되는 쿼리는 다음과 같은 형식을 가지게 됩니다. 아래의 쿼리는 "감자가 안들어간 파미나 사료"에 대한 예시이며 검색 결과는 링크를 통해 확인할 수 있습니다.

{ "$search": { "query": "(Farmina 사료 -감자) AND (product_name_embed:[0.0271,-0.0008, ...] OR ingredients_embed:[0.0271,-0.0008, ...] OR ingredient_amounts_embed:[0.0271,-0.0008, ...] OR additives_embed:[0.0271,-0.0008, ...] OR other_info_embed:[0.0271,-0.0008, ...", "highlight": true, "limit": 12 } }

$search에 입력되는 쿼리를 살펴보면 크게 두 부분으로 나뉩니다.

  • Farmina 사료 -감자
    • 키워드 기반의 FTS
    • 필드명 지정이 없기 때문에 sk_fts에 벡터 서치 필드를 제외한 정의된 모든 필드가 대상이 됨
  • product_name_embed:[0.0271,-0.0008, ...] OR ingredients_embed:[0.0271,-0.0008, ...] ...
    • 벡터 서치
    • 자연어에서 임베딩된 벡터간 유사도를 기반으로 검색

이로써 자연어 입력을 정형 데이터에서 적절한 필드를 고려한 쿼리로 변환하여 숫자, 범위값을 찾고 텍스트 데이터는 FTS 혹은 벡터 서치로 하나의 쿼리를 통해 Aeca에서 통합된 점수로 랭킹하여 문서를 가져올 수 있습니다.

Aeca 만나보기

Aeca를 더 사용해 보고 싶다면 Docker로 간단히 설치하여 바로 사용할 수 있습니다. Aeca 도입에 대한 더 자세한 설명이나 제품 소개서 요청은 고객 지원에서 문의해 주세요.

함께 보면 좋을 글

자연어로 판례 데이터 검색하기

FTS를 사용한 판례 검색 데모에 벡터 검색을 적용하여 자연어 검색 서비스를 구축하는 방법을 설명합니다.

By Aeca Team|2024-07-04

판례 데이터를 빠르게 검색 가능한 상태로 만들기

판례 데이터를 다운로드 받고 Aeca를 통해 판례 검색 서비스를 하루만에 구축한 과정을 설명합니다.

By Aeca Team|2024-06-21

Copyright © 2024 Aeca, Inc.

Made with ☕️ and 😽 in San Francisco, CA.