Space README + Gradio entrypoint
Browse files- .claude/skills/verify/SKILL.md +44 -0
- .env.example +10 -0
- .github/workflows/ci.yml +28 -0
- .gitignore +15 -0
- Dockerfile +11 -0
- LICENSE +21 -0
- Makefile +18 -0
- README.md +14 -7
- app.py +113 -0
- benchmark_results.json +36 -0
- data/docs/code-security.md +56 -0
- data/docs/data-eng.md +57 -0
- data/docs/deploys-ops.md +51 -0
- data/docs/finance-misc.md +22 -0
- data/docs/office-facilities.md +23 -0
- data/docs/people-ops.md +58 -0
- data/docs/support-postmortem.md +49 -0
- data/eval.jsonl +19 -0
- docs/case-study.md +72 -0
- lumen_rag/__init__.py +3 -0
- lumen_rag/api/__init__.py +3 -0
- lumen_rag/api/app.py +174 -0
- lumen_rag/api/static/index.html +168 -0
- lumen_rag/cli.py +89 -0
- lumen_rag/config.py +26 -0
- lumen_rag/embeddings.py +79 -0
- lumen_rag/engine.py +36 -0
- lumen_rag/eval/__init__.py +13 -0
- lumen_rag/eval/harness.py +107 -0
- lumen_rag/eval/metrics.py +58 -0
- lumen_rag/ingestion/__init__.py +5 -0
- lumen_rag/ingestion/chunker.py +44 -0
- lumen_rag/ingestion/loaders.py +111 -0
- lumen_rag/ingestion/pipeline.py +49 -0
- lumen_rag/llm.py +111 -0
- lumen_rag/retrieval/__init__.py +4 -0
- lumen_rag/retrieval/bm25.py +130 -0
- lumen_rag/retrieval/retriever.py +87 -0
- lumen_rag/store.py +74 -0
- pyproject.toml +43 -0
- requirements.txt +1 -0
- scripts/benchmark.py +83 -0
- scripts/check_eval.py +80 -0
- uv.lock +0 -0
.claude/skills/verify/SKILL.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: verify
|
| 3 |
+
description: How to build and run lumen-rag to verify a change
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Verifying lumen-rag changes
|
| 7 |
+
|
| 8 |
+
No Chrome/CDP available in this WSL box (`which google-chrome chromium` → nothing) —
|
| 9 |
+
browser-harness will fail with `DevToolsActivePort not found`. Don't waste time on it.
|
| 10 |
+
|
| 11 |
+
## CLI / core engine
|
| 12 |
+
```
|
| 13 |
+
uv venv /tmp/.../scratchpad/.venv
|
| 14 |
+
uv pip install --python /tmp/.../scratchpad/.venv/bin/python -e .
|
| 15 |
+
source /tmp/.../scratchpad/.venv/bin/activate
|
| 16 |
+
lumen ingest data/docs && lumen ask "..." && lumen eval data/eval.jsonl --k 3
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
## Gradio app (deploy/hf-space/gradio_app.py)
|
| 20 |
+
No browser to drive it visually — instead exercise the exact same Blocks event
|
| 21 |
+
endpoints the UI buttons call, via `gradio_client` (same code path as a click,
|
| 22 |
+
not a unit-test import):
|
| 23 |
+
|
| 24 |
+
```
|
| 25 |
+
uv pip install --python .../venv/bin/python -e . gradio gradio_client
|
| 26 |
+
GRADIO_SERVER_PORT=7871 .../venv/bin/python deploy/hf-space/gradio_app.py &
|
| 27 |
+
python - <<'PY'
|
| 28 |
+
from gradio_client import Client, handle_file
|
| 29 |
+
c = Client("http://127.0.0.1:7871/")
|
| 30 |
+
c.predict(api_name="/load_sample")
|
| 31 |
+
c.predict("question", 5, "hybrid", api_name="/ask")
|
| 32 |
+
c.predict(5, api_name="/run_eval")
|
| 33 |
+
c.predict(api_name="/reset_index")
|
| 34 |
+
c.predict([handle_file("path")], api_name="/upload_files")
|
| 35 |
+
PY
|
| 36 |
+
```
|
| 37 |
+
Endpoint names are the Python function names (`/load_sample`, `/ask`, `/run_eval`,
|
| 38 |
+
`/reset_index`, `/upload_files`) as declared by the `.click()`/`.upload()` wiring
|
| 39 |
+
in `gradio_app.py`. Kill the server after with `pkill -f gradio_app.py`.
|
| 40 |
+
|
| 41 |
+
## Deploy script (deploy/hf-space/push.sh)
|
| 42 |
+
Force-pushes to a real HF Space — destructive/irreversible, don't run it live.
|
| 43 |
+
Review by diff only; check README.md `app_file:` matches whatever push.sh copies
|
| 44 |
+
to `app.py` in the worktree.
|
.env.example
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LLM + embedding provider (optional).
|
| 2 |
+
# Leave OPENAI_API_KEY unset to run fully offline with the built-in
|
| 3 |
+
# deterministic hashing embedder — great for tests, demos, and CI.
|
| 4 |
+
OPENAI_API_KEY=""
|
| 5 |
+
OPENAI_BASE_URL=""
|
| 6 |
+
EMBED_MODEL="text-embedding-3-small"
|
| 7 |
+
CHAT_MODEL="gpt-4o-mini"
|
| 8 |
+
|
| 9 |
+
# Where the vector index is persisted.
|
| 10 |
+
LUMEN_INDEX_DIR=".lumen_index"
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
pull_request:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
test:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
strategy:
|
| 12 |
+
matrix:
|
| 13 |
+
python-version: ["3.10", "3.12"]
|
| 14 |
+
steps:
|
| 15 |
+
- uses: actions/checkout@v4
|
| 16 |
+
- uses: actions/setup-python@v5
|
| 17 |
+
with:
|
| 18 |
+
python-version: ${{ matrix.python-version }}
|
| 19 |
+
cache: pip
|
| 20 |
+
- run: pip install -e ".[dev]"
|
| 21 |
+
- name: Lint
|
| 22 |
+
run: ruff check lumen_rag tests
|
| 23 |
+
- name: Test (offline mode — no API key)
|
| 24 |
+
run: pytest -q
|
| 25 |
+
- name: Eval regression guard
|
| 26 |
+
run: |
|
| 27 |
+
lumen ingest data/docs
|
| 28 |
+
python scripts/check_eval.py data/eval.jsonl --k 3 --mode hybrid
|
.gitignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.egg-info/
|
| 4 |
+
.eggs/
|
| 5 |
+
build/
|
| 6 |
+
dist/
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
.env
|
| 10 |
+
.pytest_cache/
|
| 11 |
+
.ruff_cache/
|
| 12 |
+
*.npz
|
| 13 |
+
.lumen_index/
|
| 14 |
+
.DS_Store
|
| 15 |
+
.claude-flow/
|
Dockerfile
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
|
| 5 |
+
|
| 6 |
+
COPY pyproject.toml README.md ./
|
| 7 |
+
COPY lumen_rag ./lumen_rag
|
| 8 |
+
RUN pip install --no-cache-dir ".[openai]"
|
| 9 |
+
|
| 10 |
+
EXPOSE 8000
|
| 11 |
+
CMD ["lumen", "serve", "--host", "0.0.0.0", "--port", "8000"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Rishabh Verma
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
Makefile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: install test lint demo serve
|
| 2 |
+
|
| 3 |
+
install:
|
| 4 |
+
pip install -e ".[dev,openai]"
|
| 5 |
+
|
| 6 |
+
test:
|
| 7 |
+
pytest -q
|
| 8 |
+
|
| 9 |
+
lint:
|
| 10 |
+
ruff check lumen_rag tests
|
| 11 |
+
|
| 12 |
+
demo:
|
| 13 |
+
lumen ingest data/docs
|
| 14 |
+
lumen ask "How many approvals does a billing change need?"
|
| 15 |
+
lumen eval data/eval.jsonl --k 3
|
| 16 |
+
|
| 17 |
+
serve:
|
| 18 |
+
lumen serve
|
README.md
CHANGED
|
@@ -1,13 +1,20 @@
|
|
| 1 |
---
|
| 2 |
-
title: Lumen
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.20.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
|
|
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Lumen RAG
|
| 3 |
+
emoji: 🔦
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "6.20.0"
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
short_description: Transparent, evaluated hybrid RAG demo
|
| 10 |
pinned: false
|
| 11 |
+
license: mit
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Lumen RAG — live demo
|
| 15 |
+
|
| 16 |
+
Upload documents, ask questions, and watch retrieval quality (recall@k, MRR,
|
| 17 |
+
nDCG@k) render live from the eval harness. Runs 100% offline on a
|
| 18 |
+
deterministic hashing embedder — no API key required.
|
| 19 |
+
|
| 20 |
+
Source: https://github.com/WickTech/lumen-rag
|
app.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio front-end for the Lumen RAG Space (free-tier CPU, no Docker needed).
|
| 2 |
+
|
| 3 |
+
Wraps `lumen_rag.RagEngine` directly in-process — no HTTP layer.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
|
| 11 |
+
from lumen_rag.engine import RagEngine
|
| 12 |
+
from lumen_rag.eval import evaluate
|
| 13 |
+
from lumen_rag.eval.harness import load_cases
|
| 14 |
+
from lumen_rag.ingestion.loaders import _LOADERS, load_file
|
| 15 |
+
from lumen_rag.retrieval import Retriever
|
| 16 |
+
|
| 17 |
+
_SAMPLE_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "docs"
|
| 18 |
+
_EVAL_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "eval.jsonl"
|
| 19 |
+
|
| 20 |
+
engine = RagEngine()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def load_sample() -> str:
|
| 24 |
+
files = sorted(p for p in _SAMPLE_DIR.iterdir() if p.suffix.lower() in _LOADERS)
|
| 25 |
+
docs = [load_file(p) for p in files]
|
| 26 |
+
total = engine.add_documents(docs)
|
| 27 |
+
return f"Indexed {len(docs)} sample files, {total} chunks."
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def upload_files(files: list[str]) -> str:
|
| 31 |
+
docs = []
|
| 32 |
+
for f in files:
|
| 33 |
+
suffix = Path(f).suffix.lower()
|
| 34 |
+
if suffix not in _LOADERS:
|
| 35 |
+
continue
|
| 36 |
+
doc = load_file(f)
|
| 37 |
+
doc["id"] = Path(f).stem
|
| 38 |
+
docs.append(doc)
|
| 39 |
+
if not docs:
|
| 40 |
+
return f"No supported files. Supported: {sorted(_LOADERS)}"
|
| 41 |
+
total = engine.add_documents(docs)
|
| 42 |
+
return f"Indexed {len(docs)} files, {total} chunks."
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def reset_index() -> str:
|
| 46 |
+
global engine
|
| 47 |
+
engine = RagEngine()
|
| 48 |
+
return "Index reset."
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def ask(question: str, k: int, mode: str):
|
| 52 |
+
if len(engine.store) == 0:
|
| 53 |
+
return "Index is empty — load the sample corpus or upload files first.", ""
|
| 54 |
+
result = engine.query(question, k=int(k), mode=mode)
|
| 55 |
+
citations = "\n".join(
|
| 56 |
+
f"[{c['n']}] {c['doc_id']} — {c['source']} (score={c['score']:.3f})"
|
| 57 |
+
for c in result.citations
|
| 58 |
+
)
|
| 59 |
+
return result.text, citations
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def run_eval(k: int) -> str:
|
| 63 |
+
if not _EVAL_PATH.exists():
|
| 64 |
+
return "Bundled eval set not found."
|
| 65 |
+
if len(engine.store) == 0:
|
| 66 |
+
return "Index is empty — load the sample corpus first."
|
| 67 |
+
cases = load_cases(_EVAL_PATH)
|
| 68 |
+
report = evaluate(Retriever(engine.store, engine.embedder), cases, k=int(k))
|
| 69 |
+
d = report.as_dict()
|
| 70 |
+
return "\n".join(f"{key}: {value}" for key, value in d.items())
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
with gr.Blocks(title="Lumen RAG") as demo:
|
| 74 |
+
gr.Markdown(
|
| 75 |
+
"# Lumen RAG\n"
|
| 76 |
+
"Transparent, evaluated RAG: ingest documents, retrieve with hybrid "
|
| 77 |
+
"vector+BM25 search, answer with citations. Runs 100% offline on a "
|
| 78 |
+
"deterministic hashing embedder — no API key required."
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
with gr.Row():
|
| 82 |
+
sample_btn = gr.Button("Load sample corpus")
|
| 83 |
+
reset_btn = gr.Button("Reset index")
|
| 84 |
+
upload = gr.File(label="Or upload documents", file_count="multiple")
|
| 85 |
+
status = gr.Textbox(label="Index status", interactive=False)
|
| 86 |
+
|
| 87 |
+
sample_btn.click(load_sample, outputs=status)
|
| 88 |
+
reset_btn.click(reset_index, outputs=status)
|
| 89 |
+
upload.upload(upload_files, inputs=upload, outputs=status)
|
| 90 |
+
|
| 91 |
+
gr.Markdown("---")
|
| 92 |
+
|
| 93 |
+
question = gr.Textbox(label="Question")
|
| 94 |
+
with gr.Row():
|
| 95 |
+
k = gr.Slider(1, 20, value=5, step=1, label="k")
|
| 96 |
+
mode = gr.Dropdown(["hybrid", "vector", "bm25"], value="hybrid", label="Retrieval mode")
|
| 97 |
+
ask_btn = gr.Button("Ask", variant="primary")
|
| 98 |
+
answer_box = gr.Textbox(label="Answer", lines=4)
|
| 99 |
+
citations_box = gr.Textbox(label="Citations", lines=4)
|
| 100 |
+
|
| 101 |
+
ask_btn.click(ask, inputs=[question, k, mode], outputs=[answer_box, citations_box])
|
| 102 |
+
|
| 103 |
+
gr.Markdown("---")
|
| 104 |
+
|
| 105 |
+
with gr.Row():
|
| 106 |
+
eval_k = gr.Slider(1, 20, value=5, step=1, label="eval k")
|
| 107 |
+
eval_btn = gr.Button("Run retrieval eval (recall@k, MRR, nDCG@k)")
|
| 108 |
+
eval_box = gr.Textbox(label="Eval report", lines=6)
|
| 109 |
+
eval_btn.click(run_eval, inputs=eval_k, outputs=eval_box)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
demo.launch()
|
benchmark_results.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"k": 5,
|
| 3 |
+
"n_cases": 19,
|
| 4 |
+
"results": [
|
| 5 |
+
{
|
| 6 |
+
"config": "naive (1 chunk/doc, vector-only)",
|
| 7 |
+
"k": 5,
|
| 8 |
+
"n_cases": 19,
|
| 9 |
+
"recall@k": 0.9737,
|
| 10 |
+
"precision@k": 0.2,
|
| 11 |
+
"mrr": 0.9342,
|
| 12 |
+
"ndcg@k": 0.9418,
|
| 13 |
+
"hit_rate": 1.0
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"config": "+ sentence chunking (vector-only)",
|
| 17 |
+
"k": 5,
|
| 18 |
+
"n_cases": 19,
|
| 19 |
+
"recall@k": 0.9737,
|
| 20 |
+
"precision@k": 0.2,
|
| 21 |
+
"mrr": 0.9737,
|
| 22 |
+
"ndcg@k": 0.9677,
|
| 23 |
+
"hit_rate": 1.0
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"config": "+ hybrid (BM25 + RRF)",
|
| 27 |
+
"k": 5,
|
| 28 |
+
"n_cases": 19,
|
| 29 |
+
"recall@k": 0.9737,
|
| 30 |
+
"precision@k": 0.2,
|
| 31 |
+
"mrr": 0.9737,
|
| 32 |
+
"ndcg@k": 0.9677,
|
| 33 |
+
"hit_rate": 1.0
|
| 34 |
+
}
|
| 35 |
+
]
|
| 36 |
+
}
|
data/docs/code-security.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Engineering handbook — code & security
|
| 2 |
+
|
| 3 |
+
## Code review
|
| 4 |
+
Every pull request requires at least one approval. Changes to billing or
|
| 5 |
+
auth code require two approvals, including one from a senior engineer. CI
|
| 6 |
+
must be green before merge.
|
| 7 |
+
|
| 8 |
+
## Billing system changes
|
| 9 |
+
Any change touching the billing pipeline, invoicing, or payment provider
|
| 10 |
+
integration requires sign-off from the billing tech lead in addition to the
|
| 11 |
+
standard two code-review approvals. Billing migrations must be dry-run
|
| 12 |
+
against a staging replica before running in production. Refund logic
|
| 13 |
+
changes require a finance stakeholder review.
|
| 14 |
+
|
| 15 |
+
## Security incident response
|
| 16 |
+
Suspected security incidents (credential leaks, unauthorized access, data
|
| 17 |
+
exposure) must be reported to the security channel within 15 minutes of
|
| 18 |
+
discovery, faster than the standard on-call acknowledgment window. The
|
| 19 |
+
security lead triages severity and decides whether to invoke the incident
|
| 20 |
+
commander process. All security incidents get a postmortem regardless of
|
| 21 |
+
severity, unlike ordinary incidents which only require one at severity-1.
|
| 22 |
+
|
| 23 |
+
## Secrets management
|
| 24 |
+
Secrets (API keys, database credentials, signing keys) are stored in the
|
| 25 |
+
central secrets manager, never in source control or environment files
|
| 26 |
+
committed to git. Secrets are rotated automatically every 90 days;
|
| 27 |
+
production database credentials rotate every 30 days. Access to production
|
| 28 |
+
secrets requires a just-in-time approval, logged and reviewed weekly.
|
| 29 |
+
|
| 30 |
+
## Linter and formatting rules
|
| 31 |
+
All Python code is formatted with ruff and must pass linting in CI before
|
| 32 |
+
merge; JavaScript code uses prettier with the shared team config. Style
|
| 33 |
+
disagreements that aren't caught by the linter are left to reviewer
|
| 34 |
+
discretion rather than escalated.
|
| 35 |
+
|
| 36 |
+
## Dependency updates
|
| 37 |
+
Automated dependency update bots open pull requests weekly for minor and
|
| 38 |
+
patch version bumps; major version bumps require a manual review from the
|
| 39 |
+
package owner and a changelog read-through before merge.
|
| 40 |
+
|
| 41 |
+
## Team communication norms
|
| 42 |
+
Teams default to public channels over DMs for anything work-related, so
|
| 43 |
+
context stays searchable. Cross-team requests go through a dedicated
|
| 44 |
+
request channel rather than pinging individuals directly, and response-time
|
| 45 |
+
expectations there are best-effort, not an SLA.
|
| 46 |
+
|
| 47 |
+
## Tooling procurement
|
| 48 |
+
New SaaS tool requests over $500/year go through a lightweight procurement
|
| 49 |
+
review covering security and data-handling questions before purchase.
|
| 50 |
+
Renewals under the same terms skip the review and are approved by finance
|
| 51 |
+
automatically.
|
| 52 |
+
|
| 53 |
+
## Internal wiki hygiene
|
| 54 |
+
Wiki pages without an update in 12 months are flagged stale and surfaced in
|
| 55 |
+
a quarterly cleanup pass; owners either refresh or archive them. Search
|
| 56 |
+
ranking on the wiki favors recently-edited pages over older ones.
|
data/docs/data-eng.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Engineering handbook — data & platform
|
| 2 |
+
|
| 3 |
+
## Data retention policy
|
| 4 |
+
Customer data is retained for the duration of the account plus 90 days
|
| 5 |
+
after deletion, to allow for accidental-deletion recovery. Application logs
|
| 6 |
+
are retained for 30 days; security audit logs are retained for 1 year to
|
| 7 |
+
satisfy compliance requirements. Backups are encrypted at rest and rotated
|
| 8 |
+
every 24 hours with a 14-day retention window.
|
| 9 |
+
|
| 10 |
+
## Database migrations
|
| 11 |
+
Schema migrations must be backward-compatible with the previous application
|
| 12 |
+
version to support zero-downtime deploys: add columns as nullable first,
|
| 13 |
+
backfill, then enforce constraints in a follow-up migration. Migrations
|
| 14 |
+
affecting tables over 10 million rows require a review from the database
|
| 15 |
+
team and must run online without locking writes.
|
| 16 |
+
|
| 17 |
+
## API versioning
|
| 18 |
+
Public APIs are versioned in the URL path (/v1/, /v2/) and each version is
|
| 19 |
+
supported for at least 12 months after the next version ships. Breaking
|
| 20 |
+
changes require a new major version; additive changes (new optional fields)
|
| 21 |
+
can ship within the current version. Deprecation notices go out at least 90
|
| 22 |
+
days before a version is sunset.
|
| 23 |
+
|
| 24 |
+
## Feature flags
|
| 25 |
+
New user-facing features must ship behind a feature flag unless the change
|
| 26 |
+
is a pure bugfix. Flags default to off in production and are rolled out
|
| 27 |
+
gradually: 1% -> 10% -> 50% -> 100%, with at least a 24-hour soak at each
|
| 28 |
+
stage for risky changes. Stale flags older than 90 days are flagged for
|
| 29 |
+
cleanup in the quarterly flag audit.
|
| 30 |
+
|
| 31 |
+
## Internal dashboards
|
| 32 |
+
Engineering metrics dashboards refresh every 15 minutes and pull from the
|
| 33 |
+
same warehouse tables used for the quarterly business review. Dashboard
|
| 34 |
+
access is open to all engineers; editing dashboard definitions requires the
|
| 35 |
+
data-platform team's review.
|
| 36 |
+
|
| 37 |
+
## Data warehouse costs
|
| 38 |
+
Ad-hoc warehouse queries over 1TB scanned trigger an automatic Slack alert
|
| 39 |
+
to the requester and the data-platform on-call, since large ad-hoc queries
|
| 40 |
+
are the leading cause of unexpected warehouse cost spikes.
|
| 41 |
+
|
| 42 |
+
## Team communication norms
|
| 43 |
+
Teams default to public channels over DMs for anything work-related, so
|
| 44 |
+
context stays searchable. Cross-team requests go through a dedicated
|
| 45 |
+
request channel rather than pinging individuals directly, and response-time
|
| 46 |
+
expectations there are best-effort, not an SLA.
|
| 47 |
+
|
| 48 |
+
## Tooling procurement
|
| 49 |
+
New SaaS tool requests over $500/year go through a lightweight procurement
|
| 50 |
+
review covering security and data-handling questions before purchase.
|
| 51 |
+
Renewals under the same terms skip the review and are approved by finance
|
| 52 |
+
automatically.
|
| 53 |
+
|
| 54 |
+
## Internal wiki hygiene
|
| 55 |
+
Wiki pages without an update in 12 months are flagged stale and surfaced in
|
| 56 |
+
a quarterly cleanup pass; owners either refresh or archive them. Search
|
| 57 |
+
ranking on the wiki favors recently-edited pages over older ones.
|
data/docs/deploys-ops.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Engineering handbook — deploys & operations
|
| 2 |
+
|
| 3 |
+
## Deployments
|
| 4 |
+
We deploy to production every weekday at 4pm using a blue-green strategy.
|
| 5 |
+
Rollbacks are automatic if error rates exceed 2% within the first five
|
| 6 |
+
minutes. Hotfixes may be deployed outside the window with approval from an
|
| 7 |
+
on-call lead.
|
| 8 |
+
|
| 9 |
+
## On-call
|
| 10 |
+
On-call rotations last one week and run Monday to Monday. The primary
|
| 11 |
+
on-call engineer must acknowledge pages within 15 minutes. Secondary on-call
|
| 12 |
+
is the fallback after 30 minutes of no response.
|
| 13 |
+
|
| 14 |
+
## Incident response
|
| 15 |
+
Severity-1 incidents require a written postmortem within 48 hours.
|
| 16 |
+
Postmortems are blameless and focus on systemic fixes, not individual fault.
|
| 17 |
+
|
| 18 |
+
## Disaster recovery
|
| 19 |
+
The disaster recovery plan targets a recovery time objective (RTO) of 4
|
| 20 |
+
hours and a recovery point objective (RPO) of 15 minutes for the primary
|
| 21 |
+
database. Full DR drills, including a simulated region failover, run twice a
|
| 22 |
+
year. Runbooks are stored outside the primary cloud region so they remain
|
| 23 |
+
accessible during a regional outage.
|
| 24 |
+
|
| 25 |
+
## Internal tooling access
|
| 26 |
+
Engineers request access to internal admin dashboards through the access
|
| 27 |
+
portal; approval routes to the resource owner and typically completes
|
| 28 |
+
within one business day. Read-only access to production dashboards is
|
| 29 |
+
granted by default to all engineers on day one.
|
| 30 |
+
|
| 31 |
+
## Office network maintenance
|
| 32 |
+
Scheduled network maintenance windows run the first Sunday of each month
|
| 33 |
+
from 2am to 4am local time. Engineers relying on VPN for weekend work
|
| 34 |
+
should check the maintenance calendar before starting a task.
|
| 35 |
+
|
| 36 |
+
## Team communication norms
|
| 37 |
+
Teams default to public channels over DMs for anything work-related, so
|
| 38 |
+
context stays searchable. Cross-team requests go through a dedicated
|
| 39 |
+
request channel rather than pinging individuals directly, and response-time
|
| 40 |
+
expectations there are best-effort, not an SLA.
|
| 41 |
+
|
| 42 |
+
## Tooling procurement
|
| 43 |
+
New SaaS tool requests over $500/year go through a lightweight procurement
|
| 44 |
+
review covering security and data-handling questions before purchase.
|
| 45 |
+
Renewals under the same terms skip the review and are approved by finance
|
| 46 |
+
automatically.
|
| 47 |
+
|
| 48 |
+
## Internal wiki hygiene
|
| 49 |
+
Wiki pages without an update in 12 months are flagged stale and surfaced in
|
| 50 |
+
a quarterly cleanup pass; owners either refresh or archive them. Search
|
| 51 |
+
ranking on the wiki favors recently-edited pages over older ones.
|
data/docs/finance-misc.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Engineering handbook — finance & referrals
|
| 2 |
+
|
| 3 |
+
## Referral bonus
|
| 4 |
+
Employees who refer a candidate who is hired and passes the 90-day mark
|
| 5 |
+
receive a $3000 referral bonus, paid in the following payroll cycle.
|
| 6 |
+
Referrals for open-source contributors converting to full-time hires get an
|
| 7 |
+
additional $500.
|
| 8 |
+
|
| 9 |
+
## Conference budget
|
| 10 |
+
Each engineer has a $2000/year conference and training budget, separate
|
| 11 |
+
from the vacation and expense-policy stipends. Unused conference budget
|
| 12 |
+
does not carry over into the next year.
|
| 13 |
+
|
| 14 |
+
## Timesheets
|
| 15 |
+
Hourly contractors submit timesheets weekly by Friday 5pm; salaried
|
| 16 |
+
engineers do not track hours. Timesheet approval is required from a
|
| 17 |
+
manager before payroll processes on the following Monday.
|
| 18 |
+
|
| 19 |
+
## Brand guidelines
|
| 20 |
+
All external-facing decks and blog posts must use the approved logo and
|
| 21 |
+
color palette from the brand kit. Marketing reviews any content mentioning
|
| 22 |
+
specific customer names before publication.
|
data/docs/office-facilities.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Engineering handbook — office & facilities
|
| 2 |
+
|
| 3 |
+
## Parking and badges
|
| 4 |
+
Office badges are issued on day one and deactivated automatically 24 hours
|
| 5 |
+
after an employee's last day. Parking passes are first-come, first-served
|
| 6 |
+
and must be renewed every 6 months at the front desk.
|
| 7 |
+
|
| 8 |
+
## Swag and equipment
|
| 9 |
+
New hires receive a laptop, monitor, and welcome swag box in their first
|
| 10 |
+
week. Laptops are refreshed every 3 years, or sooner if hardware fails
|
| 11 |
+
under warranty. Monitor and peripheral requests go through the IT ticket
|
| 12 |
+
queue with a 5-day turnaround.
|
| 13 |
+
|
| 14 |
+
## Holidays calendar
|
| 15 |
+
The company observes 10 fixed holidays per year plus 2 floating holidays
|
| 16 |
+
employees can schedule with manager approval. The office is closed between
|
| 17 |
+
Christmas and New Year's Day company-wide.
|
| 18 |
+
|
| 19 |
+
## VPN and network access
|
| 20 |
+
VPN access is required for any internal tool not exposed publicly.
|
| 21 |
+
Credentials for VPN are separate from SSO and expire after 180 days of
|
| 22 |
+
inactivity. Guest wifi is isolated from the internal network and has no
|
| 23 |
+
access to internal services.
|
data/docs/people-ops.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Engineering handbook — people ops
|
| 2 |
+
|
| 3 |
+
## Vacation policy
|
| 4 |
+
Engineers accrue 20 days of paid vacation per year. Unused days carry over
|
| 5 |
+
up to a maximum of 10 days into the next calendar year.
|
| 6 |
+
|
| 7 |
+
## Expense policy
|
| 8 |
+
Employees can expense up to $100/month for home office supplies and up to
|
| 9 |
+
$50 for a monthly phone/internet stipend, separate from the vacation and
|
| 10 |
+
time-off benefits. Travel expenses require manager pre-approval for trips
|
| 11 |
+
over $500. Receipts must be submitted within 30 days of the expense.
|
| 12 |
+
|
| 13 |
+
## Remote work policy
|
| 14 |
+
Engineers may work remotely full-time; there is no mandated office
|
| 15 |
+
attendance. Remote employees must overlap at least 4 hours with their
|
| 16 |
+
team's core hours (10am-2pm in the team's primary timezone). Equipment
|
| 17 |
+
stipends of $1000 are available every 3 years for home office setup, on top
|
| 18 |
+
of the monthly $100 supply allowance.
|
| 19 |
+
|
| 20 |
+
## Hiring process
|
| 21 |
+
Engineering candidates go through a recruiter screen, a technical phone
|
| 22 |
+
screen, and a four-part onsite loop covering coding, system design,
|
| 23 |
+
debugging, and values fit. Hiring decisions require consensus from at least
|
| 24 |
+
3 of 4 interviewers; a single strong no blocks an offer regardless of other
|
| 25 |
+
scores. Offers require approval from the hiring manager's director.
|
| 26 |
+
|
| 27 |
+
## Performance reviews
|
| 28 |
+
Performance reviews run twice a year, in April and October. Each review
|
| 29 |
+
includes self-assessment, peer feedback from 3-5 nominated colleagues, and a
|
| 30 |
+
manager writeup. Ratings range from "not meeting" to "significantly
|
| 31 |
+
exceeds" and directly inform the twice-yearly compensation adjustment cycle.
|
| 32 |
+
|
| 33 |
+
## Company swag
|
| 34 |
+
New team members receive a welcome kit with a branded hoodie, water bottle,
|
| 35 |
+
and stickers, shipped to their home address within the first two weeks.
|
| 36 |
+
Additional swag requests for conferences go through the marketing team.
|
| 37 |
+
|
| 38 |
+
## Meeting norms
|
| 39 |
+
Recurring meetings without a clear agenda are cancelled automatically by
|
| 40 |
+
the calendar bot after two no-shows. Engineers are encouraged to default to
|
| 41 |
+
async written updates over synchronous meetings when possible.
|
| 42 |
+
|
| 43 |
+
## Team communication norms
|
| 44 |
+
Teams default to public channels over DMs for anything work-related, so
|
| 45 |
+
context stays searchable. Cross-team requests go through a dedicated
|
| 46 |
+
request channel rather than pinging individuals directly, and response-time
|
| 47 |
+
expectations there are best-effort, not an SLA.
|
| 48 |
+
|
| 49 |
+
## Tooling procurement
|
| 50 |
+
New SaaS tool requests over $500/year go through a lightweight procurement
|
| 51 |
+
review covering security and data-handling questions before purchase.
|
| 52 |
+
Renewals under the same terms skip the review and are approved by finance
|
| 53 |
+
automatically.
|
| 54 |
+
|
| 55 |
+
## Internal wiki hygiene
|
| 56 |
+
Wiki pages without an update in 12 months are flagged stale and surfaced in
|
| 57 |
+
a quarterly cleanup pass; owners either refresh or archive them. Search
|
| 58 |
+
ranking on the wiki favors recently-edited pages over older ones.
|
data/docs/support-postmortem.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Engineering handbook — support & postmortems
|
| 2 |
+
|
| 3 |
+
## Authentication
|
| 4 |
+
All internal services authenticate via short-lived OAuth2 tokens issued by
|
| 5 |
+
the identity provider; tokens expire after 60 minutes and must be refreshed
|
| 6 |
+
by the client. Service-to-service calls use mutual TLS in addition to
|
| 7 |
+
tokens. Personal access tokens for CLI tools expire after 90 days and are
|
| 8 |
+
scoped to a single project.
|
| 9 |
+
|
| 10 |
+
## Customer support escalation
|
| 11 |
+
Support tickets tagged "urgent" must be triaged within 1 hour during
|
| 12 |
+
business hours. Enterprise customers on the premium support tier get a
|
| 13 |
+
15-minute first-response SLA, the same acknowledgment window as internal
|
| 14 |
+
on-call pages. Escalations to engineering go through the on-call engineer,
|
| 15 |
+
not directly to individual contributors.
|
| 16 |
+
|
| 17 |
+
## Postmortem template
|
| 18 |
+
Every postmortem must include a timeline, root cause, blast radius, and at
|
| 19 |
+
least three concrete follow-up action items with owners and due dates.
|
| 20 |
+
Postmortems for severity-1 incidents are reviewed in the weekly engineering
|
| 21 |
+
sync; severity-2 postmortems are reviewed asynchronously. Templates live in
|
| 22 |
+
the incident-response wiki space.
|
| 23 |
+
|
| 24 |
+
## Documentation standards
|
| 25 |
+
Public-facing API docs are generated from OpenAPI specs and rebuilt on
|
| 26 |
+
every merge to main; internal runbooks are written in markdown and stored
|
| 27 |
+
alongside the service they document, not in a separate wiki.
|
| 28 |
+
|
| 29 |
+
## Support tooling
|
| 30 |
+
Support agents use a shared ticketing queue with automatic tagging based on
|
| 31 |
+
keyword rules; tickets that go untagged for more than 10 minutes are
|
| 32 |
+
flagged for manual triage by a team lead.
|
| 33 |
+
|
| 34 |
+
## Team communication norms
|
| 35 |
+
Teams default to public channels over DMs for anything work-related, so
|
| 36 |
+
context stays searchable. Cross-team requests go through a dedicated
|
| 37 |
+
request channel rather than pinging individuals directly, and response-time
|
| 38 |
+
expectations there are best-effort, not an SLA.
|
| 39 |
+
|
| 40 |
+
## Tooling procurement
|
| 41 |
+
New SaaS tool requests over $500/year go through a lightweight procurement
|
| 42 |
+
review covering security and data-handling questions before purchase.
|
| 43 |
+
Renewals under the same terms skip the review and are approved by finance
|
| 44 |
+
automatically.
|
| 45 |
+
|
| 46 |
+
## Internal wiki hygiene
|
| 47 |
+
Wiki pages without an update in 12 months are flagged stale and surfaced in
|
| 48 |
+
a quarterly cleanup pass; owners either refresh or archive them. Search
|
| 49 |
+
ranking on the wiki favors recently-edited pages over older ones.
|
data/eval.jsonl
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"question": "When do we deploy to production?", "relevant_doc_ids": ["deploys-ops"]}
|
| 2 |
+
{"question": "How many approvals does a billing change need?", "relevant_doc_ids": ["code-security"]}
|
| 3 |
+
{"question": "How long is an on-call rotation?", "relevant_doc_ids": ["deploys-ops"]}
|
| 4 |
+
{"question": "How many vacation days carry over?", "relevant_doc_ids": ["people-ops"]}
|
| 5 |
+
{"question": "When is a postmortem required?", "relevant_doc_ids": ["deploys-ops", "code-security"]}
|
| 6 |
+
{"question": "How long do OAuth tokens last before they need to be refreshed?", "relevant_doc_ids": ["support-postmortem"]}
|
| 7 |
+
{"question": "How fast do I need to report a suspected credential leak?", "relevant_doc_ids": ["code-security"]}
|
| 8 |
+
{"question": "What's the monthly stipend for home office supplies?", "relevant_doc_ids": ["people-ops"]}
|
| 9 |
+
{"question": "How many core hours must remote engineers overlap with their team?", "relevant_doc_ids": ["people-ops"]}
|
| 10 |
+
{"question": "How many interviewers need to agree for a hiring offer?", "relevant_doc_ids": ["people-ops"]}
|
| 11 |
+
{"question": "How often do performance reviews happen?", "relevant_doc_ids": ["people-ops"]}
|
| 12 |
+
{"question": "How long are security audit logs kept?", "relevant_doc_ids": ["data-eng"]}
|
| 13 |
+
{"question": "What does a postmortem document need to include?", "relevant_doc_ids": ["support-postmortem"]}
|
| 14 |
+
{"question": "What's the rollout sequence for a new feature flag?", "relevant_doc_ids": ["data-eng"]}
|
| 15 |
+
{"question": "How do you migrate a database schema without downtime?", "relevant_doc_ids": ["data-eng"]}
|
| 16 |
+
{"question": "How long is an API version supported after the next version ships?", "relevant_doc_ids": ["data-eng"]}
|
| 17 |
+
{"question": "What's the first-response SLA for premium support customers?", "relevant_doc_ids": ["support-postmortem"]}
|
| 18 |
+
{"question": "What's the recovery time objective for the primary database?", "relevant_doc_ids": ["deploys-ops"]}
|
| 19 |
+
{"question": "How often do production database credentials rotate?", "relevant_doc_ids": ["code-security"]}
|
docs/case-study.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Case study: what chunking and hybrid retrieval actually buy you
|
| 2 |
+
|
| 3 |
+
Lumen ships an eval harness (`lumen_rag/eval/`) specifically so claims like
|
| 4 |
+
"chunking helps" or "hybrid retrieval helps" don't have to stay vibes. This
|
| 5 |
+
is the measurement behind the numbers in the README.
|
| 6 |
+
|
| 7 |
+
## Setup
|
| 8 |
+
|
| 9 |
+
- **Corpus**: `data/docs/` — 7 markdown documents (~2,250 words total), each
|
| 10 |
+
covering multiple unrelated handbook topics per file (e.g. `deploys-ops.md`
|
| 11 |
+
bundles deployments, on-call, incident response, disaster recovery, plus
|
| 12 |
+
three unrelated filler sections). This mirrors a real internal wiki page:
|
| 13 |
+
long, multi-topic, with the answer to any given question living in one
|
| 14 |
+
paragraph out of several.
|
| 15 |
+
- **Eval set**: `data/eval.jsonl` — 19 labelled questions, each with the
|
| 16 |
+
correct source document id(s).
|
| 17 |
+
- **Embedder**: the offline deterministic `HashingEmbedder` (bag-of-words
|
| 18 |
+
feature hashing, L2-normalised) — no API key, fully reproducible, what CI
|
| 19 |
+
and the hosted demo run by default.
|
| 20 |
+
- **Metric**: `lumen_rag.eval.harness.evaluate`, doc-level (chunk hits are
|
| 21 |
+
collapsed to unique parent documents before scoring), k=5.
|
| 22 |
+
- **Reproduce**: `python scripts/benchmark.py`
|
| 23 |
+
|
| 24 |
+
## Results
|
| 25 |
+
|
| 26 |
+
| Configuration | recall@5 | precision@5 | MRR | nDCG@5 | hit rate |
|
| 27 |
+
|---|---|---|---|---|---|
|
| 28 |
+
| naive — 1 chunk per doc, vector-only | 0.97 | 0.20 | 0.93 | 0.94 | 1.00 |
|
| 29 |
+
| + sentence-aware chunking, vector-only | 0.97 | 0.20 | **0.97** | **0.97** | 1.00 |
|
| 30 |
+
| + hybrid (BM25 + Reciprocal Rank Fusion) | 0.97 | 0.20 | **0.97** | **0.97** | 1.00 |
|
| 31 |
+
|
| 32 |
+
## Reading the numbers honestly
|
| 33 |
+
|
| 34 |
+
**Recall and hit-rate are already saturated** at 0.97/1.00 in the naive
|
| 35 |
+
config — with only 7 candidate documents and distinct enough vocabulary
|
| 36 |
+
per topic, the correct document almost always lands somewhere in the top 5
|
| 37 |
+
regardless of technique. Publishing only recall would (falsely) suggest
|
| 38 |
+
chunking doesn't matter here. It does — just not on that metric.
|
| 39 |
+
|
| 40 |
+
**MRR and nDCG@5 are where the effect shows up.** Both measure *where* the
|
| 41 |
+
correct document ranks, not just whether it's present. Moving from naive to
|
| 42 |
+
chunked lifts MRR from 0.93 to 0.97 — a small absolute jump that maps to a
|
| 43 |
+
concrete failure mode disappearing: when a whole 300–450 word multi-topic
|
| 44 |
+
document is embedded as a single vector, the sections irrelevant to the
|
| 45 |
+
query dilute the average, and the correct document occasionally ranks 2nd
|
| 46 |
+
or 3rd behind a partial-vocabulary-match decoy instead of 1st. Sentence-aware
|
| 47 |
+
chunking (120-word windows, 20-word overlap) embeds each section on its own,
|
| 48 |
+
so the answer-bearing chunk competes on its own signal instead of being
|
| 49 |
+
outvoted by the rest of the document.
|
| 50 |
+
|
| 51 |
+
**Hybrid ties chunked-vector, not because RRF doesn't work, but because the
|
| 52 |
+
offline `HashingEmbedder` is itself a bag-of-words signal** — term-frequency
|
| 53 |
+
counts, L2-normalised. That's structurally close to what BM25 computes, so
|
| 54 |
+
fusing the two rankers mostly agrees with itself. Hybrid's actual value
|
| 55 |
+
proposition — catching exact rare-term or numeric matches that a *semantic*
|
| 56 |
+
embedding model under-weights in favor of topical similarity — needs a real
|
| 57 |
+
semantic embedder (`OPENAI_API_KEY` set, `text-embedding-3-small`) and a
|
| 58 |
+
larger, noisier corpus to demonstrate honestly. That's flagged as a follow-up
|
| 59 |
+
rather than asserted with numbers we didn't measure.
|
| 60 |
+
|
| 61 |
+
## Takeaways for anyone building on Lumen
|
| 62 |
+
|
| 63 |
+
1. **Don't just report recall/hit-rate.** They saturate fast on small
|
| 64 |
+
corpora and hide ranking-quality regressions. MRR/nDCG catch what recall
|
| 65 |
+
misses.
|
| 66 |
+
2. **Chunking's benefit scales with document length and topic density**, not
|
| 67 |
+
corpus size. A corpus of short, single-topic documents won't show this
|
| 68 |
+
effect — you need documents where the answer is a minority of the content.
|
| 69 |
+
3. **Hybrid retrieval's payoff is embedder-dependent.** With a lexical/hash
|
| 70 |
+
embedder it's close to redundant with vector search; with a real semantic
|
| 71 |
+
embedder it complements it. Re-run `scripts/benchmark.py` with
|
| 72 |
+
`OPENAI_API_KEY` set to see the difference on your own corpus.
|
lumen_rag/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lumen RAG — a small, transparent, evaluated retrieval-augmented generation engine."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
lumen_rag/api/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .app import app
|
| 2 |
+
|
| 3 |
+
__all__ = ["app"]
|
lumen_rag/api/app.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI surface for the RAG engine: ingest, query, stream, stats, health."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import tempfile
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import AsyncGenerator
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI, HTTPException, UploadFile
|
| 11 |
+
from fastapi.responses import StreamingResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
from ..config import settings
|
| 16 |
+
from ..engine import RagEngine
|
| 17 |
+
from ..eval import evaluate
|
| 18 |
+
from ..eval.harness import load_cases
|
| 19 |
+
from ..ingestion.loaders import _LOADERS, load_file
|
| 20 |
+
from ..llm import answer_stream
|
| 21 |
+
from ..retrieval import Retriever, RetrievalMode
|
| 22 |
+
|
| 23 |
+
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
| 24 |
+
|
| 25 |
+
# Replaced by lifespan; initialised here so the name always exists (e.g. in tests).
|
| 26 |
+
engine: RagEngine = RagEngine()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@asynccontextmanager
|
| 30 |
+
async def lifespan(app: FastAPI):
|
| 31 |
+
global engine
|
| 32 |
+
index = Path(settings.index_dir)
|
| 33 |
+
if (index / "chunks.json").exists():
|
| 34 |
+
engine = RagEngine.load(index)
|
| 35 |
+
else:
|
| 36 |
+
engine = RagEngine()
|
| 37 |
+
yield
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
app = FastAPI(
|
| 41 |
+
title="Lumen RAG",
|
| 42 |
+
version="0.1.0",
|
| 43 |
+
description="Ingest documents, retrieve with vector search, answer with citations.",
|
| 44 |
+
lifespan=lifespan,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
if _STATIC_DIR.is_dir():
|
| 48 |
+
app.mount("/demo", StaticFiles(directory=_STATIC_DIR, html=True), name="demo")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class Document(BaseModel):
|
| 52 |
+
id: str | None = None
|
| 53 |
+
text: str = Field(min_length=1)
|
| 54 |
+
metadata: dict = Field(default_factory=dict)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class IngestRequest(BaseModel):
|
| 58 |
+
documents: list[Document]
|
| 59 |
+
chunk_size: int = 120
|
| 60 |
+
overlap: int = 20
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class QueryRequest(BaseModel):
|
| 64 |
+
question: str = Field(min_length=1)
|
| 65 |
+
k: int = Field(default=5, ge=1, le=20)
|
| 66 |
+
mode: RetrievalMode = "hybrid"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@app.get("/")
|
| 70 |
+
def root():
|
| 71 |
+
from fastapi.responses import RedirectResponse
|
| 72 |
+
|
| 73 |
+
return RedirectResponse(url="/demo/")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@app.get("/health")
|
| 77 |
+
def health() -> dict:
|
| 78 |
+
return {"status": "ok", "offline_mode": settings.offline}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@app.get("/stats")
|
| 82 |
+
def stats() -> dict:
|
| 83 |
+
return {"chunks_indexed": len(engine.store), "embedding_dim": engine.store.dim}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@app.post("/ingest")
|
| 87 |
+
def ingest(req: IngestRequest) -> dict:
|
| 88 |
+
docs = [d.model_dump() for d in req.documents]
|
| 89 |
+
total = engine.add_documents(docs, chunk_size=req.chunk_size, overlap=req.overlap)
|
| 90 |
+
engine.save()
|
| 91 |
+
return {"chunks_indexed": total}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@app.post("/query")
|
| 95 |
+
def query(req: QueryRequest) -> dict:
|
| 96 |
+
if len(engine.store) == 0:
|
| 97 |
+
raise HTTPException(status_code=409, detail="Index is empty. Ingest documents first.")
|
| 98 |
+
result = engine.query(req.question, k=req.k, mode=req.mode)
|
| 99 |
+
return {"answer": result.text, "citations": result.citations}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@app.post("/ingest/upload")
|
| 103 |
+
async def ingest_upload(files: list[UploadFile], chunk_size: int = 120, overlap: int = 20) -> dict:
|
| 104 |
+
docs = []
|
| 105 |
+
for f in files:
|
| 106 |
+
suffix = Path(f.filename or "").suffix.lower()
|
| 107 |
+
if suffix not in _LOADERS:
|
| 108 |
+
raise HTTPException(status_code=400, detail=f"Unsupported file type: {f.filename}")
|
| 109 |
+
data = await f.read()
|
| 110 |
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
|
| 111 |
+
tmp.write(data)
|
| 112 |
+
tmp.flush()
|
| 113 |
+
doc = load_file(tmp.name)
|
| 114 |
+
doc["id"] = Path(f.filename).stem
|
| 115 |
+
doc["metadata"]["source"] = f.filename
|
| 116 |
+
docs.append(doc)
|
| 117 |
+
total = engine.add_documents(docs, chunk_size=chunk_size, overlap=overlap)
|
| 118 |
+
engine.save()
|
| 119 |
+
return {"files_indexed": len(docs), "chunks_indexed": total}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@app.post("/ingest/sample")
|
| 123 |
+
def ingest_sample() -> dict:
|
| 124 |
+
sample_dir = Path(__file__).resolve().parent.parent.parent / "data" / "docs"
|
| 125 |
+
if not sample_dir.is_dir():
|
| 126 |
+
raise HTTPException(status_code=404, detail="Bundled sample corpus not found.")
|
| 127 |
+
files = sorted(p for p in sample_dir.iterdir() if p.suffix.lower() in _LOADERS)
|
| 128 |
+
docs = [load_file(p) for p in files]
|
| 129 |
+
total = engine.add_documents(docs)
|
| 130 |
+
engine.save()
|
| 131 |
+
return {"files_indexed": len(docs), "chunks_indexed": total}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.post("/reset")
|
| 135 |
+
def reset() -> dict:
|
| 136 |
+
global engine
|
| 137 |
+
engine = RagEngine()
|
| 138 |
+
engine.save()
|
| 139 |
+
return {"status": "reset"}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@app.get("/eval")
|
| 143 |
+
def run_eval(dataset: str = "data/eval.jsonl", k: int = 5) -> dict:
|
| 144 |
+
path = Path(dataset)
|
| 145 |
+
if not path.exists():
|
| 146 |
+
raise HTTPException(status_code=404, detail=f"Eval set not found: {dataset}")
|
| 147 |
+
if len(engine.store) == 0:
|
| 148 |
+
raise HTTPException(status_code=409, detail="Index is empty. Ingest documents first.")
|
| 149 |
+
cases = load_cases(path)
|
| 150 |
+
report = evaluate(Retriever(engine.store, engine.embedder), cases, k=k)
|
| 151 |
+
return report.as_dict() | {"per_case": report.per_case}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@app.post("/query/stream")
|
| 155 |
+
async def query_stream(req: QueryRequest) -> StreamingResponse:
|
| 156 |
+
"""Stream the answer as Server-Sent Events.
|
| 157 |
+
|
| 158 |
+
Each event is ``data: <json>\\n\\n``. Token events carry ``{"token": "..."}``
|
| 159 |
+
and the final event carries ``{"done": true, "citations": [...]}``.
|
| 160 |
+
"""
|
| 161 |
+
if len(engine.store) == 0:
|
| 162 |
+
raise HTTPException(status_code=409, detail="Index is empty. Ingest documents first.")
|
| 163 |
+
|
| 164 |
+
chunks = engine.retriever.retrieve(req.question, k=req.k, mode=req.mode)
|
| 165 |
+
|
| 166 |
+
async def _sse() -> AsyncGenerator[str, None]:
|
| 167 |
+
for token, citations in answer_stream(req.question, chunks):
|
| 168 |
+
if citations is not None:
|
| 169 |
+
payload = json.dumps({"done": True, "citations": citations})
|
| 170 |
+
else:
|
| 171 |
+
payload = json.dumps({"token": token})
|
| 172 |
+
yield f"data: {payload}\n\n"
|
| 173 |
+
|
| 174 |
+
return StreamingResponse(_sse(), media_type="text/event-stream")
|
lumen_rag/api/static/index.html
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>Lumen RAG — Live Demo</title>
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 7 |
+
<style>
|
| 8 |
+
:root { --bg:#0b0d12; --panel:#12151c; --border:#232733; --text:#e6e8ec; --muted:#8a8f9c; --accent:#5b8cff; --good:#4ade80; }
|
| 9 |
+
* { box-sizing: border-box; }
|
| 10 |
+
body { margin:0; background:var(--bg); color:var(--text); font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; }
|
| 11 |
+
header { padding:24px 32px; border-bottom:1px solid var(--border); }
|
| 12 |
+
header h1 { margin:0 0 4px; font-size:22px; }
|
| 13 |
+
header p { margin:0; color:var(--muted); font-size:13px; }
|
| 14 |
+
main { max-width:980px; margin:0 auto; padding:24px 32px 64px; display:grid; gap:24px; }
|
| 15 |
+
.card { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:20px; }
|
| 16 |
+
.card h2 { margin:0 0 12px; font-size:15px; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; }
|
| 17 |
+
.row { display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
|
| 18 |
+
input[type=text] { flex:1; min-width:200px; background:#0e1117; border:1px solid var(--border); color:var(--text); padding:10px 12px; border-radius:6px; font-size:14px; }
|
| 19 |
+
input[type=file] { color:var(--muted); font-size:13px; }
|
| 20 |
+
select { background:#0e1117; border:1px solid var(--border); color:var(--text); padding:9px; border-radius:6px; }
|
| 21 |
+
button { background:var(--accent); color:#fff; border:none; padding:10px 16px; border-radius:6px; cursor:pointer; font-size:14px; font-weight:600; }
|
| 22 |
+
button:hover { opacity:.9; }
|
| 23 |
+
button.ghost { background:transparent; border:1px solid var(--border); color:var(--text); }
|
| 24 |
+
button:disabled { opacity:.5; cursor:default; }
|
| 25 |
+
.answer { white-space:pre-wrap; margin-top:14px; padding:14px; background:#0e1117; border-radius:6px; border:1px solid var(--border); min-height:24px; }
|
| 26 |
+
.citation { display:inline-block; margin:2px 6px 2px 0; padding:3px 8px; background:#1a2030; border-radius:4px; font-size:12px; color:var(--muted); }
|
| 27 |
+
.metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); gap:12px; margin-top:8px; }
|
| 28 |
+
.metric { background:#0e1117; border:1px solid var(--border); border-radius:8px; padding:12px; text-align:center; }
|
| 29 |
+
.metric .val { font-size:22px; font-weight:700; color:var(--good); }
|
| 30 |
+
.metric .lbl { font-size:11px; color:var(--muted); text-transform:uppercase; margin-top:2px; }
|
| 31 |
+
.status { font-size:13px; color:var(--muted); margin-top:8px; }
|
| 32 |
+
.status.err { color:#f87171; }
|
| 33 |
+
footer { text-align:center; color:var(--muted); font-size:12px; padding:24px; }
|
| 34 |
+
a { color:var(--accent); }
|
| 35 |
+
</style>
|
| 36 |
+
</head>
|
| 37 |
+
<body>
|
| 38 |
+
<header>
|
| 39 |
+
<h1>🔦 Lumen RAG — Live Demo</h1>
|
| 40 |
+
<p>Upload docs → hybrid retrieval (vector + BM25) → cited answer. Runs 100% offline.</p>
|
| 41 |
+
</header>
|
| 42 |
+
<main>
|
| 43 |
+
|
| 44 |
+
<div class="card">
|
| 45 |
+
<h2>1 · Load documents</h2>
|
| 46 |
+
<div class="row">
|
| 47 |
+
<button id="loadSample">Load sample corpus (5 policy docs)</button>
|
| 48 |
+
<span style="color:var(--muted)">or</span>
|
| 49 |
+
<input type="file" id="fileInput" multiple accept=".txt,.md,.html,.htm,.pdf,.docx">
|
| 50 |
+
<button id="uploadBtn" class="ghost">Upload</button>
|
| 51 |
+
<button id="resetBtn" class="ghost">Reset index</button>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="status" id="ingestStatus"></div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div class="card">
|
| 57 |
+
<h2>2 · Ask</h2>
|
| 58 |
+
<div class="row">
|
| 59 |
+
<input type="text" id="question" placeholder="e.g. How long is an on-call rotation?">
|
| 60 |
+
<select id="mode">
|
| 61 |
+
<option value="hybrid" selected>hybrid</option>
|
| 62 |
+
<option value="vector">vector</option>
|
| 63 |
+
<option value="bm25">bm25</option>
|
| 64 |
+
</select>
|
| 65 |
+
<button id="askBtn">Ask</button>
|
| 66 |
+
</div>
|
| 67 |
+
<div class="answer" id="answer">Answer will appear here.</div>
|
| 68 |
+
<div id="citations"></div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div class="card">
|
| 72 |
+
<h2>3 · Retrieval quality (recall@k on the labelled sample eval set)</h2>
|
| 73 |
+
<div class="row">
|
| 74 |
+
<button id="evalBtn">Run eval</button>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="metrics" id="metrics"></div>
|
| 77 |
+
<div class="status" id="evalStatus"></div>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
</main>
|
| 81 |
+
<footer>
|
| 82 |
+
<a href="https://github.com/WickTech/lumen-rag" target="_blank">Source on GitHub</a> ·
|
| 83 |
+
API docs at <a href="/docs">/docs</a>
|
| 84 |
+
</footer>
|
| 85 |
+
<script>
|
| 86 |
+
const $ = id => document.getElementById(id);
|
| 87 |
+
|
| 88 |
+
async function api(path, opts) {
|
| 89 |
+
const res = await fetch(path, opts);
|
| 90 |
+
const body = await res.json().catch(() => ({}));
|
| 91 |
+
if (!res.ok) throw new Error(body.detail || res.statusText);
|
| 92 |
+
return body;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
$('loadSample').onclick = async () => {
|
| 96 |
+
$('ingestStatus').textContent = 'Loading sample corpus…';
|
| 97 |
+
$('ingestStatus').className = 'status';
|
| 98 |
+
try {
|
| 99 |
+
const r = await api('/ingest/sample', { method: 'POST' });
|
| 100 |
+
$('ingestStatus').textContent = `Indexed ${r.files_indexed} file(s) → ${r.chunks_indexed} chunks.`;
|
| 101 |
+
} catch (e) {
|
| 102 |
+
$('ingestStatus').textContent = 'Error: ' + e.message;
|
| 103 |
+
$('ingestStatus').className = 'status err';
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
$('uploadBtn').onclick = async () => {
|
| 108 |
+
const files = $('fileInput').files;
|
| 109 |
+
if (!files.length) { $('ingestStatus').textContent = 'Pick file(s) first.'; return; }
|
| 110 |
+
const fd = new FormData();
|
| 111 |
+
for (const f of files) fd.append('files', f);
|
| 112 |
+
$('ingestStatus').textContent = 'Uploading + indexing…';
|
| 113 |
+
$('ingestStatus').className = 'status';
|
| 114 |
+
try {
|
| 115 |
+
const r = await api('/ingest/upload', { method: 'POST', body: fd });
|
| 116 |
+
$('ingestStatus').textContent = `Indexed ${r.files_indexed} file(s) → ${r.chunks_indexed} chunks.`;
|
| 117 |
+
} catch (e) {
|
| 118 |
+
$('ingestStatus').textContent = 'Error: ' + e.message;
|
| 119 |
+
$('ingestStatus').className = 'status err';
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
$('resetBtn').onclick = async () => {
|
| 124 |
+
await api('/reset', { method: 'POST' });
|
| 125 |
+
$('ingestStatus').textContent = 'Index cleared.';
|
| 126 |
+
$('answer').textContent = 'Answer will appear here.';
|
| 127 |
+
$('citations').innerHTML = '';
|
| 128 |
+
$('metrics').innerHTML = '';
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
$('askBtn').onclick = async () => {
|
| 132 |
+
const question = $('question').value.trim();
|
| 133 |
+
if (!question) return;
|
| 134 |
+
$('answer').textContent = 'Thinking…';
|
| 135 |
+
$('citations').innerHTML = '';
|
| 136 |
+
try {
|
| 137 |
+
const r = await api('/query', {
|
| 138 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 139 |
+
body: JSON.stringify({ question, mode: $('mode').value, k: 5 })
|
| 140 |
+
});
|
| 141 |
+
$('answer').textContent = r.answer;
|
| 142 |
+
$('citations').innerHTML = r.citations.map(c =>
|
| 143 |
+
`<span class="citation">[${c.n}] ${c.doc_id} · score ${Number(c.score).toFixed(3)}</span>`).join('');
|
| 144 |
+
} catch (e) {
|
| 145 |
+
$('answer').textContent = 'Error: ' + e.message;
|
| 146 |
+
}
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
$('question').addEventListener('keydown', e => { if (e.key === 'Enter') $('askBtn').click(); });
|
| 150 |
+
|
| 151 |
+
$('evalBtn').onclick = async () => {
|
| 152 |
+
$('evalStatus').textContent = 'Running eval harness…';
|
| 153 |
+
$('evalStatus').className = 'status';
|
| 154 |
+
$('metrics').innerHTML = '';
|
| 155 |
+
try {
|
| 156 |
+
const r = await api('/eval');
|
| 157 |
+
const order = ['recall@k', 'precision@k', 'mrr', 'ndcg@k', 'hit_rate'];
|
| 158 |
+
$('metrics').innerHTML = order.map(k =>
|
| 159 |
+
`<div class="metric"><div class="val">${r[k]}</div><div class="lbl">${k}</div></div>`).join('');
|
| 160 |
+
$('evalStatus').textContent = `${r.n_cases} labelled questions, k=${r.k}.`;
|
| 161 |
+
} catch (e) {
|
| 162 |
+
$('evalStatus').textContent = 'Error: ' + e.message;
|
| 163 |
+
$('evalStatus').className = 'status err';
|
| 164 |
+
}
|
| 165 |
+
};
|
| 166 |
+
</script>
|
| 167 |
+
</body>
|
| 168 |
+
</html>
|
lumen_rag/cli.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Command-line interface: ingest, ask, eval, serve."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import typer
|
| 9 |
+
|
| 10 |
+
# Ensure Unicode output (arrows, box-drawing) doesn't crash on Windows' cp1252 console.
|
| 11 |
+
for _stream in (sys.stdout, sys.stderr):
|
| 12 |
+
try:
|
| 13 |
+
_stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
| 14 |
+
except (AttributeError, ValueError):
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
from .config import settings
|
| 18 |
+
from .engine import RagEngine
|
| 19 |
+
from .eval import evaluate
|
| 20 |
+
from .eval.harness import load_cases
|
| 21 |
+
from .ingestion.loaders import _LOADERS
|
| 22 |
+
from .retrieval import Retriever, RetrievalMode
|
| 23 |
+
|
| 24 |
+
app = typer.Typer(help="Lumen RAG — ingest, ask, and evaluate a RAG pipeline.")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@app.command()
|
| 28 |
+
def ingest(
|
| 29 |
+
paths: list[str] = typer.Argument(..., help="Files or directories (.txt/.md)."),
|
| 30 |
+
chunk_size: int = 120,
|
| 31 |
+
overlap: int = 20,
|
| 32 |
+
) -> None:
|
| 33 |
+
"""Index documents into the persistent vector store."""
|
| 34 |
+
files: list[Path] = []
|
| 35 |
+
for p in paths:
|
| 36 |
+
path = Path(p)
|
| 37 |
+
if path.is_dir():
|
| 38 |
+
for ext in _LOADERS:
|
| 39 |
+
files.extend(path.rglob(f"*{ext}"))
|
| 40 |
+
else:
|
| 41 |
+
files.append(path)
|
| 42 |
+
|
| 43 |
+
engine = (
|
| 44 |
+
RagEngine.load() if Path(settings.index_dir, "chunks.json").exists() else RagEngine()
|
| 45 |
+
)
|
| 46 |
+
docs = [
|
| 47 |
+
{"id": f.stem, "text": f.read_text(encoding="utf-8"), "metadata": {"source": str(f)}}
|
| 48 |
+
for f in files
|
| 49 |
+
]
|
| 50 |
+
total = engine.add_documents(docs, chunk_size=chunk_size, overlap=overlap)
|
| 51 |
+
engine.save()
|
| 52 |
+
typer.echo(f"Indexed {len(files)} file(s) → {total} chunks in {settings.index_dir}/")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.command()
|
| 56 |
+
def ask(
|
| 57 |
+
question: str,
|
| 58 |
+
k: int = 5,
|
| 59 |
+
mode: str = typer.Option("hybrid", help="Retrieval mode: vector | bm25 | hybrid"),
|
| 60 |
+
) -> None:
|
| 61 |
+
"""Query the index and print an answer with citations."""
|
| 62 |
+
engine = RagEngine.load()
|
| 63 |
+
result = engine.query(question, k=k, mode=mode) # type: ignore[arg-type]
|
| 64 |
+
typer.echo("\n" + result.text + "\n")
|
| 65 |
+
typer.echo("Sources:")
|
| 66 |
+
for c in result.citations:
|
| 67 |
+
typer.echo(f" [{c['n']}] {c['doc_id']} (score={c['score']})")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@app.command(name="eval")
|
| 71 |
+
def run_eval(dataset: str, k: int = 5) -> None:
|
| 72 |
+
"""Score the retriever against a JSONL of labelled questions."""
|
| 73 |
+
engine = RagEngine.load()
|
| 74 |
+
cases = load_cases(dataset)
|
| 75 |
+
report = evaluate(Retriever(engine.store, engine.embedder), cases, k=k)
|
| 76 |
+
typer.echo("\n" + report.pretty() + "\n")
|
| 77 |
+
typer.echo(json.dumps(report.as_dict()))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.command()
|
| 81 |
+
def serve(host: str = "0.0.0.0", port: int = 8000) -> None:
|
| 82 |
+
"""Run the FastAPI server."""
|
| 83 |
+
import uvicorn
|
| 84 |
+
|
| 85 |
+
uvicorn.run("lumen_rag.api.app:app", host=host, port=port, reload=False)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
app()
|
lumen_rag/config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central configuration, loaded from environment with sensible offline defaults."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class Settings:
|
| 14 |
+
openai_api_key: str = os.getenv("OPENAI_API_KEY", "")
|
| 15 |
+
openai_base_url: str | None = os.getenv("OPENAI_BASE_URL") or None
|
| 16 |
+
embed_model: str = os.getenv("EMBED_MODEL", "text-embedding-3-small")
|
| 17 |
+
chat_model: str = os.getenv("CHAT_MODEL", "gpt-4o-mini")
|
| 18 |
+
index_dir: str = os.getenv("LUMEN_INDEX_DIR", ".lumen_index")
|
| 19 |
+
|
| 20 |
+
@property
|
| 21 |
+
def offline(self) -> bool:
|
| 22 |
+
"""True when no API key is configured; engine falls back to local embeddings."""
|
| 23 |
+
return not self.openai_api_key
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
settings = Settings()
|
lumen_rag/embeddings.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embedding providers.
|
| 2 |
+
|
| 3 |
+
Two implementations behind one interface:
|
| 4 |
+
|
| 5 |
+
* ``OpenAIEmbedder`` — real embeddings when an API key is present.
|
| 6 |
+
* ``HashingEmbedder`` — a deterministic, dependency-free fallback so the
|
| 7 |
+
whole engine (and its test suite) runs offline.
|
| 8 |
+
|
| 9 |
+
The hashing embedder is a bag-of-words feature hasher with L2 normalisation.
|
| 10 |
+
It is *not* semantically smart, but it is stable and good enough to exercise
|
| 11 |
+
ranking logic and the eval harness without network access.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import hashlib
|
| 16 |
+
import re
|
| 17 |
+
from typing import Protocol
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
from .config import settings
|
| 22 |
+
|
| 23 |
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class Embedder(Protocol):
|
| 27 |
+
dim: int
|
| 28 |
+
|
| 29 |
+
def embed(self, texts: list[str]) -> np.ndarray: # (n, dim) float32, L2-normalised
|
| 30 |
+
...
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _normalize(mat: np.ndarray) -> np.ndarray:
|
| 34 |
+
norms = np.linalg.norm(mat, axis=1, keepdims=True)
|
| 35 |
+
norms[norms == 0] = 1.0
|
| 36 |
+
return (mat / norms).astype(np.float32)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class HashingEmbedder:
|
| 40 |
+
"""Deterministic feature-hashing embedder. No network, no model download."""
|
| 41 |
+
|
| 42 |
+
def __init__(self, dim: int = 512) -> None:
|
| 43 |
+
self.dim = dim
|
| 44 |
+
|
| 45 |
+
def embed(self, texts: list[str]) -> np.ndarray:
|
| 46 |
+
out = np.zeros((len(texts), self.dim), dtype=np.float32)
|
| 47 |
+
for i, text in enumerate(texts):
|
| 48 |
+
for tok in _TOKEN_RE.findall(text.lower()):
|
| 49 |
+
h = int(hashlib.md5(tok.encode()).hexdigest(), 16)
|
| 50 |
+
idx = h % self.dim
|
| 51 |
+
sign = 1.0 if (h >> 8) & 1 else -1.0
|
| 52 |
+
out[i, idx] += sign
|
| 53 |
+
return _normalize(out)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class OpenAIEmbedder:
|
| 57 |
+
"""Wraps the OpenAI embeddings endpoint (or any compatible base URL)."""
|
| 58 |
+
|
| 59 |
+
def __init__(self) -> None:
|
| 60 |
+
from openai import OpenAI # imported lazily; optional dependency
|
| 61 |
+
|
| 62 |
+
self._client = OpenAI(
|
| 63 |
+
api_key=settings.openai_api_key,
|
| 64 |
+
base_url=settings.openai_base_url,
|
| 65 |
+
)
|
| 66 |
+
self._model = settings.embed_model
|
| 67 |
+
self.dim = 1536 # text-embedding-3-small
|
| 68 |
+
|
| 69 |
+
def embed(self, texts: list[str]) -> np.ndarray:
|
| 70 |
+
resp = self._client.embeddings.create(model=self._model, input=texts)
|
| 71 |
+
vecs = np.array([d.embedding for d in resp.data], dtype=np.float32)
|
| 72 |
+
return _normalize(vecs)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def get_embedder() -> Embedder:
|
| 76 |
+
"""Pick the real embedder if configured, else the offline fallback."""
|
| 77 |
+
if settings.offline:
|
| 78 |
+
return HashingEmbedder()
|
| 79 |
+
return OpenAIEmbedder()
|
lumen_rag/engine.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""High-level facade tying ingestion, retrieval, and answering together."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from .config import settings
|
| 7 |
+
from .embeddings import get_embedder
|
| 8 |
+
from .ingestion import ingest_documents
|
| 9 |
+
from .llm import Answer, answer
|
| 10 |
+
from .retrieval import Retriever, RetrievalMode
|
| 11 |
+
from .store import VectorStore
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RagEngine:
|
| 15 |
+
def __init__(self, store: VectorStore | None = None) -> None:
|
| 16 |
+
self.embedder = get_embedder()
|
| 17 |
+
self.store = store or VectorStore(dim=self.embedder.dim)
|
| 18 |
+
self.retriever = Retriever(self.store, self.embedder)
|
| 19 |
+
|
| 20 |
+
def add_documents(self, documents: list[dict], **kwargs) -> int:
|
| 21 |
+
ingest_documents(documents, store=self.store, embedder=self.embedder, **kwargs)
|
| 22 |
+
self.retriever._bm25 = None # invalidate cached BM25 index
|
| 23 |
+
return len(self.store)
|
| 24 |
+
|
| 25 |
+
def query(self, question: str, k: int = 5, mode: RetrievalMode = "hybrid") -> Answer:
|
| 26 |
+
chunks = self.retriever.retrieve(question, k=k, mode=mode)
|
| 27 |
+
return answer(question, chunks)
|
| 28 |
+
|
| 29 |
+
# --- persistence -------------------------------------------------------
|
| 30 |
+
def save(self, directory: str | Path | None = None) -> None:
|
| 31 |
+
self.store.save(directory or settings.index_dir)
|
| 32 |
+
|
| 33 |
+
@classmethod
|
| 34 |
+
def load(cls, directory: str | Path | None = None) -> "RagEngine":
|
| 35 |
+
store = VectorStore.load(directory or settings.index_dir)
|
| 36 |
+
return cls(store=store)
|
lumen_rag/eval/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .metrics import hit_rate, mrr, ndcg_at_k, precision_at_k, recall_at_k
|
| 2 |
+
from .harness import EvalCase, EvalReport, evaluate
|
| 3 |
+
|
| 4 |
+
__all__ = [
|
| 5 |
+
"hit_rate",
|
| 6 |
+
"mrr",
|
| 7 |
+
"ndcg_at_k",
|
| 8 |
+
"precision_at_k",
|
| 9 |
+
"recall_at_k",
|
| 10 |
+
"EvalCase",
|
| 11 |
+
"EvalReport",
|
| 12 |
+
"evaluate",
|
| 13 |
+
]
|
lumen_rag/eval/harness.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run a labelled question set through a retriever and aggregate metrics.
|
| 2 |
+
|
| 3 |
+
This is what turns "the demo felt good" into "recall@5 is 0.82". Point it at a
|
| 4 |
+
JSONL of questions with known-relevant doc ids and it prints a scorecard you
|
| 5 |
+
can track across changes to chunking, embeddings, or reranking.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from ..retrieval import Retriever
|
| 14 |
+
from . import metrics
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class EvalCase:
|
| 19 |
+
question: str
|
| 20 |
+
relevant_doc_ids: list[str]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class EvalReport:
|
| 25 |
+
k: int
|
| 26 |
+
n_cases: int
|
| 27 |
+
recall_at_k: float
|
| 28 |
+
precision_at_k: float
|
| 29 |
+
mrr: float
|
| 30 |
+
ndcg_at_k: float
|
| 31 |
+
hit_rate: float
|
| 32 |
+
per_case: list[dict] = field(default_factory=list)
|
| 33 |
+
|
| 34 |
+
def as_dict(self) -> dict:
|
| 35 |
+
return {
|
| 36 |
+
"k": self.k,
|
| 37 |
+
"n_cases": self.n_cases,
|
| 38 |
+
"recall@k": round(self.recall_at_k, 4),
|
| 39 |
+
"precision@k": round(self.precision_at_k, 4),
|
| 40 |
+
"mrr": round(self.mrr, 4),
|
| 41 |
+
"ndcg@k": round(self.ndcg_at_k, 4),
|
| 42 |
+
"hit_rate": round(self.hit_rate, 4),
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
def pretty(self) -> str:
|
| 46 |
+
d = self.as_dict()
|
| 47 |
+
lines = [f" Retrieval eval — {d['n_cases']} cases @ k={d['k']}", " " + "-" * 34]
|
| 48 |
+
for key in ("recall@k", "precision@k", "mrr", "ndcg@k", "hit_rate"):
|
| 49 |
+
lines.append(f" {key:<14} {d[key]:.4f}")
|
| 50 |
+
return "\n".join(lines)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _unique_preserving_order(items) -> list[str]:
|
| 54 |
+
seen: set[str] = set()
|
| 55 |
+
out: list[str] = []
|
| 56 |
+
for item in items:
|
| 57 |
+
if item not in seen:
|
| 58 |
+
seen.add(item)
|
| 59 |
+
out.append(item)
|
| 60 |
+
return out
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def load_cases(path: str | Path) -> list[EvalCase]:
|
| 64 |
+
cases = []
|
| 65 |
+
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
| 66 |
+
line = line.strip()
|
| 67 |
+
if not line:
|
| 68 |
+
continue
|
| 69 |
+
obj = json.loads(line)
|
| 70 |
+
cases.append(EvalCase(obj["question"], list(obj["relevant_doc_ids"])))
|
| 71 |
+
return cases
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def evaluate(retriever: Retriever, cases: list[EvalCase], k: int = 5) -> EvalReport:
|
| 75 |
+
agg = {"recall": 0.0, "precision": 0.0, "mrr": 0.0, "ndcg": 0.0, "hit": 0.0}
|
| 76 |
+
per_case = []
|
| 77 |
+
|
| 78 |
+
for case in cases:
|
| 79 |
+
results = retriever.retrieve(case.question, k=k)
|
| 80 |
+
# Retrieval is chunk-level but relevance is doc-level: collapse to the
|
| 81 |
+
# rank-ordered list of *unique* doc ids so a doc with several retrieved
|
| 82 |
+
# chunks counts once (otherwise recall/nDCG can exceed 1.0).
|
| 83 |
+
retrieved_ids = _unique_preserving_order(r.chunk.doc_id for r in results)
|
| 84 |
+
row = {
|
| 85 |
+
"question": case.question,
|
| 86 |
+
"recall": metrics.recall_at_k(retrieved_ids, case.relevant_doc_ids, k),
|
| 87 |
+
"precision": metrics.precision_at_k(retrieved_ids, case.relevant_doc_ids, k),
|
| 88 |
+
"mrr": metrics.mrr(retrieved_ids, case.relevant_doc_ids),
|
| 89 |
+
"ndcg": metrics.ndcg_at_k(retrieved_ids, case.relevant_doc_ids, k),
|
| 90 |
+
"hit": metrics.hit_rate(retrieved_ids, case.relevant_doc_ids, k),
|
| 91 |
+
"retrieved": retrieved_ids,
|
| 92 |
+
}
|
| 93 |
+
for key in agg:
|
| 94 |
+
agg[key] += row[key]
|
| 95 |
+
per_case.append(row)
|
| 96 |
+
|
| 97 |
+
n = max(1, len(cases))
|
| 98 |
+
return EvalReport(
|
| 99 |
+
k=k,
|
| 100 |
+
n_cases=len(cases),
|
| 101 |
+
recall_at_k=agg["recall"] / n,
|
| 102 |
+
precision_at_k=agg["precision"] / n,
|
| 103 |
+
mrr=agg["mrr"] / n,
|
| 104 |
+
ndcg_at_k=agg["ndcg"] / n,
|
| 105 |
+
hit_rate=agg["hit"] / n,
|
| 106 |
+
per_case=per_case,
|
| 107 |
+
)
|
lumen_rag/eval/metrics.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Information-retrieval metrics for evaluating a retriever.
|
| 2 |
+
|
| 3 |
+
All functions take ``retrieved`` (an ordered list of doc ids, best first) and
|
| 4 |
+
``relevant`` (the set of ids that *should* be retrieved). This is the standard
|
| 5 |
+
way to score a RAG retriever against a labelled question set.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import math
|
| 10 |
+
from collections.abc import Iterable, Sequence
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _rel_set(relevant: Iterable[str]) -> set[str]:
|
| 14 |
+
return set(relevant)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def recall_at_k(retrieved: Sequence[str], relevant: Iterable[str], k: int) -> float:
|
| 18 |
+
rel = _rel_set(relevant)
|
| 19 |
+
if not rel:
|
| 20 |
+
return 0.0
|
| 21 |
+
hits = sum(1 for doc in retrieved[:k] if doc in rel)
|
| 22 |
+
return hits / len(rel)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def precision_at_k(retrieved: Sequence[str], relevant: Iterable[str], k: int) -> float:
|
| 26 |
+
if k <= 0:
|
| 27 |
+
return 0.0
|
| 28 |
+
rel = _rel_set(relevant)
|
| 29 |
+
hits = sum(1 for doc in retrieved[:k] if doc in rel)
|
| 30 |
+
return hits / k
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def hit_rate(retrieved: Sequence[str], relevant: Iterable[str], k: int) -> float:
|
| 34 |
+
"""1.0 if any relevant doc appears in the top-k, else 0.0."""
|
| 35 |
+
rel = _rel_set(relevant)
|
| 36 |
+
return 1.0 if any(doc in rel for doc in retrieved[:k]) else 0.0
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def mrr(retrieved: Sequence[str], relevant: Iterable[str]) -> float:
|
| 40 |
+
"""Reciprocal rank of the first relevant result."""
|
| 41 |
+
rel = _rel_set(relevant)
|
| 42 |
+
for i, doc in enumerate(retrieved, start=1):
|
| 43 |
+
if doc in rel:
|
| 44 |
+
return 1.0 / i
|
| 45 |
+
return 0.0
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def ndcg_at_k(retrieved: Sequence[str], relevant: Iterable[str], k: int) -> float:
|
| 49 |
+
"""Normalised discounted cumulative gain with binary relevance."""
|
| 50 |
+
rel = _rel_set(relevant)
|
| 51 |
+
dcg = sum(
|
| 52 |
+
1.0 / math.log2(i + 1)
|
| 53 |
+
for i, doc in enumerate(retrieved[:k], start=1)
|
| 54 |
+
if doc in rel
|
| 55 |
+
)
|
| 56 |
+
ideal_hits = min(len(rel), k)
|
| 57 |
+
idcg = sum(1.0 / math.log2(i + 1) for i in range(1, ideal_hits + 1))
|
| 58 |
+
return dcg / idcg if idcg > 0 else 0.0
|
lumen_rag/ingestion/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .chunker import chunk_text
|
| 2 |
+
from .loaders import load_file, load_files
|
| 3 |
+
from .pipeline import ingest_documents, ingest_paths
|
| 4 |
+
|
| 5 |
+
__all__ = ["chunk_text", "ingest_documents", "ingest_paths", "load_file", "load_files"]
|
lumen_rag/ingestion/chunker.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sentence-aware text chunking with overlap.
|
| 2 |
+
|
| 3 |
+
Splits on sentence boundaries and packs sentences into ~`chunk_size`-word
|
| 4 |
+
windows with `overlap` words carried into the next chunk. Overlap preserves
|
| 5 |
+
context across boundaries so an answer that straddles two chunks is still
|
| 6 |
+
retrievable.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
_SENT_RE = re.compile(r"(?<=[.!?])\s+")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _sentences(text: str) -> list[str]:
|
| 16 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 17 |
+
return [s for s in _SENT_RE.split(text) if s]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def chunk_text(text: str, chunk_size: int = 120, overlap: int = 20) -> list[str]:
|
| 21 |
+
"""Return a list of overlapping chunks, each up to ~chunk_size words."""
|
| 22 |
+
if chunk_size <= 0:
|
| 23 |
+
raise ValueError("chunk_size must be positive")
|
| 24 |
+
if overlap >= chunk_size:
|
| 25 |
+
raise ValueError("overlap must be smaller than chunk_size")
|
| 26 |
+
|
| 27 |
+
chunks: list[str] = []
|
| 28 |
+
current: list[str] = []
|
| 29 |
+
count = 0
|
| 30 |
+
|
| 31 |
+
for sentence in _sentences(text):
|
| 32 |
+
words = sentence.split()
|
| 33 |
+
if count + len(words) > chunk_size and current:
|
| 34 |
+
chunks.append(" ".join(current))
|
| 35 |
+
# carry the last `overlap` words into the next window
|
| 36 |
+
carry = current[-overlap:] if overlap else []
|
| 37 |
+
current = list(carry)
|
| 38 |
+
count = len(carry)
|
| 39 |
+
current.extend(words)
|
| 40 |
+
count += len(words)
|
| 41 |
+
|
| 42 |
+
if current:
|
| 43 |
+
chunks.append(" ".join(current))
|
| 44 |
+
return chunks
|
lumen_rag/ingestion/loaders.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document loaders: extract plain text from PDF, DOCX, and HTML files.
|
| 2 |
+
|
| 3 |
+
All loaders return a ``{"id", "text", "metadata"}`` dict ready for
|
| 4 |
+
``ingest_documents``. Each loader raises ``ImportError`` with a clear install
|
| 5 |
+
hint if its optional dependency is missing so users only pay for what they use.
|
| 6 |
+
|
| 7 |
+
Supported:
|
| 8 |
+
- ``.pdf`` → requires ``pypdf`` (``pip install lumen-rag[pdf]``)
|
| 9 |
+
- ``.docx`` → requires ``python-docx`` (``pip install lumen-rag[docx]``)
|
| 10 |
+
- ``.html`` / ``.htm`` → stdlib ``html.parser``, no extra deps
|
| 11 |
+
- ``.txt`` / ``.md`` → plain read, no extra deps
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _doc(path: Path, text: str) -> dict:
|
| 19 |
+
return {"id": path.stem, "text": text, "metadata": {"source": str(path)}}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def load_txt(path: Path) -> dict:
|
| 23 |
+
return _doc(path, path.read_text(encoding="utf-8", errors="replace"))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def load_pdf(path: Path) -> dict:
|
| 27 |
+
try:
|
| 28 |
+
from pypdf import PdfReader
|
| 29 |
+
except ImportError as e:
|
| 30 |
+
raise ImportError(
|
| 31 |
+
"pypdf is required to load PDF files. "
|
| 32 |
+
"Install it with: pip install 'lumen-rag[pdf]'"
|
| 33 |
+
) from e
|
| 34 |
+
|
| 35 |
+
reader = PdfReader(str(path))
|
| 36 |
+
pages = [page.extract_text() or "" for page in reader.pages]
|
| 37 |
+
return _doc(path, "\n\n".join(pages))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def load_docx(path: Path) -> dict:
|
| 41 |
+
try:
|
| 42 |
+
import docx
|
| 43 |
+
except ImportError as e:
|
| 44 |
+
raise ImportError(
|
| 45 |
+
"python-docx is required to load DOCX files. "
|
| 46 |
+
"Install it with: pip install 'lumen-rag[docx]'"
|
| 47 |
+
) from e
|
| 48 |
+
|
| 49 |
+
doc = docx.Document(str(path))
|
| 50 |
+
text = "\n".join(para.text for para in doc.paragraphs if para.text.strip())
|
| 51 |
+
return _doc(path, text)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def load_html(path: Path) -> dict:
|
| 55 |
+
from html.parser import HTMLParser
|
| 56 |
+
|
| 57 |
+
class _TextExtractor(HTMLParser):
|
| 58 |
+
SKIP_TAGS = {"script", "style", "head", "meta", "link"}
|
| 59 |
+
|
| 60 |
+
def __init__(self):
|
| 61 |
+
super().__init__()
|
| 62 |
+
self._parts: list[str] = []
|
| 63 |
+
self._skip = 0
|
| 64 |
+
|
| 65 |
+
def handle_starttag(self, tag, attrs):
|
| 66 |
+
if tag in self.SKIP_TAGS:
|
| 67 |
+
self._skip += 1
|
| 68 |
+
|
| 69 |
+
def handle_endtag(self, tag):
|
| 70 |
+
if tag in self.SKIP_TAGS and self._skip > 0:
|
| 71 |
+
self._skip -= 1
|
| 72 |
+
|
| 73 |
+
def handle_data(self, data):
|
| 74 |
+
if self._skip == 0:
|
| 75 |
+
stripped = data.strip()
|
| 76 |
+
if stripped:
|
| 77 |
+
self._parts.append(stripped)
|
| 78 |
+
|
| 79 |
+
def text(self) -> str:
|
| 80 |
+
return " ".join(self._parts)
|
| 81 |
+
|
| 82 |
+
extractor = _TextExtractor()
|
| 83 |
+
extractor.feed(path.read_text(encoding="utf-8", errors="replace"))
|
| 84 |
+
return _doc(path, extractor.text())
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
_LOADERS = {
|
| 88 |
+
".txt": load_txt,
|
| 89 |
+
".md": load_txt,
|
| 90 |
+
".pdf": load_pdf,
|
| 91 |
+
".docx": load_docx,
|
| 92 |
+
".html": load_html,
|
| 93 |
+
".htm": load_html,
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def load_file(path: str | Path) -> dict:
|
| 98 |
+
"""Dispatch to the right loader based on file extension."""
|
| 99 |
+
p = Path(path)
|
| 100 |
+
ext = p.suffix.lower()
|
| 101 |
+
loader = _LOADERS.get(ext)
|
| 102 |
+
if loader is None:
|
| 103 |
+
raise ValueError(
|
| 104 |
+
f"Unsupported file type '{ext}'. Supported: {sorted(_LOADERS)}"
|
| 105 |
+
)
|
| 106 |
+
return loader(p)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def load_files(paths: list[str | Path]) -> list[dict]:
|
| 110 |
+
"""Load multiple files, returning a list of document dicts."""
|
| 111 |
+
return [load_file(p) for p in paths]
|
lumen_rag/ingestion/pipeline.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document → chunks → embeddings → vector store."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import uuid
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from ..embeddings import Embedder, get_embedder
|
| 8 |
+
from ..store import Chunk, VectorStore
|
| 9 |
+
from .chunker import chunk_text
|
| 10 |
+
from .loaders import _LOADERS, load_file
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def ingest_documents(
|
| 14 |
+
documents: list[dict],
|
| 15 |
+
store: VectorStore | None = None,
|
| 16 |
+
embedder: Embedder | None = None,
|
| 17 |
+
chunk_size: int = 120,
|
| 18 |
+
overlap: int = 20,
|
| 19 |
+
) -> VectorStore:
|
| 20 |
+
"""Ingest a list of ``{"id", "text", "metadata"?}`` dicts into a store."""
|
| 21 |
+
embedder = embedder or get_embedder()
|
| 22 |
+
# NB: use an explicit None check — an empty VectorStore is falsy (__len__ == 0),
|
| 23 |
+
# so `store or VectorStore(...)` would discard a passed-in empty store.
|
| 24 |
+
if store is None:
|
| 25 |
+
store = VectorStore(dim=embedder.dim)
|
| 26 |
+
|
| 27 |
+
chunks: list[Chunk] = []
|
| 28 |
+
for doc in documents:
|
| 29 |
+
doc_id = str(doc.get("id") or uuid.uuid4())
|
| 30 |
+
for piece in chunk_text(doc["text"], chunk_size, overlap):
|
| 31 |
+
chunks.append(
|
| 32 |
+
Chunk(
|
| 33 |
+
id=str(uuid.uuid4()),
|
| 34 |
+
text=piece,
|
| 35 |
+
doc_id=doc_id,
|
| 36 |
+
metadata=dict(doc.get("metadata", {})),
|
| 37 |
+
)
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
if chunks:
|
| 41 |
+
vectors = embedder.embed([c.text for c in chunks])
|
| 42 |
+
store.add(chunks, vectors)
|
| 43 |
+
return store
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def ingest_paths(paths: list[str | Path], **kwargs) -> VectorStore:
|
| 47 |
+
"""Ingest files from disk. Supports .txt, .md, .pdf, .docx, .html, .htm."""
|
| 48 |
+
docs = [load_file(p) for p in paths]
|
| 49 |
+
return ingest_documents(docs, **kwargs)
|
lumen_rag/llm.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Answer synthesis from retrieved context, with inline citations.
|
| 2 |
+
|
| 3 |
+
Offline (no API key), ``answer`` returns an extractive answer: the top chunks
|
| 4 |
+
stitched together with citation markers. This keeps the full RAG loop runnable
|
| 5 |
+
and testable end-to-end without a model. With a key, it calls the chat model
|
| 6 |
+
using a grounded, citation-enforcing prompt.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
from .config import settings
|
| 13 |
+
from .store import ScoredChunk
|
| 14 |
+
|
| 15 |
+
SYSTEM_PROMPT = (
|
| 16 |
+
"You are a precise assistant. Answer the question using ONLY the numbered "
|
| 17 |
+
"context passages. Cite sources inline as [1], [2]. If the context does not "
|
| 18 |
+
"contain the answer, say you don't know."
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class Answer:
|
| 24 |
+
text: str
|
| 25 |
+
citations: list[dict] # [{"n": 1, "doc_id": ..., "source": ..., "score": ...}]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _format_context(chunks: list[ScoredChunk]) -> str:
|
| 29 |
+
return "\n\n".join(f"[{i + 1}] {c.chunk.text}" for i, c in enumerate(chunks))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _citations(chunks: list[ScoredChunk]) -> list[dict]:
|
| 33 |
+
return [
|
| 34 |
+
{
|
| 35 |
+
"n": i + 1,
|
| 36 |
+
"doc_id": c.chunk.doc_id,
|
| 37 |
+
"source": c.chunk.metadata.get("source"),
|
| 38 |
+
"score": round(c.score, 4),
|
| 39 |
+
}
|
| 40 |
+
for i, c in enumerate(chunks)
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def answer(question: str, chunks: list[ScoredChunk]) -> Answer:
|
| 45 |
+
if not chunks:
|
| 46 |
+
return Answer("I don't have any indexed context to answer that.", [])
|
| 47 |
+
|
| 48 |
+
citations = _citations(chunks)
|
| 49 |
+
|
| 50 |
+
if settings.offline:
|
| 51 |
+
# Extractive fallback: surface the best passages with markers.
|
| 52 |
+
body = " ".join(f"{c.chunk.text} [{i + 1}]" for i, c in enumerate(chunks[:2]))
|
| 53 |
+
return Answer(f"(offline extractive answer) {body}", citations)
|
| 54 |
+
|
| 55 |
+
from openai import OpenAI
|
| 56 |
+
|
| 57 |
+
client = OpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url)
|
| 58 |
+
resp = client.chat.completions.create(
|
| 59 |
+
model=settings.chat_model,
|
| 60 |
+
messages=[
|
| 61 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 62 |
+
{
|
| 63 |
+
"role": "user",
|
| 64 |
+
"content": f"Context:\n{_format_context(chunks)}\n\nQuestion: {question}",
|
| 65 |
+
},
|
| 66 |
+
],
|
| 67 |
+
temperature=0.1,
|
| 68 |
+
)
|
| 69 |
+
return Answer(resp.choices[0].message.content or "", citations)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def answer_stream(question: str, chunks: list[ScoredChunk]):
|
| 73 |
+
"""Yield (token: str, citations: list | None) tuples.
|
| 74 |
+
|
| 75 |
+
The final tuple carries citations; all prior tuples have citations=None.
|
| 76 |
+
Callers can use this to build an SSE stream without buffering the full answer.
|
| 77 |
+
"""
|
| 78 |
+
if not chunks:
|
| 79 |
+
yield ("I don't have any indexed context to answer that.", _citations(chunks))
|
| 80 |
+
return
|
| 81 |
+
|
| 82 |
+
citations = _citations(chunks)
|
| 83 |
+
|
| 84 |
+
if settings.offline:
|
| 85 |
+
body = " ".join(f"{c.chunk.text} [{i + 1}]" for i, c in enumerate(chunks[:2]))
|
| 86 |
+
full = f"(offline extractive answer) {body}"
|
| 87 |
+
for word in full.split(" "):
|
| 88 |
+
yield (word + " ", None)
|
| 89 |
+
yield ("", citations)
|
| 90 |
+
return
|
| 91 |
+
|
| 92 |
+
from openai import OpenAI
|
| 93 |
+
|
| 94 |
+
client = OpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url)
|
| 95 |
+
stream = client.chat.completions.create(
|
| 96 |
+
model=settings.chat_model,
|
| 97 |
+
messages=[
|
| 98 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 99 |
+
{
|
| 100 |
+
"role": "user",
|
| 101 |
+
"content": f"Context:\n{_format_context(chunks)}\n\nQuestion: {question}",
|
| 102 |
+
},
|
| 103 |
+
],
|
| 104 |
+
temperature=0.1,
|
| 105 |
+
stream=True,
|
| 106 |
+
)
|
| 107 |
+
for chunk in stream:
|
| 108 |
+
delta = chunk.choices[0].delta.content or ""
|
| 109 |
+
if delta:
|
| 110 |
+
yield (delta, None)
|
| 111 |
+
yield ("", citations)
|
lumen_rag/retrieval/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .bm25 import BM25Index, reciprocal_rank_fusion
|
| 2 |
+
from .retriever import Retriever, RetrievalMode
|
| 3 |
+
|
| 4 |
+
__all__ = ["BM25Index", "RetrievalMode", "Retriever", "reciprocal_rank_fusion"]
|
lumen_rag/retrieval/bm25.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BM25 sparse retriever with Reciprocal Rank Fusion blending.
|
| 2 |
+
|
| 3 |
+
BM25 rewards term frequency while penalising documents that are much longer
|
| 4 |
+
than average — it consistently outperforms raw TF-IDF and complements dense
|
| 5 |
+
vector search on exact-match, acronym, and entity queries.
|
| 6 |
+
|
| 7 |
+
Reciprocal Rank Fusion (RRF) is the fusion strategy: each candidate is scored
|
| 8 |
+
as Σ 1/(k+rank_i) across its rank in each sub-ranker. RRF is parameter-robust
|
| 9 |
+
and consistently beats linear score blending (Cormack, Clarke, Buettcher 2009).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import math
|
| 14 |
+
import re
|
| 15 |
+
from collections import Counter
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from typing import TYPE_CHECKING
|
| 18 |
+
|
| 19 |
+
if TYPE_CHECKING:
|
| 20 |
+
from ..store import Chunk, ScoredChunk, VectorStore
|
| 21 |
+
|
| 22 |
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _tokenize(text: str) -> list[str]:
|
| 26 |
+
return _TOKEN_RE.findall(text.lower())
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class BM25Index:
|
| 31 |
+
"""Pre-computed BM25 index over a corpus of chunks.
|
| 32 |
+
|
| 33 |
+
Build once at ingest/load time; query many times with ``score``.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
chunks: list["Chunk"]
|
| 37 |
+
avgdl: float
|
| 38 |
+
doc_freqs: dict[str, int] # term → number of docs containing it
|
| 39 |
+
term_freqs: list[dict[str, int]] # per-chunk term frequencies
|
| 40 |
+
n: int # total documents
|
| 41 |
+
|
| 42 |
+
k1: float = 1.5
|
| 43 |
+
b: float = 0.75
|
| 44 |
+
|
| 45 |
+
@classmethod
|
| 46 |
+
def build(cls, chunks: list["Chunk"], k1: float = 1.5, b: float = 0.75) -> "BM25Index":
|
| 47 |
+
term_freqs: list[dict[str, int]] = []
|
| 48 |
+
doc_freqs: dict[str, int] = Counter()
|
| 49 |
+
total_len = 0
|
| 50 |
+
|
| 51 |
+
for chunk in chunks:
|
| 52 |
+
tokens = _tokenize(chunk.text)
|
| 53 |
+
tf = Counter(tokens)
|
| 54 |
+
term_freqs.append(tf)
|
| 55 |
+
for tok in tf:
|
| 56 |
+
doc_freqs[tok] += 1
|
| 57 |
+
total_len += len(tokens)
|
| 58 |
+
|
| 59 |
+
avgdl = total_len / max(1, len(chunks))
|
| 60 |
+
idx = cls(
|
| 61 |
+
chunks=list(chunks),
|
| 62 |
+
avgdl=avgdl,
|
| 63 |
+
doc_freqs=dict(doc_freqs),
|
| 64 |
+
term_freqs=term_freqs,
|
| 65 |
+
n=len(chunks),
|
| 66 |
+
k1=k1,
|
| 67 |
+
b=b,
|
| 68 |
+
)
|
| 69 |
+
return idx
|
| 70 |
+
|
| 71 |
+
def score(self, query: str) -> list[float]:
|
| 72 |
+
"""Return a BM25 score for every chunk in the index."""
|
| 73 |
+
q_tokens = _tokenize(query)
|
| 74 |
+
scores = [0.0] * self.n
|
| 75 |
+
for tok in q_tokens:
|
| 76 |
+
df = self.doc_freqs.get(tok, 0)
|
| 77 |
+
if df == 0:
|
| 78 |
+
continue
|
| 79 |
+
idf = math.log((self.n - df + 0.5) / (df + 0.5) + 1)
|
| 80 |
+
for i, tf in enumerate(self.term_freqs):
|
| 81 |
+
f = tf.get(tok, 0)
|
| 82 |
+
if f == 0:
|
| 83 |
+
continue
|
| 84 |
+
dl = sum(tf.values())
|
| 85 |
+
numer = f * (self.k1 + 1)
|
| 86 |
+
denom = f + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
|
| 87 |
+
scores[i] += idf * numer / denom
|
| 88 |
+
return scores
|
| 89 |
+
|
| 90 |
+
def search(self, query: str, k: int) -> "list[ScoredChunk]":
|
| 91 |
+
from ..store import ScoredChunk
|
| 92 |
+
|
| 93 |
+
scores = self.score(query)
|
| 94 |
+
if not scores:
|
| 95 |
+
return []
|
| 96 |
+
k = min(k, len(scores))
|
| 97 |
+
top = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k]
|
| 98 |
+
return [ScoredChunk(self.chunks[i], scores[i]) for i in top]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
# Reciprocal Rank Fusion
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
|
| 105 |
+
RRF_K = 60 # standard constant from the original paper
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def reciprocal_rank_fusion(
|
| 109 |
+
ranked_lists: list[list["ScoredChunk"]],
|
| 110 |
+
k: int,
|
| 111 |
+
rrf_k: int = RRF_K,
|
| 112 |
+
) -> "list[ScoredChunk]":
|
| 113 |
+
"""Fuse multiple ranked lists of ScoredChunk via RRF.
|
| 114 |
+
|
| 115 |
+
Each chunk_id accumulates 1/(rrf_k + rank) from every list it appears in.
|
| 116 |
+
Returns the top-k by fused score.
|
| 117 |
+
"""
|
| 118 |
+
from ..store import ScoredChunk
|
| 119 |
+
|
| 120 |
+
fused: dict[str, float] = {}
|
| 121 |
+
chunk_map: dict[str, "Chunk"] = {}
|
| 122 |
+
|
| 123 |
+
for ranked in ranked_lists:
|
| 124 |
+
for rank, sc in enumerate(ranked, start=1):
|
| 125 |
+
cid = sc.chunk.id
|
| 126 |
+
fused[cid] = fused.get(cid, 0.0) + 1.0 / (rrf_k + rank)
|
| 127 |
+
chunk_map[cid] = sc.chunk
|
| 128 |
+
|
| 129 |
+
top = sorted(fused.items(), key=lambda x: x[1], reverse=True)[:k]
|
| 130 |
+
return [ScoredChunk(chunk_map[cid], score) for cid, score in top]
|
lumen_rag/retrieval/retriever.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Query-time retrieval: embed the query, search the store, optionally rerank.
|
| 2 |
+
|
| 3 |
+
Three retrieval modes:
|
| 4 |
+
``vector`` — dense cosine search only (original behaviour).
|
| 5 |
+
``bm25`` — sparse BM25 keyword search only.
|
| 6 |
+
``hybrid`` — Reciprocal Rank Fusion of both lists (default).
|
| 7 |
+
|
| 8 |
+
The legacy ``rerank`` parameter is preserved for backwards compatibility: when
|
| 9 |
+
mode is ``vector`` and rerank is True, the light lexical-overlap booster from
|
| 10 |
+
v0.1 is applied on top of the vector hits.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
from typing import Literal
|
| 16 |
+
|
| 17 |
+
from ..embeddings import Embedder, get_embedder
|
| 18 |
+
from ..store import ScoredChunk, VectorStore
|
| 19 |
+
from .bm25 import BM25Index, reciprocal_rank_fusion
|
| 20 |
+
|
| 21 |
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
| 22 |
+
|
| 23 |
+
RetrievalMode = Literal["vector", "bm25", "hybrid"]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _lexical_overlap(query: str, text: str) -> float:
|
| 27 |
+
q = set(_TOKEN_RE.findall(query.lower()))
|
| 28 |
+
if not q:
|
| 29 |
+
return 0.0
|
| 30 |
+
t = set(_TOKEN_RE.findall(text.lower()))
|
| 31 |
+
return len(q & t) / len(q)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class Retriever:
|
| 35 |
+
def __init__(self, store: VectorStore, embedder: Embedder | None = None) -> None:
|
| 36 |
+
self.store = store
|
| 37 |
+
self.embedder = embedder or get_embedder()
|
| 38 |
+
self._bm25: BM25Index | None = None
|
| 39 |
+
|
| 40 |
+
def _get_bm25(self) -> BM25Index:
|
| 41 |
+
"""Build (or return cached) BM25 index from the current store chunks."""
|
| 42 |
+
if self._bm25 is None or len(self._bm25.chunks) != len(self.store._chunks):
|
| 43 |
+
self._bm25 = BM25Index.build(self.store._chunks)
|
| 44 |
+
return self._bm25
|
| 45 |
+
|
| 46 |
+
def retrieve(
|
| 47 |
+
self,
|
| 48 |
+
query: str,
|
| 49 |
+
k: int = 5,
|
| 50 |
+
*,
|
| 51 |
+
mode: RetrievalMode = "hybrid",
|
| 52 |
+
rerank: bool = True,
|
| 53 |
+
rerank_weight: float = 0.25,
|
| 54 |
+
) -> list[ScoredChunk]:
|
| 55 |
+
"""Retrieve top-k chunks for *query*.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
query: Natural-language question.
|
| 59 |
+
k: Number of results to return.
|
| 60 |
+
mode: ``"vector"`` | ``"bm25"`` | ``"hybrid"`` (default).
|
| 61 |
+
rerank: When mode is ``"vector"``, blend lexical overlap into scores.
|
| 62 |
+
rerank_weight: Weight of the lexical component in vector+lexical blend.
|
| 63 |
+
"""
|
| 64 |
+
if len(self.store) == 0:
|
| 65 |
+
return []
|
| 66 |
+
|
| 67 |
+
if mode == "bm25":
|
| 68 |
+
return self._get_bm25().search(query, k)
|
| 69 |
+
|
| 70 |
+
if mode == "hybrid":
|
| 71 |
+
fetch = k * 3
|
| 72 |
+
query_vec = self.embedder.embed([query])[0]
|
| 73 |
+
dense_list = self.store.search(query_vec, k=fetch)
|
| 74 |
+
sparse_list = self._get_bm25().search(query, fetch)
|
| 75 |
+
return reciprocal_rank_fusion([dense_list, sparse_list], k=k)
|
| 76 |
+
|
| 77 |
+
# --- vector (default legacy path) ---
|
| 78 |
+
query_vec = self.embedder.embed([query])[0]
|
| 79 |
+
pool = self.store.search(query_vec, k=k * 3 if rerank else k)
|
| 80 |
+
if not rerank:
|
| 81 |
+
return pool[:k]
|
| 82 |
+
|
| 83 |
+
for sc in pool:
|
| 84 |
+
lex = _lexical_overlap(query, sc.chunk.text)
|
| 85 |
+
sc.score = (1 - rerank_weight) * sc.score + rerank_weight * lex
|
| 86 |
+
pool.sort(key=lambda s: s.score, reverse=True)
|
| 87 |
+
return pool[:k]
|
lumen_rag/store.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A tiny, persistable in-memory vector store with exact cosine search.
|
| 2 |
+
|
| 3 |
+
Deliberately not a full vector DB — the point is to make the retrieval maths
|
| 4 |
+
visible and dependency-free. Vectors are L2-normalised on insert, so cosine
|
| 5 |
+
similarity is a single matrix-vector dot product. Swap this module for
|
| 6 |
+
pgvector/Qdrant/Chroma without touching the retriever.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
from dataclasses import asdict, dataclass
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class Chunk:
|
| 19 |
+
id: str
|
| 20 |
+
text: str
|
| 21 |
+
doc_id: str
|
| 22 |
+
metadata: dict
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class ScoredChunk:
|
| 27 |
+
chunk: Chunk
|
| 28 |
+
score: float
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class VectorStore:
|
| 32 |
+
def __init__(self, dim: int) -> None:
|
| 33 |
+
self.dim = dim
|
| 34 |
+
self._vectors = np.zeros((0, dim), dtype=np.float32)
|
| 35 |
+
self._chunks: list[Chunk] = []
|
| 36 |
+
|
| 37 |
+
def __len__(self) -> int:
|
| 38 |
+
return len(self._chunks)
|
| 39 |
+
|
| 40 |
+
def add(self, chunks: list[Chunk], vectors: np.ndarray) -> None:
|
| 41 |
+
if vectors.shape[0] != len(chunks):
|
| 42 |
+
raise ValueError("chunks and vectors length mismatch")
|
| 43 |
+
if vectors.shape[1] != self.dim:
|
| 44 |
+
raise ValueError(f"expected dim {self.dim}, got {vectors.shape[1]}")
|
| 45 |
+
self._vectors = np.vstack([self._vectors, vectors.astype(np.float32)])
|
| 46 |
+
self._chunks.extend(chunks)
|
| 47 |
+
|
| 48 |
+
def search(self, query_vec: np.ndarray, k: int = 5) -> list[ScoredChunk]:
|
| 49 |
+
if len(self._chunks) == 0:
|
| 50 |
+
return []
|
| 51 |
+
scores = self._vectors @ query_vec.reshape(-1) # cosine (already normalised)
|
| 52 |
+
k = min(k, len(self._chunks))
|
| 53 |
+
# argpartition for top-k, then sort just those.
|
| 54 |
+
top = np.argpartition(-scores, k - 1)[:k]
|
| 55 |
+
top = top[np.argsort(-scores[top])]
|
| 56 |
+
return [ScoredChunk(self._chunks[i], float(scores[i])) for i in top]
|
| 57 |
+
|
| 58 |
+
# --- persistence -------------------------------------------------------
|
| 59 |
+
def save(self, directory: str | Path) -> None:
|
| 60 |
+
d = Path(directory)
|
| 61 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
np.save(d / "vectors.npy", self._vectors)
|
| 63 |
+
(d / "chunks.json").write_text(
|
| 64 |
+
json.dumps([asdict(c) for c in self._chunks], ensure_ascii=False)
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
@classmethod
|
| 68 |
+
def load(cls, directory: str | Path) -> "VectorStore":
|
| 69 |
+
d = Path(directory)
|
| 70 |
+
vectors = np.load(d / "vectors.npy")
|
| 71 |
+
store = cls(dim=int(vectors.shape[1]) if vectors.size else 0)
|
| 72 |
+
store._vectors = vectors.astype(np.float32)
|
| 73 |
+
store._chunks = [Chunk(**c) for c in json.loads((d / "chunks.json").read_text())]
|
| 74 |
+
return store
|
pyproject.toml
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "lumen-rag"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "A transparent, evaluated RAG engine: ingest documents, retrieve with vector search, answer with citations, and measure retrieval quality."
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.10"
|
| 7 |
+
license = { text = "MIT" }
|
| 8 |
+
authors = [{ name = "Rishabh Verma" }]
|
| 9 |
+
dependencies = [
|
| 10 |
+
"fastapi>=0.115",
|
| 11 |
+
"uvicorn[standard]>=0.32",
|
| 12 |
+
"pydantic>=2.9",
|
| 13 |
+
"numpy>=1.26",
|
| 14 |
+
"httpx>=0.27",
|
| 15 |
+
"python-dotenv>=1.0",
|
| 16 |
+
"typer>=0.13",
|
| 17 |
+
"python-multipart>=0.0.12",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[project.optional-dependencies]
|
| 21 |
+
openai = ["openai>=1.55"]
|
| 22 |
+
pdf = ["pypdf>=4.0"]
|
| 23 |
+
docx = ["python-docx>=1.1"]
|
| 24 |
+
loaders = ["pypdf>=4.0", "python-docx>=1.1"]
|
| 25 |
+
dev = ["pytest>=8.3", "pytest-asyncio>=0.24", "ruff>=0.8"]
|
| 26 |
+
|
| 27 |
+
[project.scripts]
|
| 28 |
+
lumen = "lumen_rag.cli:app"
|
| 29 |
+
|
| 30 |
+
[build-system]
|
| 31 |
+
requires = ["hatchling"]
|
| 32 |
+
build-backend = "hatchling.build"
|
| 33 |
+
|
| 34 |
+
[tool.hatch.build.targets.wheel]
|
| 35 |
+
packages = ["lumen_rag"]
|
| 36 |
+
|
| 37 |
+
[tool.pytest.ini_options]
|
| 38 |
+
asyncio_mode = "auto"
|
| 39 |
+
testpaths = ["tests"]
|
| 40 |
+
|
| 41 |
+
[tool.ruff]
|
| 42 |
+
line-length = 100
|
| 43 |
+
target-version = "py310"
|
requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
-e .
|
scripts/benchmark.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Benchmark retrieval quality across pipeline configurations.
|
| 3 |
+
|
| 4 |
+
Runs the same labelled eval set (data/eval.jsonl, 20 docs / 19 questions)
|
| 5 |
+
through three configurations to quantify what chunking and hybrid retrieval
|
| 6 |
+
actually buy you:
|
| 7 |
+
|
| 8 |
+
1. naive — whole document as one chunk, vector search only
|
| 9 |
+
2. + chunking — sentence-aware chunking (size=120, overlap=20), vector only
|
| 10 |
+
3. + hybrid — same chunking, + BM25 and Reciprocal Rank Fusion
|
| 11 |
+
|
| 12 |
+
Prints a markdown table (paste straight into the README) and writes
|
| 13 |
+
benchmark_results.json alongside it.
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
python scripts/benchmark.py
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
ROOT = Path(__file__).parent.parent
|
| 25 |
+
sys.path.insert(0, str(ROOT))
|
| 26 |
+
|
| 27 |
+
from lumen_rag.embeddings import get_embedder
|
| 28 |
+
from lumen_rag.eval import evaluate
|
| 29 |
+
from lumen_rag.eval.harness import load_cases
|
| 30 |
+
from lumen_rag.ingestion.loaders import _LOADERS, load_file
|
| 31 |
+
from lumen_rag.ingestion.pipeline import ingest_documents
|
| 32 |
+
from lumen_rag.retrieval import Retriever
|
| 33 |
+
from lumen_rag.store import VectorStore
|
| 34 |
+
|
| 35 |
+
DOCS_DIR = ROOT / "data" / "docs"
|
| 36 |
+
EVAL_PATH = ROOT / "data" / "eval.jsonl"
|
| 37 |
+
K = 5
|
| 38 |
+
|
| 39 |
+
CONFIGS = [
|
| 40 |
+
{"name": "naive (1 chunk/doc, vector-only)", "chunk_size": 100_000, "overlap": 0, "mode": "vector"},
|
| 41 |
+
{"name": "+ sentence chunking (vector-only)", "chunk_size": 120, "overlap": 20, "mode": "vector"},
|
| 42 |
+
{"name": "+ hybrid (BM25 + RRF)", "chunk_size": 120, "overlap": 20, "mode": "hybrid"},
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def build_retriever(chunk_size: int, overlap: int) -> Retriever:
|
| 47 |
+
embedder = get_embedder()
|
| 48 |
+
store = VectorStore(dim=embedder.dim)
|
| 49 |
+
docs = [load_file(p) for p in sorted(DOCS_DIR.iterdir()) if p.suffix.lower() in _LOADERS]
|
| 50 |
+
ingest_documents(docs, store=store, embedder=embedder, chunk_size=chunk_size, overlap=overlap)
|
| 51 |
+
return Retriever(store, embedder)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def main() -> None:
|
| 55 |
+
cases = load_cases(EVAL_PATH)
|
| 56 |
+
rows = []
|
| 57 |
+
for cfg in CONFIGS:
|
| 58 |
+
retriever = build_retriever(cfg["chunk_size"], cfg["overlap"])
|
| 59 |
+
report = evaluate(retriever, cases, k=K)
|
| 60 |
+
d = report.as_dict()
|
| 61 |
+
rows.append({"config": cfg["name"], **d})
|
| 62 |
+
|
| 63 |
+
header = ["Configuration", "recall@5", "precision@5", "MRR", "nDCG@5", "hit rate"]
|
| 64 |
+
lines = [
|
| 65 |
+
"| " + " | ".join(header) + " |",
|
| 66 |
+
"|" + "---|" * len(header),
|
| 67 |
+
]
|
| 68 |
+
for r in rows:
|
| 69 |
+
lines.append(
|
| 70 |
+
"| {config} | {recall@k:.2f} | {precision@k:.2f} | {mrr:.2f} | {ndcg@k:.2f} | {hit_rate:.2f} |".format(
|
| 71 |
+
**r
|
| 72 |
+
)
|
| 73 |
+
)
|
| 74 |
+
table = "\n".join(lines)
|
| 75 |
+
print(table)
|
| 76 |
+
|
| 77 |
+
out = ROOT / "benchmark_results.json"
|
| 78 |
+
out.write_text(json.dumps({"k": K, "n_cases": len(cases), "results": rows}, indent=2))
|
| 79 |
+
print(f"\nWrote {out}")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
main()
|
scripts/check_eval.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Eval regression guard: fail with a non-zero exit code if any metric drops below threshold.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python scripts/check_eval.py data/eval.jsonl --k 3
|
| 6 |
+
|
| 7 |
+
The thresholds below represent a floor derived from the baseline offline run.
|
| 8 |
+
If scores *drop* below them, this script exits 1 so CI fails. Raise the
|
| 9 |
+
thresholds when you improve the pipeline; never lower them to pass CI.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# Add project root so the script works without install when run from CI.
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 20 |
+
|
| 21 |
+
from lumen_rag.engine import RagEngine
|
| 22 |
+
from lumen_rag.eval import evaluate
|
| 23 |
+
from lumen_rag.eval.harness import load_cases
|
| 24 |
+
from lumen_rag.retrieval import Retriever
|
| 25 |
+
|
| 26 |
+
# Minimum acceptable scores. Adjust upward as the pipeline improves.
|
| 27 |
+
THRESHOLDS: dict[str, float] = {
|
| 28 |
+
"recall@k": 0.80,
|
| 29 |
+
"hit_rate": 0.80,
|
| 30 |
+
"mrr": 0.70,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main() -> int:
|
| 35 |
+
parser = argparse.ArgumentParser(description="Lumen RAG eval regression guard")
|
| 36 |
+
parser.add_argument("dataset", help="Path to JSONL eval set")
|
| 37 |
+
parser.add_argument("--k", type=int, default=3, help="Top-k to evaluate")
|
| 38 |
+
parser.add_argument(
|
| 39 |
+
"--mode",
|
| 40 |
+
default="hybrid",
|
| 41 |
+
choices=["vector", "bm25", "hybrid"],
|
| 42 |
+
help="Retrieval mode",
|
| 43 |
+
)
|
| 44 |
+
parser.add_argument(
|
| 45 |
+
"--index-dir",
|
| 46 |
+
default=None,
|
| 47 |
+
help="Override index directory (defaults to LUMEN_INDEX_DIR or .lumen_index)",
|
| 48 |
+
)
|
| 49 |
+
args = parser.parse_args()
|
| 50 |
+
|
| 51 |
+
engine = RagEngine.load(args.index_dir) if args.index_dir else RagEngine.load()
|
| 52 |
+
cases = load_cases(args.dataset)
|
| 53 |
+
report = evaluate(Retriever(engine.store, engine.embedder), cases, k=args.k)
|
| 54 |
+
|
| 55 |
+
scores = report.as_dict()
|
| 56 |
+
print(f"\n Retrieval eval — {scores['n_cases']} cases @ k={scores['k']}")
|
| 57 |
+
print(" " + "-" * 34)
|
| 58 |
+
for key in ("recall@k", "precision@k", "mrr", "ndcg@k", "hit_rate"):
|
| 59 |
+
threshold = THRESHOLDS.get(key)
|
| 60 |
+
status = ""
|
| 61 |
+
if threshold is not None:
|
| 62 |
+
status = " ✓" if scores[key] >= threshold else f" ✗ (threshold {threshold})"
|
| 63 |
+
print(f" {key:<14} {scores[key]:.4f}{status}")
|
| 64 |
+
|
| 65 |
+
failures = [
|
| 66 |
+
f"{key}={scores[key]:.4f} < threshold {thr}"
|
| 67 |
+
for key, thr in THRESHOLDS.items()
|
| 68 |
+
if scores[key] < thr
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
if failures:
|
| 72 |
+
print("\n REGRESSION DETECTED:", ", ".join(failures), file=sys.stderr)
|
| 73 |
+
return 1
|
| 74 |
+
|
| 75 |
+
print("\n All thresholds met.")
|
| 76 |
+
return 0
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
sys.exit(main())
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|