Add RAG ask: retrieve passages + gemma4 cited answer

- rag.py: chunk reconstruction from text_path+index, per-paper-capped
  retrieval, JSON-mode generation (gemma4:E4B emits blank tokens in plain mode
  on long academic context; JSON-constrained decoding fixes it), parsed answer.
- CLI `ask QUERY [--k N] [--model M]`; SearchHit gains chunk index.
- 1 new test (offline retrieval + ask). Live-verified on QEC and LoRA queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-03 20:11:27 -05:00
co-authored by Claude Opus 4.8
parent 0706d18799
commit 766e8f84dc
5 changed files with 208 additions and 5 deletions
+31
View File
@@ -283,6 +283,31 @@ def cmd_search(args: argparse.Namespace) -> int:
return 0 return 0
def cmd_ask(args: argparse.Namespace) -> int:
from .embedstore import EmbedStore
from .ollama_client import OllamaClient
from .rag import ask
config, _ = _load(args.config, args.topics)
store = EmbedStore.load(Path(config.vault.root))
if store.vectors.size == 0:
console.print("[yellow]No embeddings yet. Run `clawlibrary embed` first.[/yellow]")
return 0
oc = OllamaClient(text_model=args.model)
if not oc.is_up():
console.print("[red]Ollama not reachable.[/red]")
return 2
with console.status("Thinking..."):
result = ask(store, oc, args.question, k=args.k)
console.print(f"\n[bold]{args.question}[/bold]\n")
console.print(result.answer)
if result.sources:
console.print("\n[dim]Sources:[/dim]")
for s in result.sources:
console.print(f" [cyan][{s.n}][/cyan] {s.title[:75]} [dim]{s.doi}[/dim]")
return 0
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="clawlibrary", description=__doc__) p = argparse.ArgumentParser(prog="clawlibrary", description=__doc__)
p.add_argument("--config", default="config.yaml", help="path to config.yaml") p.add_argument("--config", default="config.yaml", help="path to config.yaml")
@@ -325,6 +350,12 @@ def build_parser() -> argparse.ArgumentParser:
pq.add_argument("--k", type=int, default=10, help="number of results") pq.add_argument("--k", type=int, default=10, help="number of results")
pq.add_argument("--snippets", action="store_true", help="show matching snippets") pq.add_argument("--snippets", action="store_true", help="show matching snippets")
pq.set_defaults(func=cmd_search) pq.set_defaults(func=cmd_search)
pa = sub.add_parser("ask", help="ask a question, answered from retrieved passages (RAG)")
pa.add_argument("question", help="natural-language question")
pa.add_argument("--k", type=int, default=6, help="passages to retrieve")
pa.add_argument("--model", default="gemma4:E4B", help="Ollama model for the answer")
pa.set_defaults(func=cmd_ask)
return p return p
+2
View File
@@ -134,6 +134,7 @@ class SearchHit:
topic: str topic: str
snippet: str snippet: str
text_path: str text_path: str
chunk: int = 0
def search( def search(
@@ -164,6 +165,7 @@ def search(
topic=m.get("topic", ""), topic=m.get("topic", ""),
snippet=m.get("snippet", ""), snippet=m.get("snippet", ""),
text_path=m.get("text_path", ""), text_path=m.get("text_path", ""),
chunk=int(m.get("chunk", 0)),
) )
) )
if len(hits) >= k: if len(hits) >= k:
+111
View File
@@ -0,0 +1,111 @@
"""Retrieval-augmented question answering over the vault.
Retrieve the most relevant passages (chunk-level, capped per paper for
diversity), reconstruct their full text, and have a local LLM answer the
question grounded ONLY in those passages, with bracketed citations.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from .embedstore import EmbedStore, chunk_text, search
from .ollama_client import OllamaClient
CONTEXT_CHARS_PER_PASSAGE = 2000
@lru_cache(maxsize=256)
def _chunks_of(text_path: str) -> tuple[str, ...]:
try:
return tuple(chunk_text(Path(text_path).read_text()))
except OSError:
return ()
def full_chunk(text_path: str, idx: int) -> str:
chunks = _chunks_of(text_path)
if 0 <= idx < len(chunks):
return chunks[idx]
return ""
@dataclass
class Source:
n: int
title: str
doi: str
topic: str
passage: str
def retrieve(
store: EmbedStore, client: OllamaClient, question: str, *, k: int = 6, per_paper: int = 2
) -> list[Source]:
"""Top passages for the question, at most `per_paper` chunks from one paper."""
raw = search(store, client, question, k=k * 4, dedupe_by_doi=False)
sources: list[Source] = []
counts: dict[str, int] = {}
for h in raw:
if counts.get(h.doi, 0) >= per_paper:
continue
passage = full_chunk(h.text_path, h.chunk) or h.snippet
counts[h.doi] = counts.get(h.doi, 0) + 1
sources.append(
Source(
n=len(sources) + 1,
title=h.title or "(untitled)",
doi=h.doi,
topic=h.topic,
passage=passage[:CONTEXT_CHARS_PER_PASSAGE],
)
)
if len(sources) >= k:
break
return sources
# gemma4:E4B reliably produces content only under JSON-constrained decoding for
# long academic contexts (plain mode emits blank tokens), so we force a JSON
# object and read the "answer" field.
_PROMPT = """Answer the question using ONLY the passages below. Cite the passages \
you use with bracketed numbers like [1], [2]. If the passages do not contain the \
answer, say so plainly. Be concise and technical.
Respond as a JSON object: {{"answer": "your answer with [n] citations"}}
Question: {question}
Passages:
{context}"""
def build_context(sources: list[Source]) -> str:
return "\n\n".join(f"[{s.n}] ({s.title}) {s.passage}" for s in sources)
@dataclass
class RagAnswer:
answer: str
sources: list[Source]
def ask(
store: EmbedStore, client: OllamaClient, question: str, *, k: int = 6
) -> RagAnswer:
sources = retrieve(store, client, question, k=k)
if not sources:
return RagAnswer(answer="No relevant passages found in the vault.", sources=[])
prompt = _PROMPT.format(question=question, context=build_context(sources))
raw = client.generate(prompt, json_mode=True, num_predict=700, temperature=0.1)
answer = raw.strip()
try:
parsed = json.loads(raw)
if isinstance(parsed, dict) and parsed.get("answer"):
answer = str(parsed["answer"]).strip()
except (json.JSONDecodeError, TypeError):
pass
return RagAnswer(answer=answer, sources=sources)
+21
View File
@@ -0,0 +1,21 @@
summarize 20/1202 (done 5, skipped 15)
summarize 30/1202 (done 15, skipped 15)
summarize 40/1202 (done 25, skipped 15)
summarize 50/1202 (done 35, skipped 15)
summarize 60/1202 (done 45, skipped 15)
summarize 70/1202 (done 55, skipped 15)
summarize 80/1202 (done 65, skipped 15)
summarize 90/1202 (done 75, skipped 15)
summarize 100/1202 (done 85, skipped 15)
summarize 110/1202 (done 95, skipped 15)
summarize 120/1202 (done 105, skipped 15)
summarize 130/1202 (done 115, skipped 15)
summarize 140/1202 (done 125, skipped 15)
summarize 150/1202 (done 135, skipped 15)
summarize 160/1202 (done 145, skipped 15)
summarize 170/1202 (done 155, skipped 15)
summarize 180/1202 (done 165, skipped 15)
summarize 190/1202 (done 175, skipped 15)
summarize 200/1202 (done 185, skipped 15)
summarize 210/1202 (done 195, skipped 15)
summarize 220/1202 (done 205, skipped 15)
+43 -5
View File
@@ -7,21 +7,30 @@ import numpy as np
from clawlibrary.embedstore import EmbedStore, add_document, chunk_text, merge, search from clawlibrary.embedstore import EmbedStore, add_document, chunk_text, merge, search
from clawlibrary.extract import extract_one, extract_pdf from clawlibrary.extract import extract_one, extract_pdf
from clawlibrary.ollama_client import OllamaClient from clawlibrary.ollama_client import OllamaClient
from clawlibrary.rag import ask, retrieve
from clawlibrary.summarize import build_prompt, summarize_paper from clawlibrary.summarize import build_prompt, summarize_paper
# ---- a fake Ollama client for offline tests -------------------------------- # ---- a fake Ollama client for offline tests --------------------------------
_VOCAB = [
"rust", "memory", "safety", "ownership", "systems", "programming",
"quantum", "error", "correction", "surface", "code", "qubit", "threshold",
"agentic", "llm", "planning", "tools",
]
class FakeClient: class FakeClient:
"""Deterministic stand-in: embeds text to a tiny bag-of-chars vector.""" """Deterministic stand-in: bag-of-words over a fixed vocabulary, so semantic
overlap (shared keywords) drives cosine similarity in tests."""
def __init__(self, gen_response="{}"): def __init__(self, gen_response="{}"):
self.gen_response = gen_response self.gen_response = gen_response
def _vec(self, t): def _vec(self, t):
v = np.zeros(8, dtype=np.float32) words = t.lower().split()
for ch in t.lower(): v = np.zeros(len(_VOCAB), dtype=np.float32)
if "a" <= ch <= "z": for i, term in enumerate(_VOCAB):
v[(ord(ch) - 97) % 8] += 1.0 v[i] = float(sum(1 for w in words if term in w))
return v.tolist() return v.tolist()
def embed_documents(self, texts): def embed_documents(self, texts):
@@ -31,6 +40,7 @@ class FakeClient:
return self._vec(text) return self._vec(text)
def generate(self, prompt, **kw): def generate(self, prompt, **kw):
self.last_prompt = prompt
return self.gen_response return self.gen_response
@@ -109,6 +119,34 @@ def test_embed_merge_and_search():
assert len({h.doi for h in hits}) == len(hits) assert len({h.doi for h in hits}) == len(hits)
def test_rag_retrieve_and_ask(tmp_path):
fc = FakeClient(gen_response="Quantum error correction uses surface codes [1].")
store = EmbedStore.empty()
docs = {
"10.1/a": "rust borrow checker memory safety ownership systems programming",
"10.1/b": "quantum error correction surface code logical qubit threshold",
}
for doi, txt in docs.items():
tp = tmp_path / f"{doi.replace('/', '_')}.txt"
tp.write_text(txt)
v, m = add_document(fc, text=txt, doi=doi, title=doi, topic="t", text_path=str(tp))
store = merge(store, v, m)
sources = retrieve(store, fc, "surface code error correction", k=3, per_paper=1)
assert sources
assert sources[0].doi == "10.1/b"
assert "surface code" in sources[0].passage # full chunk reconstructed from file
# per_paper cap respected
from collections import Counter
assert max(Counter(s.doi for s in sources).values()) <= 1
res = ask(store, fc, "How does quantum error correction work?", k=3)
assert "[1]" in res.answer
assert res.sources
# the LLM prompt must contain the retrieved passages
assert "surface code" in fc.last_prompt
def test_store_roundtrip(tmp_path): def test_store_roundtrip(tmp_path):
fc = FakeClient() fc = FakeClient()
v, m = add_document(fc, text="hello world", doi="10.1/x", title="X", topic="t", text_path="p") v, m = add_document(fc, text="hello world", doi="10.1/x", title="X", topic="t", text_path="p")