rndubs commited on
Commit
824acee
·
verified ·
1 Parent(s): 5748e4c

Upload rag_pipeline.py

Browse files
Files changed (1) hide show
  1. rag_pipeline.py +312 -0
rag_pipeline.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG Pipeline for Internal Python Codebase
3
+
4
+ Architecture based on:
5
+ - cAST (arxiv:2506.15655): AST-aware chunking via tree-sitter
6
+ - AllianceCoder (arxiv:2503.20589): API-first retrieval (signatures > similar code)
7
+ - CodeSage-v2 / Jina-Code-v2 for embeddings
8
+ - Gorilla (arxiv:2305.15334): retriever-aware generation pattern
9
+
10
+ Components:
11
+ 1. CodebaseIndexer — parses repo, chunks via AST, extracts API signatures
12
+ 2. CodeRetriever — semantic search over code chunks and API signatures
13
+ 3. ContextBuilder — assembles retrieval context for the LLM prompt
14
+
15
+ Usage:
16
+ # Index and search a codebase
17
+ python rag_pipeline.py /path/to/repo --query "authentication token validation"
18
+
19
+ # Save/load index for fast startup
20
+ python rag_pipeline.py /path/to/repo --save-index ./index
21
+ python rag_pipeline.py /path/to/repo --load-index ./index --query "user permissions"
22
+ """
23
+
24
+ import os
25
+ import json
26
+ import hashlib
27
+ from pathlib import Path
28
+ from dataclasses import dataclass, field
29
+ from typing import Optional
30
+ import numpy as np
31
+
32
+
33
+ @dataclass
34
+ class CodeChunk:
35
+ """A semantically meaningful piece of code."""
36
+ content: str
37
+ file_path: str
38
+ start_line: int
39
+ end_line: int
40
+ chunk_type: str # "function", "class", "method", "module_level"
41
+ name: Optional[str] = None
42
+ parent_class: Optional[str] = None
43
+ signature: Optional[str] = None
44
+ docstring: Optional[str] = None
45
+ imports: list = field(default_factory=list)
46
+
47
+ @property
48
+ def id(self) -> str:
49
+ return hashlib.md5(f"{self.file_path}:{self.start_line}:{self.end_line}".encode()).hexdigest()
50
+
51
+ @property
52
+ def metadata_str(self) -> str:
53
+ parts = []
54
+ if self.chunk_type in ("function", "method"):
55
+ parts.append(f"Function {self.name}")
56
+ if self.signature: parts.append(f"with signature {self.signature}")
57
+ if self.docstring: parts.append(f"described as: {self.docstring}")
58
+ if self.parent_class: parts.append(f"in class {self.parent_class}")
59
+ elif self.chunk_type == "class":
60
+ parts.append(f"Class {self.name}")
61
+ if self.docstring: parts.append(f"described as: {self.docstring}")
62
+ parts.append(f"in file {self.file_path}")
63
+ return " ".join(parts)
64
+
65
+
66
+ class ASTChunker:
67
+ """Parse Python files using AST and extract semantically meaningful chunks."""
68
+
69
+ def __init__(self, max_chunk_chars: int = 3000):
70
+ self.max_chunk_chars = max_chunk_chars
71
+
72
+ def chunk_file(self, file_path: str, source_code: str) -> list[CodeChunk]:
73
+ import ast
74
+ chunks = []
75
+ try:
76
+ tree = ast.parse(source_code)
77
+ except SyntaxError:
78
+ return [CodeChunk(content=source_code, file_path=file_path,
79
+ start_line=1, end_line=source_code.count("\\n") + 1,
80
+ chunk_type="module_level", name=Path(file_path).stem)]
81
+
82
+ lines = source_code.splitlines()
83
+ module_imports = []
84
+ for node in ast.walk(tree):
85
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
86
+ module_imports.append(ast.get_source_segment(source_code, node) or "")
87
+
88
+ for node in ast.iter_child_nodes(tree):
89
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
90
+ chunks.append(self._extract_function(node, source_code, lines, file_path, module_imports))
91
+ elif isinstance(node, ast.ClassDef):
92
+ chunks.append(self._extract_class(node, source_code, lines, file_path, module_imports))
93
+ for item in node.body:
94
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
95
+ chunks.append(self._extract_function(item, source_code, lines, file_path, module_imports, parent_class=node.name))
96
+
97
+ module_lines = []
98
+ top_level_defs = {n.lineno for n in ast.iter_child_nodes(tree)
99
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))}
100
+ for i, line in enumerate(lines, 1):
101
+ if i not in top_level_defs:
102
+ in_def = False
103
+ for node in ast.iter_child_nodes(tree):
104
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
105
+ if hasattr(node, 'end_lineno') and node.lineno <= i <= node.end_lineno:
106
+ in_def = True; break
107
+ if not in_def:
108
+ module_lines.append(line)
109
+
110
+ if module_lines:
111
+ module_content = "\\n".join(module_lines).strip()
112
+ if module_content:
113
+ chunks.append(CodeChunk(content=module_content, file_path=file_path,
114
+ start_line=1, end_line=len(lines), chunk_type="module_level",
115
+ name=Path(file_path).stem, imports=module_imports))
116
+ return chunks
117
+
118
+ def _extract_function(self, node, source, lines, file_path, module_imports, parent_class=None):
119
+ import ast
120
+ start, end = node.lineno, node.end_lineno or node.lineno
121
+ content = "\\n".join(lines[start - 1:end])
122
+ args = []
123
+ for arg in node.args.args:
124
+ arg_str = arg.arg
125
+ if arg.annotation:
126
+ ann = ast.get_source_segment(source, arg.annotation)
127
+ if ann: arg_str += f": {ann}"
128
+ args.append(arg_str)
129
+ sig = f"def {node.name}({', '.join(args)})"
130
+ if node.returns:
131
+ ret = ast.get_source_segment(source, node.returns)
132
+ if ret: sig += f" -> {ret}"
133
+ return CodeChunk(content=content, file_path=file_path, start_line=start, end_line=end,
134
+ chunk_type="method" if parent_class else "function", name=node.name,
135
+ parent_class=parent_class, signature=sig, docstring=(ast.get_docstring(node) or "")[:500],
136
+ imports=module_imports)
137
+
138
+ def _extract_class(self, node, source, lines, file_path, module_imports):
139
+ import ast
140
+ start, end = node.lineno, node.end_lineno or node.lineno
141
+ bases = [ast.get_source_segment(source, b) or "" for b in node.bases]
142
+ sig = f"class {node.name}" + (f"({', '.join(bases)})" if bases else "")
143
+ full_content = "\\n".join(lines[start - 1:end])
144
+ return CodeChunk(content=full_content, file_path=file_path, start_line=start, end_line=end,
145
+ chunk_type="class", name=node.name, signature=sig,
146
+ docstring=(ast.get_docstring(node) or "")[:500], imports=module_imports)
147
+
148
+
149
+ class CodeRetriever:
150
+ """Semantic search over code chunks using sentence-transformers embeddings."""
151
+
152
+ def __init__(self, embedding_model: str = "jinaai/jina-embeddings-v2-base-code"):
153
+ self.embedding_model_name = embedding_model
154
+ self.model = None
155
+ self.chunks: list[CodeChunk] = []
156
+ self.embeddings: Optional[np.ndarray] = None
157
+ self.signature_embeddings: Optional[np.ndarray] = None
158
+
159
+ def load_model(self):
160
+ if self.model is None:
161
+ try:
162
+ from sentence_transformers import SentenceTransformer
163
+ self.model = SentenceTransformer(self.embedding_model_name, trust_remote_code=True)
164
+ except ImportError:
165
+ self.model = "tfidf"
166
+
167
+ def index_chunks(self, chunks: list[CodeChunk]):
168
+ self.load_model()
169
+ self.chunks = chunks
170
+ if self.model == "tfidf":
171
+ from sklearn.feature_extraction.text import TfidfVectorizer
172
+ self.tfidf = TfidfVectorizer(max_features=10000, ngram_range=(1, 2))
173
+ self.tfidf_matrix = self.tfidf.fit_transform([c.content + " " + c.metadata_str for c in chunks])
174
+ return
175
+ contents = [c.content for c in chunks]
176
+ self.embeddings = self.model.encode(contents, batch_size=32, show_progress_bar=True, normalize_embeddings=True)
177
+ metadata = [c.metadata_str for c in chunks]
178
+ self.signature_embeddings = self.model.encode(metadata, batch_size=32, show_progress_bar=True, normalize_embeddings=True)
179
+
180
+ def search(self, query: str, top_k: int = 5, search_type: str = "hybrid") -> list[tuple[CodeChunk, float]]:
181
+ self.load_model()
182
+ if self.model == "tfidf":
183
+ query_vec = self.tfidf.transform([query])
184
+ scores = (self.tfidf_matrix @ query_vec.T).toarray().flatten()
185
+ top_indices = scores.argsort()[-top_k:][::-1]
186
+ return [(self.chunks[i], float(scores[i])) for i in top_indices if scores[i] > 0]
187
+ query_emb = self.model.encode([query], normalize_embeddings=True)
188
+ if search_type == "code":
189
+ scores = (query_emb @ self.embeddings.T).flatten()
190
+ elif search_type == "semantic":
191
+ scores = (query_emb @ self.signature_embeddings.T).flatten()
192
+ else:
193
+ scores = 0.4 * (query_emb @ self.embeddings.T).flatten() + 0.6 * (query_emb @ self.signature_embeddings.T).flatten()
194
+ top_indices = scores.argsort()[-top_k:][::-1]
195
+ return [(self.chunks[i], float(scores[i])) for i in top_indices]
196
+
197
+ def save_index(self, path: str):
198
+ os.makedirs(path, exist_ok=True)
199
+ if self.embeddings is not None:
200
+ np.save(os.path.join(path, "embeddings.npy"), self.embeddings)
201
+ np.save(os.path.join(path, "signature_embeddings.npy"), self.signature_embeddings)
202
+ with open(os.path.join(path, "chunks.json"), "w") as f:
203
+ json.dump([{"content": c.content, "file_path": c.file_path, "start_line": c.start_line,
204
+ "end_line": c.end_line, "chunk_type": c.chunk_type, "name": c.name,
205
+ "parent_class": c.parent_class, "signature": c.signature,
206
+ "docstring": c.docstring, "imports": c.imports} for c in self.chunks], f)
207
+
208
+ def load_index(self, path: str):
209
+ self.embeddings = np.load(os.path.join(path, "embeddings.npy"))
210
+ self.signature_embeddings = np.load(os.path.join(path, "signature_embeddings.npy"))
211
+ with open(os.path.join(path, "chunks.json")) as f:
212
+ self.chunks = [CodeChunk(**d) for d in json.load(f)]
213
+
214
+
215
+ class CodebaseIndexer:
216
+ """Index an entire Python codebase."""
217
+
218
+ def __init__(self, repo_path: str, embedding_model: str = "jinaai/jina-embeddings-v2-base-code",
219
+ max_chunk_chars: int = 3000, exclude_patterns: list[str] = None):
220
+ self.repo_path = Path(repo_path)
221
+ self.chunker = ASTChunker(max_chunk_chars=max_chunk_chars)
222
+ self.retriever = CodeRetriever(embedding_model=embedding_model)
223
+ self.exclude_patterns = exclude_patterns or ["__pycache__", ".git", ".venv", "venv", "node_modules"]
224
+
225
+ def index(self) -> CodeRetriever:
226
+ py_files = sorted(f for f in self.repo_path.rglob("*.py")
227
+ if not any(e in f.parts for e in self.exclude_patterns) and f.stat().st_size < 100_000)
228
+ print(f"Found {len(py_files)} Python files")
229
+ all_chunks = []
230
+ for fpath in py_files:
231
+ try:
232
+ source = fpath.read_text(encoding="utf-8", errors="ignore")
233
+ chunks = self.chunker.chunk_file(str(fpath.relative_to(self.repo_path)), source)
234
+ all_chunks.extend(chunks)
235
+ except Exception as e:
236
+ print(f" Warning: {fpath}: {e}")
237
+ print(f"Extracted {len(all_chunks)} chunks")
238
+ self.retriever.index_chunks(all_chunks)
239
+ return self.retriever
240
+
241
+
242
+ class ContextBuilder:
243
+ """Build retrieval context for LLM prompts (AllianceCoder pattern)."""
244
+
245
+ def __init__(self, retriever: CodeRetriever, max_context_tokens: int = 4000):
246
+ self.retriever = retriever
247
+ self.max_context_chars = max_context_tokens * 4
248
+
249
+ def build_context(self, query: str, current_file_content: Optional[str] = None,
250
+ current_file: Optional[str] = None, top_k: int = 5) -> str:
251
+ context_parts = []
252
+ total_chars = 0
253
+ if current_file_content:
254
+ in_ctx = self._extract_in_context_deps(current_file_content)
255
+ if in_ctx:
256
+ context_parts.append(f"# In-context dependencies from {current_file or 'current file'}:\\n{in_ctx}")
257
+ total_chars += len(in_ctx)
258
+ for chunk, score in self.retriever.search(query, top_k=top_k, search_type="hybrid"):
259
+ if total_chars >= self.max_context_chars: break
260
+ if chunk.signature:
261
+ entry = f"# From {chunk.file_path} (relevance: {score:.2f})\\n{chunk.signature}\\n"
262
+ if chunk.docstring: entry += f' \"\"\"{chunk.docstring[:200]}\"\"\"\\n'
263
+ else:
264
+ entry = f"# From {chunk.file_path}:{chunk.start_line}-{chunk.end_line}\\n{chunk.content[:1000]}\\n"
265
+ context_parts.append(entry)
266
+ total_chars += len(entry)
267
+ return "\\n\\n".join(context_parts)
268
+
269
+ def _extract_in_context_deps(self, source: str) -> str:
270
+ import ast
271
+ try: tree = ast.parse(source)
272
+ except SyntaxError: return ""
273
+ deps = []
274
+ for node in ast.walk(tree):
275
+ if isinstance(node, ast.Import):
276
+ for alias in node.names:
277
+ deps.append(f"import {alias.name}" + (f" as {alias.asname}" if alias.asname else ""))
278
+ elif isinstance(node, ast.ImportFrom):
279
+ deps.append(f"from {node.module} import {', '.join(a.name for a in node.names)}")
280
+ for node in ast.iter_child_nodes(tree):
281
+ if isinstance(node, ast.ClassDef):
282
+ deps.append(f"class {node.name}: ...")
283
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
284
+ deps.append(f"def {node.name}({', '.join(a.arg for a in node.args.args)}): ...")
285
+ return "\\n".join(deps)
286
+
287
+ def format_prompt_with_context(self, user_query: str, context: str, system_prompt: Optional[str] = None) -> list[dict]:
288
+ if not system_prompt:
289
+ system_prompt = "You are an expert Python programmer with access to our internal codebase via retrieval search."
290
+ user_content = f"{user_query}\\n\\n--- Retrieved context ---\\n{context}\\n--- End ---" if context else user_query
291
+ return [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}]
292
+
293
+
294
+ if __name__ == "__main__":
295
+ import argparse
296
+ parser = argparse.ArgumentParser()
297
+ parser.add_argument("repo_path")
298
+ parser.add_argument("--query", "-q", default=None)
299
+ parser.add_argument("--model", default="jinaai/jina-embeddings-v2-base-code")
300
+ parser.add_argument("--save-index", default=None)
301
+ parser.add_argument("--load-index", default=None)
302
+ args = parser.parse_args()
303
+
304
+ if args.load_index:
305
+ retriever = CodeRetriever(args.model)
306
+ retriever.load_index(args.load_index)
307
+ else:
308
+ retriever = CodebaseIndexer(args.repo_path, embedding_model=args.model).index()
309
+ if args.save_index: retriever.save_index(args.save_index)
310
+ if args.query:
311
+ for i, (chunk, score) in enumerate(retriever.search(args.query, top_k=5)):
312
+ print(f"[{score:.3f}] {chunk.file_path}/{chunk.name} ({chunk.chunk_type})")