엔지니어링 노트메모리와 검색

조사 붙은 검색어는 전문 검색에 안 걸린다, 낱말을 OR로 잇는다

테이블 메모에 「반품 주문은 ORD_RTN 테이블에 따로 쌓인다」라고 적혀 있고, 사용자가 「지난달 반품 주문 건수를 알려 줘」라고 묻습니다. 사람이 보기에는 바로 이어지는 두 문장인데, PostgreSQL 전문 검색으로 찾으면 0건입니다. 형태소 분석을 하지 않는 simple 설정은 「주문은」과 「주문」을 서로 다른 토큰으로 보고, 질문을 AND로 묶으면 「지난달」, 「건수를」, 「알려」, 「줘」까지 모두 든 메모만 찾기 때문입니다. 같은 문제가 테이블 카드, 문서, 지난 대화를 찾는 키워드 검색에도 똑같이 걸립니다.

OwlAI는 한국어 형태소 분석기를 넣지 않고 검색어 쪽에서 풉니다. 검색어를 낱말로 쪼개 기호를 걷어 내고 OR로 잇습니다. 찾아야 할 행을 놓치지 않게 넓게 잡고, 순서는 순위 함수와 의미 검색이 바로잡습니다. 이 글은 검색어를 어떻게 만드는지, 그래도 남는 틈을 무엇이 메우는지, 임베딩 서버가 멈추면 무엇이 약해지는지를 다룹니다.

simple 설정에서는 어절 하나가 토큰 하나다

PostgreSQL 전문 검색은 텍스트를 토큰으로 쪼갠 뒤 언어 설정의 사전으로 다듬어 색인합니다. 영어 설정이면 cats를 cat으로 줄이는 어간 추출이 붙습니다. OwlAI의 키워드 검색 네 가지는 모두 simple 설정을 쓰는데, 이 설정은 대소문자만 접고 낱말 모양은 그대로 둡니다. 이 설정을 고른 이유는 「테이블을 잘못 고른 SQL도 실행된다, 검색이 후보부터 좁힌다」에서 다룹니다.

한국어에서는 이 차이가 그대로 드러납니다. PostgreSQL 16에서 메모 두 개를 simple 설정으로 색인하면 토큰은 다음과 같습니다.

메모 원문 토큰
반품 주문은 ORD_RTN 테이블에 따로 쌓인다 반품 주문은 ord rtn 테이블에 따로 쌓인다
주문 금액은 부가세 포함, 취소 주문도 들어 있다 주문 금액은 부가세 포함 취소 주문도 들어 있다

조사와 어미가 붙은 어절이 통째로 토큰 하나가 됩니다. 「주문은」, 「주문도」, 「주문」이 모두 다른 토큰이라, 검색어에 「주문」을 넣으면 둘째 메모에만 걸립니다.

영문 식별자는 사정이 다릅니다. 기본 파서가 밑줄에서 끊어 ORD_RTN을 ord와 rtn으로 나누고, 검색어 쪽의 ORD_RTN도 「ord 바로 뒤에 rtn」이라는 구절 조건으로 바뀌어 그대로 맞습니다. 질문에 나온 테이블 이름이나 코드 값을 글자 그대로 찾는 일은 simple 설정에서도 됩니다. 깨지는 쪽은 한국어 낱말입니다.

자연어 질문을 AND로 묶으면 0건이 된다

검색어를 전문 검색 질의로 바꾸는 가장 쉬운 함수는 plainto_tsquery입니다. 낱말을 모두 AND로 묶어 줍니다. 「지난달 반품 주문 건수를 알려 줘」는 지난달 & 반품 & 주문 & 건수를 & 알려 & 줘가 되고, 여섯 토큰이 모두 든 행만 걸립니다. 위의 두 메모는 둘 다 빠집니다. 첫째 메모에는 「주문」 대신 「주문은」이 있고, 두 메모 모두 「지난달」, 「건수를」, 「알려」, 「줘」가 없습니다.

질문에는 찾는 대상과 상관없는 말이 늘 섞입니다. 「알려 줘」, 「정리해 줘」 같은 요청 표현, 「지난달」 같은 기간, 「건수를」처럼 조사가 붙은 집계 낱말입니다. 메모나 문서 청크에 이 말이 다 들어 있을 리 없으니, AND로 묶으면 질문이 길어질수록 0건에 가까워집니다. 키워드 검색이 0건이면 하이브리드 검색에는 의미 검색 결과만 남고, 테이블 이름과 코드 값을 글자로 맞혀 주던 몫이 통째로 빠집니다.

