Islam Mamedov commited on
Commit
597f640
·
1 Parent(s): 98aa8c9

Day 3: embedding index + baseline ask pipeline

Browse files
Files changed (2) hide show
  1. src/ask.py +107 -0
  2. src/index.py +74 -0
src/ask.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ask a question about the codebase — the full RAG pipeline, v0 (baseline).
2
+
3
+ Flow:
4
+ 1. Embed your question with the same model used for the chunks
5
+ 2. Ask ChromaDB for the 5 most similar chunks (dense retrieval only, for now)
6
+ 3. Hand those chunks to an LLM and have it answer USING ONLY THEM
7
+ 4. Print the answer plus links to the sources
8
+
9
+ Usage:
10
+ export GROQ_API_KEY=gsk_... # free key from console.groq.com
11
+ python src/ask.py "How do I return a custom status code?"
12
+ python src/ask.py --show-chunks "..." # also print retrieved chunks
13
+ """
14
+
15
+ import argparse
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import chromadb
21
+ from groq import Groq
22
+ from sentence_transformers import SentenceTransformer
23
+
24
+ DATA_DIR = Path("data")
25
+ EMBED_MODEL = "BAAI/bge-small-en-v1.5"
26
+ # BGE models retrieve better when queries carry this instruction prefix
27
+ QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
28
+ LLM_MODEL = os.environ.get("GROQ_MODEL", "openai/gpt-oss-120b")
29
+ TOP_K = 5
30
+
31
+ SYSTEM_PROMPT = """\
32
+ You are a precise assistant answering questions about the FastAPI codebase.
33
+ Answer ONLY from the numbered context chunks provided. Rules:
34
+ - Cite chunks inline like [1] or [2][3] after each claim they support.
35
+ - If the context does not contain the answer, say exactly:
36
+ "I couldn't find this in the indexed codebase." Do not guess.
37
+ - Prefer short code examples when the context contains them.
38
+ - Be concise."""
39
+
40
+
41
+ def retrieve(question: str, k: int) -> list[dict]:
42
+ model = SentenceTransformer(EMBED_MODEL)
43
+ query_emb = model.encode(QUERY_PREFIX + question,
44
+ normalize_embeddings=True)
45
+ client = chromadb.PersistentClient(path=str(DATA_DIR / "chroma"))
46
+ collection = client.get_collection("chunks")
47
+ res = collection.query(query_embeddings=[query_emb.tolist()], n_results=k)
48
+ return [{
49
+ "text": doc,
50
+ "meta": meta,
51
+ "distance": dist,
52
+ } for doc, meta, dist in zip(res["documents"][0],
53
+ res["metadatas"][0],
54
+ res["distances"][0])]
55
+
56
+
57
+ def build_prompt(question: str, hits: list[dict]) -> str:
58
+ parts = []
59
+ for i, h in enumerate(hits, 1):
60
+ parts.append(f"[{i}] ({h['meta']['source_type']}: "
61
+ f"{h['meta']['path']})\n{h['text']}")
62
+ context = "\n\n---\n\n".join(parts)
63
+ return f"Context chunks:\n\n{context}\n\nQuestion: {question}"
64
+
65
+
66
+ def answer(question: str, hits: list[dict]) -> str:
67
+ api_key = os.environ.get("GROQ_API_KEY")
68
+ if not api_key:
69
+ sys.exit("Set GROQ_API_KEY first (free key at console.groq.com).")
70
+ client = Groq(api_key=api_key)
71
+ response = client.chat.completions.create(
72
+ model=LLM_MODEL,
73
+ messages=[
74
+ {"role": "system", "content": SYSTEM_PROMPT},
75
+ {"role": "user", "content": build_prompt(question, hits)},
76
+ ],
77
+ temperature=0.1, # low = factual, less creative
78
+ )
79
+ return response.choices[0].message.content
80
+
81
+
82
+ def main() -> None:
83
+ parser = argparse.ArgumentParser()
84
+ parser.add_argument("question")
85
+ parser.add_argument("--k", type=int, default=TOP_K)
86
+ parser.add_argument("--show-chunks", action="store_true",
87
+ help="print retrieved chunks (debugging/learning)")
88
+ args = parser.parse_args()
89
+
90
+ hits = retrieve(args.question, args.k)
91
+
92
+ if args.show_chunks:
93
+ for i, h in enumerate(hits, 1):
94
+ print(f"\n=== [{i}] dist={h['distance']:.3f} "
95
+ f"{h['meta']['path']} :: {h['meta']['symbol']} ===")
96
+ print(h["text"][:500])
97
+ print("\n" + "=" * 60)
98
+
99
+ print("\n" + answer(args.question, hits))
100
+
101
+ print("\nSources:")
102
+ for i, h in enumerate(hits, 1):
103
+ print(f" [{i}] {h['meta']['url']}")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
src/index.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the search index: embed every chunk and store it in ChromaDB.
2
+
3
+ What "embedding" means: the model converts each chunk's text into a vector
4
+ (384 numbers) that captures its MEANING. Chunks about similar things end up
5
+ with similar vectors, so later we can find relevant chunks even when the
6
+ question uses different words than the code/docs do.
7
+
8
+ Reads data/chunks.jsonl (from chunk.py)
9
+ Writes data/chroma/ (persistent vector database)
10
+
11
+ Usage:
12
+ python src/index.py
13
+ """
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ import chromadb
19
+ from sentence_transformers import SentenceTransformer
20
+
21
+ DATA_DIR = Path("data")
22
+ COLLECTION = "chunks"
23
+ EMBED_MODEL = "BAAI/bge-small-en-v1.5" # small, strong, runs fine on CPU
24
+ BATCH = 128
25
+
26
+
27
+ def main() -> None:
28
+ chunks = [json.loads(line)
29
+ for line in (DATA_DIR / "chunks.jsonl").read_text().splitlines()]
30
+ print(f"[index] {len(chunks)} chunks to embed")
31
+
32
+ print(f"[index] loading embedding model {EMBED_MODEL} "
33
+ "(first run downloads ~130MB)...")
34
+ model = SentenceTransformer(EMBED_MODEL)
35
+
36
+ # Embed all chunk texts. normalize_embeddings=True -> cosine similarity
37
+ # becomes a simple dot product, which is what Chroma will compute.
38
+ texts = [c["text"] for c in chunks]
39
+ embeddings = model.encode(texts, batch_size=BATCH,
40
+ show_progress_bar=True,
41
+ normalize_embeddings=True)
42
+
43
+ client = chromadb.PersistentClient(path=str(DATA_DIR / "chroma"))
44
+ # Start fresh each run so re-indexing never leaves stale chunks behind
45
+ try:
46
+ client.delete_collection(COLLECTION)
47
+ except Exception:
48
+ pass
49
+ collection = client.create_collection(COLLECTION,
50
+ metadata={"hnsw:space": "cosine"})
51
+
52
+ # Chroma metadata can't hold None values, so default line numbers to 0
53
+ metadatas = [{
54
+ "source_type": c["source_type"],
55
+ "path": c["path"],
56
+ "symbol": c["symbol"] or "",
57
+ "url": c["url"],
58
+ "start_line": c["start_line"] or 0,
59
+ "end_line": c["end_line"] or 0,
60
+ } for c in chunks]
61
+
62
+ for i in range(0, len(chunks), BATCH):
63
+ j = i + BATCH
64
+ collection.add(
65
+ ids=[c["id"] for c in chunks[i:j]],
66
+ embeddings=embeddings[i:j].tolist(),
67
+ documents=texts[i:j],
68
+ metadatas=metadatas[i:j],
69
+ )
70
+ print(f"[done] indexed {collection.count()} chunks -> {DATA_DIR / 'chroma'}")
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()