Skip to content

Commit 453dcb3

Browse files
authored
Merge pull request #9 from NadaBhm/feature/nada-rag
Feature/nada rag
2 parents 259e291 + 11a2a93 commit 453dcb3

8 files changed

Lines changed: 314 additions & 14 deletions

File tree

src/lib/rag/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from __future__ import annotations
1313

14+
from .api import ask_about_repo, get_repo_context
1415
from .config import RAGConfig, get_rag_config
1516
from .embeddings import EmbeddingClient, get_embedding
1617
from .ingestion import ingest_repo, ingest_text
@@ -29,4 +30,6 @@
2930
"ask_repo",
3031
"retrieve_context",
3132
"similarity_search",
33+
"ask_about_repo",
34+
"get_repo_context",
3235
]

src/lib/rag/api.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
RAG Public API — Semantic Retrieval
3+
===============================================
4+
Single entry-point for the Orchestrator and Chat backend.
5+
6+
Usage:
7+
from lib.rag.api import ask_about_repo
8+
answer = ask_about_repo(job_id="550e-...", question="What framework?")
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import logging
14+
15+
from .config import RAGConfig, get_rag_config
16+
from .retrieval import ask_repo, retrieve_context
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
def ask_about_repo(
22+
job_id: str,
23+
question: str,
24+
top_k: int = 5,
25+
config: RAGConfig | None = None,
26+
) -> str:
27+
"""
28+
Ask a natural-language question about an analyzed repository.
29+
30+
This is the primary integration point for the Orchestrator Chat node.
31+
It retrieves relevant chunks from Qdrant and generates an answer via Gemini.
32+
33+
Args:
34+
job_id: The CodeSec analysis job ID (links to Qdrant collection).
35+
question: User question in any language.
36+
top_k: Number of document chunks to retrieve.
37+
38+
Returns:
39+
LLM-generated answer, or a fallback message if no context is found.
40+
"""
41+
logger.info("[RAG API] job=%s question=%r", job_id, question)
42+
return ask_repo(question, job_id, top_k, config)
43+
44+
45+
def get_repo_context(
46+
job_id: str,
47+
question: str,
48+
top_k: int = 5,
49+
config: RAGConfig | None = None,
50+
) -> str:
51+
"""
52+
Retrieve raw context chunks for a question (debug / custom prompting).
53+
54+
Returns the formatted context string that would be fed to the LLM,
55+
without actually calling the LLM.
56+
"""
57+
return retrieve_context(question, job_id, top_k, config)

src/lib/rag/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
DEFAULT_QDRANT_COLLECTION: Final[str] = os.getenv("QDRANT_COLLECTION", "devguard_repos")
1515

1616
# Hugging Face embeddings (local, free)
17-
DEFAULT_HF_MODEL: Final[str] = os.getenv("HF_MODEL", "BAAI/bge-base-en-v1.5")
17+
DEFAULT_HF_MODEL: Final[str] = os.getenv("HF_MODEL", "BAAI/bge-large-en-v1.5")
1818
DEFAULT_EMBEDDING_DIM: Final[int] = int(os.getenv("EMBEDDING_DIM", "768"))
1919

2020
# Gemini LLM (free tier)

src/lib/rag/embeddings.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,20 @@ def _load_model(self) -> SentenceTransformer:
3737

3838
return EmbeddingClient._model_cache[model_name]
3939

40-
def embed(self, texts: list[str]) -> list[list[float]]:
41-
"""Generate embeddings for texts."""
40+
def embed(self, texts: list[str], is_query: bool = False) -> list[list[float]]:
41+
"""Generate embeddings for texts.
42+
43+
Args:
44+
texts: List of texts to embed.
45+
is_query: If True, apply BGE query prefix for retrieval-optimized
46+
embedding. Documents should use is_query=False (no prefix).
47+
"""
4248
if not texts:
4349
return []
4450

