feat: 知识库朴素语义搜索(输入→检索→结果列表)

This commit is contained in:
simonkoson
2026-05-27 19:25:41 +08:00
parent 0c7d2d7400
commit 783e212bb1
4 changed files with 193 additions and 8 deletions
+27 -1
View File
@@ -1,8 +1,9 @@
""" """
知识库 API — 上传 / 列表 / 删除 / 来源筛选 知识库 API — 上传 / 列表 / 删除 / 来源筛选 / 语义搜索
""" """
from typing import Optional from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, status from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, status
from sqlmodel import Session from sqlmodel import Session
@@ -104,3 +105,28 @@ def get_grouped_knowledge_items(
""" """
svc = KnowledgeService() svc = KnowledgeService()
return svc.get_grouped_items() return svc.get_grouped_items()
class SearchRequest(BaseModel):
query: str
top_k: int = 5
@router.post("/search")
def search_knowledge(
body: SearchRequest,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""
语义检索:输入一段文字,返回最相关的知识库条目及相似度。
查询向量用 type="query"(区分于存入时的 type="db")。
三角色均可读。
"""
svc = KnowledgeService()
results = svc.search_similar(query_text=body.query, top_k=body.top_k)
return {
"results": results,
"query": body.query,
"count": len(results),
}
+21 -5
View File
@@ -232,7 +232,10 @@ class KnowledgeService:
def search_similar(self, query_text: str, top_k: int = 5) -> list[dict]: def search_similar(self, query_text: str, top_k: int = 5) -> list[dict]:
""" """
语义检索:查询句转为向量,用 SQL 余弦距离(<=>)在数据库层检索 语义检索:查询句转为向量,用 SQL 余弦距离(<=>)在数据库层检索
返回 top_k 条相似笔记,含相似度分数 返回 top_k 条相似笔记,含相似度分数 + 原文片段(SQL 端截断前 200 字)。
注意:当前取前 200 字是已知妥协(整篇向量检索无法定位中段命中点),
Phase 4a 做切块检索(chunk)时可优化为取最相关片段。
""" """
query_vector = self.embedder.embed_single(query_text, embed_type="query") query_vector = self.embedder.embed_single(query_text, embed_type="query")
vec_str = "[" + ",".join(str(v) for v in query_vector) + "]" vec_str = "[" + ",".join(str(v) for v in query_vector) + "]"
@@ -243,6 +246,9 @@ class KnowledgeService:
ki.id, ki.id,
ki.title, ki.title,
ki.source_type, ki.source_type,
ki.author,
ki.tags,
SUBSTRING(ki.content_md, 1, 200) AS snippet,
1 - (ke.embedding <=> '{vec_str}'::vector) AS similarity 1 - (ke.embedding <=> '{vec_str}'::vector) AS similarity
FROM knowledge_embeddings ke FROM knowledge_embeddings ke
JOIN knowledge_items ki ON ke.knowledge_id = ki.id JOIN knowledge_items ki ON ke.knowledge_id = ki.id
@@ -252,10 +258,20 @@ class KnowledgeService:
""" """
stmt = text(sql) stmt = text(sql)
rows = session.execute(stmt).all() rows = session.execute(stmt).all()
return [ results = []
{"id": r.id, "title": r.title, "source_type": r.source_type, "similarity": round(r.similarity, 4)} for r in rows:
for r in rows tags = r.tags or {}
] source_detail = tags.get("source_detail") if isinstance(tags, dict) else None
results.append({
"id": r.id,
"title": r.title,
"source_type": r.source_type,
"author": r.author,
"source_detail": source_detail,
"snippet": r.snippet,
"similarity": round(r.similarity, 4),
})
return results
def get_item_count(self) -> int: def get_item_count(self) -> int:
with Session(engine) as session: with Session(engine) as session:
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag } from 'antd' import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag, Input } from 'antd'
import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons' import { DeleteOutlined, ReloadOutlined, UploadOutlined, SearchOutlined, ClearOutlined } from '@ant-design/icons'
import useAuthStore from '../../stores/authStore' import useAuthStore from '../../stores/authStore'
import knowledgeService from '../../services/knowledgeService' import knowledgeService from '../../services/knowledgeService'
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree' import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
@@ -34,6 +34,11 @@ export default function KnowledgeBase() {
const [sourceDetailFilter, setSourceDetailFilter] = useState('') const [sourceDetailFilter, setSourceDetailFilter] = useState('')
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail } const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail }
// 搜索状态
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searchLoading, setSearchLoading] = useState(false)
const fetchItems = async () => { const fetchItems = async () => {
setLoading(true) setLoading(true)
try { try {
@@ -109,6 +114,30 @@ export default function KnowledgeBase() {
} }
} }
// 搜索处理
const handleSearch = async () => {
if (!searchQuery.trim()) {
setSearchResults([])
return
}
setSearchLoading(true)
try {
const data = await knowledgeService.searchItems(searchQuery.trim())
setSearchResults(data.results || [])
} catch {
message.error('搜索失败')
setSearchResults([])
} finally {
setSearchLoading(false)
}
}
// 清空搜索 → 恢复树形浏览(不丢树状态)
const handleClearSearch = () => {
setSearchQuery('')
setSearchResults([])
}
// 树节点选中 → 联动过滤 // 树节点选中 → 联动过滤
const handleNodeSelect = (node) => { const handleNodeSelect = (node) => {
setSelectedTreeNode(node) setSelectedTreeNode(node)
@@ -229,6 +258,107 @@ export default function KnowledgeBase() {
<span style={{ fontSize: 12, color: '#aaa' }}>支持批量上传系统自动解析 yaml frontmatter 并生成语义向量</span> <span style={{ fontSize: 12, color: '#aaa' }}>支持批量上传系统自动解析 yaml frontmatter 并生成语义向量</span>
</div> </div>
{/* 搜索栏(搜索模式/浏览模式互斥,清空恢复树形浏览不丢状态) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Input
placeholder="输入一段文字,语义搜索知识库…"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 340, borderRadius: 10 }}
prefix={<SearchOutlined style={{ color: '#aaa' }} />}
suffix={
searchQuery ? (
<ClearOutlined
style={{ color: '#aaa', cursor: 'pointer' }}
onClick={handleClearSearch}
/>
) : null
}
/>
<Button
icon={<SearchOutlined />}
onClick={handleSearch}
loading={searchLoading}
style={{ borderRadius: 10 }}
>
搜索
</Button>
{searchQuery && (
<span style={{ fontSize: 12, color: '#888' }}>
{searchResults.length > 0
? `找到 ${searchResults.length} 条相关结果`
: '无相关结果'}
</span>
)}
</div>
{/* 搜索结果列表(搜索模式显示,浏览模式隐藏) */}
{searchQuery && searchResults.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 13, color: '#666', marginBottom: 8, fontWeight: 500 }}>
搜索结果
</div>
{searchResults.map(item => (
<Card
key={item.id}
size="small"
style={{ borderRadius: 10, marginBottom: 8 }}
bodyStyle={{ padding: '12px 16px' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: '#3b4a3b', marginBottom: 4 }}>
{item.title}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<Tag color="default" style={{ borderRadius: 6, fontSize: 12 }}>
{SOURCE_TYPE_LABEL[item.source_type] || item.source_type}
</Tag>
{item.author && (
<span style={{ fontSize: 12, color: '#888' }}>作者{item.author}</span>
)}
{item.source_detail && (
<span style={{ fontSize: 12, color: '#888' }}>出处{item.source_detail}</span>
)}
</div>
<div
style={{
fontSize: 12,
color: '#555',
background: '#f5f5f5',
borderRadius: 6,
padding: '6px 10px',
lineHeight: 1.6,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
>
{item.snippet}
</div>
</div>
<div style={{
marginLeft: 16,
minWidth: 56,
textAlign: 'center',
background: item.similarity >= 0.7 ? '#e6f4ff' : '#f5f5f5',
borderRadius: 8,
padding: '4px 8px',
}}>
<div style={{ fontSize: 18, fontWeight: 700, color: item.similarity >= 0.7 ? '#1677ff' : '#666' }}>
{Math.max(0, Math.round(item.similarity * 100))}%
</div>
<div style={{ fontSize: 11, color: '#888' }}>相关度</div>
</div>
</div>
</Card>
))}
</div>
)}
{/* 主体:左侧树 + 右侧列表(flex 左右并列,宽度稳定) */} {/* 主体:左侧树 + 右侧列表(flex 左右并列,宽度稳定) */}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{/* 左侧树 */} {/* 左侧树 */}
+13
View File
@@ -52,6 +52,19 @@ const knowledgeService = {
const resp = await http.get('/knowledge/grouped') const resp = await http.get('/knowledge/grouped')
return resp.data return resp.data
}, },
/**
* 语义检索:输入一段文字,返回最相关的知识库条目
* @param {string} queryText - 查询文字
* @param {number} topK - 返回条数,默认 5
*/
async searchItems(queryText, topK = 5) {
const resp = await http.post('/knowledge/search', {
query: queryText,
top_k: topK,
})
return resp.data
},
} }
export default knowledgeService export default knowledgeService