eoinedge commited on
Commit
e208fbc
·
verified ·
1 Parent(s): a20e3cf

ROS 2 docs RAG index and tooling

Browse files
Files changed (1) hide show
  1. scripts/ask.py +127 -0
scripts/ask.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Answer a ROS 2 question from the local index.
2
+
3
+ Retrieval and generation are deliberately separable. `retrieve()` needs only
4
+ FAISS and the embedding model — a few hundred MB — and is useful on its own for
5
+ "where is this documented?". Generation loads Qwen on top of that.
6
+
7
+ python scripts/ask.py "How do I define a devicetree binding?"
8
+ python scripts/ask.py "What is a work queue?" --k 8
9
+ python scripts/ask.py "How do I enable logging?" --retrieve-only
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ if hasattr(sys.stdout, "reconfigure"):
20
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
21
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
+ import retrieval # noqa: E402
25
+
26
+ ROOT = Path(__file__).resolve().parent.parent
27
+ DEFAULT_INDEX = ROOT / "data" / "index"
28
+ DEFAULT_MODEL = "Qwen/Qwen2.5-Coder-1.5B-Instruct"
29
+
30
+ SYSTEM_PROMPT = """You are a Zephyr RTOS assistant. Answer only from the documentation \
31
+ excerpts provided.
32
+
33
+ Zephyr is large and moves fast, and a confident wrong answer costs an engineer \
34
+ hours on hardware. If the excerpts do not cover the question, say so and name \
35
+ what is missing rather than filling the gap from general RTOS knowledge.
36
+
37
+ Cite the source path for anything you assert. Prefer Zephyr's own terminology \
38
+ (devicetree, Kconfig, west, k_work) over generic equivalents. Show configuration \
39
+ as it would actually appear — a Kconfig symbol, a devicetree node, a west \
40
+ command — not as prose describing it."""
41
+
42
+
43
+ def retrieve(question: str, k: int = 6, index_dir: Path = DEFAULT_INDEX) -> list[dict]:
44
+ """Shared with the Space via scripts/retrieval.py, including the
45
+ release-note down-weight — a CLI that ranked differently from the hosted
46
+ app would make every comparison between them meaningless."""
47
+ return retrieval.search(question, k, index_dir)
48
+
49
+
50
+ def build_context(hits: list[dict]) -> str:
51
+ return "\n\n".join(
52
+ f"[{index + 1}] {hit['source']} - {hit['section']}\n{hit['text']}"
53
+ for index, hit in enumerate(hits)
54
+ )
55
+
56
+
57
+ def generate(question: str, hits: list[dict], model_id: str, max_new_tokens: int = 512) -> str:
58
+ import torch
59
+ from transformers import AutoModelForCausalLM, AutoTokenizer
60
+
61
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
62
+ model = AutoModelForCausalLM.from_pretrained(
63
+ model_id,
64
+ torch_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
65
+ device_map="auto" if torch.cuda.is_available() else None,
66
+ )
67
+
68
+ messages = [
69
+ {"role": "system", "content": SYSTEM_PROMPT},
70
+ {
71
+ "role": "user",
72
+ "content": f"Documentation excerpts:\n\n{build_context(hits)}\n\nQuestion: {question}",
73
+ },
74
+ ]
75
+ prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
76
+ inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
77
+
78
+ output = model.generate(
79
+ **inputs,
80
+ max_new_tokens=max_new_tokens,
81
+ do_sample=False,
82
+ pad_token_id=tokenizer.eos_token_id,
83
+ )
84
+ return tokenizer.decode(output[0][inputs["input_ids"].shape[1] :], skip_special_tokens=True).strip()
85
+
86
+
87
+ def main() -> int:
88
+ parser = argparse.ArgumentParser(description=__doc__)
89
+ parser.add_argument("question")
90
+ parser.add_argument("--k", type=int, default=6)
91
+ parser.add_argument("--index", type=Path, default=DEFAULT_INDEX)
92
+ parser.add_argument("--model", default=DEFAULT_MODEL)
93
+ parser.add_argument("--max-new-tokens", type=int, default=512)
94
+ parser.add_argument(
95
+ "--retrieve-only",
96
+ action="store_true",
97
+ help="show the retrieved passages and stop - no model download or load",
98
+ )
99
+ args = parser.parse_args()
100
+
101
+ hits = retrieve(args.question, args.k, args.index)
102
+ if not hits:
103
+ print("Nothing retrieved.")
104
+ return 1
105
+
106
+ print(f"\nRetrieved {len(hits)} passages:\n")
107
+ for position, hit in enumerate(hits, start=1):
108
+ print(f" [{position}] {hit['score']:.3f} {hit['source']} - {hit['section']}")
109
+
110
+ if args.retrieve_only:
111
+ print()
112
+ for position, hit in enumerate(hits, start=1):
113
+ print(f"--- [{position}] {hit['source']} ---")
114
+ print(hit["text"][:500])
115
+ print()
116
+ return 0
117
+
118
+ print(f"\nGenerating with {args.model}...\n")
119
+ print(generate(args.question, hits, args.model, args.max_new_tokens))
120
+ print("\nSources:")
121
+ for hit in hits:
122
+ print(f" {hit['source']} -> {hit['url']}")
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main())