iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
生成式 AI

練習AI系列 第 20

引用高亮(Grounded Answers)

  • 分享至 

  • xImage
  •  

🆕 程式碼

  1. src/utils/nlp.js(新增)
    // src/utils/nlp.js

/** 粗略的中英混合句子切分:保留標點作為邊界 */
export function splitSentences(text = "") {
if (!text) return [];
// 將多空白壓縮
const t = text.replace(/\s+/g, " ").trim();
// 以常見終止符號切分(中/英/數字併用)
const parts = t.split(/(?<=[。!?!?;;]|(?:.\s)|(?:?\s)|(?:!\s))/g).map(s => s.trim());
// 過濾掉太短的碎片
return parts.filter(s => s.length >= 2);
}

/** 簡單清理:去除全形空白、行首尾空白 */
export function cleanText(text = "") {
return (text || "").replace(/\u3000/g, " ").trim();
}

/** 對齊預處理:將句子/片段做輕量正規化,便於 embedding 前清理噪音 */
export function normalizeForEmbed(text = "") {
return cleanText(text).replace(/\s+/g, " ");
}

  1. src/day19_highlight.js(新增)

把模型答案切句,為每句做 embedding,與 Top-K 檢索到的 chunk 逐一比對,挑選最高分且超過閾值者建立引用。

// src/day19_highlight.js
import { openai } from "./aiClient.js";
import { normalizeForEmbed, splitSentences } from "./utils/nlp.js";

/** 餵 OpenAI 取向量 */
async function embedMany(texts = [], model = process.env.OPENAI_EMBEDDING_MODEL || "text-embedding-3-small") {
if (!texts.length) return [];
const res = await openai.embeddings.create({ model, input: texts });
return res.data.map(d => d.embedding);
}

function cosine(a, b) {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9);
}

/**

  • 將答案切句並對齊到檢索片段
  • @param {string} answer - 模型自然語言答案
  • @param {Array<{docId:string,id:string,text:string,vector:number[],score:number}>} hits - 檢索到的片段(含向量)
  • @param {number} threshold - 最低相似度門檻(0~1)
  • @returns {{
  • spans: Array<{ sentence:string, sourceIndex:number|null, score:number|null }>,
  • sources: Array<{ index:number, docId:string, id:string, score:number }>,
  • html: string
  • }}
    */
    export async function alignAnswerToSources(answer, hits, threshold = 0.27) {
    const sentences = splitSentences(answer);
    if (!sentences.length) {
    return { spans: [], sources: [], html: answer };
    }

// 句子向量
const sentVecs = await embedMany(sentences.map(normalizeForEmbed));
// 每句找最相近的 hit
const spans = sentences.map((s, i) => {
let best = { j: -1, sc: -1 };
for (let j = 0; j < hits.length; j++) {
const sc = cosine(sentVecs[i], hits[j].vector);
if (sc > best.sc) best = { j, sc };
}
if (best.sc >= threshold) return { sentence: s, sourceIndex: best.j, score: best.sc };
return { sentence: s, sourceIndex: null, score: null };
});

// 建立來源清單(去重),保留原 hits 順序
const used = new Set(spans.filter(x => x.sourceIndex != null).map(x => x.sourceIndex));
const sources = [...used].sort((a, b) => a - b).map((idx, k) => {
return { index: k + 1, docId: hits[idx].docId, id: hits[idx].id, score: hits[idx].score };
});

// 將 sourceIndex 映射為引用號碼(依 sources 順序)
const idxMap = new Map(); // hitIdx -> displayIndex
sources.forEach((s, i) => {
const hitIdx = [...used][i]; // 原來的 hits index
idxMap.set(hitIdx, s.index);
});

// 產生帶上標引用的 HTML(安全起見僅加上 與 )
const html = spans.map(x => {
if (x.sourceIndex == null) return <span>${escapeHtml(x.sentence)}</span>;
const n = idxMap.get(x.sourceIndex);
return <span>${escapeHtml(x.sentence)}<sup class="ml-1 align-super text-primary">[${n}]</sup></span>;
}).join(" ");

return { spans, sources, html };
}

function escapeHtml(s="") {
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
}

  1. src/day16_rag_store.js(修改:加入高亮版 API)

保留原有 answerWithRAG,新增 answerWithRAGHighlighted。
只展示新增與小幅改動處,其他維持 Day 18 版本。

// ...既有 import
import { alignAnswerToSources } from "./day19_highlight.js";

