하이브리드 검색
의미 기반(Vector) 검색은 '뜻이 비슷한 것'은 잘 찾음.
- 하지만 에러 코드(ORA-01555), 특정 제품명, 고유명사처럼 '이 단어가 정확히 들어있어야 하는' 검색은 의미 기반 검색만으로는 놓치기 쉬움
- 벡터는 '느낌'은 비슷해도 '정확한 문자열'을 보장하지 않기 때문에 전통적인 키워드 검색(Oracle Text)과 의미 기반 검색을 동시에 쓰고 결과를 합치는 것이 하이브리드 검색
결과를 어떻게 '합치나'(융합, Fusion)?
- RSF (Relevance Score Fusion)
- 두 검색 각각의 점수 자체를 가중치로 '키워드 점수 90점 + 벡터 점수 70점'처럼 실제 점수 크기가 반영.
- RRF (Reciprocal Rank Fusion)
- 점수 크기 대신 순위(1등, 2등, 3등...)만 보고 합침. 점수 스케일이 서로 다른 두 검색을 억지로 정규화할 필요가 없어서 안정적.
- WRRF (Weighted RRF)
- RRF와 같은데, '키워드 쪽 순위를 더 중요하게' 같은 가중치를 추가로 줄 수 있는 버전
그리고 결과를 합칠 때 UNION(둘 중 하나라도 맞으면 포함) 방식으로 할지, INTERSECT(키워드+의미 둘 다 맞아야 포함) 방식으로 할지도 정할 수 있음.
[oracle@rhel10nm ~]$ --#권한 준비 + Vectorizer Preference 생성
[oracle@rhel10nm ~]$ id
uid=5001(oracle) gid=500(dba) groups=500(dba) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
[oracle@rhel10nm ~]$
[oracle@rhel10nm ~]$ env | grep SID
ORACLE_SID=CDB1
[oracle@rhel10nm ~]$ sqlplus "/as sysdba"
SQL> show con_name
CON_NAME
------------------------------
CDB$ROOT
SQL>
SQL> ALTER SESSION SET CONTAINER = PDB1;
Session altered.
SQL> show con_name
CON_NAME
------------------------------
PDB1
SQL> show user
USER is "SYS"
SQL>
SQL> GRANT CTXAPP TO VECLAB;
Grant succeeded.
SQL> GRANT CREATE SEQUENCE, CREATE TRIGGER, CREATE JOB TO VECLAB;
Grant succeeded.
SQL> CONNECT veclab/VecLab_2026#@//localhost:1521/pdb1
Connected.
SQL>
SQL> show user
USER is "VECLAB"
SQL>
SQL> --Vectorizer preference
SQL> BEGIN
2 DBMS_VECTOR_CHAIN.CREATE_PREFERENCE(
3 'lab_hybrid_vectorizer',
4 DBMS_VECTOR_CHAIN.VECTORIZER,
5 JSON('{
6 "vector_idxtype": "hnsw",
7 "model": "ALL_MINILM_L12_V2",
8 "by": "words",
9 "max": 30,
10 "overlap": 8
11 }')
12 );
13 END;
14 /
PL/SQL procedure successfully completed.
SQL> --결과 확인
SQL> col PRE_NAME for a25
SQL> col PRE_OBJECT for a20
SQL> SELECT pre_name, pre_object FROM ctx_preferences WHERE pre_name = 'LAB_HYBRID_VECTORIZER';
PRE_NAME PRE_OBJECT
------------------------- --------------------
LAB_HYBRID_VECTORIZER BASIC_VECTORIZER
SQL>
SQL> --Hybrid Vector Index 생성
SQL> CREATE HYBRID VECTOR INDEX lab_docs_hybrid_idx
2 ON lab_raw_docs(doc_text)
3 PARAMETERS('VECTORIZER lab_hybrid_vectorizer');
Index created.
SQL> --인덱스 생성에 시간이 좀 걸림(5개 문서를 내부적으로 청크+임베딩+키워드 색인까지 다 하는 과정)
SQL>
SQL> COLUMN index_name FORMAT A25
SQL> SELECT index_name, index_type FROM user_indexes WHERE index_name = 'LAB_DOCS_HYBRID_IDX';
INDEX_NAME INDEX_TYPE
------------------------- ---------------------------
LAB_DOCS_HYBRID_IDX DOMAIN
SQL> --인덱스가 DOMAIN 타입 생성 확인
SQL>
SQL> --하이브리드 검색 실행
SQL> set echo on
SQL> SET LONG 100000
SQL> SET LONGCHUNKSIZE 100000
SQL> SET PAGESIZE 1000
SQL> SET LINESIZE 200
SQL>
SQL> SELECT JSON_SERIALIZE(
2 DBMS_HYBRID_VECTOR.SEARCH(
3 JSON('{
4 "hybrid_index_name": "LAB_DOCS_HYBRID_IDX",
5 "search_scorer": "rsf",
6 "search_fusion": "UNION",
7 "vector": {
8 "search_text": "recovering from data loss",
9 "search_mode": "DOCUMENT",
10 "score_weight": 1,
11 "rank_penalty": 5
12 },
13 "text": {
14 "contains": "RMAN",
15 "score_weight": 10,
16 "rank_penalty": 1
17 },
18 "return": {
19 "values": ["rowid", "score", "vector_score", "text_score"],
20 "topN": 5
21 }
22 }')
23 ) RETURNING CLOB PRETTY
24 ) AS result
25 FROM dual;
RESULT
---------------------------------------------------------------
[
{
"rowid" : "AAARluAAAAAAAN+AAC",
"score" : 14.58,
"vector_score" : 60.38,
"text_score" : 10
},
{
"rowid" : "AAARluAAAAAAAN+AAA",
"score" : 5.46,
"vector_score" : 60.1,
"text_score" : 0
},
{
"rowid" : "AAARluAAAAAAAN+AAD",
"score" : 5.25,
"vector_score" : 57.71,
"text_score" : 0
},
{
"rowid" : "AAARluAAAAAAAN+AAB",
"score" : 5.2,
"vector_score" : 57.18,
"text_score" : 0
},
{
"rowid" : "AAARluAAAAAAAN+AAE",
"score" : 5.15,
"vector_score" : 56.66,
"text_score" : 0
}
]
SQL> --에러 없이 JSON 텍스트가 출력 되어야 함
SQL>
SQL> --결과를 읽기 좋은 테이블로 변환
SQL> SET LINESIZE 200
SQL> COLUMN doc_title FORMAT A25
SQL>
SQL> SELECT dt.doc_id, dt.doc_title,
2 jt.score, jt.vector_score, jt.text_score
3 FROM JSON_TABLE(
4 DBMS_HYBRID_VECTOR.SEARCH(
5 JSON('{
6 "hybrid_index_name": "LAB_DOCS_HYBRID_IDX",
7 "search_scorer": "rsf",
8 "search_fusion": "UNION",
9 "vector": {
10 "search_text": "recovering from data loss",
11 "search_mode": "DOCUMENT",
12 "score_weight": 1,
13 "rank_penalty": 5
14 },
15 "text": {
16 "contains": "RMAN",
17 "score_weight": 10,
18 "rank_penalty": 1
19 },
20 "return": {
21 "values": ["rowid", "score", "vector_score", "text_score"],
22 "topN": 5
23 }
24 }')
25 ),
26 '$[*]' COLUMNS (
27 row_id VARCHAR2(20) PATH '$.rowid',
28 score NUMBER PATH '$.score',
29 vector_score NUMBER PATH '$.vector_score',
30 text_score NUMBER PATH '$.text_score'
31 )
32 ) jt,
33 lab_raw_docs dt
34 WHERE dt.rowid = CHARTOROWID(jt.row_id)
35 ORDER BY jt.score DESC;
DOC_ID DOC_TITLE SCORE VECTOR_SCORE TEXT_SCORE
---------- ------------------------- ---------- ------------ ----------
3 RMAN Guide 14.58 60.38 10
1 Oracle DB Guide 5.46 60.1 0
4 ASM Guide 5.25 57.71 0
2 RAC Guide 5.2 57.18 0
5 GoldenGate Guide 5.15 56.66 0
SQL> --1등이 doc_title = 'RMAN Guide', text_score=10(키워드 "RMAN" 실제로 포함)과 vector_score(의미상 "data loss recovery"와 유사) 둘 다 만족해서 압도적으로 높은 score를 받은 것임
SQL> --나머지 4개 문서(Data Guard, RAC, ASM, GoldenGate)는 text_score=0(RMAN이란 단어가 없음)이지만, 의미상으로는 어느 정도 관련 있어서 UNION 방식이라 결과에 포함된 것.
SQL>
SQL> --INTERSECT로 바꿔서 비교
SQL> col DOC_TITLE for a20
SQL> SELECT dt.doc_id, dt.doc_title,
2 jt.score, jt.vector_score, jt.text_score
3 FROM JSON_TABLE(
4 DBMS_HYBRID_VECTOR.SEARCH(
5 JSON('{
6 "hybrid_index_name": "LAB_DOCS_HYBRID_IDX",
7 "search_scorer": "rsf",
8 "search_fusion": "INTERSECT",
9 "vector": {
10 "search_text": "recovering from data loss",
11 "search_mode": "DOCUMENT",
12 "score_weight": 1,
13 "rank_penalty": 5
14 },
15 "text": {
16 "contains": "RMAN",
17 "score_weight": 10,
18 "rank_penalty": 1
19 },
20 "return": {
21 "values": ["rowid", "score", "vector_score", "text_score"],
22 "topN": 5
23 }
24 }')
25 ),
26 '$[*]' COLUMNS (
27 row_id VARCHAR2(20) PATH '$.rowid',
28 score NUMBER PATH '$.score',
29 vector_score NUMBER PATH '$.vector_score',
30 text_score NUMBER PATH '$.text_score'
31 )
32 ) jt,
33 lab_raw_docs dt
34 WHERE dt.rowid = CHARTOROWID(jt.row_id)
35 ORDER BY jt.score DESC;
DOC_ID DOC_TITLE SCORE VECTOR_SCORE TEXT_SCORE
---------- -------------------- ---------- ------------ ----------
3 RMAN Guide 14.58 60.38 10
SQL> --RMAN Guide 한 건만 출력되어야 함 / INTERSECT는 '키워드도 있고 + 의미도 맞아야' 하기 때문에, RMAN이란 단어가 없는 나머지 4개는 출력되지 않음
SQL>
SQL> --search_scorer 비교 (rsf vs rrf vs wrrf)
SQL> --같은 UNION 검색을 search_scorer만 바꿔서 수행
SQL> col DOC_TITLE for a20
SQL> -- RRF: rank-based fusion
SQL> SELECT dt.doc_id, dt.doc_title,
2 jt.score, jt.vector_score, jt.text_score
3 FROM JSON_TABLE(
4 DBMS_HYBRID_VECTOR.SEARCH(
5 JSON('{
6 "hybrid_index_name": "LAB_DOCS_HYBRID_IDX",
7 "search_scorer": "rrf",
8 "search_fusion": "UNION",
9 "vector": {
10 "search_text": "recovering from data loss",
11 "search_mode": "DOCUMENT",
12 "score_weight": 1,
13 "rank_penalty": 5
14 },
15 "text": {
16 "contains": "RMAN",
17 "score_weight": 10,
18 "rank_penalty": 1
19 },
20 "return": {
21 "values": ["rowid", "score", "vector_score", "text_score"],
22 "topN": 5
23 }
24 }')
25 ),
26 '$[*]' COLUMNS (
27 row_id VARCHAR2(20) PATH '$.rowid',
28 score NUMBER PATH '$.score',
29 vector_score NUMBER PATH '$.vector_score',
30 text_score NUMBER PATH '$.text_score'
31 )
32 ) jt,
33 lab_raw_docs dt
34 WHERE dt.rowid = CHARTOROWID(jt.row_id)
35 ORDER BY jt.score DESC;
DOC_ID DOC_TITLE SCORE VECTOR_SCORE TEXT_SCORE
---------- -------------------- ---------- ------------ ----------
3 RMAN Guide 100 60.38 10
1 Oracle DB Guide 28.57 60.1 0
4 ASM Guide 25.89 57.71 0
2 RAC Guide 23.81 57.18 0
5 GoldenGate Guide 22.14 56.66 0
SQL> -- WRRF: rank-based fusion
SQL> SELECT dt.doc_id, dt.doc_title,
2 jt.score, jt.vector_score, jt.text_score
3 FROM JSON_TABLE(
4 DBMS_HYBRID_VECTOR.SEARCH(
5 JSON('{
6 "hybrid_index_name": "LAB_DOCS_HYBRID_IDX",
7 "search_scorer": "wrrf",
8 "search_fusion": "UNION",
9 "vector": {
10 "search_text": "recovering from data loss",
11 "search_mode": "DOCUMENT",
12 "score_weight": 1,
13 "rank_penalty": 5
14 },
15 "text": {
16 "contains": "RMAN",
17 "score_weight": 10,
18 "rank_penalty": 1
19 },
20 "return": {
21 "values": ["rowid", "score", "vector_score", "text_score"],
22 "topN": 5
23 }
24 }')
25 ),
26 '$[*]' COLUMNS (
27 row_id VARCHAR2(20) PATH '$.rowid',
28 score NUMBER PATH '$.score',
29 vector_score NUMBER PATH '$.vector_score',
30 text_score NUMBER PATH '$.text_score'
31 )
32 ) jt,
33 lab_raw_docs dt
34 WHERE dt.rowid = CHARTOROWID(jt.row_id)
35 ORDER BY jt.score DESC;
DOC_ID DOC_TITLE SCORE VECTOR_SCORE TEXT_SCORE
---------- -------------------- ---------- ------------ ----------
3 RMAN Guide 100 60.38 10
1 Oracle DB Guide 11.98 60.1 0
4 ASM Guide 11.64 57.71 0
2 RAC Guide 11.37 57.18 0
5 GoldenGate Guide 11.15 56.66 0
SQL> --RMAN Guide가 1등이라는 순위 자체는 세 방식(rsf/rrf/wrrf) 모두 동일
SQL> --다만 score 컬럼의 절대값과, 2~5등 사이의 점수 격차가 방식마다 달라 짐
SQL> exit
[oracle@rhel10nm ~]$
| Score | 1위(RMAN) | 2위 | 3위 | 4위 | 5위 | 2~5위간 점수폭 |
| RSF | 14.58 | 5.46 | 5.25 | 5.2 | 5.15 | 0.31 |
| RRF | 100 | 28.57 | 25.89 | 23.81 | 22.14 | 6.43 |
| WRRF | 100 | 11.98 | 11.64 | 11.37 | 11.15 | 0.82 |
세 방식 모두 순위(등수) 자체는 완전히 동일하며 text_score가 0으로 동률인 2~5위는 결국 vector_score 순서(60.1 > 57.71 > 57.18 > 56.66)를 그대로 따랐기 때문 임.
다만 점수 간 '격차'의 표현 방식이 확연히 다름
- RSF는 원래 점수(가중합)를 그대로 반영해서, 5개 문서의 vector_score 자체가 원래 비슷비슷했던 것(56~60점대)이 그대로 드러나 2~5위 격차가 거의 없음.
- RRF는 등수 기반이라 1위와 100점으로 정규화되고, 나머지는 '몇 등이냐'로 계산되면서 RSF보다 격차가 더 벌어 짐.
- WRRF는 RRF와 같은 등수 기반인데, text_score 쪽에 준 높은 가중치(score_weight:10)가 '키워드 없는 나머지'를 더 강하게 한 덩어리로 해서, 2~5위끼리는 오히려 RRF보다 더 좁게 뭉처 짐.
원본 점수 스케일을 그대로 믿고 싶으면 RSF, 검색엔진마다 점수 스케일이 달라 신뢰하기 어려우면 RRF, 거기에 '그래도 키워드 쪽에 더 무게를 싣고 싶다'면 WRRF.
| 항목 | 확인 결과 |
| Vectorizer Preference + Hybrid Vector Index | 원본 텍스트 컬럼에 직접 생성 완료 |
| UNION 검색 | 키워드 없어도 의미 유사하면 포함 확인 |
| INTERSECT 검색 | 키워드+의미 둘 다 맞는 것만 남는 것 확인 |
| RSF/RRF/WRRF 비교 | 순위는 동일, 점수 격차 표현 방식 차이 확인 |

