🆕 程式碼
/** 粗略的中英混合句子切分:保留標點作為邊界 */
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, " ");
}
把模型答案切句,為每句做 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);
}
/**
// 句子向量
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,">");
}
保留原有 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(含全文)
};
}
新增 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"]);
只展示需要替換/新增的片段;若你懶得 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