// === 新增:高亮版回答 ===
export async function answerWithRAGHighlighted({ tenant, ns, query, topK = 4, alignThreshold = 0.27 }) {
const hits = await retrieve({ tenant, ns, query, topK });

// 組上下文讓模型先「依據片段」回答
const ctx = hits.map((h,i)=># 片段${i+1}(${h.docId}, score=${h.score.toFixed(3)})\n${h.text}).join("\n\n");
const res = await openai.chat.completions.create({
model:"gpt-4o-mini", temperature:0.2,
messages:[
{ role:"system", content:"你是嚴謹的客服知識庫助理。僅依據片段回答;片段不足時要明確說明需要哪些資訊。" },
{ role:"user", content:問題:${query}\n\n片段:\n${ctx}\n\n請用繁體中文回答。先結論、再步驟與注意事項。 }
]
});
const answer = res.choices?.[0]?.message?.content?.trim() || "目前找不到足夠資訊。";

// 將答案句子對齊到 hits(用向量相似度)
const aligned = await alignAnswerToSources(answer, hits, alignThreshold);

// 附上 hits 原文,方便前端彈窗展示
const sourceChunks = hits.map((h, i) => ({
displayIndex: i + 1, docId: h.docId, id: h.id, score: h.score, text: h.text
}));

return {
answer, // 原始答案(純文字)
answerHtml: aligned.html, // 帶 [n] 的 HTML
spans: aligned.spans, // 每句對齊結果(可做更細緻渲染)
sources: aligned.sources, // 用到的來源(去重後)
sourceChunks, // Top-K 來源 chunk(含全文)
};
}

  1. app/api/kb/[tenant]/[ns]/ask/route.js(修改)

新增 highlight 控制回傳型態(預設 true)。

import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { answerWithRAG, answerWithRAGHighlighted } from "../../../../../src/day16_rag_store.js";

export const runtime = "nodejs";

export const POST = withAuth(async (req, ctx) => {
const { tenant, ns } = ctx.params;
const { q, highlight = true } = await req.json();
if (!q || !q.trim()) return NextResponse.json({ ok:false, error:"q 必填" }, { status:400 });

if (highlight) {
const out = await answerWithRAGHighlighted({ tenant, ns, query: q, topK: 4 });
return NextResponse.json({ ok:true, ...out });
} else {
const { answer, sources } = await answerWithRAG({ tenant, ns, query: q, topK: 4 });
return NextResponse.json({ ok:true, answer, sources });
}
}, ["viewer","editor","admin"]);

  1. app/studio/page.tsx(修改:渲染引用高亮 + 來源彈窗)

只展示需要替換/新增的片段;若你懶得 patch,可直接覆蓋整檔。

// ...原有 imports
type Chunk = { displayIndex:number; docId:string; id:string; score:number; text:string };

export default function Studio() {
// ...既有 state
const [answerHtml, setAnswerHtml] = useState("");
const [chunks, setChunks] = useState<Chunk[]>([]);
const [activeSrc, setActiveSrc] = useState<Chunk | null>(null);

// ...既有函式

async function ask() {
setAsking(true); setErr(""); setAnswer(""); setAnswerHtml(""); setSources([]); setChunks([]);
try {
const r = await fetch(/api/kb/${tenant}/${ns}/ask, {
method:"POST",
headers: { "Content-Type":"application/json", ...authHeaders() },
body: JSON.stringify({ q: askQ, highlight: true })
});
const j = await r.json(); if (!j.ok) throw new Error(j.error);
setAnswer(j.answer);
setAnswerHtml(j.answerHtml || "");
setSources(j.sources || []);
setChunks(j.sourceChunks || []);
// 啟用上標點擊:委派事件(簡單處理)
setTimeout(() => {
document.querySelectorAll("sup.text-primary").forEach((el) => {
el.addEventListener("click", () => {
const n = Number((el.textContent||"").replace(/[^\d]/g,""));
const c = (j.sourceChunks||[]).find((x:Chunk)=>x.displayIndex===n);
if (c) setActiveSrc(c);
});
});
}, 0);
} catch(e:any){ setErr(e.message); }
finally { setAsking(false); }
}

return (

{/* ...navbar 與左側維持不變 */}

  {/* 右:RAG 問答卡片內替換渲染區塊 */}
  {/* ...卡片頭 */}
  {answerHtml ? (
    <div className="prose max-w-none">
      <h3>回答(含引用)</h3>
      <div className="whitespace-pre-wrap" dangerouslySetInnerHTML={{ __html: answerHtml }} />
      <h3>來源</h3>
      <ul className="list-disc pl-6">
        {chunks.map(c => (
          <li key={c.id}>
            <button className="link link-primary" onClick={()=>setActiveSrc(c)}>
              [{c.displayIndex}] {c.docId}({c.score.toFixed(2)})
            </button>
          </li>
        ))}
      </ul>
    </div>
  ) : answer ? (
    <div className="prose max-w-none">
      <h3>回答</h3>
      <div className="whitespace-pre-wrap">{answer}</div>
    </div>
  ) : null}

  {/* 來源內容 Modal */}
  {activeSrc && (
    <div className="modal modal-open">
      <div className="modal-box max-w-3xl">
        <h3 className="font-bold text-lg">來源 [{activeSrc.displayIndex}] {activeSrc.docId}</h3>
        <p className="text-sm opacity-60 mb-2">{activeSrc.id} | score {activeSrc.score.toFixed(3)}</p>
        <pre className="whitespace-pre-wrap">{activeSrc.text}</pre>
        <div className="modal-action">
          <button className="btn" onClick={()=>setActiveSrc(null)}>關閉</button>
        </div>
      </div>
    </div>
  )}
</div>

);
}

▶️ 驗收流程

開發模式

npm run day18:dev

/studio → 登入(任一 tenant/ns)→ 問一題

看見:

1) 答案句子內出現 [1][2] 上標

2) 右側「來源」清單顯示各 chunk 與分數

3) 點上標或來源條目 → 展開 Modal 顯示 chunk 原文


上一篇
多租戶(Tenant)+命名空間(Namespace)+RBAC 權限
下一篇
AI 數據分析助手(CSV/JSON → 統計摘要+洞察+可選圖表)
系列文
練習AI24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言