{#vector-vector-search} # 벡터 검색 {#vector-overview} ## 개요 AgensGraph는 PostgreSQL 위에 만들어졌으므로, [pgvector](https://github.com/pgvector/pgvector) 확장이 그래프에 벡터 유사도 검색을 곧바로 더합니다. 그러면 임베딩이 그래프 데이터 옆에 놓이고, 데이터베이스 하나가 의미(벡터) 검색과 Cypher 그래프 순회를 결합할 수 있습니다. 검색 증강 생성(RAG)과 *GraphRAG* 응용의 토대가 그것입니다. 벡터 검색은 단어가 아니라 *의미*로 일치시킵니다. 두 글이 어휘를 하나도 공유하지 않아도 벡터 공간에서는 가까울 수 있습니다. 키워드로 대조하는 [전문 검색](full-text.md)에 견준 강점이 그것입니다. 둘을 결합하는 것은 [하이브리드 검색](hybrid.md)이 다룹니다. 세 페이지의 예제가 모두 같은 영화 집합을 쓰므로 동작을 직접 견주어 볼 수 있습니다. 임베딩은 정점이나 간선의 수치 배열 **속성**으로 저장됩니다. 속성은 `jsonb`이므로 배열은 JSON 배열로 보관됩니다. 벡터 연산이 필요할 때마다 `::vector(n)` 형 변환이 그것을 pgvector의 `vector` 타입으로 바꿉니다. 그 형 변환은 읽을 때마다 일어납니다. 행이 몇천 개일 때는 괜찮지만 규모가 커지면 낭비입니다. 2.18부터는 대신 [그 속성을 실제 `vector` 컬럼으로 승격](../../querying/typed-columns.md)할 수 있습니다. 그러면 형 변환은 쓰기 시점에 한 번 일어나고, HNSW 인덱스를 그 컬럼 자체에 만들 수 있습니다. 승격된 컬럼에 남아 있는 `::vector(n)`은 접혀 사라지므로, 이 페이지의 쿼리는 그대로 동작하고 인덱스도 계속 씁니다. 승격하면 두 가지가 달라집니다. 값의 *타입*입니다. 승격된 `d.emb`는 `vector`로 돌아오고 승격하지 않은 것은 `jsonb`로 돌아오므로, JSON 형태를 파싱하는 클라이언트는 고쳐야 합니다. 인덱스의 모양입니다. 승격된 컬럼 위에서는 형 변환 표현식에 대한 인덱스가 아니라 평범한 컬럼 인덱스가 되며, 그래서 덤프하고 복원할 인덱스로는 이 형태가 낫습니다. {#vector-installing-pgvector} ## pgvector 설치 [pgvector](https://github.com/pgvector/pgvector)는 별도의 확장이며, 쓰고 있는 AgensGraph 설치본에 맞춰 빌드해 설치해야 합니다. 쓸 수 있게 된 뒤에는 데이터베이스마다 켜십시오: ```sql CREATE EXTENSION vector; ``` {#vector-example-data} ## 예제 데이터 예제는 [예제 데이터셋](../../get_started/example-dataset.md)을 씁니다. 영화 18편이 각각 `plot`과 그 줄거리의 4차원 `embedding`을 가지고 있습니다. 더 가기 전에 그것을 적재하십시오. 실제 임베딩은 `text-embedding-3-small`이나 로컬 `sentence-transformers` 모델 같은 모델에서 나오며 차원이 수백 개입니다. 여기서는 지면에서 기하 구조가 읽히도록 넷으로 줄였습니다. {#vector-similarity-search} ## 유사도 검색 pgvector는 거리 연산자와 그에 대응하는 함수를 제공합니다. 두 피연산자를 `vector(n)`으로 변환한 뒤 거리로 정렬하십시오. | 연산자 | 함수 | 거리 | HNSW 연산자 클래스 | | -------- | ----------------- | --------------------- | ------------------- | | `<=>` | `cosine_distance` | 코사인 거리 | `vector_cosine_ops` | | `<->` | `l2_distance` | 유클리드(L2) | `vector_l2_ops` | | `<#>` | `inner_product` | (음의) 내적 | `vector_ip_ops` | | `<+>` | `l1_distance` | 맨해튼(L1) | `vector_l1_ops` | 줄거리가 쿼리 벡터에 가장 가까운(코사인 거리) 영화를 찾습니다. 여기서 쿼리는 *The Matrix*의 줄거리 임베딩이므로, 검색은 의미가 가장 비슷한 영화를 돌려줍니다: ```cypher MATCH (m:movie) RETURN m.title AS title ORDER BY m.embedding::vector(4) <=> '[-0.07594558, 0.04081754, 0.29592122, -0.11921061]'::vector(4) LIMIT 4; ``` ``` title ---------------------------- "The Matrix" "The Matrix Reloaded" "The Matrix Resurrections" "Total Recall" (4 rows) ``` 처음 세 결과는 뻔한 속편이지만, 흥미로운 것은 네 번째입니다. *Total Recall*의 줄거리는 *"a construction worker discovers that his memories have been implanted"* 입니다. *The Matrix*의 *"a hacker … a simulated world controlled by machines"* 와 **키워드를 하나도** 공유하지 않습니다. 그런데도 벡터 검색은 그것을 네 번째로 올립니다. 두 영화 모두 자기 현실이 꾸며진 것임을 알아차리는 인물의 이야기이기 때문입니다. 키워드 검색은 둘을 결코 잇지 못합니다. 의미 검색은 바로 그것을 잡아내고 어휘 검색은 그러지 못합니다. 같은 데이터에 대한 [전문 검색](full-text.md) 결과와 견주어 보고, 둘을 결합하는 것은 [하이브리드 검색](hybrid.md)을 참고하십시오. > `ORDER BY`는 거리 표현식이 `RETURN` 목록에 없어도 그것을 참조할 수 있습니다(AgensGraph 2.17 이상). `title`만 반환하면 출력이 깔끔해지고, 더 중요하게는 HNSW 인덱스가 그 정렬을 맡을 수 있습니다. 거리를 그대로 반환하면 `jsonb`로 변환되어 인덱스를 쓰지 못하게 됩니다. 함수 형태는 「*The Terminator*와 가장 비슷한 영화」처럼 저장된 다른 벡터와 견줄 때 편리합니다. 쿼리 벡터를 손으로 쓰지 않아도 됩니다: ```cypher MATCH (m:movie), (q:movie {title: 'The Terminator'}) RETURN m.title AS title ORDER BY m.embedding::vector(4) <=> q.embedding::vector(4) LIMIT 4; ``` ``` title ---------------------------- "The Terminator" "Terminator 2: Judgment Day" "Minority Report" "Blade Runner" (4 rows) ``` {#vector-indexing-with-hnsw} ## HNSW로 인덱싱하기 인덱스가 없으면 유사도 쿼리마다 모든 행을 스캔하며 각각의 거리를 계산합니다. 데이터가 크다면 플래너가 인덱스 스캔을 쓸 수 있도록 HNSW 인덱스를 만드십시오. 인덱싱하는 값이 형 변환 표현식이므로 **속성 인덱스**를 쓰십시오: ```sql CREATE PROPERTY INDEX ON movie USING hnsw ((embedding::vector(4)) vector_cosine_ops); ``` 연산자 클래스는 쿼리에 쓰는 거리에 맞추십시오(`<=>`에는 `vector_cosine_ops`, `<->`에는 `vector_l2_ops` 하는 식입니다). 그러면 앞의 유사도 쿼리가 인덱스를 씁니다: ```psql EXPLAIN MATCH (m:movie) RETURN m.title AS title ORDER BY m.embedding::vector(4) <=> '[-0.07594558, 0.04081754, 0.29592122, -0.11921061]'::vector(4) LIMIT 4; ``` ``` Limit -> Index Scan using movie_embedding_idx on movie m Order By: ((properties.'embedding'::text)::vector(4) <=> '[...]'::vector(4)) ``` > 행이 18개뿐인 이 예제에서는 순차 스캔이 더 싸므로 플래너가 여전히 그것을 고를 수 있습니다. 인덱스 계획을 보려면 `SET enable_seqscan = off`를 더하십시오. 실제 말뭉치에서는 플래너가 알아서 인덱스를 고릅니다. > `::vector(n)` 형 변환에 대한 HNSW 속성 인덱스는 AgensGraph 2.17 이상이 필요합니다. {#vector-indexing-a-promoted-column} ## 승격된 컬럼 인덱싱하기 위의 인덱스는 형 변환 표현식 위에 만들어집니다. 속성을 승격하면 형 변환이 사라지므로, 인덱스는 컬럼에 대한 평범한 인덱스가 됩니다. 승격은 jsonb `properties` 옆에 그 속성을 위한 실제 타입 컬럼을 만들고 쓰기 시점에 채웁니다. 선언하는 것은 컬럼 이름이고, 그것이 비추는 속성은 같은 단어로 지칭됩니다: ```cypher DROP PROPERTY INDEX movie_embedding_idx; ALTER VLABEL movie ADD COLUMN embedding vector(4) GENERATED; ``` `GENERATED`만 쓰면 컬럼 이름과 같은 이름의 속성, 여기서는 `embedding`을 비춥니다. 컬럼을 다른 이름으로 부르려면 `GENERATED (sourceKey)`라고 쓰십시오 — `ALTER VLABEL movie ADD COLUMN emb vector(4) GENERATED (embedding)`은 같은 속성을 `emb`이라는 컬럼에 저장합니다. 어느 쪽이든 **쿼리는 컬럼이 아니라 속성의 이름을 계속 댑니다.** 해석은 속성 키로 이루어지고 컬럼 이름은 Cypher에 나타나지 않습니다. 자세한 것은 [속성을 컬럼으로 승격하기](../../querying/typed-columns.md)에 있습니다. 그러면 인덱스는 형 변환도 괄호도 없는, 컬럼에 대한 평범한 인덱스가 됩니다: ```cypher CREATE PROPERTY INDEX ON movie USING hnsw (embedding vector_cosine_ops); ``` 이 페이지의 모든 쿼리는 고치지 않아도 그대로 동작합니다. 승격된 컬럼에 남은 `::vector(4)`는 접혀 사라지므로 `m.embedding`과 `m.embedding::vector(4)`는 같은 계획을 만듭니다: ```psql EXPLAIN (COSTS OFF) MATCH (m:movie) RETURN m.title AS title ORDER BY m.embedding <=> '[-0.07594558, 0.04081754, 0.29592122, -0.11921061]'::vector(4) LIMIT 4; ``` ``` Limit -> Index Scan using movie_embedding_idx on movie m Order By: (embedding <=> '[-0.07594558,0.04081754,0.2959212,-0.11921061]'::vector(4)) ``` ```{important} 위에서처럼, 표현식 인덱스가 읽는 키를 승격하기 전에 그 인덱스를 지우십시오. 표현식에 대한 속성 인덱스는 jsonb `properties`를 상대로 만들어지고 승격된 키에 대한 쿼리는 컬럼을 읽으므로, 둘은 더는 만나지 않습니다. [표현식 속성 인덱스는 `properties`에 걸린다](#typed-columns-an-expression-property-index-is-built-over-the-bag)를 참고하십시오. ``` 승격하면 값의 타입이 달라집니다. 이제 `m.embedding`은 `vector`로 돌아오며, 승격하지 않은 속성은 `jsonb`로 돌아왔습니다. {#vector-graphrag} ## GraphRAG 그래프 데이터베이스 안의 벡터 검색에는 남다른 강점이 있습니다. 의미 일치가 그래프 순회의 *출발점*이 될 수 있다는 것입니다. 전형적인 GraphRAG 검색은 쿼리 하나로 두 가지를 합니다. 벡터 유사도로 질문에 가장 관련 있는 항목을 찾고, 그다음 관계를 따라 뻗어 나가 언어 모델에 줄 연결된 맥락을 모읍니다. [예제 데이터셋](../../get_started/example-dataset.md)에는 영화 사이의 `related_to` 간선이 이미 들어 있습니다. 시리즈 속편과 주제로 이어진 작품들입니다. 그래서 아래 순회가 따라갈 것이 있습니다. 쿼리할 때는 줄거리에 쓴 것과 같은 모델로 사용자의 질문을 임베딩합니다. 그런 다음 가장 가까운 영화를 가져와 그 이웃으로 뻗는 Cypher 쿼리 하나를 실행합니다. 여기서 질문 임베딩은 *The Matrix*의 벡터입니다: ```cypher MATCH (seed:movie) WITH seed ORDER BY seed.embedding::vector(4) <=> '[-0.07594558, 0.04081754, 0.29592122, -0.11921061]'::vector(4) LIMIT 2 MATCH (seed)-[:related_to]-(ctx:movie) RETURN seed.title AS seed, collect(DISTINCT ctx.title) AS related_context; ``` ``` seed | related_context -----------------------+----------------------------------------------------------- "The Matrix" | ["The Matrix Reloaded", "The Terminator", "Total Recall"] "The Matrix Reloaded" | ["The Matrix", "The Matrix Revolutions"] (2 rows) ``` `WITH … ORDER BY … LIMIT 2`가 벡터 유사도로 질문에 가장 잘 맞는 영화 둘을 고릅니다. 그것이 *시드*입니다. 두 번째 `MATCH`는 각 시드에서 `related_to` 간선을 따라가 이웃 영화를 모읍니다. 합쳐진 집합은 시드와 그 그래프 이웃입니다. 벡터 검색만으로 얻는 것보다 풍부한 맥락을 이루며, 언어 모델에 넘겨 최종 답을 만들게 할 수 있습니다. 이 방식은 일반화됩니다. 시드를 여러 홉으로 뻗거나, 간선 종류나 속성으로 거르거나, 다른 레이블(사람·스튜디오·출처)에 조인해 RAG 파이프라인에 필요한 맥락을 정확히 조립할 수 있습니다. 프레임워크가 그 일을 대신 해 주기를 바란다면, [AI·LLM 연동](../../drivers/ai-integrations.md)이 그런 라이브러리를 나열합니다.