형태소 분석과 n-gram 색인은 PostgreSQL 확장을 더 요구한다

정석은 형태소 분석입니다. 형태소 분석기는 「주문은」을 「주문」과 조사 「은」으로 나눠 색인하므로, 검색어와 본문의 낱말 모양이 맞춰집니다. Elasticsearch의 Nori 분석기가 하는 일이 이것입니다. PostgreSQL에는 한국어 설정이 기본으로 들어 있지 않아서, 형태소 분석을 하려면 확장과 사전을 따로 설치해야 합니다. 사전에 없는 사내 시스템 이름이나 약어는 사용자 사전에 등록해야 제대로 나뉩니다.

글자 단위로 잘라 색인하는 n-gram도 있습니다. pg_trgm(세 글자)이나 pg_bigm(두 글자) 확장을 쓰면 「반품」으로 「반품된」을 찾을 수 있습니다. 대신 색인이 커지고 검색 연산자도 바뀝니다. 두 음절 낱말이 흔한 한국어에서는 세 글자 단위가 거칠어서, 같은 PostgreSQL 16에서 similarity('반품', '반품된')은 0.4였습니다.

OwlAI 메타스토어가 요구하는 PostgreSQL 확장은 pgvector 하나입니다. 메타스토어는 설치형 제품의 내장 PostgreSQL에서도, 관리형 PostgreSQL에서도 돌아야 하고, 관리형 서비스는 설치할 수 있는 확장이 서비스마다 정해져 있습니다. 확장이 하나 늘면 그 확장을 설치할 수 있는지가 설치 조건에 더해집니다. 그래서 색인은 기본 배포만으로 도는 simple 설정으로 두고, 문제는 검색어 쪽에서 풉니다.

검색어를 낱말로 쪼개 OR로 잇는다

테이블 카드, 테이블 메모, 문서 청크, 지난 대화를 찾는 키워드 검색은 모두 같은 함수로 검색어를 만듭니다.

  1. 검색어를 공백에서 쪼갠다.
  2. 낱말마다 글자(한글 포함)·숫자·밑줄만 남기고 나머지 기호를 지운다.
  3. 빈 낱말과 중복을 뺀다.
  4. 남은 낱말을 OR로 잇는다. 남은 낱말이 없으면 키워드 검색을 건너뛴다.

예시 질문은 지난달 | 반품 | 주문 | 건수를 | 알려 | 줘가 되고, 두 메모가 모두 걸립니다. 첫째 메모는 「반품」으로, 둘째 메모는 「주문」으로 걸렸습니다.

같은 질문을 AND로 묶으면 0건, OR로 이으면 메모 둘이 다 걸린다
그림 1. 같은 질문을 AND로 묶으면 0건, OR로 이으면 메모 둘이 다 걸린다

2단계는 지울 기호를 고르지 않고 남길 글자를 고릅니다. to_tsquery는 &, |, !, <-> 같은 연산자와 괄호, 따옴표, 콜론을 문법으로 읽기 때문입니다. 사용자가 「price<100인 상품」이라고 쓴 검색어가 그대로 들어가면 to_tsquery가 구문 오류를 내고, 그 검색 전체가 실패합니다. 연산자 몇 개만 골라 지우는 방식이면 빠뜨린 기호 하나로 같은 오류가 납니다. 글자·숫자·밑줄만 남기면 어떤 기호도 질의 문법에 닿지 않습니다. 밑줄을 남기는 것은 CUST_STAT_CD 같은 식별자를 한 덩어리로 지키기 위해서입니다. 이어 붙인 질의 문자열은 SQL에 그대로 넣지 않고 바인드 파라미터로 넘깁니다.

OR로 넓힌 후보는 순위가 거른다

OR는 정밀도를 내주고 재현율을 얻는 선택입니다. 예시에서 둘째 메모는 「주문」 하나로 걸렸지만 반품과는 상관이 없습니다. 맞은 토큰이 하나씩이라 PostgreSQL 16의 ts_rank도 두 메모에 같은 점수(0.0101)를 줬습니다. 키워드 검색만으로는 둘을 가를 수 없습니다.

