feat: Obsidian 知识库批量导入 + 语义搜索体验升级

- 新增 import_obsidian_kb.py 批量导入脚本(164篇入库,知识库186条)
- parse_md_file 补强:来源fallback、相关装备/应用领域实体提取、
  Obsidian双链去括号、原始类别/源文件路径存metadata、TYPE_MAP扩充
- search_similar 改进:智能摘要(中文2-gram拆词+加权段落匹配)、
  min_similarity=0.3过滤、top_k 5→10、返回完整content_md
- 前端搜索卡片升级:展开全文、关键词加粗渲染、相关度分档样式
- CLAUDE.md 状态更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
simonkoson
2026-07-08 15:26:17 +08:00
parent c21bc7d093
commit db9d10a795
6 changed files with 426 additions and 69 deletions
@@ -5,6 +5,19 @@ import useAuthStore from '../../stores/authStore'
import knowledgeService from '../../services/knowledgeService'
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
// 渲染 snippet 中的 **keyword** 加粗标记(不使用 dangerouslySetInnerHTML
function renderSnippet(text) {
if (!text) return null
const parts = text.split(/\*\*(.+?)\*\*/g)
return parts.map((part, i) => {
// 奇数索引是被 ** 包裹的关键词
if (i % 2 === 1) {
return <strong key={i} style={{ color: '#1677ff' }}>{part}</strong>
}
return <span key={i}>{part}</span>
})
}
const { Dragger } = Upload
// source_type 枚举值(固定五类,不写死但供 Select 用)
@@ -38,6 +51,20 @@ export default function KnowledgeBase() {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searchLoading, setSearchLoading] = useState(false)
const [expandedIds, setExpandedIds] = useState(new Set()) // 展开全文的卡片 id 集合
// 切换卡片展开/收起
const toggleExpand = (id) => {
setExpandedIds(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const fetchItems = async () => {
setLoading(true)
@@ -287,8 +314,8 @@ export default function KnowledgeBase() {
{searchQuery && (
<span style={{ fontSize: 12, color: '#888' }}>
{searchResults.length > 0
? `找到 ${searchResults.length} 条相关结果`
: '无相关结果'}
? `找到 ${searchResults.length} 条相关结果(相关度 > 30%`
: '未找到相关内容,试试换个说法?'}
</span>
)}
</div>
@@ -299,63 +326,110 @@ export default function KnowledgeBase() {
<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>
{searchResults.map(item => {
const isHigh = item.similarity >= 0.7
const isLow = item.similarity < 0.5
const isExpanded = expandedIds.has(item.id)
return (
<Card
key={item.id}
size="small"
style={{
borderRadius: 10,
marginBottom: 8,
opacity: isLow ? 0.65 : 1,
background: isLow ? '#fafafa' : undefined,
borderLeft: isHigh ? '3px solid #1677ff' : undefined,
}}
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',
}}
>
{renderSnippet(item.snippet)}
</div>
{/* 展开全文区域 */}
{isExpanded && item.content_md && (
<div
style={{
fontSize: 12,
color: '#444',
background: '#fafafa',
borderRadius: 6,
padding: '10px 12px',
marginTop: 8,
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
maxHeight: 400,
overflowY: 'auto',
border: '1px solid #e8e8e8',
}}
>
{item.content_md}
</div>
)}
{item.source_detail && (
<span style={{ fontSize: 12, color: '#888' }}>出处{item.source_detail}</span>
{/* 展开/收起按钮 */}
{item.content_md && (
<div style={{ marginTop: 6 }}>
<span
onClick={() => toggleExpand(item.id)}
style={{
fontSize: 12,
color: '#1677ff',
cursor: 'pointer',
userSelect: 'none',
}}
>
{isExpanded ? '收起全文 ▲' : '展开全文 ▼'}
</span>
</div>
)}
</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',
background: isHigh ? '#e6f4ff' : '#f5f5f5',
borderRadius: 8,
padding: '4px 8px',
}}>
<div style={{ fontSize: 18, fontWeight: 700, color: item.similarity >= 0.7 ? '#1677ff' : '#666' }}>
<div style={{ fontSize: 18, fontWeight: 700, color: isHigh ? '#1677ff' : '#666' }}>
{Math.max(0, Math.round(item.similarity * 100))}%
</div>
<div style={{ fontSize: 11, color: '#888' }}>相关度</div>
</div>
</div>
</Card>
))}
</div>
</Card>
)
})}
</div>
)}
+2 -2
View File
@@ -56,9 +56,9 @@ const knowledgeService = {
/**
* 语义检索:输入一段文字,返回最相关的知识库条目
* @param {string} queryText - 查询文字
* @param {number} topK - 返回条数,默认 5
* @param {number} topK - 返回条数,默认 10
*/
async searchItems(queryText, topK = 5) {
async searchItems(queryText, topK = 10) {
const resp = await http.post('/knowledge/search', {
query: queryText,
top_k: topK,