하이브리드 검색

개요

벡터 검색은 의미로 매칭하고 전문 검색은 단어로 매칭합니다. 각 방식은 상대방이 약한 부분에서 강점을 가집니다. 벡터 검색은 다른 표현이나 관련 개념을 찾아내지만 정확한 용어(제품 코드, 이름, 드문 키워드)는 놓칠 수 있는 반면, 전문 검색은 정확한 용어는 정확히 잡아내지만 동의어와 의도는 놓칩니다. 하이브리드 검색은 두 방식을 모두 실행하고 그 결과를 병합하여, 의미를 인식하면서도 어휘적으로 정밀한 검색을 제공합니다. AgensGraph는 pgvector와 PostgreSQL 전문 검색을 하나의 데이터베이스에서 모두 제공하므로, 하이브리드 쿼리는 동일한 그래프에 대한 단일 구문으로 작성됩니다.

이 페이지는 *검색 결과 융합(fusion)*에 관한 내용입니다. 하나의 구문에서 SQL과 Cypher를 혼합하는 것에 관한 SQL and Cypher 페이지와는 관련이 없습니다.

두 순위 결합하기: Reciprocal Rank Fusion

두 검색은 서로 다른 척도(코사인 거리 대 ts_rank)로 점수를 산출하므로, 단순히 더할 수 없습니다. **Reciprocal Rank Fusion(RRF)**은 점수가 아니라 순위를 결합함으로써 이 문제를 회피합니다. 각 결과는 자신이 나타나는 각 검색에서 1 / (k + rank)만큼 기여하며, 그 기여분들이 합산됩니다.

score(d) = 1/(k + rank_vector(d)) + 1/(k + rank_fulltext(d))

k는 최상위 순위가 지나치게 지배하지 않도록 제한하는 평활화 상수입니다(흔히 사용되는 기본값은 60입니다). 어느 한쪽 검색에서 좋은 순위를 받은 문서는 적정한 점수를 얻고, 양쪽 검색에서 모두 좋은 순위를 받은 문서는 가장 높은 점수를 얻습니다.

예제 데이터

각 영화는 plot(전문 검색용)과 그 plot의 embedding(벡터 검색용)을 모두 가지고 있습니다. 이는 Vector Search와 Full Text Search에서 사용한 것과 동일한 데이터셋입니다.

CREATE GRAPH moviekb;
SET graph_path = moviekb;
CREATE VLABEL movie;

