WickTech commited on
Commit
fd3cc1a
·
verified ·
1 Parent(s): 8f7b71b

Space README + Gradio entrypoint

Browse files
.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 Rag
3
- emoji: 🌖
4
- colorFrom: red
5
- colorTo: yellow
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
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