File size: 6,028 Bytes
710fe08
 
 
 
 
 
 
 
 
 
 
 
12c693a
 
 
 
710fe08
 
12c693a
710fe08
 
 
12c693a
710fe08
 
 
 
 
 
 
 
 
 
 
12c693a
710fe08
12c693a
710fe08
12c693a
 
 
710fe08
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12c693a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710fe08
12c693a
 
 
710fe08
 
 
12c693a
 
 
 
710fe08
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#!/usr/bin/env python
"""End-to-end check on a real document: the NIST AI Risk Management Framework 1.0.

Downloads NIST AI 100-1 (January 2023, 48 pages, a US-government work in the public
domain), indexes it, and asks ten questions with known outcomes: six the document
answers, three near misses (on-topic, but the fact is not in it) and one off-topic.
Each question runs under three setups so the effect of each abstention gate shows:
no gates, the retrieval-similarity gate, and the similarity gate plus the reader.

This is a spot check on one document, not a benchmark; see scripts/benchmark.py
for calibrated numbers.

``--retrieval`` instead measures retrieval inside the document with and without
contextual chunk headers, on 22 questions graded by verbatim evidence spans
(data/nist_rmf/eval.jsonl).

Usage (needs the embed, reader and pdf extras; downloads ~200 MB of models once):
  python scripts/real_pdf_check.py
  python scripts/real_pdf_check.py --retrieval   # embed and pdf extras only
"""
from __future__ import annotations

import argparse
import sys
import tempfile
import time
from pathlib import Path

import httpx

ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))

from lumen_rag import config
from lumen_rag.embeddings import FastEmbedEmbedder, HashingEmbedder
from lumen_rag.engine import RagEngine
from lumen_rag.eval.evidence import evaluate_evidence, load_evidence_cases
from lumen_rag.ingestion.loaders import load_file
from lumen_rag.ingestion.pipeline import ingest_documents
from lumen_rag.retrieval import Retriever
from lumen_rag.store import VectorStore

PDF_URL = "https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.100-1.pdf"
READER = "onnx-community/roberta-base-squad2-ONNX"

# (kind, question): "answerable", "near-miss" or "off-topic".
QUESTIONS = [
    ("answerable", "Who was the Secretary of Commerce when the AI RMF was published?"),
    ("answerable", "Which function is designed to be cross-cutting across the other three?"),
    ("answerable", "What does TEVV stand for?"),
    ("answerable", "Where can comments on the AI RMF Playbook be sent?"),
    ("answerable", "When is a formal review of the framework expected to take place?"),
    ("answerable", "What are the characteristics of trustworthy AI systems?"),
    ("near-miss", "When will version 2.0 of the AI RMF be released?"),
    ("near-miss", "How much does AI RMF certification cost?"),
    ("near-miss", "What fine does an organization pay for not following the AI RMF?"),
    ("off-topic", "What is the boiling point of water at sea level?"),
]

SETUPS = [
    ("no gates", None, ""),
    ("similarity >= 0.66", 0.66, ""),
    ("similarity >= 0.66, then reader >= -3.5", 0.66, READER),
]


def download(tmp: str) -> Path:
    pdf = Path(tmp) / "NIST.AI.100-1.pdf"
    print(f"Downloading {PDF_URL}")
    # nvlpubs.nist.gov refuses Python's default user agent.
    resp = httpx.get(PDF_URL, follow_redirects=True, timeout=60, headers={"User-Agent": "lumen-rag/0.1"})
    resp.raise_for_status()
    pdf.write_bytes(resp.content)
    return pdf


def retrieval(pdf: Path) -> None:
    """hit@5 and MRR inside the document, with and without contextual chunk headers."""
    cases = load_evidence_cases(ROOT / "data" / "nist_rmf" / "eval.jsonl")
    doc = load_file(pdf)
    print(f"{len(cases)} questions graded by evidence spans, k=5, 120-word chunks\n")
    print("| Embedder | Mode | hit@5 | MRR | hit@5 + headers | MRR + headers |")
    print("|---|---|---|---|---|---|")
    for name, embedder in (("hashing", HashingEmbedder()), ("bge-small", FastEmbedEmbedder())):
        stores = {}
        for ctx in (False, True):
            stores[ctx] = VectorStore(embedder.dim)
            ingest_documents([doc], store=stores[ctx], embedder=embedder, contextual=ctx)
        for mode in ("vector", "bm25", "hybrid"):
            plain, headed = (
                evaluate_evidence(Retriever(stores[ctx], embedder, reranker=None), cases, k=5, mode=mode)
                for ctx in (False, True)
            )
            print(
                f"| {name} | {mode} | {plain['hit@k']:.2f} | {plain['mrr']:.2f} "
                f"| {headed['hit@k']:.2f} | {headed['mrr']:.2f} |"
            )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    parser.add_argument("--retrieval", action="store_true", help="compare retrieval with and without headers")
    args = parser.parse_args()
    config.settings.embedder = "fastembed"
    config.settings.reader_threshold = -3.5
    with tempfile.TemporaryDirectory() as tmp:
        pdf = download(tmp)
        if args.retrieval:
            retrieval(pdf)
            return
        engine = RagEngine()
        doc = load_file(pdf)
        total = engine.add_documents([doc])
        print(f"Indexed {pdf.name}: {total} chunks\n")

        summary = []
        for name, threshold, reader in SETUPS:
            config.settings.abstain_threshold = threshold
            config.settings.reader_model = reader
            right = 0
            print(f"=== {name}")
            for kind, question in QUESTIONS:
                start = time.perf_counter()
                result = engine.query(question, k=5, mode="vector")
                ms = (time.perf_counter() - start) * 1000
                ok = result.abstained == (kind != "answerable")
                right += ok
                sim = "-" if result.confidence is None else f"{result.confidence:.3f}"
                read = "-" if result.answerability is None else f"{result.answerability:+.1f}"
                verdict = "refused " if result.abstained else "answered"
                print(f"  [{'ok' if ok else 'XX'}] {kind:10} {verdict} sim={sim} reader={read} {ms:5.0f} ms  {question}")
            summary.append((name, right))
            print()

        print("| Setup | Right outcome |")
        print("|---|---|")
        for name, right in summary:
            print(f"| {name} | {right}/{len(QUESTIONS)} |")


if __name__ == "__main__":
    main()