그래서 키워드 검색은 후보를 넓게 내는 일만 맡고, 순서는 다음 단계가 바로잡습니다.

질문의 뜻에 가까운 첫째 메모가 의미 검색에서 위에 오르면, 합친 순위에서 둘째 메모를 앞지릅니다.

같은 낱말의 다른 모양은 의미 검색이 잇는다

OR로도 잇지 못하는 경우가 있습니다. 「반품된 주문 테이블」로 찾으면 질의는 반품된 | 주문 | 테이블인데, 첫째 메모의 토큰은 반품, 주문은, 테이블에라 하나도 맞지 않습니다. 걸리는 것은 「주문」이 든 둘째 메모뿐입니다. 키워드 검색 결과만 보면 찾던 메모는 빠지고 상관없는 메모가 1등에 섭니다.

이 틈은 검색어를 아무리 다듬어도 키워드 검색 안에서는 닫히지 않습니다. 「반품된」에서 「된」을 떼려면 그것이 어미라는 것을 알아야 하고, 그 판단이 곧 형태소 분석입니다. 이 몫은 의미 검색이 맡습니다. 임베딩은 「반품된 주문」과 「반품 주문은」을 낱말 모양이 아니라 뜻으로 비교하므로, 첫째 메모가 의미 검색 쪽에서 후보에 오릅니다. 하이브리드 검색에서 키워드 검색은 ORD_RTN 같은 글자를 정확히 맞히고, 의미 검색은 조사와 어미가 달라진 한국어를 잇습니다.

임베딩 서버가 멈추면 한국어 검색이 약해진다

임베딩은 별도의 모델 서버가 만듭니다. 이 서버가 응답하지 않으면 의미 검색이 빠지고, 위의 틈을 키워드 검색이 혼자 떠안습니다. 질의 임베딩을 받지 못했을 때 테이블 메모·지난 대화·테이블·문서 검색은 다음처럼 키워드 검색만으로 이어집니다.

검색 질의 임베딩이 없을 때
테이블 메모, 지난 대화 키워드 검색만으로 순위를 냅니다. 저장할 때 임베딩을 받지 못한 행도 전문 검색 색인은 함께 만들어 두므로 검색 대상에 남습니다.
테이블, 문서 키워드 검색만으로 찾습니다. 모델에게는 임베딩 실패 경고가 함께 갑니다.

OR 질의 덕분에 이때도 키워드 검색이 0건으로 끝나지는 않습니다. 「반품 주문」으로 물으면 「반품」이 든 메모를 찾아 줍니다. 대신 「반품된」처럼 모양이 달라진 낱말로만 물으면 놓칩니다. 임베딩 서버가 멈춘 동안 한국어 검색은 이만큼 약해집니다.

설계에서 고른 것

재현율을 정밀도보다 먼저 골랐습니다. OR 질의는 찾아야 할 행을 놓치지 않는 대신 후보에 잡음을 섞고, 순서는 순위 함수와 의미 검색, 리랭커가 바로잡습니다. AND로 묶으면 후보는 깨끗하지만 대화체 질문에서는 그 깨끗한 후보가 0건입니다.

형태소 분석 확장을 들이지 않았습니다. 메타스토어의 설치 조건을 pgvector 하나로 두는 대가로, 조사와 어미가 달라진 같은 낱말은 키워드 검색에서 끝까지 다른 토큰으로 남고 그 틈은 의미 검색에 기댑니다. 임베딩 서버가 멈추면 그 틈이 그대로 드러납니다.

기호는 지울 것을 고르지 않고 남길 글자만 골랐습니다. 대가로 price<100은 price100이라는 토큰 하나가 되어, 본문의 price나 100과는 맞지 않습니다. 검색 전체가 구문 오류로 실패하는 것보다 낱말 하나를 놓치는 편을 골랐습니다.

검색 대상이 한국어 문장이고 질문이 대화체로 들어오는데, 형태소 분석 확장을 설치 조건에 넣기 어렵고 의미 검색을 함께 쓸 수 있다면 이 조합이 맞습니다. 임베딩 없이 키워드 검색만으로 한국어 문서를 찾아야 하는 환경이라면 형태소 분석 확장을 먼저 검토하는 편이 낫습니다.

어떤 문제를 해결하나요?

메모리와 검색의 다른 글