45-
# BGE models need instruction prefix for retrieval
46-
if "bge" in self.config.hf_model.lower():
51+
# BGE models: query gets prefix, documents stay raw
52+
# Official BGE usage: only query uses instruction
53+
if is_query and "bge" in self.config.hf_model.lower():
4754
texts = [
4855
"Represent this sentence for searching relevant passages: " + t
4956
for t in texts
@@ -60,12 +67,12 @@ def embed(self, texts: list[str]) -> list[list[float]]:
6067
logger.error("Embedding generation failed: %s", exc)
6168
raise
6269

63-
def embed_single(self, text: str) -> list[float]:
70+
def embed_single(self, text: str, is_query: bool = False) -> list[float]:
6471
"""Embed single text."""
65-
return self.embed([text])[0]
72+
return self.embed([text], is_query=is_query)[0]
6673

6774

6875
def get_embedding(text: str, config: RAGConfig | None = None) -> list[float]:
6976
"""Convenience function."""
7077
client = EmbeddingClient(config)
71-
return client.embed_single(text)
78+
return client.embed_single(text, is_query=True)

src/lib/rag/retrieval.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ def similarity_search(
3131
client = QdrantClient(url=config.qdrant_url)
3232
embedder = EmbeddingClient(config)
3333

34-
query_vector = embedder.embed_single(query)
34+
# Use query-specific embedding for better retrieval quality
35+
query_vector = embedder.embed_single(query, is_query=True)
3536

3637
try:
3738
result = client.query_points(

src/lib/rag/tests/test_api.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Tests for RAG Public API."""
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
6+
from lib.rag.api import ask_about_repo, get_repo_context
7+
8+
9+
class TestAskAboutRepo:
10+
"""Primary integration point for Orchestrator Chat."""
11+
12+
@patch("lib.rag.api.ask_repo")
13+
def test_ask_about_repo_delegates(self, mock_ask_repo):
14+
mock_ask_repo.return_value = "It uses FastAPI."
15+
16+
result = ask_about_repo(
17+
job_id="550e8400-e29b-41d4-a716-446655440000",
18+
question="What framework?",
19+
top_k=3,
20+
)
21+
22+
assert result == "It uses FastAPI."
23+
mock_ask_repo.assert_called_once_with(
24+
"What framework?",
25+
"550e8400-e29b-41d4-a716-446655440000",
26+
3,
27+
None,
28+
)
29+
30+
@patch("lib.rag.api.ask_repo")
31+
def test_ask_about_repo_no_context(self, mock_ask_repo):
32+
mock_ask_repo.return_value = "No relevant context found for this repository."
33+
34+
result = ask_about_repo(job_id="test-job", question="random?")
35+
36+
assert "No relevant context" in result
37+
38+
39+
class TestGetRepoContext:
40+
"""Raw context retrieval for debugging."""
41+
42+
@patch("lib.rag.api.retrieve_context")
43+
def test_get_repo_context_delegates(self, mock_retrieve):
44+
mock_retrieve.return_value = "[Source: README.md]\nThis is a FastAPI project."
45+
46+
result = get_repo_context(
47+
job_id="test-job",
48+
question="What framework?",
49+
top_k=5,
50+
)
51+
52+
assert "FastAPI" in result
53+
mock_retrieve.assert_called_once_with("What framework?", "test-job", 5, None)

src/lib/rag/tests/test_embeddings.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ def test_model_caching(self, mock_st_class):
3131
assert client1.model is client2.model
3232

3333
@patch("lib.rag.embeddings.SentenceTransformer")
34-
def test_bge_prefix_added(self, mock_st_class):
35-
"""BGE models need instruction prefix."""
34+
def test_bge_query_prefix_added(self, mock_st_class):
35+
"""BGE queries need instruction prefix."""
3636
mock_model = MagicMock()
3737
mock_model.encode.return_value = np.array([[0.1] * 768])
3838
mock_st_class.return_value = mock_model
3939

4040
config = RAGConfig(hf_model="BAAI/bge-base-en-v1.5")
4141
client = EmbeddingClient(config)
42-
client.embed(["hello world"])
42+
client.embed(["hello world"], is_query=True)
4343

4444
call_args = mock_model.encode.call_args
4545
texts = call_args[0][0]
@@ -48,6 +48,23 @@ def test_bge_prefix_added(self, mock_st_class):
4848
for t in texts
4949
)
5050

51+
@patch("lib.rag.embeddings.SentenceTransformer")
52+
def test_bge_document_no_prefix(self, mock_st_class):
53+
"""BGE documents should NOT get prefix — only queries do."""
54+
mock_model = MagicMock()
55+
mock_model.encode.return_value = np.array([[0.1] * 768])
56+
mock_st_class.return_value = mock_model
57+
58+
config = RAGConfig(hf_model="BAAI/bge-base-en-v1.5")
59+
client = EmbeddingClient(config)
60+
client.embed(["hello world"], is_query=False)
61+
62+
call_args = mock_model.encode.call_args
63+
texts = call_args[0][0]
64+
assert not any(t.startswith("Represent") for t in texts)
65+
# Raw text preserved
66+
assert texts[0] == "hello world"
67+
5168
@patch("lib.rag.embeddings.SentenceTransformer")
5269
def test_non_bge_no_prefix(self, mock_st_class):
5370
"""Non-BGE models should not get prefix."""
@@ -57,7 +74,7 @@ def test_non_bge_no_prefix(self, mock_st_class):
5774

5875
config = RAGConfig(hf_model="sentence-transformers/all-MiniLM-L6-v2")
5976
client = EmbeddingClient(config)
60-
client.embed(["hello world"])
77+
client.embed(["hello world"], is_query=True)
6178

6279
call_args = mock_model.encode.call_args
6380
texts = call_args[0][0]
@@ -84,7 +101,6 @@ def test_embed_single(self, mock_st_class):
84101

85102
assert isinstance(result, list)
86103
assert len(result) == 768
87-
# Value is normalized by encode(); just check it's a valid float
88104
assert isinstance(result[0], float)
89105

90106
@patch("lib.rag.embeddings.SentenceTransformer")

0 commit comments

Comments
 (0)