하이브리드 검색
개요
벡터 검색은 의미로 매칭하고 전문 검색은 단어로 매칭합니다. 둘은 각각 상대가 약한 부분에서 강점을 보입니다. 벡터 검색은 같은 뜻의 다른 표현이나 관련 개념을 찾아내지만 정확한 용어(제품 코드, 이름, 희귀한 키워드)는 놓칠 수 있는 반면, 전문 검색은 정확한 용어는 정확히 잡아내지만 동의어와 의도를 놓칩니다. 하이브리드 검색은 둘 다 실행한 뒤 그 결과를 병합하여, 의미를 인지하면서도 어휘적으로 정밀한 검색을 제공합니다. AgensGraph는 pgvector와 PostgreSQL 전문 검색을 하나의 데이터베이스에서 모두 제공하므로, 하이브리드 쿼리는 동일한 그래프에 대한 단일 구문이 됩니다.
이 페이지는 검색 결과 융합에 관한 내용입니다. 하나의 구문에서 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(전문 검색용)과 그 줄거리의 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()가 정렬된 각 결과를 순위로 변환하고, 외부 쿼리가 역순위들을 합산합니다. 각 서브쿼리는 정점 id만 반환하고 (반환하지 않는) 순위 표현식으로 정렬하므로, 벡터 및 전문 검색 속성 인덱스가 정렬과 필터링을 직접 처리할 수 있습니다. 아래 쿼리는 The Matrix 류의 영화를 찾는데(줄거리 임베딩이 벡터 쿼리입니다), 동시에 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와 같은 가짜 현실 이야기로 인식했기 때문에 결과에 남아 있습니다. 반대로 The Matrix Revolutions는 벡터 기준으로는 7번째로 가까운 영화에 불과하여 벡터 단독 상위 5위권에서는 벗어날 만큼 낮지만, 줄거리에 machines가 문자 그대로 등장하기 때문에 전문 검색 측에서 3위로 순위화하며, 따라서 융합이 이를 다시 끌어올립니다. 두 검색을 모두 만족하는 영화들(The Matrix, Reloaded, Resurrections)이 최상위 자리를 차지합니다. 벡터 검색은 재현율(recall)에 기여하고, 전문 검색은 정밀도(precision)에 기여하며, RRF가 둘을 혼합합니다.
인덱싱
쿼리의 각 검색 측은 일반적인 유사도 / 전문 검색이며, Vector Search 및 Full Text Search에서 설명한 것과 동일한 속성 인덱스, 즉 임베딩에 대한 HNSW 인덱스와 평문 줄거리에 대한 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에 도달합니다. 이는 어느 한쪽 검색만으로는 드러나지 않았을 컨텍스트입니다. 하이브리드 순위화된 시드와 그 그래프 이웃이라는 이 결과는 세 가지 접근 방식 중 가장 풍부한 컨텍스트입니다. 의미적 유사도, 정확한 키워드, 명시적 관계를 한꺼번에 활용하기 때문입니다.