| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796 |
- <template>
- <div class="search-engine-container">
- <div class="header-section">
- <div class="title-info">
- <h2>检索引擎</h2>
- <p class="subtitle">检索知识库中的内容</p>
- </div>
- <div class="header-right">
- <!-- 可以在这里放用户信息或其他按钮 -->
- </div>
- </div>
- <!-- Search Form Card -->
- <el-card class="search-card" shadow="never">
- <div class="search-form-container">
- <!-- 顶部行:知识库 + 检索模式 + 关键字 -->
- <div class="search-form-row main-row">
- <div class="form-item kb-select">
- <div class="label">知识库</div>
- <el-select
- v-model="searchForm.kb_id"
- placeholder="选择知识库"
- style="width: 100%"
- clearable
- @change="handleKbChange"
- >
- <el-option
- v-for="kb in kbList"
- :key="kb.id"
- :label="kb.name"
- :value="kb.collection_name_children || kb.collection_name_parent || kb.collection_name"
- >
- <span>{{ kb.name }}</span>
- </el-option>
- </el-select>
- </div>
-
- <div class="form-item search-mode">
- <div class="label">检索模式</div>
- <el-select
- v-model="searchForm.mode"
- placeholder="请选择"
- style="width: 100%"
- @change="handleModeChange"
- >
- <el-option label="简单模式" value="simple" />
- <el-option label="高级模式" value="advanced" />
- </el-select>
- </div>
- <div class="form-item keyword-input">
- <div class="label">检索关键字</div>
- <el-input
- v-model="searchForm.query"
- placeholder="请输入检索内容..."
- clearable
- @keyup.enter="handleSearch"
- :disabled="!searchForm.kb_id"
- >
- <template #append>
- <el-button :icon="Search" @click="handleSearch" :disabled="!searchForm.kb_id">
- 检索
- </el-button>
- </template>
- </el-input>
- </div>
- </div>
- <!-- 文档范围过滤 (简单模式可见) -->
- <div class="search-form-row secondary-row" v-if="searchForm.kb_id">
- <div class="form-item doc-filter">
- <div class="label">文档范围过滤</div>
- <el-select
- v-model="searchForm.doc_names"
- placeholder="默认检索全部文档,可选择特定文档进行范围限定"
- multiple
- filterable
- remote
- clearable
- collapse-tags
- collapse-tags-tooltip
- :remote-method="loadDocOptions"
- :loading="docLoading"
- style="width: 100%"
- @focus="() => loadDocOptions('')"
- >
- <el-option
- v-for="item in docOptions"
- :key="item.value"
- :label="item.label"
- :value="item.value"
- />
- </el-select>
- </div>
- </div>
- <!-- 高级过滤区域 (仅在高级模式显示) -->
- <div v-if="searchForm.mode === 'advanced'" class="advanced-filter-area">
- <!-- 顶部操作栏:标题 + 添加按钮 -->
- <div class="filter-header">
- <div class="filter-section-title">元数据过滤条件</div>
- <el-button type="primary" link :icon="Plus" size="small" @click="addFilter">添加条件</el-button>
- </div>
-
- <div class="filter-list">
- <div v-for="(filter, index) in searchForm.filters" :key="index" class="filter-item">
- <el-row :gutter="10" align="middle">
- <el-col :span="10">
- <el-select
- v-model="filter.field"
- placeholder="选择字段"
- style="width: 100%"
- clearable
- :disabled="!searchForm.kb_id"
- >
- <el-option
- v-for="field in metadataFields"
- :key="field.id"
- :label="field.field_zh_name + ' (' + field.field_en_name + ')'"
- :value="field.field_en_name"
- />
- </el-select>
- </el-col>
- <el-col :span="1" style="text-align: center; color: #909399;">=</el-col>
- <el-col :span="11">
- <el-input
- v-model="filter.value"
- placeholder="输入值"
- :disabled="!filter.field"
- @keyup.enter="handleSearch"
- />
- </el-col>
- <el-col :span="2" style="text-align: center;">
- <el-button
- v-if="searchForm.filters.length > 1"
- type="danger"
- link
- :icon="Delete"
- @click="removeFilter(index)"
- />
- </el-col>
- </el-row>
- </div>
- </div>
- </div>
- </div>
- </el-card>
- <!-- Results Table Card -->
- <el-card class="result-card" shadow="never" v-if="hasSearched">
- <el-table :data="tableData" v-loading="loading" style="width: 100%">
- <el-table-column label="知识库" width="150" show-overflow-tooltip>
- <template #default="{ row }">
- <span>{{ getKbName(row.kb_name) }}</span>
- </template>
- </el-table-column>
- <el-table-column label="文档名称" width="200" show-overflow-tooltip>
- <template #default="{ row }">
- <span v-if="docNamesMap[row.document_id]">{{ docNamesMap[row.document_id] }}</span>
- <span v-else>{{ row.doc_name }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="content" label="检索片段内容" min-width="400">
- <template #default="{ row }">
- <el-tooltip
- effect="light"
- placement="top-start"
- :disabled="!row.content || row.content.length <= 200"
- >
- <template #content>
- <div class="tooltip-content-scroll" v-html="highlightKeyword(row.content)"></div>
- </template>
- <div class="snippet-content" v-html="highlightKeyword(truncateText(row.content, 200))"></div>
- </el-tooltip>
- </template>
- </el-table-column>
- <el-table-column label="元数据信息" width="250" show-overflow-tooltip>
- <template #default="{ row }">
- <span>{{ formatMetaInfo(row) }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="score" label="相似度" width="100">
- <template #default="{ row }">
- <el-tag :type="getScoreType(row.score)">{{ row.score }}%</el-tag>
- </template>
- </el-table-column>
- <el-table-column label="操作" width="100" fixed="right">
- <template #default="{ row }">
- <el-button link type="primary" @click="handleDetail(row)">
- 查看详情
- </el-button>
- </template>
- </el-table-column>
- </el-table>
- <div class="pagination-container" v-if="total > 0">
- <div class="pagination-info">
- 显示 {{ (currentPage - 1) * pageSize + 1 }} 到
- {{ Math.min(currentPage * pageSize, total) }} 条,
- 共 {{ total }} 条记录
- </div>
- <el-pagination
- v-model:current-page="currentPage"
- v-model:page-size="pageSize"
- :total="total"
- :page-sizes="[10, 20, 50]"
- layout="prev, pager, next, sizes"
- @size-change="handleSearch"
- @current-change="handleSearch"
- />
- </div>
- </el-card>
-
- <div v-else-if="!searchForm.kb_id" class="empty-placeholder">
- <el-empty description="请选择知识库并输入关键字进行检索" />
- </div>
- <!-- Detail Dialog -->
- <el-dialog v-model="detailVisible" title="片段详情" width="800px">
- <div class="detail-content">
- <div class="detail-item">
- <span class="label">知识库:</span>
- <span>{{ getKbName(currentDetail?.kb_name) }}</span>
- </div>
- <div class="detail-item">
- <span class="label">文档:</span>
- <span>{{ currentDetail?.doc_name }}</span>
- </div>
- <div class="detail-item">
- <span class="label">相似度:</span>
- <span>{{ currentDetail?.score }}%</span>
- </div>
- <div class="view-content-section" v-if="currentDetail?.parent_id && currentDetail?.parent_id !== '0'">
- <div class="section-title">
- <el-icon><Connection /></el-icon> 父段内容
- </div>
- <div v-loading="parentLoading">
- <el-tabs v-if="parentSegments.length > 1" v-model="activeParentTab" type="card">
- <el-tab-pane
- v-for="(seg, idx) in parentSegments"
- :key="seg.id || idx"
- :label="`父段片段 ${idx + 1}`"
- :name="String(idx)"
- >
- <div class="content-box parent-content">{{ seg.content || '暂无内容' }}</div>
- </el-tab-pane>
- </el-tabs>
- <div v-else class="content-box parent-content">{{ parentSegments[0]?.content || '暂无内容' }}</div>
- </div>
- </div>
- <div class="detail-item full-content">
- <span class="label">完整内容:</span>
- <div class="text-box">{{ currentDetail?.content }}</div>
- </div>
- <div class="detail-item">
- <span class="label">元数据:</span>
- <span>{{ formatMetaInfo(currentDetail) }}</span>
- </div>
- </div>
- </el-dialog>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, reactive, onMounted } from 'vue'
- import { Search, Plus, Delete, Connection } from '@element-plus/icons-vue'
- import { ElMessage } from 'element-plus'
- import { getKnowledgeBases, getKnowledgeBaseMetadata, type KnowledgeBase } from '@/api/knowledge-base'
- import { searchKnowledgeBase, type KBSearchResultItem } from '@/api/search-engine'
- import { getSnippetDetail } from '@/api/snippet'
- import { documentApi } from '@/api/document'
- // Data
- const kbList = ref<KnowledgeBase[]>([])
- const metadataFields = ref<any[]>([]) // Store available metadata fields for selected KB
- const loading = ref(false)
- const hasSearched = ref(false)
- const tableData = ref<KBSearchResultItem[]>([])
- const total = ref(0)
- const currentPage = ref(1)
- const pageSize = ref(10)
- const searchForm = reactive({
- kb_id: '',
- mode: 'simple',
- filters: [{ field: '', value: '' }] as { field: string, value: string }[],
- query: '',
- doc_names: [] as string[] // 文档名称过滤
- })
- const docOptions = ref<{label: string, value: string}[]>([])
- const docLoading = ref(false)
- const loadDocOptions = async (query: string) => {
- docLoading.value = true
- try {
- const res = await documentApi.getList({
- page: 1,
- size: 50,
- keyword: query,
- }, true)
- if (res.code === 0) {
- // 映射为 label (title) 和 value (title) - 这里我们用 title 作为过滤值
- // 因为 Milvus 中存的是 doc_name/title,而不是 ID
- docOptions.value = res.data.items.map(item => ({
- label: item.title,
- value: item.title
- }))
- }
- } catch (error) {
- console.error("加载文档列表失败", error)
- } finally {
- docLoading.value = false
- }
- }
- const detailVisible = ref(false)
- const currentDetail = ref<KBSearchResultItem | null>(null)
- const parentSegments = ref<any[]>([])
- const parentLoading = ref(false)
- const activeParentTab = ref('0')
- // Methods
- const handleModeChange = () => {
- // Reset advanced fields when switching to simple
- if (searchForm.mode === 'simple') {
- searchForm.filters = [{ field: '', value: '' }]
- }
- }
- const addFilter = () => {
- searchForm.filters.push({ field: '', value: '' })
- }
- const removeFilter = (index: number) => {
- searchForm.filters.splice(index, 1)
- }
- const loadKBs = async () => {
- try {
- const res = await getKnowledgeBases({ page: 1, page_size: 100 }) // Load all KBs (simplified)
- kbList.value = res.data
- } catch (error) {
- console.error(error)
- }
- }
- const handleKbChange = async () => {
- // Reset search when KB changes
- hasSearched.value = false
- tableData.value = []
- total.value = 0
-
- // Reset metadata selection
- searchForm.filters = [{ field: '', value: '' }]
- metadataFields.value = []
-
- if (searchForm.kb_id) {
- // Find selected KB object to get ID (kb_id in form is collection_name)
- const selectedKb = kbList.value.find(k =>
- k.collection_name_children === searchForm.kb_id ||
- k.collection_name_parent === searchForm.kb_id ||
- k.collection_name === searchForm.kb_id
- )
- if (selectedKb) {
- try {
- const res = await getKnowledgeBaseMetadata(selectedKb.id)
- // 确保后端返回的数据格式正确
- if (res.code === 0) {
- if (Array.isArray(res.data)) {
- metadataFields.value = res.data
- } else if (res.data && Array.isArray(res.data.metadata_fields)) {
- metadataFields.value = res.data.metadata_fields
- } else {
- metadataFields.value = []
- }
- } else {
- console.warn("Invalid metadata response:", res)
- metadataFields.value = []
- }
- } catch (error) {
- console.error("Failed to load metadata fields", error)
- metadataFields.value = []
- }
- }
- }
- }
- const docNamesMap = ref<Record<string, string>>({})
- const formatMetaInfo = (row: any) => {
- // 优先展示 metadata 字段中的信息
- if (row.metadata) {
- // 如果是 JSON 对象,尝试格式化展示
- if (typeof row.metadata === 'object') {
- const displayParts = []
- for (const [key, value] of Object.entries(row.metadata)) {
- const valStr = typeof value === 'object' ? JSON.stringify(value) : String(value)
- displayParts.push(`${key}: ${valStr}`)
- }
- if (displayParts.length > 0) return displayParts.join(' | ')
- } else if (typeof row.metadata === 'string') {
- try {
- const metaObj = JSON.parse(row.metadata)
- const displayParts = []
- for (const [key, value] of Object.entries(metaObj)) {
- const valStr = typeof value === 'object' ? JSON.stringify(value) : String(value)
- displayParts.push(`${key}: ${valStr}`)
- }
- if (displayParts.length > 0) return displayParts.join(' | ')
- } catch (e) {
- return String(row.metadata)
- }
- }
- }
- // 如果 row 本身有 vector 属性,也不显示在 meta_info 中
- // 这里只处理了 metadata 字段内的
- return row.meta_info || '-'
- }
- const loadDocNames = async (docIds: string[]) => {
- if (docIds.length === 0) return
-
- // 过滤掉已经有的
- const missingIds = docIds.filter(id => !docNamesMap.value[id] && id)
- if (missingIds.length === 0) return
- // 批量查询文档详情比较低效,documentApi 如果有批量接口最好
- // 暂时循环查询,或者如果 documentApi.getList 支持 ids 参数
- // 假设我们只能单个查,为了性能,我们这里只查前 20 个 unique 的
-
- // 更好的方式:如果后端 search 接口能直接返回 doc_name 最好。
- // 但用户要求 "文档来源就根据这个片段的document_id去数据库里面查找"
- // 意味着我们需要前端二次查询或者后端聚合。
- // 为了前端响应速度,我们异步查询。
-
- for (const id of missingIds) {
- try {
- // 这里用 getDetail 查
- const res = await documentApi.getDetail(id)
- if (res.code === 0 && res.data) {
- docNamesMap.value[id] = res.data.title
- } else {
- docNamesMap.value[id] = '未知文档'
- }
- } catch (e) {
- console.error(`加载文档 ${id} 失败`, e)
- }
- }
- }
- const handleSearch = async () => {
- if (!searchForm.kb_id) {
- ElMessage.warning('请选择知识库')
- return
- }
-
- // If keyword is empty but KB is selected, user might want to see all or needs keyword?
- // Requirement says "search based on keyword", but usually empty keyword is allowed or blocked.
- // We will allow it but maybe warn if strict. Let's assume standard search behavior.
-
- loading.value = true
- hasSearched.value = true
-
- try {
- // 处理多重过滤
- // 后端目前可能只支持单一 metadata_field/value,或者我们需要修改后端接口支持 filters 数组
- // 这里我们先转换成后端能理解的格式,或者假设后端已更新
- // 假设后端接口 searchKnowledgeBase 支持 filters 参数: { field: string, value: string }[]
-
- // 过滤掉空的条件
- const validFilters = searchForm.mode === 'advanced'
- ? searchForm.filters.filter(f => f.field && f.value)
- : []
-
- // 将文档过滤也作为一种特殊的 filter 加入
- // 注意:即便是简单模式,如果用户选了文档,也应该生效
- if (searchForm.doc_names.length > 0) {
- // 对于多选文档,我们需要构建 OR 逻辑,但目前的 filters 结构是 AND
- // 我们可以约定 field 为 "doc_name_in",value 为逗号分隔的字符串
- validFilters.push({
- field: 'doc_name_in',
- value: JSON.stringify(searchForm.doc_names)
- })
- }
-
- const res = await searchKnowledgeBase({
- kb_id: searchForm.kb_id,
- query: searchForm.query || '',
- // 传递 filters 数组 (需要后端支持,或者我们在前端做兼容处理)
- // 为了兼容旧接口,如果只有一个 filter,传旧参数;如果有多个,传新参数 filters
- metadata_field: validFilters.length === 1 && validFilters[0].field !== 'doc_name_in' ? validFilters[0].field : undefined,
- metadata_value: validFilters.length === 1 && validFilters[0].field !== 'doc_name_in' ? validFilters[0].value : undefined,
- filters: validFilters.length > 0 ? validFilters : undefined,
-
- top_k: pageSize.value,
- page: currentPage.value,
- page_size: pageSize.value,
- metric_type: 'hybrid',
- })
-
- tableData.value = res.data.results
- total.value = res.data.total
-
- // 异步加载文档名称
- const docIds = res.data.results.map((r: any) => r.document_id).filter((id: string) => id)
- // 不阻塞 UI
- loadDocNames(docIds)
-
- } catch (error: any) {
- console.error(error)
- ElMessage.error(error.message || '检索失败,请检查配置或稍后重试')
- } finally {
- loading.value = false
- }
- }
- const handleDetail = (row: KBSearchResultItem) => {
- currentDetail.value = row
- parentSegments.value = []
- parentLoading.value = false
- activeParentTab.value = '0'
- if (row.parent_id) {
- parentLoading.value = true
- getSnippetDetail(row.kb_name, row.parent_id)
- .then((res: any) => {
- if (res.code === 0 && res.data) {
- if (Array.isArray(res.data.parent_segments) && res.data.parent_segments.length > 0) {
- parentSegments.value = res.data.parent_segments
- } else if (res.data.parent_content) {
- parentSegments.value = [{ id: '', content: res.data.parent_content }]
- }
- }
- })
- .finally(() => {
- parentLoading.value = false
- })
- }
- detailVisible.value = true
- }
- const getKbName = (collectionName?: string) => {
- if (!collectionName) return '-'
- const matched = kbList.value.find(k =>
- k.collection_name_children === collectionName ||
- k.collection_name_parent === collectionName ||
- (k as any).collection_name === collectionName
- )
- return matched?.name || collectionName
- }
- const getScoreType = (score: number) => {
- if (score >= 90) return 'success'
- if (score >= 70) return 'warning'
- return 'info'
- }
- const highlightKeyword = (text: string) => {
- if (!searchForm.query) return text
- const keyword = searchForm.query
- const regex = new RegExp(keyword, 'gi')
- return text.replace(regex, `<span style="color: #409eff; font-weight: bold;">$&</span>`)
- }
- const truncateText = (text: string, length: number) => {
- if (!text) return ''
- if (text.length <= length) return text
- return text.slice(0, length) + '...'
- }
- onMounted(() => {
- loadKBs()
- })
- </script>
- <style scoped>
- .search-engine-container {
- padding: 20px;
- background-color: #f5f7fa;
- min-height: calc(100vh - 84px);
- }
- .view-content-section {
- margin-top: 20px;
- }
- .view-content-section .section-title {
- display: flex;
- align-items: center;
- gap: 8px;
- font-weight: 600;
- margin-bottom: 12px;
- font-size: 16px;
- color: #303133;
- border-left: 4px solid #409EFF;
- padding-left: 10px;
- }
- .content-box {
- background-color: #f5f7fa;
- padding: 15px;
- border-radius: 4px;
- border: 1px solid #e4e7ed;
- color: #606266;
- line-height: 1.6;
- max-height: 400px;
- overflow-y: auto;
- white-space: pre-wrap;
- word-break: break-all;
- }
- .parent-content {
- background-color: #fcf6ec;
- border-color: #f3d19e;
- color: #e6a23c;
- }
- .header-section {
- margin-bottom: 20px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .title-info h2 {
- font-size: 20px;
- font-weight: 600;
- color: #303133;
- margin: 0;
- display: inline-block;
- margin-right: 12px;
- }
- .subtitle {
- display: inline-block;
- color: #909399;
- font-size: 14px;
- margin: 0;
- }
- .search-card {
- margin-bottom: 20px;
- border-radius: 4px;
- }
- .search-form-row {
- display: flex;
- gap: 20px;
- align-items: flex-end;
- }
- .search-form-row.main-row {
- align-items: flex-end;
- margin-bottom: 0;
- }
- .kb-select {
- flex: 0 0 250px;
- }
- .search-mode {
- flex: 0 0 150px;
- }
- .keyword-input {
- flex: 1;
- }
- .form-item .label {
- font-size: 13px;
- color: #606266;
- margin-bottom: 8px;
- font-weight: 500;
- }
- .advanced-filter-area {
- margin-top: 20px;
- padding: 20px;
- border-top: 1px solid #ebeef5;
- background-color: #fafafa;
- border-radius: 4px;
- }
- .filter-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 12px;
- }
- .filter-title {
- font-size: 13px;
- font-weight: 600;
- color: #606266;
- }
- .filter-list {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- }
- .filter-item {
- width: calc(33.33% - 8px); /* 一行三个 */
- background-color: #f5f7fa;
- padding: 10px;
- border-radius: 4px;
- }
- @media screen and (max-width: 1200px) {
- .filter-item {
- width: calc(50% - 6px); /* 一行两个 */
- }
- }
- @media screen and (max-width: 768px) {
- .filter-item {
- width: 100%; /* 一行一个 */
- }
- }
- .result-card {
- border-radius: 4px;
- }
- .snippet-content {
- line-height: 1.5;
- color: #606266;
- word-break: break-all;
- }
- .tooltip-content-scroll {
- max-width: 500px;
- max-height: 400px;
- overflow-y: auto;
- line-height: 1.6;
- word-break: break-all;
- }
- .pagination-container {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-top: 20px;
- }
- .pagination-info {
- color: #909399;
- font-size: 13px;
- }
- .empty-placeholder {
- padding: 40px;
- background: #fff;
- border-radius: 4px;
- display: flex;
- justify-content: center;
- align-items: center;
- }
- .detail-content {
- padding: 10px;
- }
- .detail-item {
- margin-bottom: 16px;
- display: flex;
- }
- .detail-item .label {
- font-weight: bold;
- width: 80px;
- flex-shrink: 0;
- color: #606266;
- }
- .detail-item.full-content {
- flex-direction: column;
- }
- .detail-item.full-content .label {
- margin-bottom: 8px;
- }
- .text-box {
- background: #f5f7fa;
- padding: 12px;
- border-radius: 4px;
- line-height: 1.6;
- max-height: 300px;
- overflow-y: auto;
- white-space: pre-wrap;
- }
- </style>
|