CREATE (:movie {title: 'The Matrix', year: 1999, genre: 'Action', plot: 'A computer hacker learns about the true nature of reality and joins a rebellion to free humanity from a simulated world controlled by machines.', embedding: [-0.07594558, 0.04081754, 0.29592122, -0.11921061]}),
       (:movie {title: 'The Matrix Reloaded', year: 2003, genre: 'Action', plot: 'The rebels continue their fight against the machines, uncovering deeper truths about the Matrix and the nature of their mission.', embedding: [0.30228977, -0.22839354, 0.35070436, 0.01262819]}),
       (:movie {title: 'The Matrix Revolutions', year: 2003, genre: 'Action', plot: 'The final battle between humans and machines reaches its climax as the fate of both worlds hangs in the balance.', embedding: [0.12240622, -0.29752459, 0.22620453, 0.24454723]}),
       (:movie {title: 'The Matrix Resurrections', year: 2021, genre: 'Action', plot: 'Neo returns to a new version of the Matrix and must once again fight to save the people from the control of the machines.', embedding: [0.34717246, -0.13820869, 0.29214213, 0.08090488]}),
       (:movie {title: 'Inception', year: 2010, genre: 'Sci-Fi', plot: 'A skilled thief is given a chance at redemption if he can successfully perform an inception: planting an idea into someone''s subconscious.', embedding: [0.03923657, 0.39284106, -0.20927092, -0.17770818]}),
       (:movie {title: 'Interstellar', year: 2014, genre: 'Sci-Fi', plot: 'A group of explorers travel through a wormhole in space in an attempt to ensure humanity''s survival.', embedding: [-0.29302418, -0.39615033, -0.23393948, -0.09601383]}),
       (:movie {title: 'Avatar', year: 2009, genre: 'Sci-Fi', plot: 'A paraplegic Marine is sent to the moon Pandora, where he becomes torn between following orders and protecting the world he feels is his home.', embedding: [-0.13663386, 0.00635589, -0.03038832, -0.08252723]}),
       (:movie {title: 'Blade Runner', year: 1982, genre: 'Sci-Fi', plot: 'A blade runner must pursue and terminate four replicants who have stolen a ship in space and returned to Earth.', embedding: [0.27215557, -0.1479577, -0.09972772, -0.08234394]}),
       (:movie {title: 'Blade Runner 2049', year: 2017, genre: 'Sci-Fi', plot: 'A new blade runner unearths a long-buried secret that has the potential to plunge what''s left of society into chaos.', embedding: [0.21560573, -0.07505179, -0.01331814, 0.13403069]}),
       (:movie {title: 'Minority Report', year: 2002, genre: 'Sci-Fi', plot: 'In a future where a special police unit can arrest murderers before they commit their crimes, a top officer is accused of a future murder.', embedding: [0.24008012, 0.44954908, -0.30905488, 0.15195407]}),
       (:movie {title: 'Total Recall', year: 1990, genre: 'Sci-Fi', plot: 'A construction worker discovers that his memories have been implanted and becomes embroiled in a conspiracy on Mars.', embedding: [-0.17471036, 0.14695261, -0.06272433, -0.21795064]}),
       (:movie {title: 'Elysium', year: 2013, genre: 'Sci-Fi', plot: 'In a future where the rich live on a luxurious space station while the rest of humanity lives in squalor, a man fights to bring equality.', embedding: [-0.33280967, 0.07733926, 0.11015328, 0.53382836]}),
       (:movie {title: 'Gattaca', year: 1997, genre: 'Sci-Fi', plot: 'In a future where genetic engineering determines social class, a man defies his fate to achieve his dreams.', embedding: [-0.21629286, 0.31114665, 0.08303899, 0.46199759]}),
       (:movie {title: 'The Fifth Element', year: 1997, genre: 'Sci-Fi', plot: 'In a futuristic world, a cab driver becomes the key to saving humanity from an impending cosmic threat.', embedding: [-0.11528205, -0.0208782, -0.0735215, 0.14327449]}),
       (:movie {title: 'The Terminator', year: 1984, genre: 'Action', plot: 'A cyborg assassin is sent back in time to kill the mother of the future resistance leader.', embedding: [0.33666933, 0.18040994, -0.01075103, -0.11117851]}),
       (:movie {title: 'Terminator 2: Judgment Day', year: 1991, genre: 'Action', plot: 'A reprogrammed Terminator is sent to protect the future leader of the human resistance from a more advanced Terminator.', embedding: [0.34698868, 0.06439331, 0.06232323, -0.19534876]}),
       (:movie {title: 'Jurassic Park', year: 1993, genre: 'Adventure', plot: 'Scientists clone dinosaurs to create a theme park, but things go awry when the creatures escape.', embedding: [0.01794725, -0.11434246, -0.46831815, -0.01049593]}),
       (:movie {title: 'The Avengers', year: 2012, genre: 'Action', plot: 'Superheroes assemble to face a global threat from an alien invasion led by Loki.', embedding: [0.00546514, -0.37005171, -0.42612838, 0.07968612]});

하이브리드 쿼리

Reciprocal Rank Fusion은 각 검색에서 순위를 필요로 하며, 이는 SQL 윈도우 함수가 산출합니다. 따라서 융합은 그래프 데이터를 Cypher MATCH 서브쿼리에서 가져오는 SQL 쿼리입니다. 하나는 벡터 거리로 영화의 순위를 매기고, 다른 하나는 전문 관련도로 순위를 매기며, row_number()가 정렬된 각 결과를 순위로 변환하고, 외부 쿼리가 역수 순위(reciprocal rank)를 합산합니다. 각 서브쿼리는 정점 id만 반환하고 (반환되지 않는) 순위 표현식으로 정렬하므로, 벡터 및 전문 속성 인덱스가 정렬과 필터링을 직접 처리할 수 있습니다. 아래 쿼리는 The Matrix 계열의 영화(그 plot 임베딩이 벡터 쿼리에 해당함)이면서 동시에 machines를 문자 그대로 언급하는 영화를 찾습니다.

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와 같은 거짓 현실(false-reality) 이야기로 인식했기 때문에 결과에 남아 있습니다. 반대로 The Matrix Revolutions는 7번째로 가까운 벡터에 불과하여(벡터 단독 상위 5위 밖으로 밀려날 만큼 낮음) 있으나, 그 plot이 문자 그대로 machines를 언급하기 때문에 전문 검색 측에서 3위로 순위를 매기고, 그 결과 융합이 이를 다시 끌어올립니다. 양쪽 검색을 모두 충족하는 영화(The Matrix, Reloaded, Resurrections)가 최상위를 차지합니다. 벡터 검색은 재현율(recall)에 기여하고, 전문 검색은 정밀도(precision)에 기여하며, RRF가 둘을 혼합합니다.

인덱싱

