feat: 知识库朴素语义搜索(输入→检索→结果列表)
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
知识库 API — 上传 / 列表 / 删除 / 来源筛选
|
||||
知识库 API — 上传 / 列表 / 删除 / 来源筛选 / 语义搜索
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, status
|
||||
from sqlmodel import Session
|
||||
@@ -104,3 +105,28 @@ def get_grouped_knowledge_items(
|
||||
"""
|
||||
svc = KnowledgeService()
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -232,7 +232,10 @@ class KnowledgeService:
|
||||
def search_similar(self, query_text: str, top_k: int = 5) -> list[dict]:
|
||||
"""
|
||||
语义检索:查询句转为向量,用 SQL 余弦距离(<=>)在数据库层检索
|
||||
返回 top_k 条相似笔记,含相似度分数
|
||||
返回 top_k 条相似笔记,含相似度分数 + 原文片段(SQL 端截断前 200 字)。
|
||||
|
||||
注意:当前取前 200 字是已知妥协(整篇向量检索无法定位中段命中点),
|
||||
Phase 4a 做切块检索(chunk)时可优化为取最相关片段。
|
||||
"""
|
||||
query_vector = self.embedder.embed_single(query_text, embed_type="query")
|
||||
vec_str = "[" + ",".join(str(v) for v in query_vector) + "]"
|
||||
@@ -243,6 +246,9 @@ class KnowledgeService:
|
||||
ki.id,
|
||||
ki.title,
|
||||
ki.source_type,
|
||||
ki.author,
|
||||
ki.tags,
|
||||
SUBSTRING(ki.content_md, 1, 200) AS snippet,
|
||||
1 - (ke.embedding <=> '{vec_str}'::vector) AS similarity
|
||||
FROM knowledge_embeddings ke
|
||||
JOIN knowledge_items ki ON ke.knowledge_id = ki.id
|
||||
@@ -252,10 +258,20 @@ class KnowledgeService:
|
||||
"""
|
||||
stmt = text(sql)
|
||||
rows = session.execute(stmt).all()
|
||||
return [
|
||||
{"id": r.id, "title": r.title, "source_type": r.source_type, "similarity": round(r.similarity, 4)}
|
||||
for r in rows
|
||||
]
|
||||
results = []
|
||||
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:
|
||||
with Session(engine) as session:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag } from 'antd'
|
||||
import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag, Input } from 'antd'
|
||||
import { DeleteOutlined, ReloadOutlined, UploadOutlined, SearchOutlined, ClearOutlined } from '@ant-design/icons'
|
||||
import useAuthStore from '../../stores/authStore'
|
||||
import knowledgeService from '../../services/knowledgeService'
|
||||
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
|
||||
@@ -34,6 +34,11 @@ export default function KnowledgeBase() {
|
||||
const [sourceDetailFilter, setSourceDetailFilter] = useState('')
|
||||
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail }
|
||||
|
||||
// 搜索状态
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true)
|
||||
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) => {
|
||||
setSelectedTreeNode(node)
|
||||
@@ -229,6 +258,107 @@ export default function KnowledgeBase() {
|
||||
<span style={{ fontSize: 12, color: '#aaa' }}>支持批量上传,系统自动解析 yaml frontmatter 并生成语义向量</span>
|
||||
</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 左右并列,宽度稳定) */}
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
{/* 左侧树 */}
|
||||
|
||||
@@ -52,6 +52,19 @@ const knowledgeService = {
|
||||
const resp = await http.get('/knowledge/grouped')
|
||||
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
|
||||
Reference in New Issue
Block a user