{#hybrid-hybrid-search} # 하이브리드 검색 {#hybrid-overview} ## 개요 [벡터 검색](vector.md)은 *의미*로 대조합니다. [전문 검색](full-text.md)은 *단어*로 대조합니다. 각각은 상대가 약한 곳에서 강합니다. 벡터 검색은 바꿔 쓴 표현과 관련 개념을 찾아내지만, 제품 코드나 이름, 드문 키워드 같은 정확한 단어를 놓칠 수 있습니다. 전문 검색은 정확한 단어를 정확히 잡지만 동의어와 의도를 놓칩니다. **하이브리드 검색**은 둘 다 실행하고 결과를 합치므로, 검색이 의미를 알면서도 어휘에서 정확해집니다. AgensGraph는 [pgvector](https://github.com/pgvector/pgvector)와 PostgreSQL 전문 검색을 한 데이터베이스에서 제공하므로, 하이브리드 쿼리는 같은 그래프에 대한 문장 하나입니다. > 이 페이지는 *검색 결과의 융합*에 관한 것입니다. 한 문장에서 SQL과 Cypher를 섞는 것을 다루는 [SQL과 Cypher 섞어 쓰기](../../querying/combining-sql-and-cypher.md) 페이지와는 관계가 없습니다. {#hybrid-reciprocal-rank-fusion} ## 상호 순위 융합(RRF) 두 검색은 척도가 다른 점수(코사인 거리 대 `ts_rank`)를 내므로 더할 수 없습니다. **상호 순위 융합(RRF)**은 점수가 아니라 *순위*를 합쳐 그 문제를 피합니다. 각 결과는 자기가 나타난 검색마다 `1 / (k + rank)`를 보태고, 그 값들을 모두 더합니다: ``` score(d) = 1/(k + rank_vector(d)) + 1/(k + rank_fulltext(d)) ``` `k`는 최상위 순위가 얼마나 지배하는지를 누르는 평활 상수입니다. 흔히 쓰는 기본값은 60입니다. 두 검색 *어느 한쪽*에서 순위가 높은 문서는 그럭저럭 점수를 얻고, *둘 다*에서 높은 문서가 가장 높은 점수를 얻습니다. {#hybrid-example-data} ## 예제 데이터 예제는 [예제 데이터셋](../../get_started/example-dataset.md)을 씁니다. 영화 18편이 각각 `plot`과 그 줄거리의 4차원 `embedding`을 가지고 있습니다. 더 가기 전에 그것을 적재하십시오. 실제 임베딩은 `text-embedding-3-small`이나 로컬 `sentence-transformers` 모델 같은 모델에서 나오며 차원이 수백 개입니다. 여기서는 지면에서 기하 구조가 읽히도록 넷으로 줄였습니다. {#hybrid-the-hybrid-query} ## 하이브리드 쿼리 상호 순위 융합에는 각 검색의 *순위*가 필요하고, 그것을 만들어 내는 것이 SQL 윈도 함수입니다. 그래서 융합은 그래프 데이터를 Cypher `MATCH` 서브쿼리에서 받는 SQL 쿼리입니다. 한 서브쿼리는 벡터 거리로, 다른 하나는 전문 검색 관련도로 영화의 순위를 매깁니다. `row_number()`가 정렬된 결과를 순위로 바꾸고, 바깥 쿼리가 역수 순위를 더합니다. 각 서브쿼리는 정점 id만 반환하고, 반환하지 않는 순위 표현식으로 정렬합니다. 그래서 벡터와 전문 검색 속성 인덱스가 정렬과 걸러 내기를 곧바로 맡을 수 있습니다. 아래 쿼리는 *The Matrix* 계열이면서 `machines`를 글자 그대로 언급하는 영화를 찾습니다. 벡터 쿼리는 그 영화의 줄거리 임베딩입니다: ```psql WITH vec AS ( SELECT id, row_number() OVER () AS rnk FROM (MATCH (m:movie) RETURN id(m) AS id ORDER BY m.embedding::vector(4) <=> '[-0.07594558, 0.04081754, 0.29592122, -0.11921061]'::vector(4) LIMIT 10) v ), fts AS ( SELECT id, row_number() OVER () AS rnk FROM (MATCH (m:movie) WHERE to_tsvector('english', m.plot::text) @@ websearch_to_tsquery('english', 'machines') RETURN id(m) AS id ORDER BY ts_rank(to_tsvector('english', m.plot::text), websearch_to_tsquery('english', 'machines')) DESC) f ) SELECT m.title AS title, round((COALESCE(1.0/(60 + vec.rnk), 0) + COALESCE(1.0/(60 + fts.rnk), 0))::numeric, 5) AS rrf_score, vec.rnk AS vec_rank, fts.rnk AS fts_rank FROM (MATCH (m:movie) RETURN id(m) AS id, m.title AS title) m LEFT JOIN vec ON vec.id = m.id LEFT JOIN fts ON fts.id = m.id WHERE vec.id IS NOT NULL OR fts.id IS NOT NULL ORDER BY rrf_score DESC LIMIT 6; ``` ``` title | rrf_score | vec_rank | fts_rank ------------------------------+-----------+----------+---------- "The Matrix" | 0.03279 | 1 | 1 "The Matrix Reloaded" | 0.03226 | 2 | 2 "The Matrix Resurrections" | 0.03150 | 3 | 4 "The Matrix Revolutions" | 0.03080 | 7 | 3 "Total Recall" | 0.01563 | 4 | "Avatar" | 0.01538 | 5 | (6 rows) ``` 여기서 융합은 두 가지를 동시에 합니다. *Total Recall*(벡터 순위 4)은 `machines`라는 단어를 쓰지 않으므로 전문 검색 쪽은 그것을 아예 놓칩니다. 그런데도 결과에 살아남습니다. 벡터 검색이 그것을 *The Matrix*와 같은 *가짜 현실* 이야기로 알아보았기 때문입니다. *The Matrix Revolutions*는 반대 경우입니다. 벡터로는 7번째로 가까울 뿐이라 벡터만 쓰는 상위 다섯 안에 들지 못하지만, 줄거리가 *machines*를 글자 그대로 말하므로 전문 검색 쪽이 3위로 올립니다. 융합이 그것을 다시 끌어올립니다. 양쪽 **모두**를 만족하는 영화(*The Matrix*·*Reloaded*·*Resurrections*)가 최상위를 차지합니다. 벡터 검색은 재현율을, 전문 검색은 정확도를 보태고, RRF가 둘을 섞습니다. {#hybrid-indexing-both-arms} ## 양쪽 모두 인덱싱하기 쿼리의 각 갈래는 평범한 유사도 검색과 전문 검색이므로, [벡터 검색](vector.md)과 [전문 검색](full-text.md)에서 설명한 것과 같은 **속성 인덱스**가 그것을 빠르게 합니다. 임베딩에 대한 HNSW 인덱스와 평문 줄거리에 대한 GIN 인덱스입니다: ```sql CREATE PROPERTY INDEX ON movie USING hnsw ((embedding::vector(4)) vector_cosine_ops); CREATE PROPERTY INDEX ON movie USING gin ((to_tsvector('english', plot::text))); ``` 각 Cypher 서브쿼리는 id만 반환하고 가공하지 않은 순위 표현식으로 정렬하므로, 플래너가 각 갈래를 인덱스로 처리할 수 있습니다. HNSW 인덱스가 벡터 후보를 정렬하고 GIN 인덱스가 전문 검색 일치를 걸러 내므로, 어느 검색도 레이블 전체를 스캔하지 않습니다. 융합 쿼리에 대한 `EXPLAIN`에서 관련 있는 부분은 다음과 같습니다: ``` ... -> Subquery Scan on vec -> Limit -> Index Scan using movie_embedding_idx on movie m Order By: ((properties.'embedding'::text)::vector(4) <=> '[...]'::vector(4)) ... -> Subquery Scan on fts -> Bitmap Heap Scan on movie m Recheck Cond: (to_tsvector('english', (properties.'plot'::text)::text) @@ '...'::tsquery) -> Bitmap Index Scan on movie_plot_idx ``` > 아주 작은 예제 데이터에서는 행이 몇 개뿐이라 인덱스보다 순차 스캔이 싸므로 플래너가 여전히 그것을 고를 수 있습니다. 인덱스 계획을 보려면 `SET enable_seqscan = off`를 더하십시오. 실제 말뭉치에서는 플래너가 알아서 인덱스를 고릅니다. 위의 두 인덱스는 모두 속성에 대한 표현식입니다. 두 속성 중 어느 하나라도 [타입 컬럼으로 승격](../../querying/typed-columns.md)한다면 그쪽 갈래의 인덱스를 다시 만드십시오. 표현식 속성 인덱스는 jsonb `properties` 위에 만들어지는데, 승격된 키에 대한 쿼리는 컬럼을 읽기 때문입니다. 그러면 임베딩 갈래는 컬럼에 대한 평범한 인덱스가 되고, 전문 검색 갈래는 그 컬럼에 대한 SQL 인덱스가 됩니다: ```cypher ALTER VLABEL movie ADD COLUMN embedding vector(4) GENERATED; CREATE PROPERTY INDEX ON movie USING hnsw (embedding vector_cosine_ops); ``` 융합 쿼리 자체는 바뀌지 않습니다. [벡터 검색](#vector-indexing-a-promoted-column)과 [전문 검색](#full-text-indexing-a-promoted-column)이 각자의 쪽을 보여 줍니다. {#hybrid-tuning} ## 조정 * **`k` 상수** — 값이 크면 최상위 순위의 기여가 평평해지고, 작으면 앞의 몇 결과가 지배합니다. 60이 무난한 출발점입니다. * **가중치** — 한쪽 방법에 무게를 실으려면 그 항에 곱하십시오. 의미 쪽으로 기울이려면 예를 들어 `1.5 * COALESCE(1.0/(60 + vec.rnk), 0)` 처럼 씁니다. * **후보 깊이** — 각 CTE 안의 `LIMIT`이 융합 전에 각 검색이 얼마나 깊이 가는지를 정합니다. 지연을 조금 치르고 재현율을 높이려면 이 값을 키우십시오. {#hybrid-graphrag} ## GraphRAG 하이브리드 검색은 그래프 순회의 가장 강한 출발점입니다. 의미 유사도와 키워드 관련도 둘 다로 영화의 순위를 매기고(RRF), 상위 결과를 관계를 따라 뻗어 언어 모델에 줄 맥락을 조립합니다. 상호 순위 융합에는 SQL 윈도 함수가 필요하지만, 그래프에 손대는 부분은 모두 `FROM` 절의 Cypher `MATCH` 서브쿼리로 쓰여 있습니다. 두 검색*과* 뻗어 나가기가 다 그렇습니다. 그것이 [SQL과 Cypher 섞어 쓰기](../../querying/combining-sql-and-cypher.md)의 장치입니다. 쿼리는 그래프에 머무르며 위에서 만든 속성 인덱스를 그대로 씁니다. [예제 데이터셋](../../get_started/example-dataset.md)에는 영화 사이의 `related_to` 간선이 이미 들어 있습니다. 시리즈 속편과 주제로 이어진 작품들입니다. 그래서 아래 순회가 따라갈 것이 있습니다. 여기서 벡터 갈래는 *The Matrix* 같은 영화를 가져오고, 전문 검색 갈래는 키워드 `memories`를 대조합니다. SQL `row_number()`가 정렬된 결과를 순위로 바꿉니다. `seeds` CTE가 RRF로 둘을 융합해 상위 둘을 남기고, 마지막 Cypher 순회가 각 시드의 이웃을 모읍니다: ```psql WITH vec AS ( SELECT id, row_number() OVER () AS rnk FROM (MATCH (m:movie) RETURN id(m) AS id ORDER BY m.embedding::vector(4) <=> '[-0.07594558, 0.04081754, 0.29592122, -0.11921061]'::vector(4) LIMIT 10) v ), fts AS ( SELECT id, row_number() OVER () AS rnk FROM (MATCH (m:movie) WHERE to_tsvector('english', m.plot::text) @@ websearch_to_tsquery('english', 'memories') RETURN id(m) AS id ORDER BY ts_rank(to_tsvector('english', m.plot::text), websearch_to_tsquery('english', 'memories')) DESC) f ), seeds AS ( SELECT m.id, m.title, COALESCE(1.0/(60 + vec.rnk), 0) + COALESCE(1.0/(60 + fts.rnk), 0) AS score FROM (MATCH (m:movie) RETURN id(m) AS id, m.title AS title) m LEFT JOIN vec ON vec.id = m.id LEFT JOIN fts ON fts.id = m.id WHERE vec.id IS NOT NULL OR fts.id IS NOT NULL ORDER BY score DESC LIMIT 2 ) SELECT s.title AS seed, jsonb_agg(DISTINCT ctx.title) AS expanded_context FROM seeds s LEFT JOIN (MATCH (a:movie)-[:related_to]-(b:movie) RETURN id(a) AS aid, b.title AS title) ctx ON ctx.aid = s.id GROUP BY s.title ORDER BY s.title; ``` ``` seed | expanded_context ----------------+----------------------------------------------------------- "The Matrix" | ["The Matrix Reloaded", "The Terminator", "Total Recall"] "Total Recall" | ["Inception", "The Matrix"] (2 rows) ``` 융합은 어떤 영화가 순회의 시드가 되는지를 바꿉니다. *Total Recall*은 *The Matrix*와 벡터로는 4번째로 가까울 뿐이므로, 벡터만으로 시드를 뽑았다면 *The Matrix*와 *The Matrix Reloaded*를 뻗었을 것입니다. 그러나 키워드 `memories`가 *Total Recall*과 정확히 일치하고, RRF가 그것을 상위 둘로 끌어올립니다. *Total Recall*이 시드에 들어오면 순회는 그 `related_to` 간선을 따라 *Inception*에 닿습니다. 어느 한쪽만으로는 떠오르지 않았을 맥락입니다. 결과는 하이브리드로 순위를 매긴 시드와 그 그래프 이웃입니다. 의미 유사도와 정확한 키워드, 그리고 명시적 관계를 한꺼번에 끌어오므로 세 방법 중 가장 풍부한 맥락입니다. [AI·LLM 연동](../../drivers/ai-integrations.md)의 프레임워크들이 이와 같은 형태를 대신 만들어 주며, 그중 몇몇은 그것을 리트리버 하나로 노출합니다.