최근에 저희는 카탈로그에 AI 기반의 의미 검색을 구현하는 작업을 주어졌고, 이미 Snowflake를 사용하고 있으므로 Cortex Search Service를 사용하여 이 기능을 구현하기로 결정했습니다.
Cortex Search가 무엇인지 아직 모르신다면, 이 링크를 빠르게 확인해 보세요 기본적으로 Cortex Search는 이미 Snowflake에 저장된 데이터 위에서 저지연 시간의 의미론적 검색과 전체 텍스트 검색을 직접 구축할 수 있게 해줍니다.
이 기사에서는 Cortex Search 서비스를 우리의 Python 백엔드 앱에 통합하는 경험을 공유하며, 우리가 마주한 함정들을 포함합니다.
검색 열에 집중하세요
TheSEARCH_TEXT 열, 또는 정확히는 Cortex Search “검색 열”은 Cortex Search이 검색 회수를 위해 인덱싱하고 사용하는 열입니다.
처음에 저가 했던 실수 중 하나는 가능한 모든 필드를 SEARCH_TEXT 열에 넣으려고 시도하는 것입니다.
초기 로직은 합리적으로 보였습니다: Cortex Search이 더 많은 필드를 볼수록 더 많은 맥락을 가집니다. 하지만 실제로는 검색 가능한 텍스트가 지나치게 잡음이 많아질 수 있습니다.
예를 들어, IDs, 상태 플래그, 임차인/회사 IDs, 통화 IDs, 그리고 내부 카테고리 IDs와 같은 필드들은 일반적으로 의미론적 검색에 유용하지 않습니다:
'id:' || COALESCE(id::STRING, ''),
'warehouse_id:' || COALESCE(warehouse_id::STRING, ''),
'project_id:' || COALESCE(project_id::STRING, ''),
'is_active:' || COALESCE(is_active::STRING, ''),
'status:' || COALESCE(status, ''),
'currency_id:' || COALESCE(currency_id::STRING, '')
이 필드들은 일반적으로 사용자가 검색하는 내용의 일부가 아닙니다. 이들은 노출되어야 합니다.ATTRIBUTES그리고 필터링에 사용하도록 합니다. 다음 섹션에서 설명하겠습니다
SEARCH_TEXT 필드는 항목의 실제 의미를 설명하는 필드에 집중해야 합니다:
CONCAT_WS(
' ',
title,
brand,
origin,
category_name,
subcategory_name,
short_description
) AS SEARCH_TEXT
우리는 최종 사용자 검색 쿼리에 유용한 필드를 포함해야 합니다.
필터링 가능한 필드를 ATTRIBUTES
Cortex Search Service 구성의 다른 하나의 작지만 중요한 세부 사항은ATTRIBUTES 속성.
ON 열은 검색 가능한 텍스트 열입니다. 이것이 Cortex Search가 사용자 쿼리를 일치시키는 데 사용하는 것입니다. 하지만 조직, 상태, 카테고리, 브랜드, 지역 또는 사용 가능성과 같은 메타데이터로 결과를 필터링해야 하는 경우, 해당 열은 서비스가 생성될 때 ATTRIBUTES에 추가해야 합니다.
속성은 주요 검색 텍스트가 아닙니다. 검색 결과와 함께 반환되는 열이며 필터링 또는 표시에 사용할 수 있습니다. Cortex Search 필터링이 작동하는 Snowflake 문서는 CREATE CORTEX SEARCH SERVICE 명령에서 지정한 ATTRIBUTES 열에 작동합니다.
CREATE OR REPLACE CORTEX SEARCH SERVICE DEMO_DB.SEARCH.PRODUCT_SEARCH_SERVICE ON SEARCH_TEXT
ATTRIBUTES (
CITY,
COUNTRY,
CURRENCY,
IS_ACTIVE
)
WAREHOUSE = WH_SEARCH_DEMO
TARGET_LAG = '1 hour'
AS
SELECT
ID,
CITY,
COUNTRY,
CURRENCY,
IS_ACTIVE
FROM DEMO_DB.SEARCH.PRODUCT_SEARCH_SOURCE;
기억해야 할 주요 점: 나중에 필터링하고 싶은 각 열은 속성으로 사용할 수 있어야 합니다. 또한, Snowflake는 ATTRIBUTES에 있는 열은 서비스를 만들 때 사용된 소스 쿼리에 포함되어야 한다고 언급합니다.
그런 다음 우리는 파이썬 코드에서 이 필터를 적용할 수 있습니다.
from typing import Any, Dict, List
COUNTRY_ATTRIBUTE = "COUNTRY"
CITY_ATTRIBUTE = "CITY"
IS_ACTIVE_ATTRIBUTE = "IS_ACTIVE"
filters: List[Dict[str, Any]] = [
{
"@or": [
{
"@eq": {
COUNTRY_ATTRIBUTE: "UK",
}
},
{
"@eq": {
CITY_ATTRIBUTE: "London",
}
},
],
},
{
"@eq": {
IS_ACTIVE_ATTRIBUTE: True,
}
},
]
response = search_service.search(
query=query,
columns=[
"ID",
"COUNTRY",
"CITY",
"IS_ACTIVE",
],
filter={
"@and": filters,
},
limit=20,
)
필터를 선택적이고 이해하기 쉽게 유지하려고 노력하세요. 좋은 필터는 Cortex Search가 결과를 반환하기 전에 검색 공간을 줄여야 합니다. 필터 페이로드가 너무 크거나 너무 중첩되면, 검색 소스 테이블을 조정해야 할 수도 있으며, 너무 많은 애플리케이션 로직을 검색 쿼리로 이동하는 것이 아닌 것입니다.
TARGET_LAG
에 대한 또 다른 중요한 주장에 주의하세요.CREATE OR REPLACE CORTEX SEARCH SERVICE 명령어는TARGET_LAG.
간단히 말해, TARGET_LAG는 Cortex Search 인덱스가 원본 테이블과 비교하여 얼마나 신선한지를 제어합니다.
예를 들어:
TARGET_LAG = '10 minutes'
이것은 새로운 행이 즉시 검색 가능해진다는 의미는 아닙니다. Cortex Search는 여전히 내부 인덱스를 새로 고침해야 합니다. 따라서 원본 테이블에서 행을 삽입하거나 업데이트하면, 그 변경 사항은 다음 새로 고침이 발생한 후에 검색 결과에 나타납니다.
이는 관리형 임베딩을 사용하는 경우 특히 중요합니다. Cortex Search는 사용자가 의미론적 검색을 통해 해당 기록을 찾을 수 있도록 업데이트된 소스 데이터를 처리하고, 임베딩을 생성하거나 업데이트하고, 검색 인덱스를 새로 고침해야 합니다.
따라서 TARGET_LAG이 너무 길면 검색 결과가 구식이 될 수 있습니다. 새로 발행된 항목이 소스 테이블에 이미 존재할 수 있지만, 사용자는 일정 시간 동안 검색에서 그것을 찾지 못할 수 있습니다.
그와 동시에, TARGET_LAG을 너무 낮게 설정하는 것은 항상 최선의 답이 아닙니다. 더 자주 업데이트할수록 배경에서 Snowflake 작업이 늘어날 수 있으며, 이는 크레딧 사용량을 증가시킬 수 있습니다. Snowflake는 또한 너무 낮은 목표 지연 시 인덱스를 필요 이상으로 자주 새로 고칠 수 있다고 언급합니다.
따라서 올바른 값은 검색 결과가 정말로 얼마나 신선하게 유지되어야 하는지에 따라 달라집니다.
CREATE OR REPLACE CORTEX SEARCH SERVICE DEMO_DB.SEARCH.PRODUCT_SEARCH_SERVICE
ON SEARCH_TEXT
ATTRIBUTES
(
ITEM_ID,
STATUS,
IS_ACTIVE,
ACCOUNT_ID
)
WAREHOUSE = WH_SEARCH_DEMO
TARGET_LAG = '30 minutes'
AS
SELECT
ITEM_ID,
SEARCH_TEXT,
STATUS,
IS_ACTIVE,
ACCOUNT_ID
FROM DEMO_DB.SEARCH.PRODUCT_SEARCH_SOURCE;
사용자에게 보이는 카탈로그 검색에서 새로 발행된 항목이 빨리 나타나야 하는 경우, 15-20 분 정도가 적합할 수 있습니다.
내부 지식 베이스, 문서 검색 또는 자주 변경되지 않는 데이터의 경우 1 시간 또는 그 이상이 완전히 괜찮을 수 있습니다.
적절한 임베딩 모델을 사용 사례에 맞게 선택하세요
Cortex Search는 벡터 검색 단계에서 임베딩 모델을 사용합니다. 간단히 말해, 이 모델은 사용자의 검색 열과 쿼리를 벡터로 변환하여 Cortex Search가 동의어적으로 유사한 기록을 찾을 수 있도록 합니다. Snowflake는 Cortex Search 서비스를 생성할 때 EMBEDDING_MODEL 매개변수를 사용하여 모델을 선택할 수 있습니다.
CREATE OR REPLACE CORTEX SEARCH SERVICE DEMO_DB.SEARCH.PRODUCT_SEARCH_SERVICE
ON SEARCH_TEXT
ATTRIBUTES
(
ITEM_ID,
STATUS,
IS_ACTIVE,
ACCOUNT_ID
)
WAREHOUSE = WH_SEARCH_DEMO
TARGET_LAG = '30 minutes'
EMBEDDING_MODEL = 'snowflake-arctic-embed-m-v1.5' # custom EMBEDDING_MODEL
AS
SELECT
ITEM_ID,
SEARCH_TEXT,
STATUS,
IS_ACTIVE,
ACCOUNT_ID
FROM DEMO_DB.SEARCH.PRODUCT_SEARCH_SOURCE;
Snowflake 목록snowflake-arctic-embed-m-v1.5을 기본 Cortex 검색 임베딩 모델로 지정합니다. 이 모델은 768개의 출력 차원, 512토큰의 컨텍스트 윈도우, 그리고 영어만 지원합니다. Snowflake는 이를 사용 가능한 Cortex 검색 모델 중 가장 빠른 인덱싱 옵션으로 설명합니다. 이로 인해 영어만 사용하는 카탈로그나 인덱싱 속도가 중요한 내부 검색에 적합한 시작점이 됩니다.
만약 콘텐츠가 다국어라면, 기본 영어만 지원하는 모델은 당신에게 적합하지 않을 수 있습니다. 그 경우, snowflake-arctic-embed-l-v2.0, snowflake-arctic-embed-l-v2.0-8k 또는 voyage-multilingual-2와 같은 다국어 모델을 확인하세요. 지원되는 모델, 차원, 컨텍스트 윈도우, 언어 지원의 전체 목록은 공식 Snowflake 임베딩 모델 표을 참조하세요.
Snowflake의CREATE CORTEX SEARCH SERVICE 문서에서는 EMBEDDING_MODEL을 서비스 정의의 일부로 표시하며, 이를 변경하는 것은 일반적으로 서비스를 재생성하는 것보다 런타임 매개변수를 조정하는 것보다 의미가 있습니다. 따라서 이를 조기에 테스트하는 것이 가치가 있으며, 특히 제품이 다국어 데이터를 가지거나 사용자가 다른 언어로 검색하는 경우에 더욱 그렇습니다.
Cortex 연결 웜업
우리가 코르티کس 검색을 통합한 주된 이유 중 하나는 아이템이 매우 많기 때문에 검색 쿼리 지연 시간을 줄이는 것이었습니다.
첫 번째 병목 현상은 연결 처리에서 비롯되었습니다. 저는 Snowflake 파이썬 라이브러리를 사용했습니다.는, 새로운 검색 요청마다 새로운 연결을 열어야 했습니다. 그 연결 설정 자체만 약 1–1.5초가 걸렸고, 실제 검색 쿼리는 오직 500–600ms가 걸렸습니다. 따라서 검색 자체를 최적화하기 전에, 요청 경로에서 연결 설정 비용을 제거하는 데 집중했습니다.
최적화는 Snowflake 연결 설정을 요청 경로에서 이동시키는 것이었습니다. 저는 warmup() 메서드를 추가하여 Cortex Search 서비스를 한 번 해결하고 Snowflake 연결, Root 객체, 서비스 참조를 워커 프로세스 수준에서 캐싱했습니다. 캐시는 락으로 보호되어 있어 여러 요청이 동시에 도착하더라도 초기화가 안전하게 유지됩니다.
def warmup(self) -> None:
self._get_service()
def _get_service(self):
cache_key = (self.database, self.schema, self.service_name)
service = self.__class__._service_cache.get(cache_key)
if service:
return service
with self.__class__._lock:
service = self.__class__._service_cache.get(cache_key)
if service:
return service
root = self._get_root()
service = (
root.databases[self.database]
.schemas[self.schema]
.cortex_search_services[self.service_name]
)
self.__class__._service_cache[cache_key] = service
return service
def _get_root(self):
root = self.__class__._root
connection = self.__class__._connection
if root and connection and not connection.is_closed():
return root
with self.__class__._lock:
root = self.__class__._root
connection = self.__class__._connection
if root and connection and not connection.is_closed():
return root
if connection and not connection.is_closed():
try:
connection.close()
except Exception:
pass
self.__class__._connection = None
self.__class__._root = None
self.__class__._service_cache = {}
try:
connection = snowflake.connector.connect(**self._connection_parameters)
except Exception as exc:
raise CortexSearchCatalogServiceError(
f"Failed to create Snowflake connection for Cortex search: {exc}"
)
self.__class__._connection = connection
root = Root(connection)
self.__class__._root = root
return root
그런 다음 Gunicorn의 post_fork hook에서 이 워밍업 메서드를 호출했습니다. Gunicorn 워커는 별도의 프로세스이므로, 각 워커는 포크한 후 자신만의 Snowflake 연결을 만들어야 합니다. 이 변경 사항으로 인해 연결은 워커 시작 중에 열리는 대신 첫 번째 검색 요청 중에 열리지 않게 되어, 사용자에게 노출되는 경로에서 1-1.5초의 연결 설정 비용이 제거되었습니다.
def post_fork(server, worker):
# Warm the Snowflake Cortex client in each worker process so the first user
# search does not pay the connection/session setup cost on the request path.
try:
if not _cortex_warmup_is_configured():
server.log.debug(
"Skipping Cortex warmup for worker pid=%s because Snowflake settings are incomplete.",
worker.pid,
)
return
from apps.db.cortex_search_services.cortex_search_catalog_service import CortexSearchCatalogService
CortexSearchCatalogService().warmup()
server.log.info("Cortex warmup completed for worker pid=%s", worker.pid)
except Exception:
server.log.exception("Cortex warmup failed for worker pid=%s", worker.pid)
코어트록스 검색 점수 가중치 조정
다음 중요한 최적화는 지연 시간과는 관련 없었으며, 결과 품질과 관련이 있었습니다.
Cortex Search는 혼합된 순위를 사용합니다. 의미론적/벡터 유사성, 키워드/텍스트 일치, 신경망 재순위화를 결합할 수 있습니다. Snowflake는 scoring_config.weights를 통해 이를 노출합니다. 여기서 벡터, 텍스트, 재순위화기는 각 점수 구성 요소의 상대적 기여도를 제어합니다. 기본적으로 이 구성 요소들은 동일한 가중치를 가지지만, 사용 사례에 따라 쿼리마다 조정할 수 있습니다.
예를 들어, 우리의 경우, 순수한 키워드 일치는 때때로 일치하는 단어를 포함하기만 해도 잘못된 항목을 더 높은 순위로 매칭할 수 있었습니다. 실제 예로는 '가죽 보호' 표지가 실제 장갑보다 높은 순위로 매칭된 경우가 있었습니다. 텍스트 일치는 강력했지만, 시맨틱적으로 사용자가 기대한 제품은 아니었습니다.
이를 해결하기 위해 저는 벡터 점수 가중치를 텍스트 점수에 비해 증가시켰습니다:
# Tune these weights to adjust Cortex ranking before results reach application code.
# vectors = semantic similarity, texts = keyword match, reranker = neural reranker.
# Raise vectors relative to texts to prevent keyword-heavy but semantically irrelevant
# items from outranking semantically correct ones.
DEFAULT_SCORING_CONFIG = {
"weights": {
"vectors": 2,
"texts": 1,
"reranker": 1,
}
}
이것은 중요한 매개변수입니다. 이는 Cortex Search가 애플리케이션 코드에 도달하기 전에 결과를 순서대로 변경하는 방식을 직접적으로 변경하기 때문입니다. 검색이 전통적인 전체 텍스트 검색에 더 가까우면, 텍스트의 중요도를 높이고 싶을 수 있습니다. 사용자가 의미, 동의어, 설명 또는 자연어로 검색하는 경우, 벡터의 중요도를 높이면 더 나은 결과를 얻을 수 있습니다.
테스트할 가치가 있는 다른 매개변수는 reranker입니다. Cortex Search는 기본적으로 의미론적 재정렬을 사용하여 관련성을 향상시킵니다. 하지만 재정렬은 쿼리 지연 시간을 증가시킬 수도 있습니다. Snowflake는 추가적인 품질 향상보다 낮은 지연 시간이 더 중요할 때 reranking을 비활성화할 수 있습니다.
Cortex Search에서 필요한 데이터만 반환하세요
Cortex Search를 생산 코드에 연결하기 전에 알아두어야 할 실용적인 한계는 페이로드 크기입니다.
Cortex Search 쿼리에 대한 Snowflake 문서 응답 크기 제한: REST API와 Python API 응답 페이로드는 10 MB를 초과해서는 안 됩니다.
이는 쿼리의 양쪽에 주의해야 한다는 의미입니다: Cortex Search에게 보내는 것과 Cortex Search에게 반환하도록 요청하는 것.
필터에 대해, 정말 필요한 것만 보내려고 시도하세요. 거대한 중첩은 생산 환경에서 조용한 버그를 해결하는 가장 짧은 방법입니다. 같은 필드에 대해 큰 OR 조건이 있다면, 가능하다면 더 컴팩트한 연산자를 선호하세요. 예를 들어, 긴 or 목록을 만드는 대신:
filter={
"@or": [
{"@eq": {"STATUS": "published"}},
{"@eq": {"STATUS": "scheduled"}},
{"@eq": {"STATUS": "archived"}},
]
}
사용하려고 시도하세요in 만약 당신의 경우에 맞다면:
filter={
"@in": {
"STATUS": ["published", "scheduled", "archived"]
}
}
동일하게 적용됩니다. 모든 검색 요청이 많은 필터 로직이 필요하다면, 검색 모델이 충분히 잘 형성되지 않았다는 신호일 수 있습니다. 때로는 너무 많은 애플리케이션 로직을 Cortex Search 요청으로 밀어 넣는 대신, 필터링하기 쉬운 필드를 갖춘 더 깔끔한 검색 소스 테이블이나 뷰를 준비하는 것이 가치 있을 수 있습니다.
응답에 대해, 저는 Cortex Search 결과 열을 작게 유지하는 것을 선호합니다. 대부분의 애플리케이션 검색 흐름에서 Cortex Search는 전체 객체 페이로드를 반환할 필요가 없습니다. 그냥 내부 ID만 반환하고 순위 지정이나 디버깅에 필요한 몇 가지 가벼운 필드만 반환할 수 있습니다.
response = search_service.search(
query=query,
columns=[
"ID",
],
filter=filter,
limit=50,
)
그런 다음 애플리케이션은 그 ID를 사용하여 주 애플리케이션 데이터베이스에서 전체 기록을 가져올 수 있습니다:
item_ids = [row["ID"] for row in response.results]
items = (
CatalogItem.objects
.filter(id__in=item_ids)
.select_related("brand", "category")
.prefetch_related("tags")
)
이는 Cortex Search가 최고의 역할을 수행하도록 집중하도록 합니다: 관련 기록을 찾는 것. 귀하의 애플리케이션 데이터베이스는 여전히 모든 도메인 객체를 로드할 책임이 있으며, 또한 쿼리 비용을 절약할 수 있습니다.
결론
Cortex Search는 Snowflake에 데이터가 이미 있고, 별도의 검색 파이프라인을 처음부터 만들지 않고 저지연 시간의 의미론적 검색과 전체 텍스트 검색이 필요할 때 강력한 옵션입니다.
하지만 중요한 부분은 완전히 “설정하고 잊어버릴 수 없다”는 것입니다. 품질과 성능은 서비스를 구성하는 방법과 백엔드 애플리케이션에서 이를 사용하는 방법에 크게 의존합니다.
제 경험에서 배운 가장 큰 교훈은 다음과 같습니다:
- 처음 사용자 요청 전에 Snowflake 연결을 준비해주세요;
- 검색 열을 사용자가 실제로 검색하는 필드에 집중해주세요;
- 데이터와 언어 요구 사항에 따라 임베딩 모델을 선택해주세요;
- 사용 사례가 의미론적 일치보다 키워드 기반 일치에 더 필요한지에 따라 점수 가중치를 조정해주세요;
- 필터링이 필요하다면 ATTRIBUTES에 필요한 필드를 추가해주세요;
- TARGET_LAG를 검색 결과가 얼마나 신선한지에 따라 구성하세요;
- Cortex Search에서 필요한 데이터만 반환하고, 주요 애플리케이션 데이터베이스에서 전체 객체를 로드하세요.
전반적으로 Cortex Search는 애플리케이션 수준의 검색에 매우 잘 작동할 수 있으며, 특히 카탈로그 검색, 문서 검색 및 Snowflake에 이미 저장된 다른 데이터에 유용합니다.












