给AI回答加引用角标citation:RAG前端实现

4 阅读3分钟

RAG 答案最值钱的部分,是「这句话出自哪」。模型说得头头是道,用户却不敢信,直到你在句末挂个可点的角标 ¹,点开是原文出处——信任感立刻不一样。这个 citation 角标前端怎么做,看着小,流式场景下坑不少。聊聊我的实现。

后端给的是什么

先说数据。我们后端返回的是文本 + 一组引用标记,标记用占位符嵌在文本里,类似:

根据财报,二季度营收同比增长 18%[cite:3]。利润率持平[cite:1]

外加一个 sources 数组:

interface Source {
  id: number;
  title: string;
  url: string;
  snippet: string; // 命中的原文片段
}

前端的活就是:把 [cite:N] 替换成可交互的角标,点击/悬浮展示对应 source。

解析:别用 dangerouslySetInnerHTML

最省事的写法是字符串替换成 <sup> 再 innerHTML 塞进去,但 RAG 答案里混着用户/模型生成的内容,直接 innerHTML 等于开 XSS 大门。我把文本切成片段数组,交给框架渲染,安全也可控:

function parseCitations(text: string): (string | { cite: number })[] {
  const parts: (string | { cite: number })[] = [];
  const re = /[cite:(\d+)]/g;
  let last = 0, m: RegExpExecArray | null;
  while ((m = re.exec(text))) {
    if (m.index > last) parts.push(text.slice(last, m.index));
    parts.push({ cite: Number(m[1]) });
    last = m.index + m[0].length;
  }
  if (last < text.length) parts.push(text.slice(last));
  return parts;
}

渲染时字符串段走正常文本,{ cite } 段渲染成角标组件,绑上对应 source 的悬浮卡。安全边界守住了,心里踏实。

流式下的坑:标记被切两半

这是 RAG + 流式特有的暗雷。[cite:3] 这七个字符,可能被 SSE 拆成两个 chunk:先来 ...增长 18%[cite: ,下一包才来 3]。如果你每个 chunk 都立刻 parse,第一包会把 [cite: 当普通文本渲染出来,闪一下乱码,第二包再变回去——抖得很难看。

解法是攒一个 buffer,只在「不可能切到标记中间」的安全点才渲染。简单粗暴版:buffer 末尾若有未闭合的 [,就先扣住不渲染,等闭合:

let buf = '';
function onChunk(delta: string) {
  buf += delta;
  // 找最后一个未闭合的 [,从那截断,剩下的留到下一包
  const open = buf.lastIndexOf('[');
  const safe = open !== -1 && !buf.slice(open).includes(']')
    ? buf.slice(0, open)
    : buf;
  render(parseCitations(safe));
}

这个 buffer 截断逻辑我前后改了三版才稳,核心就一句:标记可能跨包,渲染必须等它齐了。没意识到这点的话,线上就是满屏一闪一闪的 [cite:

角标点击:定位 + 高亮原文

点角标得跳到出处。如果出处在同页的来源列表里,平滑滚过去并高亮两秒,比直接弹新窗口体验好:

function onCiteClick(id: number) {
  const el = document.getElementById(`source-${id}`);
  el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
  el?.classList.add('flash');
  setTimeout(() => el?.classList.remove('flash'), 2000);
}

一个没解决干净的

模型偶尔会幻觉出一个 sources 里根本不存在的编号,比如 [cite:9] 但数组里最大才 5。我加了校验,越界的角标降级成普通文本不渲染成可点链接——但这只是遮羞,根上是检索/生成的对齐问题,前端兜不住。我能做的就是别让一个死链摆在用户面前,治标不治本,老实承认。

RAG 的检索和模型那套我没自己搭,挂在讯飞这类 MaaS 上,场景化检索和来源回传它那边给,前端只负责把引用呈现得可信、可点。

你们的 citation 是悬浮卡还是脚注式?流式切包的坑有没有人也踩过?评论区对个答案。