之前知识只能"存",不能"找"。这一版把链路补上:切片 → 向量 → 打分 → 带来源返回。
- chunk 表 + 迁移 0006:正文按标题归段切、代码块整块保留、超长按句切、
相邻片重叠 80 字;RLS 与 GRANT 与其它租户表一致
- 向量提供方做成协议:内置 hash(字符二元组,零依赖可复现,作为基线)
与 OpenAI 兼容端点两种,换实现不动业务代码
- 检索是混合打分:向量 0.6 + pg_trgm 0.4。专有编号、单号这类必须原样命中的词
靠词法兜,换个说法表达的同一件事靠向量兜
- 三条纪律:只搜 published(过滤写在 SQL 里)、低于阈值宁可不返回、
每条命中都带来源标题与 item_id
- 发布时自动建索引,改完正文用 POST /knowledge/{id}/reindex 重建
- Web 知识页加检索框与命中卡片;新增 e2e/retrieval.mjs 6 项
为什么不是 pgvector:本机 Docker 起不来、fnsvr-01 没有该扩展且明确不动。
先用 real[] + SQL 余弦把链路跑通,将来换列类型与索引即可,接口不变。
后端 79 passed,e2e smoke/knowledge/richtext/retrieval 全绿。
This commit is contained in:
@@ -157,3 +157,16 @@ export interface Knowledge extends Item {
|
||||
attachment_count?: number;
|
||||
activities?: Activity[];
|
||||
}
|
||||
|
||||
/** 一条检索命中:切片级结果,带来源,点得回原文。 */
|
||||
export interface SearchHit {
|
||||
item_id: string;
|
||||
title: string;
|
||||
doc_type: string;
|
||||
idx: number;
|
||||
heading: string | null;
|
||||
content: string;
|
||||
score: number;
|
||||
vector_score: number;
|
||||
lexical_score: number;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
StatusPill,
|
||||
} from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import type { Attachment, Knowledge, KnowledgeStatus } from "../lib/types";
|
||||
import type { Attachment, Knowledge, KnowledgeStatus, SearchHit } from "../lib/types";
|
||||
|
||||
const TABS = [
|
||||
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
|
||||
@@ -63,6 +63,8 @@ export function KnowledgePage() {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const reqId = useRef(0);
|
||||
|
||||
const load = useCallback(
|
||||
@@ -99,6 +101,32 @@ export function KnowledgePage() {
|
||||
void load(null);
|
||||
}, [load]);
|
||||
|
||||
/**
|
||||
* 语义检索走的是切片接口,和上面的列表筛选不是一回事:
|
||||
* 列表按标题/正文做子串匹配,检索按向量 + trigram 打分,只覆盖已发布的知识。
|
||||
* 所以不把 q 直接接到列表上——两套结果混在一起会让人搞不清命中在哪。
|
||||
*/
|
||||
async function runSearch() {
|
||||
const keyword = q.trim();
|
||||
if (!keyword) {
|
||||
setHits(null);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const query = new URLSearchParams({ q: keyword, limit: "12" });
|
||||
const page = await api.get<{ items: SearchHit[] }>(
|
||||
`/workspaces/${wsId}/knowledge/search?${query.toString()}`,
|
||||
);
|
||||
setHits(page.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "检索失败");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addNote() {
|
||||
const title = draftTitle.trim();
|
||||
if (!title) return;
|
||||
@@ -133,11 +161,21 @@ export function KnowledgePage() {
|
||||
</span>
|
||||
<input
|
||||
className="input h-9 w-48 pl-8 text-sm"
|
||||
placeholder="搜标题或正文…"
|
||||
placeholder="搜标题或正文…(回车检索)"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
if (!e.target.value.trim()) setHits(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void runSearch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" disabled={searching || !q.trim()} onClick={() => void runSearch()}>
|
||||
<Search size={14} />
|
||||
{searching ? "检索中…" : "检索"}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setAdding((v) => !v)}>
|
||||
<Plus size={14} />
|
||||
记一条
|
||||
@@ -204,7 +242,52 @@ export function KnowledgePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && docs.length === 0 ? (
|
||||
{hits !== null ? (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-baseline justify-between px-1">
|
||||
<p className="text-xs text-ink-500">
|
||||
在已发布的知识里检索「{q.trim()}」,命中 {hits.length} 段
|
||||
</p>
|
||||
<button className="link text-2xs" onClick={() => setHits(null)}>
|
||||
退出检索
|
||||
</button>
|
||||
</div>
|
||||
{hits.length === 0 ? (
|
||||
<div className="card">
|
||||
<EmptyState
|
||||
icon={<Search size={18} />}
|
||||
title="没有检索到相关内容"
|
||||
hint="只检索已发布的知识。换成更具体的说法,或者先把手头的草稿核对发布。"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
hits.map((hit) => (
|
||||
<article key={`${hit.item_id}-${hit.idx}`} className="card p-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="chip bg-primary-50 text-primary-700">
|
||||
{DOC_TYPE_LABEL[hit.doc_type] ?? hit.doc_type}
|
||||
</span>
|
||||
{hit.heading ? (
|
||||
<span className="chip bg-paper-200 text-ink-600">{hit.heading}</span>
|
||||
) : null}
|
||||
<span className="ml-auto text-2xs text-ink-400">
|
||||
相关度 {hit.score.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-medium text-ink-900">{hit.title}</p>
|
||||
<p className="mt-1.5 line-clamp-3 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{hit.content}
|
||||
</p>
|
||||
<div className="mt-2 text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpenId(hit.item_id)}>
|
||||
打开原文
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
) : loading && docs.length === 0 ? (
|
||||
<div className="card overflow-hidden">
|
||||
<SkeletonRows rows={3} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user