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:
co-authored by
Claude Opus 4.8
parent
0706d18799
commit
766e8f84dc
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user