Skip to content

Commit f0f07f8

Browse files
committed
Implement hybrid search with semantic + BM25 weighted fusion
1 parent b592065 commit f0f07f8

1 file changed

Lines changed: 71 additions & 18 deletions

File tree

core/rag_engine.py

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,93 @@
11
# HRBuddy
22
# core/rag_engine.py
33

4-
# Required Libraries
54
from langchain_community.vectorstores import Chroma
5+
from langchain_community.retrievers import BM25Retriever
66
from langchain_ollama import OllamaEmbeddings
7+
from langchain_core.documents import Document
78
import ollama
89
from core.logger import log
910
from core.config_loader import cfg
1011

11-
# Main Class
12+
13+
def _prepare_bm25_chunks(chunks):
14+
bm25_chunks = []
15+
for doc in chunks:
16+
heading = doc.metadata.get("heading", "")
17+
text = f"{heading}\n{doc.page_content}" if heading else doc.page_content
18+
bm25_chunks.append(Document(page_content=text, metadata=doc.metadata))
19+
return bm25_chunks
20+
21+
22+
class _HybridRetriever:
23+
def __init__(self, retrievers, weights=None, top_k=6):
24+
self.retrievers = retrievers
25+
self.weights = weights if weights else [1.0 / len(retrievers)] * len(retrievers)
26+
self.top_k = top_k
27+
28+
def invoke(self, query):
29+
all_docs = []
30+
for retriever, weight in zip(self.retrievers, self.weights):
31+
docs = retriever.invoke(query)
32+
for rank, doc in enumerate(docs):
33+
doc.metadata["_score"] = doc.metadata.get("_score", 0) + weight / (rank + 1)
34+
all_docs.append(doc)
35+
36+
seen = set()
37+
unique = []
38+
for doc in all_docs:
39+
key = doc.page_content[:200]
40+
if key not in seen:
41+
seen.add(key)
42+
unique.append(doc)
43+
44+
unique.sort(key=lambda d: d.metadata.get("_score", 0), reverse=True)
45+
return unique[:self.top_k]
46+
47+
1248
class HRBuddyEngine:
1349
def __init__(self, chunks):
14-
"""
15-
Initialization parameters are pulled entirely from cfg (JSON).
16-
"""
50+
hs = cfg.get("hybrid_search", {})
51+
top_k = hs.get("top_k", 6)
52+
fetch_k = hs.get("fetch_k", top_k * 3)
53+
1754
log.info(f"[RAG] Initializing with model: {cfg['vector_store']['embedding_model']}")
18-
19-
# Embeddings
55+
2056
self.embeddings = OllamaEmbeddings(
2157
model=cfg["vector_store"]["embedding_model"],
2258
base_url=cfg.get("ollama_base_url", "http://localhost:11434")
2359
)
24-
25-
# Vector DB
60+
2661
self.vectorstore = Chroma.from_documents(
27-
documents=chunks,
62+
documents=chunks,
2863
embedding=self.embeddings
2964
)
30-
31-
# k (number of documents)
32-
search_k = cfg["vector_store"].get("search_top_k", 2)
33-
self.retriever = self.vectorstore.as_retriever(search_kwargs={"k": search_k})
34-
35-
log.info(f"[RAG] Search depth set to k={search_k}")
36-
37-
# Generate Response
65+
semantic_retriever = self.vectorstore.as_retriever(
66+
search_type="mmr",
67+
search_kwargs={"k": top_k, "fetch_k": fetch_k}
68+
)
69+
70+
bm25_chunks = _prepare_bm25_chunks(chunks)
71+
bm25_retriever = BM25Retriever.from_documents(
72+
documents=bm25_chunks, k=top_k, preprocess_func=str.lower
73+
)
74+
75+
if hs.get("enabled", True):
76+
semantic_w = hs.get("semantic_weight", 0.7)
77+
bm25_w = hs.get("bm25_weight", 0.3)
78+
log.info(
79+
f"[RAG] Hybrid search enabled "
80+
f"(semantic={semantic_w}, bm25={bm25_w}, top_k={top_k})"
81+
)
82+
self.retriever = _HybridRetriever(
83+
retrievers=[semantic_retriever, bm25_retriever],
84+
weights=[semantic_w, bm25_w],
85+
top_k=top_k
86+
)
87+
else:
88+
log.info("[RAG] Hybrid search disabled, falling back to semantic only")
89+
self.retriever = semantic_retriever
90+
3891
def generate_response(self, user_input, history, session_id):
3992
docs = self.retriever.invoke(user_input)
4093
context_block = "\n\n".join([doc.page_content for doc in docs])

0 commit comments

Comments
 (0)