diff --git a/README.md b/README.md index 8c570b8..f6d8cc5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ The `Dockerfile` is multi-stage — stage one compiles the PyO3 wheel with the R - **No `ANTHROPIC_API_KEY`.** It runs in mock mode: answers are extractive, tagged with a visible `mock` badge. Nothing calls Claude, so there's no key on a public endpoint and no spend to burn. Retrieval — the HNSW index, which is the point of the project — is fully real. - **The hashed fallback embedder**, not `sentence-transformers` (which would drag `torch` into the image). That means retrieval matches on *term overlap, not meaning*. Don't mistake the demo for semantic search; install `sentence-transformers` and set `RAG_EMBEDDER=model` for that. `GET /stats` reports which backend is live so you never have to guess. - **Cold starts.** Scaled to zero, the first request after an idle period waits a second or two for the machine to wake. +- **A live document workspace.** The UI accepts Markdown or plain text, lists every indexed document, and shows the active corpus, embedding, HNSW, relevance, and generation configuration. Documents are held in memory and disappear when the service starts fresh. ## Quick start @@ -97,6 +98,18 @@ sources and says the indexed documents do not address the question. Tune the threshold for a different embedding model or set it to `-1` to preserve every cosine-search hit. +The web UI makes the runtime modes explicit instead of collapsing them into a +single model badge. **Retrieval** is labeled lexical/hashed or semantic based on +the active embedder; **answer generation** is labeled extractive mock or Claude. +The HNSW index remains real in every mode. Its live document count, chunk count, +vector dimension, metric, `M`, `ef_construction`, and relevance floor are +visible alongside the document library. + +To add material from the UI, select a `.md`, `.markdown`, or `.txt` file +(or paste text), give it a title, and choose **Add to index**. The browser reads +the file as text and sends the title and content to the existing document API; +no file is stored on disk. + ## Roadmap - [x] **Phase 0 — Scaffolding**: workspace layout, README with architecture. diff --git a/app/app/api/documents/route.ts b/app/app/api/documents/route.ts index 396616d..fefefdd 100644 --- a/app/app/api/documents/route.ts +++ b/app/app/api/documents/route.ts @@ -14,11 +14,15 @@ export async function GET() { export async function POST(req: Request) { try { - const { title, text } = await req.json(); - if (!title || !text) { - return NextResponse.json({ error: "title and text are required" }, { status: 400 }); + const body = (await req.json()) as { title?: unknown; text?: unknown }; + if (typeof body.title !== "string" || !body.title.trim()) { + return NextResponse.json({ error: "title is required" }, { status: 400 }); } - return NextResponse.json(await addDocument(title, text)); + if (typeof body.text !== "string" || !body.text.trim()) { + return NextResponse.json({ error: "document text is required" }, { status: 400 }); + } + + return NextResponse.json(await addDocument(body.title.trim(), body.text)); } catch (err) { return NextResponse.json( { error: err instanceof Error ? err.message : "unknown error" }, diff --git a/app/app/api/stats/route.ts b/app/app/api/stats/route.ts new file mode 100644 index 0000000..1027f0f --- /dev/null +++ b/app/app/api/stats/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { getStats } from "@/app/lib/rag"; + +export async function GET() { + try { + return NextResponse.json(await getStats()); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "unknown error" }, + { status: 502 }, + ); + } +} diff --git a/app/app/globals.css b/app/app/globals.css index 5f6b252..0359bd2 100644 --- a/app/app/globals.css +++ b/app/app/globals.css @@ -1,136 +1,711 @@ :root { - --bg: #0f1115; - --panel: #181b22; - --border: #262a33; - --text: #e6e8ec; - --muted: #9aa2b1; - --accent: #6ea8fe; - --cited: #2ea043; + --bg: #090d14; + --panel: rgba(18, 24, 35, 0.88); + --panel-strong: #141b27; + --panel-soft: #101620; + --border: #263244; + --border-soft: rgba(148, 163, 184, 0.14); + --text: #eef3f8; + --muted: #94a3b8; + --muted-strong: #bac5d3; + --accent: #7dd3fc; + --accent-strong: #38bdf8; + --green: #5ee2a0; + --amber: #f7c873; + --danger: #ff8b8b; + --radius: 16px; } * { box-sizing: border-box; } +html { + background: var(--bg); +} + body { + min-height: 100vh; margin: 0; - font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - background: var(--bg); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; color: var(--text); - line-height: 1.5; + background: + radial-gradient(circle at 12% 0%, rgba(56, 189, 248, 0.1), transparent 28rem), + radial-gradient(circle at 88% 8%, rgba(94, 226, 160, 0.07), transparent 24rem), + var(--bg); + line-height: 1.55; } -.layout { - display: grid; - grid-template-columns: 1fr 380px; - gap: 24px; - max-width: 1100px; +button, +input, +textarea { + font: inherit; +} + +button { + border: 0; +} + +.shell { + width: min(1220px, calc(100% - 40px)); margin: 0 auto; - padding: 32px 24px; + padding: 48px 0 72px; } -@media (max-width: 820px) { - .layout { - grid-template-columns: 1fr; - } +.hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 40px; + padding: 14px 0 34px; +} + +.eyebrow, +.section-kicker { + margin: 0 0 8px; + color: var(--accent); + font-size: 0.7rem; + font-weight: 750; + letter-spacing: 0.16em; +} + +.hero h1 { + max-width: 700px; + margin: 0; + font-size: clamp(2.45rem, 6vw, 4.85rem); + font-weight: 650; + letter-spacing: -0.055em; + line-height: 0.98; +} + +.hero-copy { + max-width: 650px; + margin: 18px 0 0; + color: var(--muted-strong); + font-size: 1.02rem; +} + +.mode-strip { + display: grid; + gap: 10px; + min-width: 220px; +} + +.mode-badge { + display: flex; + align-items: center; + gap: 11px; + min-width: 210px; + padding: 10px 13px; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(10, 15, 23, 0.72); + backdrop-filter: blur(12px); +} + +.mode-badge .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 14px currentColor; +} + +.mode-badge.blue { + color: var(--accent); } -h1 { - font-size: 1.5rem; - margin: 0 0 4px; +.mode-badge.green { + color: var(--green); } -.subtitle { +.mode-badge.amber { + color: var(--amber); +} + +.mode-badge span:last-child { + display: grid; +} + +.mode-badge small { color: var(--muted); - margin: 0 0 24px; - font-size: 0.9rem; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.mode-badge strong { + color: var(--text); + font-size: 0.86rem; + font-weight: 650; +} + +.banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; + padding: 12px 14px; + border: 1px solid rgba(255, 139, 139, 0.4); + border-radius: 12px; + background: rgba(92, 24, 34, 0.28); + color: #ffc0c0; + font-size: 0.86rem; +} + +.text-button { + padding: 0; + color: inherit; + background: transparent; + cursor: pointer; + font-weight: 700; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + overflow: hidden; + margin-bottom: 18px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(17, 23, 33, 0.7); +} + +.metric-card { + display: grid; + gap: 2px; + min-height: 112px; + padding: 20px; + border-right: 1px solid var(--border); +} + +.metric-card:last-child { + border-right: 0; +} + +.metric-card > span { + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metric-card strong { + margin-top: 7px; + font-size: 1.45rem; + font-weight: 620; + letter-spacing: -0.03em; +} + +.metric-card small { + color: var(--muted); + font-size: 0.75rem; +} + +.query-grid { + display: grid; + grid-template-columns: minmax(0, 1.18fr) minmax(340px, 0.82fr); + gap: 18px; +} + +.panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(14px); +} + +.query-panel, +.sources-panel, +.upload-panel, +.library-panel { + padding: 24px; +} + +.panel-heading, +.section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.panel-heading { + margin-bottom: 22px; +} + +.panel-heading.compact { + margin-bottom: 18px; +} + +.panel-heading h2, +.panel-heading h3, +.section-heading h2 { + margin: 0; + font-weight: 620; + letter-spacing: -0.025em; +} + +.panel-heading h2, +.section-heading h2 { + font-size: 1.25rem; +} + +.panel-heading h3 { + font-size: 1.08rem; +} + +.step-label { + color: #506078; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; +} + +label { + display: block; + margin: 14px 0 7px; + color: var(--muted-strong); + font-size: 0.78rem; + font-weight: 650; } textarea, input { width: 100%; - background: var(--panel); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 11px; + outline: none; color: var(--text); - padding: 10px 12px; - font-size: 0.95rem; - font-family: inherit; + background: var(--panel-soft); + padding: 12px 13px; + transition: + border-color 160ms ease, + box-shadow 160ms ease; } -button { - background: var(--accent); - color: #0b1020; - border: none; - border-radius: 8px; +textarea { + resize: vertical; + line-height: 1.55; +} + +textarea:focus, +input:focus { + border-color: rgba(56, 189, 248, 0.75); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.1); +} + +textarea::placeholder, +input::placeholder { + color: #617086; +} + +.form-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-top: 13px; +} + +.form-hint { + color: var(--muted); + font-size: 0.72rem; +} + +.form-actions button { + flex: 0 0 auto; padding: 10px 16px; - font-weight: 600; + border-radius: 10px; + color: #061018; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); cursor: pointer; - font-size: 0.95rem; + font-size: 0.83rem; + font-weight: 750; + transition: + transform 150ms ease, + opacity 150ms ease; +} + +.form-actions button:hover:not(:disabled) { + transform: translateY(-1px); } button:disabled { - opacity: 0.5; cursor: default; + opacity: 0.46; } -.row { - display: flex; - gap: 8px; - margin-top: 12px; +.inline-message { + margin: 14px 0 0; + padding: 10px 12px; + border-radius: 9px; + font-size: 0.8rem; } -.answer { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 16px; - margin-top: 20px; - white-space: pre-wrap; +.error-message { + color: #ffc2c2; + background: rgba(127, 29, 29, 0.28); } -.model-badge { - display: inline-block; - font-size: 0.72rem; +.success-message { + color: #aff2cd; + background: rgba(20, 83, 55, 0.32); +} + +.answer-card { + margin-top: 22px; + padding: 20px; + border: 1px solid rgba(125, 211, 252, 0.26); + border-radius: 13px; + background: rgba(9, 16, 25, 0.68); +} + +.answer-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; color: var(--muted); + font-size: 0.65rem; + font-weight: 750; + letter-spacing: 0.12em; +} + +.answer-mode { border: 1px solid var(--border); border-radius: 999px; - padding: 1px 8px; - margin-bottom: 8px; + padding: 3px 8px; + color: var(--accent); + letter-spacing: 0.04em; + text-transform: none; } -.sources h2 { - font-size: 1rem; - margin: 0 0 12px; +.answer-mode.live { + color: var(--green); } -.source { - background: var(--panel); +.answer-copy { + color: #dbe5ef; + font-size: 0.92rem; + white-space: pre-wrap; +} + +.sources-panel { + min-height: 430px; +} + +.source-count, +.document-total { + display: grid; + min-width: 30px; + height: 30px; + place-items: center; border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; - margin-bottom: 10px; - font-size: 0.85rem; + border-radius: 9px; + color: var(--muted-strong); + font-size: 0.74rem; + font-weight: 700; +} + +.empty-state { + display: grid; + gap: 5px; + place-items: center; + min-height: 170px; + padding: 25px; + border: 1px dashed #2d3a4d; + border-radius: 12px; + text-align: center; +} + +.empty-state strong { + font-size: 0.9rem; + font-weight: 620; +} + +.empty-state span { + max-width: 290px; + color: var(--muted); + font-size: 0.78rem; +} + +.source-list { + display: grid; + gap: 10px; +} + +.source-card { + padding: 14px; + border: 1px solid var(--border-soft); + border-radius: 12px; + background: rgba(8, 13, 20, 0.52); +} + +.source-card.cited { + border-color: rgba(94, 226, 160, 0.52); + box-shadow: inset 3px 0 0 rgba(94, 226, 160, 0.74); +} + +.source-card-head { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; + align-items: center; + color: var(--muted); + font-size: 0.7rem; +} + +.source-index { + color: var(--accent); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.source-title { + overflow: hidden; + color: var(--muted-strong); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-score { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.source.cited { - border-color: var(--cited); +.source-card p { + display: -webkit-box; + overflow: hidden; + margin: 10px 0; + color: #bdc8d6; + font-size: 0.78rem; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; } -.source-head { +.source-footer { display: flex; justify-content: space-between; + color: #6f7e91; + font-size: 0.66rem; +} + +.cited-label { + color: var(--green); + font-weight: 700; +} + +.document-section { + margin-top: 54px; +} + +.section-heading { + align-items: flex-end; + margin-bottom: 18px; +} + +.section-heading p:not(.section-kicker) { + max-width: 650px; + margin: 7px 0 0; + color: var(--muted); + font-size: 0.84rem; +} + +.ghost-button { + padding: 9px 13px; + border: 1px solid var(--border); + border-radius: 9px; + color: var(--muted-strong); + background: rgba(15, 21, 31, 0.75); + cursor: pointer; + font-size: 0.76rem; + font-weight: 650; +} + +.document-grid { + display: grid; + grid-template-columns: minmax(0, 0.92fr) minmax(340px, 1.08fr); + gap: 18px; +} + +.file-input { + padding: 8px; + color: var(--muted); + font-size: 0.78rem; +} + +.file-input::file-selector-button { + margin-right: 11px; + padding: 7px 10px; + border: 0; + border-radius: 7px; + color: #07121b; + background: var(--accent); + cursor: pointer; + font-weight: 700; +} + +.divider { + display: flex; + align-items: center; + gap: 10px; + margin: 17px 0 3px; + color: #607087; + font-size: 0.66rem; + text-transform: uppercase; +} + +.divider::before, +.divider::after { + height: 1px; + flex: 1; + background: var(--border-soft); + content: ""; +} + +.document-list { + display: grid; + gap: 8px; +} + +.document-row { + display: grid; + grid-template-columns: 34px 1fr auto; + gap: 12px; + align-items: center; + padding: 13px; + border: 1px solid var(--border-soft); + border-radius: 11px; + background: rgba(8, 13, 20, 0.45); +} + +.document-number { + color: #5e7189; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.7rem; +} + +.document-row > div { + display: grid; + min-width: 0; +} + +.document-row strong { + overflow: hidden; + font-size: 0.84rem; + font-weight: 620; + text-overflow: ellipsis; + white-space: nowrap; +} + +.document-row div span { + color: var(--muted); + font-size: 0.69rem; +} + +.indexed-mark { + color: var(--green); + font-size: 0.65rem; + font-weight: 700; +} + +.mode-explainer { + margin-top: 20px; + padding: 17px; + border: 1px solid rgba(125, 211, 252, 0.16); + border-radius: 12px; + background: rgba(14, 28, 40, 0.48); +} + +.mode-explainer h4 { + margin: 0 0 8px; + font-size: 0.82rem; +} + +.mode-explainer p { + margin: 6px 0 0; color: var(--muted); font-size: 0.75rem; - margin-bottom: 6px; } -.cited-tag { - color: var(--cited); - font-weight: 600; +@media (max-width: 900px) { + .hero { + align-items: flex-start; + flex-direction: column; + } + + .mode-strip { + width: 100%; + grid-template-columns: 1fr 1fr; + } + + .mode-badge { + min-width: 0; + } + + .metrics-grid { + grid-template-columns: 1fr 1fr; + } + + .metric-card:nth-child(2) { + border-right: 0; + } + + .metric-card:nth-child(-n + 2) { + border-bottom: 1px solid var(--border); + } + + .query-grid, + .document-grid { + grid-template-columns: 1fr; + } } -.error { - color: #ff6b6b; - margin-top: 16px; +@media (max-width: 560px) { + .shell { + width: min(100% - 24px, 1220px); + padding-top: 25px; + } + + .hero h1 { + font-size: 2.6rem; + } + + .mode-strip, + .metrics-grid { + grid-template-columns: 1fr; + } + + .metric-card, + .metric-card:nth-child(2) { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .metric-card:last-child { + border-bottom: 0; + } + + .query-panel, + .sources-panel, + .upload-panel, + .library-panel { + padding: 18px; + } + + .form-actions, + .section-heading { + align-items: stretch; + flex-direction: column; + } + + .form-actions button, + .ghost-button { + width: 100%; + } } diff --git a/app/app/layout.tsx b/app/app/layout.tsx index 106f6c8..39707a6 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -2,8 +2,9 @@ import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "RAG Engine", - description: "Ask questions over a document set, backed by a from-scratch HNSW index.", + title: "RAG Engine · Retrieval Workbench", + description: + "Index documents, inspect a from-scratch HNSW graph, and ask grounded questions.", }; export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/app/app/lib/rag.ts b/app/app/lib/rag.ts index 6ad02b0..85a84d2 100644 --- a/app/app/lib/rag.ts +++ b/app/app/lib/rag.ts @@ -28,6 +28,18 @@ export interface DocumentInfo { n_chunks: number; } +export interface Stats { + documents: number; + chunks: number; + dim: number; + metric: string; + min_score: number | null; + m: number; + ef_construction: number; + embedder: string; + generation: "mock" | "claude"; +} + async function call(path: string, init?: RequestInit): Promise { const res = await fetch(`${SERVICE_URL}${path}`, { ...init, @@ -58,3 +70,7 @@ export function addDocument(title: string, text: string): Promise export function listDocuments(): Promise { return call("/documents"); } + +export function getStats(): Promise { + return call("/stats"); +} diff --git a/app/app/page.tsx b/app/app/page.tsx index 98a7e05..d1f693a 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -1,87 +1,444 @@ "use client"; -import { useState } from "react"; -import type { QueryResult } from "./lib/rag"; +import { useCallback, useEffect, useState } from "react"; +import type { ChangeEvent, FormEvent } from "react"; +import type { DocumentInfo, QueryResult, Stats } from "./lib/rag"; + +async function requestJson(path: string, init?: RequestInit): Promise { + const response = await fetch(path, init); + const body = (await response.json()) as T & { error?: string }; + if (!response.ok) { + throw new Error(body.error ?? "Request failed"); + } + return body; +} + +function ModeBadge({ + label, + value, + tone, +}: { + label: string; + value: string; + tone: "blue" | "green" | "amber"; +}) { + return ( +
+
+ ); +} + +function Metric({ + label, + value, + detail, +}: { + label: string; + value: string; + detail: string; +}) { + return ( +
+ {label} + {value} + {detail} +
+ ); +} export default function Home() { const [question, setQuestion] = useState(""); const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - async function ask(e: React.FormEvent) { - e.preventDefault(); - if (!question.trim()) return; - setLoading(true); - setError(null); + const [queryLoading, setQueryLoading] = useState(false); + const [queryError, setQueryError] = useState(null); + + const [documents, setDocuments] = useState([]); + const [stats, setStats] = useState(null); + const [workspaceLoading, setWorkspaceLoading] = useState(true); + const [workspaceError, setWorkspaceError] = useState(null); + + const [documentTitle, setDocumentTitle] = useState(""); + const [documentText, setDocumentText] = useState(""); + const [uploadLoading, setUploadLoading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [uploadNotice, setUploadNotice] = useState(null); + const [fileInputKey, setFileInputKey] = useState(0); + + const loadWorkspace = useCallback(async () => { + setWorkspaceLoading(true); + setWorkspaceError(null); + try { + const [nextDocuments, nextStats] = await Promise.all([ + requestJson("/api/documents"), + requestJson("/api/stats"), + ]); + setDocuments(nextDocuments); + setStats(nextStats); + } catch (err) { + setWorkspaceError(err instanceof Error ? err.message : "Unable to load index"); + } finally { + setWorkspaceLoading(false); + } + }, []); + + useEffect(() => { + void loadWorkspace(); + }, [loadWorkspace]); + + async function ask(event: FormEvent) { + event.preventDefault(); + const trimmedQuestion = question.trim(); + if (!trimmedQuestion) return; + + setQueryLoading(true); + setQueryError(null); try { - const res = await fetch("/api/query", { + const nextResult = await requestJson("/api/query", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ question, k: 5 }), + body: JSON.stringify({ question: trimmedQuestion, k: 5 }), }); - const body = await res.json(); - if (!res.ok) throw new Error(body.error ?? "request failed"); - setResult(body); + setResult(nextResult); } catch (err) { - setError(err instanceof Error ? err.message : "unknown error"); + setQueryError(err instanceof Error ? err.message : "Unable to run query"); setResult(null); } finally { - setLoading(false); + setQueryLoading(false); + } + } + + async function chooseFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + + setUploadError(null); + try { + const text = await file.text(); + setDocumentText(text); + if (!documentTitle.trim()) { + setDocumentTitle(file.name.replace(/\.(md|markdown|txt)$/i, "")); + } + } catch { + setUploadError("The selected file could not be read."); + } + } + + async function uploadDocument(event: FormEvent) { + event.preventDefault(); + if (!documentTitle.trim() || !documentText.trim()) return; + + setUploadLoading(true); + setUploadError(null); + setUploadNotice(null); + try { + const created = await requestJson("/api/documents", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: documentTitle.trim(), + text: documentText, + }), + }); + setUploadNotice( + `Indexed “${created.title}” as ${created.n_chunks} ${created.n_chunks === 1 ? "chunk" : "chunks"}.`, + ); + setDocumentTitle(""); + setDocumentText(""); + setFileInputKey((key) => key + 1); + await loadWorkspace(); + } catch (err) { + setUploadError(err instanceof Error ? err.message : "Unable to index document"); + } finally { + setUploadLoading(false); } } + const hashedRetrieval = stats?.embedder === "HashedEmbedder"; + const retrievalLabel = stats + ? hashedRetrieval + ? "Lexical · hashed" + : "Semantic embeddings" + : "Loading"; + const generationLabel = + stats?.generation === "claude" ? "Claude" : stats ? "Extractive mock" : "Loading"; + return ( -
-
-

RAG Engine

-

- Ask a question over the indexed documents. Retrieval runs on a - from-scratch HNSW index; the answer is generated with cited sources. -

- -
-