diff --git a/_posts/2024-01-27-postgres-16-korean.md b/_posts/2024-01-27-postgres-16-korean.md index d93bbf0e31..191e70c3b2 100644 --- a/_posts/2024-01-27-postgres-16-korean.md +++ b/_posts/2024-01-27-postgres-16-korean.md @@ -6,7 +6,7 @@ tags: ["extension", "korean", "collate", "rum", "gin"] image: "https://dyclassroom.com/image/topic/postgresql/postgresql.jpg" --- -> Postgresql 에서 한글 검색을 위한 gin 과 rum 인덱스를 살펴보고, 예제 데이터로 검색 사례들을 테스트 한다. +> Postgresql 에서 한글 검색을 위한 gin 과 rum 인덱스를 살펴보고, 한글 텍스트 데이터로 검색 사례들을 테스트 합니다. {: .prompt-tip } - [PostgreSQL 15 한글 검색 설정](/posts/2023-05-14-postgres-15-korean/) : ko-x-icu, mecab @@ -16,7 +16,7 @@ image: "https://dyclassroom.com/image/topic/postgresql/postgresql.jpg" ### 데이터베이스 설정 -> 기본 collate 는 'C.utf8' 이지만, 필요시 한글 텍스트 컬럼은 'ko-x-icu' 를 지정하면 된다. +> 기본 collate 는 'C.utf8' 이지만, 한글 정렬 필요시 컬럼별로 'ko-x-icu' 를 지정하면 된다. ```sql -- 데이터베이스 생성: tablespace, owner, collate @@ -93,20 +93,20 @@ limit 5; ### [textsearch 함수 및 연산자](https://www.postgresql.org/docs/current/functions-textsearch.html) ```sql -# '제주시' 검색 -> select content from {DB} +-- '제주시' 검색 +select content from {DB} where to_tsvector('korean', content) @@ to_tsquery('korean','제주시') limit 5; -# not '제주' 검색 -> select content from {DB} +-- not '제주' 검색 +select content from {DB} where to_tsvector('korean', content) @@ to_tsquery('korean','!제주') limit 5; -# '서귀포 & 모슬포' 검색 -> select content from {DB} +-- '서귀포 & 모슬포' 검색 +select content from {DB} where to_tsvector('korean', content) @@ to_tsquery('korean','서귀포 & 모슬포') order by content limit 5; @@ -127,7 +127,7 @@ limit 5; - `websearch_to_tsquery('english', '"fat rat" or cat dog') → 'fat' <-> 'rat' | 'cat' & 'dog'` :
웹검색처럼 텍스트를 tsquery 로 변환 (구절 검색, or/and 연산자) - `plainto_tsquery('english', 'The Fat Rats') → 'fat' & 'rat'` :
- 텍스트를 term 들의 tsquery 로 변환 + 텍스트를 '&'로 연결된 tsquery 로 변환 - `phraseto_tsquery('english', 'The Fat Rats') → 'fat' <-> 'rat'`
`phraseto_tsquery('english', 'The Cat and Rats') → 'cat' <2> 'rat'` :
텍스트 문장을 term 들과 연결관계를 포함된 tsquery 로 변환 @@ -136,13 +136,36 @@ limit 5; 우선순위 문자(A/B/C/D) 를 tsvector 에 부여 - `ts_headline('The fat cat ate the rat.', 'cat') → The fat cat ate the rat.` :
(tsvector 가 아니라) raw 문자열에서 매칭된 단어를 강조하여 출력 -- `ts_rank(to_tsvector('raining cats and dogs'), 'cat') → 0.06079271` :
- tf 기반 매칭 점수를 반환 (비교: `ts_rank_cd` 는 밀도 기반이라 더 정확하다) - - [가중치 A/B/C/D 사용시 기본 가중치 사용](https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-RANKING) : `{A:1.0, B:0.4 C:0.2 D:0.1}` - `tsvector_to_array ( tsvector ) → text[]` :
tsvector 로부터 token 배열을 출력 - `ts_stat('SELECT vector FROM apod')` :
문서(record)의 document statistics 출력 (word, 문서수, 총 TF) +- `ts_rank(to_tsvector('raining cats and dogs'), 'cat') → 0.06079271` :
+ tf 기반 매칭 점수를 반환 (비교: `ts_rank_cd` 는 밀도 기반 랭킹을 사용) + - [가중치 A/B/C/D 사용시 기본 가중치 사용](https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-RANKING) : `{A:1.0, B:0.4 C:0.2 D:0.1}` + +> ts_rank 함수의 normalization 옵션 (flag bit) + +- 기본값 0 : 문서 길이를 무시 +- 1 : `1 + log(문서길이)` 로 나누기 (짧은 문서에서 우위) +- 4 : 근접우선인 조화평균거리로 나누기 (ts_rank_cd 에서만 사용) +- 8 : 문서의 유일 단어수로 나누기 (문서의 정보성) +- 32 : `rank / (rank+1)` (백분율 환산) + +```sql +SELECT title, + ts_rank_cd(textsearch, query, 32 /* rank/(rank+1) */ ) AS rank +FROM apod, to_tsquery('neutrino|(dark & matter)') query +WHERE query @@ textsearch +ORDER BY rank DESC +LIMIT 10; +-- title | rank +-- -----------------------------------------+------------------- +-- Neutrinos in the Sun | 0.756097569485493 +-- The Sudbury Neutrino Detector | 0.705882361190954 +-- A MACHO View of Galactic Dark Matter | 0.668123210574724 +-- Hot Gas and Dark Matter | 0.65655958650282 +``` #### 단어별 [document statistics](https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-STATISTICS) 출력 @@ -154,9 +177,9 @@ limit 5; ```sql SELECT * FROM ts_stat('SELECT gin_vec FROM public.test_gin') -where length(word) > 2 -ORDER BY nentry DESC, ndoc DESC, word -LIMIT 10; + where length(word) > 2 + ORDER BY nentry DESC, ndoc DESC, word + LIMIT 10; -- ------------------------ -- | word | ndoc | nentry | -- ------------------------ @@ -273,7 +296,7 @@ explain (analyze) -- | "title" | "rnk" | -- -------------------- -- "고성림 서귀포해경서장 취임 ‘희망의 서귀포 바다 만들겠다’" 1.064899 --- "고성림 서귀포해양경찰서장 취임...‘안전하고 깨끗한 서귀포 바다 만들 것’" 1.0530303 +-- "고성림 서귀포해양경찰서장 취임...‘안전하고 깨끗한 서귀포...’" 1.0530303 -- "서귀포시, ‘제25회 서귀포 겨울바다 국제펭귄수영대회’ 개최" 0.6617919 -- "문화재청, 서귀포 문섬 바다 관광잠수함 운항 재허가 '불허'" 0.60041153 -- "제주 거점 여성 문학인 동백문학회, ‘동백문학 3호’ 발간" 0.05 @@ -315,7 +338,7 @@ insert into rum_test ) t(c1); -- 20만건 생성 -$ pgbench -M prepared -n -r -P 1 -f ./test.sql -c 50 -j 50 -t 200000 +-- $ pgbench -M prepared -n -r -P 1 -f ./test.sql -c 50 -j 50 -t 200000 -- 색인 생성 set maintenance_work_mem ='64GB'; @@ -330,10 +353,14 @@ explain analyze ------------------------------------------ QUERY PLAN ------------------------------------------ - Limit (cost=18988.45..19088.30 rows=100 width=1391) (actual time=58.912..59.165 rows=100 loops=1) - -> Index Scan using rumidx on rum_test (cost=16.00..99620.35 rows=99749 width=1391) (actual time=16.426..57.892 rows=19100 loops=1) - Index Cond: (c1 @@ '''1'' | ''2'''::tsquery) - Order By: (c1 <=> '''1'' | ''2'''::tsquery) + Limit + (cost=18988.45..19088.30 rows=100 width=1391) + (actual time=58.912..59.165 rows=100 loops=1) + -> Index Scan using rumidx on rum_test + (cost=16.00..99620.35 rows=99749 width=1391) + (actual time=16.426..57.892 rows=19100 loops=1) + Index Cond: (c1 @@ '''1'' | ''2'''::tsquery) + Order By: (c1 <=> '''1'' | ''2'''::tsquery) Planning time: 0.133 ms Execution time: 59.220 ms (6 rows) @@ -364,6 +391,11 @@ sudo -u postgres psql -d {DB} -c `create extension rum`; ### rum 검색 테스트 (예제) +RUM 은 ts_rank 와 ts_tank_cd 을 결합한 새로운 ranking 함수를 사용한다. ([출처](https://pgconf.ru/media/2017/04/03/20170316H3_Korotkov-rum.pdf)) + +- ts_rank 는 논리 연산자를 지원하지 않고 +- ts_rank_cd 는 OR 쿼리에서 잘 작동하지 않는다. + > 더 많은 내용은 RUM 깃허브 [readme 문서](https://github.com/postgrespro/rum/blob/master/README.md) 참조 - text 컬럼 't' 를 tsvector 로 색인 diff --git a/_posts/2024-02-01-postgres-pgvector-day1.md b/_posts/2024-02-01-postgres-pgvector-day1.md new file mode 100644 index 0000000000..5eb9eaad04 --- /dev/null +++ b/_posts/2024-02-01-postgres-pgvector-day1.md @@ -0,0 +1,323 @@ +--- +date: 2024-02-01 00:00:00 +0900 +title: PostgreSQL pgvector - 1일차 +categories: ["database", "postgres"] +tags: ["pg16", "korean", "pgvector", "1st-day"] +image: "https://dyclassroom.com/image/topic/postgresql/postgresql.jpg" +--- + +> PostgreSQL 에서 한글 검색을 위한 pgvector 사용법을 알아봅니다. 우선은 pgvector 설정부터 시작합니다. +{: .prompt-tip } + +## 1. [pgvector](https://github.com/pgvector/pgvector) 설정 + +### pgvector 설치 + +```bash +git clone --branch v0.6.0 https://github.com/pgvector/pgvector.git +cd pgvector + +sudo make +sudo make install + +sudo -u postgres psql -d {DB} -c 'CREATE EXTENSION vector' +``` + +> [supabase 의 pgvector](https://supabase.com/docs/guides/database/extensions/pgvector) 는 `create extension` 만 하면 된다. + +```bash +# 로컬 docker 인스턴스 접속 +psql -h localhost -p 54322 -d postgres -U postgres +``` + +### pgvector 쿼리 + +```sql +CREATE TABLE items ( + id bigserial PRIMARY KEY, + embedding vector(3) + ); +-- 또는 ALTER TABLE items ADD COLUMN embedding vector(3); + +-- upsert vectors +INSERT INTO items (id, embedding) VALUES + (1, '[1,2,3]'), + (2, '[4,5,6]'), + (3, '[6,3,1]') + ON CONFLICT (id) DO + UPDATE SET embedding = EXCLUDED.embedding; + +-- -------------------------------------------------- +-- query : distacne, inner_product, cosine_similarity +-- -------------------------------------------------- + +SELECT id, embedding, + embedding <-> '[3,1,2]' as dist + FROM items + ORDER BY dist; +-- "id" | "embedding" | "dist" +-- ------------------------------- +-- 1 "[1,2,3]" 2.449489742783178 +-- 3 "[6,3,1]" 3.7416573867739413 +-- 2 "[4,5,6]" 5.744562646538029 + +SELECT embedding, + (embedding <#> '[3,1,2]') * -1 AS inner_product + FROM items; +-- "embedding" | "inner_product" +-- ----------------------------- +-- "[1,2,3]" 11 +-- "[4,5,6]" 29 +-- "[6,3,1]" 23 + +SELECT embedding, + 1 - (embedding <=> '[3,1,2]') AS cosine_similarity + FROM items; +-- "embedding" | "cosine_similarity" +-- --------------------------------- +-- "[1,2,3]" 0.7857142857142857 +-- "[4,5,6]" 0.8832601106161003 +-- "[6,3,1]" 0.9063269671749657 + +-- -------------------------------------------------- +-- Aggregation : avg +-- -------------------------------------------------- + +SELECT AVG(embedding) FROM items; +-- "[3.6666667,3.3333333,3.3333333]" +``` + +### [HNSW](https://github.com/pgvector/pgvector/blob/master/README.md#hnsw) 인덱스 + +Approximate NN(근사적인 근접 이웃) 탐색을 위해 사용되는 그래프 기반 인덱스이다. + +- 거리 (L2 distance) `vector_l2_ops` +- 내적 (Inner product) `vector_ip_ops` +- 코사인 (Cosine distance) `vector_cosine_ops` + +```sql +SET maintenance_work_mem = '8GB'; +SET max_parallel_maintenance_workers = 7; -- speed up build (default=2) +SET max_parallel_workers_per_gather = 4; -- speed up query + +CREATE INDEX ON items USING hnsw (embedding vector_l2_ops); +CREATE INDEX ON items USING hnsw (embedding vector_ip_ops); +CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops); +``` + +> HNSW vs IVFFlat [인덱스 비교](https://tembo.io/blog/vector-indexes-in-pgvector#picking-the-right-index-for-your-use-case) + +- 인덱스 크기와 빌드 시간이 중요하면 IVFFlat 를 선택 => 대규모 정적 데이터 + - lists 크기의 클러스터를 중심점을 기준으로 vector 탐색 +- 검색 속도와 업데이트 반영이 중요하다면 HNSW 를 선택 => 소규모 동적 데이터 + - multi-layer 그래프 기반으로 가까운 node(vector) 를 탐색 + +| 비교 | IVFFlat | HNSW | +| :--- | :--- | :-- | +| Build Time (in seconds) | 128 | 4,065 | +| Size (in MB) | 257 | 729 | +| Speed (in QPS) | 2.6 | 40.5 | +| 업데이트 이후 리콜 영향 | Significant | Negligible | + + +## 2. openai 임베딩으로 vector 변환 + +### foot 리뷰 데이터 + +- 1천건 +- CSV 다운로드 : [fine_food_reviews_1k.csv](https://github.com/openai/openai-cookbook/blob/main/examples/data/fine_food_reviews_1k.csv) + - embedding 포함 CSV : [fine_food_reviews_with_embeddings_1k.csv](https://github.com/openai/openai-cookbook/blob/main/examples/data/fine_food_reviews_with_embeddings_1k.csv) + +```sql +-- drop table if exists foodreview1k; +create table foodreview1k ( + id bigserial primary key, + regdt bigint, /* to_timestamp() at time zone 'Asia/Seoul', */ + productid text, + userid text, + score smallint, + summary text, + body text +) + +-- psql 에서 실행 +\copy foodreview1k(id, regdt, productid, userid, score, summary, body) from '$HOME/Downloads/fine_food_reviews_1k.csv' delimiter ',' csv header; + +-- supabase : Row Level Security +alter table foodreview1k enable row level security; +-- supabase : policy +create policy "foodreview1k are viewable by everyone" +on foodreview1k for select +to authenticated, anon +using ( true ); + +------------------------------------------- + +drop table if exists foodreview1k_embedding; +create table foodreview1k_embedding ( + id bigserial primary key, + productid text, + userid text, + score smallint, + summary text, + body text, + combined text, + n_tokens int, + embedding vector +) + +-- psql 에서 실행 +\copy foodreview1k_embedding(id, productid, userid, score, summary, body, combined, n_tokens, embedding) from '/Volumes/SSD2T_WORK/Users/bgmin/Downloads/fine_food_reviews_with_embeddings_1k.csv' delimiter ',' csv header; + +-- supabase : Row Level Security +alter table foodreview1k_embedding enable row level security; +-- supabase : policy +create policy "foodreview1k_embedding are viewable by everyone" +on foodreview1k_embedding for select +to authenticated, anon +using ( true ); + +-- TEST +select id, data.embedding <-> query.embedding as dist, summary +from foodreview1k_embedding data, +(select embedding from foodreview1k_embedding where id=0) query +order by dist +limit 5; +-- 0 0 "where does one start... with a treat like this" +-- 726 0.915856064307327 "Perfect treat" +-- 359 0.924484765683111 "Wonderful!" +-- 59 0.950686317853922 "Yummy & Great Small Gift" +-- 907 0.953927412158517 "NTune@60" +``` + +### openai api 로 임베딩하기 + +- 0번 리뷰 문서의 summary(제목) 만으로 임베딩 벡터 변환 요청 +- postgresql 에서 api 를 요청해서 임베딩 벡터를 가져오기 + - json 의 텍스트 결과에서 new-lines, spaces 등을 제거 + - vector 타입으로 변환 +- foodreview1k_embedding 의 임베딩 벡터와의 거리 기준 상위 5개 문서 가져오기 + - 앞에서 자신의 임베딩 벡터로 뽑은 상위 5개 문서와 비교하기 + +#### [pgsql-http](https://github.com/pramsey/pgsql-http) : HTTP Client + +postgresql 에서 OPENAI API 를 간단히 사용하기 위해 extension 을 설치한다. + +- http : http_get, http_post, http_put, http_patch, http_delete +- [http_request](https://github.com/pramsey/pgsql-http/blob/master/http--1.6.sql) + - method + - uri + - headers : http_header[] + - content_type + - content + +```sql +-- supabase +create extension http; + +SELECT urlencode('my special string''s & things?'); +-- "my+special+string%27s+%26+things%3F" + +SELECT content::json->>'origin' as origin + FROM http_get('http://httpbun.com/ip'); +-- {자신의 WAN IP} + +SELECT content::json->>'Authorization' + FROM http(( + 'GET', + 'http://httpbun.com/headers', + ARRAY[http_header('Authorization','Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9')], + NULL, + NULL + )::http_request); +-- "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9" + + +SELECT (unnest(headers)).* + FROM http_get('http://httpbun.com/'); +``` + +#### openai API 사용법 + +- API_KEY 가 필요하다. + +```bash +curl https://api.openai.com/v1/embeddings \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -d '{ + "input": "Your text string goes here", + "model": "text-embedding-3-small" + }' +``` + +#### 쿼리 임베딩 벡터로 거리 기준 5개 문서 출력 + +```sql +create table foodreview1k_query( + id bigserial primary key, + summary text, + embedding vector +); + +-- openapi 로부터 embedding 결과를 받아 테이블에 저장 +insert into foodreview1k_query +with + query_text as ( + select 0 as id + ,'where does one start...and stop... with a treat like this' as summary + ), + query_embedding as ( + select content::json->'data'->0->>'embedding' as embedding + FROM http(( + 'POST', + 'https://api.openai.com/v1/embeddings', + ARRAY[http_header('Authorization','Bearer $OPENAI_API_KEY')], + 'application/json', + '{ + "input": "Your text string goes here", + "model": "text-embedding-3-small" + }' + )::http_request) + ) + select regexp_replace(embedding,'[\r\n\t ]', '', 'g')::vector as qvector + from query_embedding; + +-- 리뷰 테이블에 쿼리 벡터로 유사 문서 검색 +select id, data.embedding <-> query.embedding as dist, summary +from foodreview1k_embedding data, +(select embedding from foodreview1k_query where id=0) query +order by dist +limit 5; +-- 0 0.832656749864305 "where does one start... with a treat like this" +-- 726 1.06856213209488 "Perfect treat" +-- 390 1.12354179478311 "Good Girl Treats (GGTs)" +-- 269 1.12738237577285 "Fantastic -- But ..." +-- 194 1.13635072197582 "More Good Stuff" +``` + +> (이전 원본 embedding 쿼리 결과와) 0번 리뷰의 summary 벡터를 쿼리한 결과 비교 + +| rank | 원본 벡터 | | summary 쿼리 | | +| | idx | dist | idx | dist | +| :--- | :--- | :--- | :--- | :--- | +| 1 | 0 | 0 | 0 | 0.832656749864305 | +| 2 | 726 | 0.915856064307327 | 726 | 1.06856213209488 | +| 3 | 359 | 0.924484765683111 | 390 | 1.12354179478311 | +| 4 | 59 | 0.950686317853922 | 269 | 1.12738237577285 | +| 5 | 907 | 0.953927412158517 | 194 | 1.13635072197582 | + + +## 9. Review + +- supabase 의 pg 에는 여러 확장 기능들이 준비되어 있어 편리하다. + - Row Level Security 와 policy 도 사용해 보았다. +- 영문 리뷰 데이터를 대상으로 임베딩과 pg 벡터 연산자를 사용해보았다. + - 일단 생각대로 잘 되었다. 한글 데이터에서는 어떻게 될지 모르겠지만. +- 여기까지 하고 다음 문서에서 계속하자. + + 

+ +> **끝!**   읽어주셔서 감사합니다. +{: .prompt-info }