docling-rag 0.52.2

Pluggable RAG subsystem for docling.rs: chunking, embeddings, vector store, and semantic search.
Documentation
<!doctype html>
<!-- docling-rag search UI: a single self-contained page served at GET /
     (embedded into the binary via include_str!). Talks to the crate's own
     REST API with the key from localStorage — nothing external is loaded. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>docling-rag</title>
<style>
  :root {
    --bg: #f7f7f8; --card: #ffffff; --text: #1c1c1e; --muted: #6e6e73;
    --accent: #0a66c2; --border: #e2e2e6; --ok: #1a7f37; --bad: #b42318;
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --bg: #131316; --card: #1d1d21; --text: #ececf0; --muted: #9a9aa2;
      --accent: #6cb2ff; --border: #313138; --ok: #4ade80; --bad: #f87171;
    }
  }
  * { box-sizing: border-box; }
  body { margin: 0; background: var(--bg); color: var(--text);
         font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
  main { max-width: 860px; margin: 0 auto; padding: 24px 16px 64px; }
  h1 { font-size: 20px; margin: 0; }
  header { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
  #status { font-size: 13px; color: var(--muted); }
  #status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
                 background: var(--muted); margin-right: 4px; }
  #status.ok .dot { background: var(--ok); }
  #status.bad .dot { background: var(--bad); }
  .card { background: var(--card); border: 1px solid var(--border);
          border-radius: 10px; padding: 14px 16px; margin-top: 14px; }
  label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 2px; }
  input, select { font: inherit; color: var(--text); background: var(--bg);
                  border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px; }
  input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
  .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
  .grow { flex: 1 1 260px; } .grow input { width: 100%; }
  button { font: inherit; font-weight: 600; color: #fff; background: var(--accent);
           border: 0; border-radius: 7px; padding: 8px 18px; cursor: pointer; }
  button:disabled { opacity: .5; cursor: default; }
  .check { display: flex; align-items: center; gap: 6px; font-size: 13px;
           color: var(--muted); padding-bottom: 8px; }
  #stats { font-size: 13px; color: var(--muted); }
  #error { color: var(--bad); font-size: 14px; margin-top: 12px; white-space: pre-wrap; }
  .answer { border-left: 3px solid var(--accent); }
  .answer h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
               color: var(--muted); margin: 0 0 6px; }
  .hit { margin-top: 10px; }
  .hit .meta { display: flex; gap: 10px; font-size: 12px; color: var(--muted);
               margin-bottom: 6px; flex-wrap: wrap; }
  .hit .score { color: var(--accent); font-weight: 600; }
  .hit pre { margin: 0; white-space: pre-wrap; word-break: break-word;
             font: 13.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
  details.token summary { cursor: pointer; font-size: 13px; color: var(--muted); }
  details.docs summary { cursor: pointer; font-size: 13px; color: var(--muted); }
  .doc { display: flex; align-items: center; gap: 10px; padding: 7px 0;
         border-top: 1px solid var(--border); }
  .doc:first-of-type { border-top: 0; }
  .doc .title { flex: 1 1 auto; min-width: 0; overflow: hidden;
                text-overflow: ellipsis; white-space: nowrap; }
  .doc .src { font-size: 12px; color: var(--muted); overflow: hidden;
              text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
  .doc button { background: transparent; color: var(--bad); font-weight: 500;
                padding: 2px 8px; border: 1px solid var(--border); }
  .doc a.md { font-size: 12px; color: var(--accent); text-decoration: none;
              padding: 2px 8px; border: 1px solid var(--border); border-radius: 7px; }
  .doc a.md:hover { text-decoration: underline; }
  #upload-row { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
  #upload-note { font-size: 13px; color: var(--muted); }
</style>
</head>
<body>
<main>
  <header>
    <h1>docling-rag</h1>
    <span id="status"><span class="dot"></span><span id="status-text">checking…</span></span>
    <span id="stats"></span>
  </header>

  <div class="card">
    <details class="token" id="token-box">
      <summary>API key</summary>
      <div class="row" style="margin-top:8px">
        <div class="grow">
          <label for="token">Stored in this browser's localStorage; sent as <code>X-Api-Key</code></label>
          <input id="token" type="password" placeholder="RAG_API_KEYS entry" autocomplete="off">
        </div>
      </div>
    </details>
  </div>

  <div class="card">
    <details class="docs" id="docs-box">
      <summary>Documents</summary>
      <div id="doc-list" style="margin-top:8px"></div>
      <div class="row" id="upload-row">
        <input id="file" type="file">
        <div class="check" title="Classify pictures (chart / logo / …); needs models/picture_classifier.onnx">
          <input id="enrich-pictures" type="checkbox"><label for="enrich-pictures" style="margin:0">Enrich pictures</label>
        </div>
        <div class="check" title="Transcribe code blocks with the CodeFormula model; needs the --enrich download">
          <input id="enrich-code" type="checkbox"><label for="enrich-code" style="margin:0">Enrich code</label>
        </div>
        <div class="check" title="Transcribe formulas to LaTeX with the CodeFormula model; needs the --enrich download">
          <input id="enrich-formulas" type="checkbox"><label for="enrich-formulas" style="margin:0">Enrich formulas</label>
        </div>
        <button type="button" id="upload">Upload</button>
        <span id="upload-note"></span>
      </div>
    </details>
  </div>

  <form class="card" id="form">
    <div class="row">
      <div class="grow">
        <label for="q">Query</label>
        <input id="q" type="text" required placeholder="What are the payment terms?">
      </div>
      <div>
        <label for="mode">Mode</label>
        <select id="mode">
          <option value="">server default</option>
          <option>vector</option>
          <option>bm25</option>
          <option>hybrid</option>
          <option>multi-query</option>
          <option>hyde</option>
        </select>
      </div>
      <div>
        <label for="k">Top-k</label>
        <input id="k" type="number" min="1" max="100" style="width:5.5em" placeholder="cfg">
      </div>
      <div class="check">
        <input id="answer" type="checkbox"><label for="answer" style="margin:0">LLM answer</label>
      </div>
      <div class="check" title="Show each hit with one chunk before and one after it">
        <input id="extend" type="checkbox"><label for="extend" style="margin:0">extend context</label>
      </div>
      <button id="go">Search</button>
    </div>
  </form>

  <div id="error" hidden></div>
  <div id="out"></div>
</main>

<script>
"use strict";
const $ = (id) => document.getElementById(id);
const TOKEN_KEY = "docling-rag-token";

$("token").value = localStorage.getItem(TOKEN_KEY) || "";
if (!$("token").value) $("token-box").open = true;
$("token").addEventListener("input", () => {
  localStorage.setItem(TOKEN_KEY, $("token").value.trim());
  refreshStats();
  refreshDocs();
});

function headers() {
  const h = { "Content-Type": "application/json" };
  const t = localStorage.getItem(TOKEN_KEY);
  if (t) h["X-Api-Key"] = t;
  return h;
}

async function api(path, init) {
  const res = await fetch(path, init);
  if (res.status === 401) {
    $("token-box").open = true;
    throw new Error("unauthorized — set a valid API key (one of RAG_API_KEYS)");
  }
  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
  return body;
}

async function refreshHealth() {
  const el = $("status");
  try {
    await fetch("/health").then((r) => { if (!r.ok) throw 0; });
    el.className = "ok"; $("status-text").textContent = "online";
  } catch {
    el.className = "bad"; $("status-text").textContent = "offline";
  }
}

async function refreshStats() {
  try {
    const s = await api("/api/stats", { headers: headers() });
    $("stats").textContent = `${s.documents} documents · ${s.chunks} chunks`;
  } catch { $("stats").textContent = ""; }
}

async function refreshDocs() {
  const list = $("doc-list");
  try {
    const d = await api("/api/documents", { headers: headers() });
    list.innerHTML = (d.documents || []).map((doc) => {
      const busy = String(doc.hash || "").startsWith("pending:");
      // target=_blank can't send headers, so the md link authenticates with
      // the stored key as ?api_key= (same key list, query-param form).
      const mdHref = `/api/documents/${encodeURIComponent(doc.id)}/markdown` +
        `?api_key=${encodeURIComponent(localStorage.getItem(TOKEN_KEY) || "")}`;
      const md = (doc.metadata && doc.metadata.has_markdown)
        ? `<a class="md" target="_blank" rel="noopener" href="${esc(mdHref)}">md</a>` : "";
      return `<div class="doc" data-id="${esc(doc.id)}">` +
      `<span class="title">${esc(doc.title)}${busy ? " ⏳ processing…" : ""}</span>` +
      `<span class="src">${esc(doc.source_uri)}</span>` + md +
      `<button type="button" class="del">delete</button></div>`;
    }).join("") || `<span id="upload-note">No documents yet.</span>`;
  } catch { list.innerHTML = ""; }
}

// One-line summary of where the ingest time went, from the metrics the
// upload response carries.
function phaseSummary(m) {
  if (!m) return "";
  const s = (p) => (m[p] && m[p].seconds != null) ? `${p} ${m[p].seconds.toFixed(1)}s` : null;
  const parts = [s("parsing"), s("chunking"), s("embedding")].filter(Boolean);
  return parts.length ? ` · ${parts.join(" · ")}` : "";
}

// While an upload runs, poll the documents API: the row appears with a
// pending hash as soon as conversion starts, and its chunk count grows as
// batches stream into the store.
function startUploadProgress(name) {
  const started = Date.now();
  const uri = `upload:///${name}`;
  const tick = async () => {
    const secs = Math.round((Date.now() - started) / 1000);
    try {
      const d = await api("/api/documents", { headers: headers() });
      const row = (d.documents || []).find((doc) => doc.source_uri === uri);
      if (row && String(row.hash || "").startsWith("pending:")) {
        const info = await api(`/api/documents/${encodeURIComponent(row.id)}`,
                               { headers: headers() });
        $("upload-note").textContent =
          `converting + embedding ${info.chunks} chunks · ${secs}s`;
        return;
      }
    } catch { /* keep the plain elapsed note */ }
    $("upload-note").textContent = `converting ${secs}s`;
  };
  tick();
  return setInterval(tick, 2000);
}

$("doc-list").addEventListener("click", async (ev) => {
  const btn = ev.target.closest("button.del");
  if (!btn) return;
  const row = btn.closest(".doc");
  const title = row.querySelector(".title").textContent;
  if (!confirm(`Delete "${title}" and all its chunks?`)) return;
  btn.disabled = true;
  try {
    await api(`/api/documents/${encodeURIComponent(row.dataset.id)}`, {
      method: "DELETE", headers: headers(),
    });
    refreshDocs(); refreshStats();
  } catch (e) {
    btn.disabled = false;
    $("error").textContent = String(e.message || e); $("error").hidden = false;
  }
});

$("upload").addEventListener("click", async () => {
  const file = $("file").files[0];
  if (!file) { $("upload-note").textContent = "pick a file first"; return; }
  $("upload").disabled = true;
  $("error").hidden = true;
  const progress = startUploadProgress(file.name);
  try {
    let qs = `name=${encodeURIComponent(file.name)}`;
    if ($("enrich-pictures").checked) qs += "&enrich_pictures=true";
    if ($("enrich-code").checked) qs += "&enrich_code=true";
    if ($("enrich-formulas").checked) qs += "&enrich_formulas=true";
    const r = await api(`/api/documents?${qs}`, {
      method: "POST", headers: headers(), body: file,
    });
    $("upload-note").textContent = r.outcome === "skipped"
      ? "already stored (identical content)"
      : `ingested  ${r.chunks} chunks${phaseSummary(r.metrics)}`;
    $("file").value = "";
    refreshDocs(); refreshStats();
  } catch (e) {
    $("upload-note").textContent = "";
    $("error").textContent = String(e.message || e); $("error").hidden = false;
  } finally { clearInterval(progress); $("upload").disabled = false; }
});

function esc(s) {
  return String(s).replace(/[&<>"]/g, (c) =>
    ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}

function render(data) {
  const out = [];
  if (data.answer) {
    out.push(`<div class="card answer"><h2>Answer (${esc(data.mode)})</h2>` +
             `<div>${esc(data.answer)}</div></div>`);
  }
  for (const hit of data.results || []) {
    const c = hit.chunk || {};
    const extended = hit.context != null && hit.context !== c.text;
    out.push(`<div class="card hit"><div class="meta">` +
             `<span class="score">${Number(hit.score).toFixed(4)}</span>` +
             `<span>doc ${esc(c.doc_id ?? "?")}</span>` +
             `<span>chunk #${esc(c.ordinal ?? "?")}${extended ? " ±1" : ""}</span></div>` +
             `<pre>${esc(hit.context ?? c.text ?? "")}</pre></div>`);
  }
  if (!out.length) out.push(`<div class="card">No results.</div>`);
  $("out").innerHTML = out.join("");
}

$("form").addEventListener("submit", async (ev) => {
  ev.preventDefault();
  $("error").hidden = true; $("out").innerHTML = ""; $("go").disabled = true;
  const body = { query: $("q").value, answer: $("answer").checked,
                 extend: $("extend").checked };
  if ($("mode").value) body.mode = $("mode").value;
  if ($("k").value) body.top_k = Number($("k").value);
  try {
    render(await api("/api/search", {
      method: "POST", headers: headers(), body: JSON.stringify(body),
    }));
  } catch (e) {
    $("error").textContent = String(e.message || e); $("error").hidden = false;
  } finally { $("go").disabled = false; }
});

refreshHealth();
refreshStats();
refreshDocs();
</script>
</body>
</html>