쿼리의 각 측은 평범한 유사도 / 전문 검색이며, Vector Search와 Full Text Search에서 설명한 동일한 속성 인덱스, 즉 embedding에 대한 HNSW 인덱스와 평문 plot에 대한 GIN 인덱스로 가속됩니다.

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를 추가하십시오. 실제 코퍼스(corpus)에서는 플래너가 자동으로 인덱스를 선택합니다.

튜닝

  • k 상수 — 값이 클수록 최상위 순위의 기여가 평탄해지고, 값이 작을수록 처음 몇 개의 결과가 지배하게 됩니다. 60이 합리적인 출발점입니다.

  • 가중치 — 한 방식을 선호하려면 해당 항에 곱하십시오. 예를 들어 의미 검색 쪽으로 치우치게 하려면 1.5 * COALESCE(1.0/(60 + vec.rnk), 0)과 같이 작성합니다.

  • 후보 깊이 — 각 CTE 내부의 LIMIT은 융합 전에 각 검색이 얼마나 깊이까지 도달할지를 제어합니다. 약간의 지연 시간 비용을 감수하고 더 높은 재현율을 얻으려면 이 값을 늘리십시오.

GraphRAG: 하이브리드 검색과 그래프 순회 결합하기

하이브리드 검색은 그래프 순회를 위한 가장 강력한 출발점입니다. 의미 유사도와 키워드 관련도(RRF) 양쪽으로 영화의 순위를 매긴 다음, 상위 결과를 관계를 따라 확장하여 언어 모델을 위한 컨텍스트를 구성합니다. Reciprocal Rank Fusion은 SQL 윈도우 함수를 필요로 하지만, 그래프를 다루는 모든 부분(각 검색 및 확장)은 FROM 절의 Cypher MATCH 서브쿼리(SQL and Cypher 메커니즘)로 작성되므로, 쿼리는 그래프 네이티브 상태를 유지하며 위에서 구축한 속성 인덱스를 재사용합니다.

영화 그래프를 재사용하여, 영화들을 몇 개의 related_to 간선(프랜차이즈 속편 및 주제적으로 연결된 작품)으로 연결합니다.

CREATE ELABEL related_to;

MATCH (a:movie {title: 'The Matrix'}),             (b:movie {title: 'The Matrix Reloaded'})       CREATE (a)-[:related_to]->(b);
MATCH (a:movie {title: 'The Matrix Reloaded'}),    (b:movie {title: 'The Matrix Revolutions'})     CREATE (a)-[:related_to]->(b);
MATCH (a:movie {title: 'The Matrix Revolutions'}), (b:movie {title: 'The Matrix Resurrections'})   CREATE (a)-[:related_to]->(b);
MATCH (a:movie {title: 'The Matrix'}),             (b:movie {title: 'Total Recall'})               CREATE (a)-[:related_to]->(b);
MATCH (a:movie {title: 'The Matrix'}),             (b:movie {title: 'The Terminator'})             CREATE (a)-[:related_to]->(b);
MATCH (a:movie {title: 'Total Recall'}),           (b:movie {title: 'Inception'})                  CREATE (a)-[:related_to]->(b);
MATCH (a:movie {title: 'The Terminator'}),         (b:movie {title: 'Terminator 2: Judgment Day'}) CREATE (a)-[:related_to]->(b);
MATCH (a:movie {title: 'Blade Runner'}),           (b:movie {title: 'Blade Runner 2049'})          CREATE (a)-[:related_to]->(b);
MATCH (a:movie {title: 'Interstellar'}),           (b:movie {title: 'Avatar'})                     CREATE (a)-[:related_to]->(b);
MATCH (a:movie {title: 'Jurassic Park'}),          (b:movie {title: 'The Avengers'})               CREATE (a)-[:related_to]->(b);
MATCH (a:movie {title: 'Minority Report'}),        (b:movie {title: 'Inception'})                  CREATE (a)-[:related_to]->(b);

여기서 벡터 측은 The Matrix와 같은 영화를 검색하고, 전문 검색 측은 키워드 memories를 매칭합니다. SQL row_number()가 정렬된 각 결과를 순위로 변환하고, seeds CTE가 RRF로 이들을 융합하여 상위 두 개를 유지하며, 마지막 Cypher 순회가 각 시드의 이웃을 수집합니다.

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에 도달합니다. 이는 어느 한쪽 검색만으로는 드러나지 않았을 컨텍스트입니다. 그 결과, 하이브리드 순위가 매겨진 시드와 그 그래프 이웃은 세 가지 접근 방식 중 가장 풍부한 컨텍스트입니다. 의미 유사도, 정확한 키워드, 명시적 관계를 동시에 활용하기 때문입니다.