feat: add matched question field to FAQ search results

This commit is contained in:
wizardchen
2026-01-21 17:58:06 +08:00
committed by lyingbug
parent 9b1381a844
commit faf1c9a37b
11 changed files with 75 additions and 9 deletions
+3
View File
@@ -31,6 +31,9 @@ type FAQEntry struct {
Score float64 `json:"score,omitempty"`
MatchType string `json:"match_type,omitempty"`
ChunkType string `json:"chunk_type"`
// MatchedQuestion is the actual question text that was matched in FAQ search
// Could be the standard question or one of the similar questions
MatchedQuestion string `json:"matched_question,omitempty"`
}
// FAQEntryPayload is used to create or update a FAQ entry.
+3
View File
@@ -127,6 +127,9 @@ type SearchResult struct {
Metadata map[string]string `json:"metadata"`
KnowledgeFilename string `json:"knowledge_filename"`
KnowledgeSource string `json:"knowledge_source"`
// MatchedContent is the actual content that was matched in vector search
// For FAQ: this is the matched question text (standard or similar question)
MatchedContent string `json:"matched_content,omitempty"`
}
// HybridSearchResponse hybrid search response
+1
View File
@@ -1160,6 +1160,7 @@ export default {
noResults: 'No matching FAQ entries found',
score: 'Similarity',
matchType: 'Match Type',
matchedQuestion: 'Matched',
matchTypeEmbedding: 'Vector Match',
matchTypeKeywords: 'Keyword Match',
similarityThresholdLabel: 'Similarity Threshold',
+1
View File
@@ -1366,6 +1366,7 @@ export default {
noResults: "일치하는 FAQ 항목을 찾을 수 없습니다",
score: "유사도",
matchType: "매칭 유형",
matchedQuestion: "일치된 질문",
matchTypeEmbedding: "벡터 매칭",
matchTypeKeywords: "키워드 매칭",
similarityThresholdLabel: "유사도 임계값",
+1
View File
@@ -1123,6 +1123,7 @@ export default {
noResults: 'Совпадающих записей FAQ не найдено',
score: 'Сходство',
matchType: 'Тип совпадения',
matchedQuestion: 'Совпавший вопрос',
matchTypeEmbedding: 'Векторное совпадение',
matchTypeKeywords: 'Совпадение ключевых слов',
similarityThresholdLabel: 'Порог сходства',
+1
View File
@@ -1561,6 +1561,7 @@ export default {
noResults: "未找到匹配的 FAQ 条目",
score: "相似度",
matchType: "匹配类型",
matchedQuestion: "命中问题",
matchTypeEmbedding: "向量匹配",
matchTypeKeywords: "关键词匹配",
similarityThresholdLabel: "相似度阈值",
@@ -1091,13 +1091,19 @@
>
<div class="result-header" @click="toggleResult(result)">
<div class="result-question-wrapper">
<div class="result-question">
<span class="result-index">{{ index + 1 }}.</span>
{{ result.standard_question }}
<div class="result-main">
<div class="result-question">
<span class="result-index">{{ index + 1 }}.</span>
{{ result.standard_question }}
</div>
<div v-if="result.matched_question && result.matched_question !== result.standard_question" class="matched-question">
<span class="matched-label">{{ $t('knowledgeEditor.faq.matchedQuestion') }}:</span>
<span class="matched-text">{{ result.matched_question }}</span>
</div>
</div>
<div class="result-meta">
<t-tag size="small" variant="light-outline" class="score-tag">
{{ $t('knowledgeEditor.faq.score') }}: {{ (result.score || 0).toFixed(3) }}
{{ (result.score || 0).toFixed(3) }}
</t-tag>
</div>
<t-icon
@@ -1195,6 +1201,7 @@ interface FAQEntry {
showMore?: boolean
score?: number
match_type?: string
matched_question?: string
expanded?: boolean
similarCollapsed?: boolean
negativeCollapsed?: boolean
@@ -5791,14 +5798,20 @@ watch(() => entries.value.map(e => ({
.result-question-wrapper {
display: flex;
align-items: center;
align-items: flex-start;
gap: 10px;
width: 100%;
}
.result-question {
.result-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.result-question {
font-family: "PingFang SC";
font-size: 14px;
font-weight: 600;
@@ -5816,6 +5829,29 @@ watch(() => entries.value.map(e => ({
}
}
.matched-question {
display: flex;
align-items: flex-start;
gap: 4px;
padding-left: 20px;
font-size: 12px;
line-height: 1.5;
.matched-label {
flex-shrink: 0;
color: #E37318;
font-weight: 500;
}
.matched-text {
color: #92400E;
background: linear-gradient(90deg, rgba(251, 191, 36, 0.15) 0%, rgba(251, 191, 36, 0.05) 100%);
padding: 1px 6px;
border-radius: 4px;
word-break: break-word;
}
}
.result-meta {
display: flex;
gap: 8px;
+8 -1
View File
@@ -4937,16 +4937,18 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
return []*types.FAQEntry{}, nil
}
// Extract chunk IDs and build score/match type maps
// Extract chunk IDs and build score/match type/matched content maps
chunkIDs := make([]string, 0, len(searchResults))
chunkScores := make(map[string]float64)
chunkMatchTypes := make(map[string]types.MatchType)
chunkMatchedContents := make(map[string]string)
for _, result := range searchResults {
// SearchResult.ID is the chunk ID
chunkID := result.ID
chunkIDs = append(chunkIDs, chunkID)
chunkScores[chunkID] = result.Score
chunkMatchTypes[chunkID] = result.MatchType
chunkMatchedContents[chunkID] = result.MatchedContent
}
// Batch fetch chunks
@@ -5003,6 +5005,11 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
entry.MatchType = matchType
}
// Set MatchedQuestion from search result's matched content
if matchedContent, ok := chunkMatchedContents[chunk.ID]; ok && matchedContent != "" {
entry.MatchedQuestion = matchedContent
}
entries = append(entries, entry)
}
@@ -998,6 +998,7 @@ func (s *knowledgeBaseService) processSearchResults(ctx context.Context,
var chunkIDs []string
chunkScores := make(map[string]float64)
chunkMatchTypes := make(map[string]types.MatchType)
chunkMatchedContents := make(map[string]string)
processedKnowledgeIDs := make(map[string]bool)
// Collect all knowledge and chunk IDs
@@ -1010,6 +1011,7 @@ func (s *knowledgeBaseService) processSearchResults(ctx context.Context,
chunkIDs = append(chunkIDs, chunk.ChunkID)
chunkScores[chunk.ChunkID] = chunk.Score
chunkMatchTypes[chunk.ChunkID] = chunk.MatchType
chunkMatchedContents[chunk.ChunkID] = chunk.Content
}
// Batch fetch knowledge data
@@ -1110,7 +1112,8 @@ func (s *knowledgeBaseService) processSearchResults(ctx context.Context,
score := chunkScores[chunk.ID]
if knowledge, ok := knowledgeMap[chunk.KnowledgeID]; ok {
matchType := chunkMatchTypes[chunk.ID]
searchResults = append(searchResults, s.buildSearchResult(chunk, knowledge, score, matchType))
matchedContent := chunkMatchedContents[chunk.ID]
searchResults = append(searchResults, s.buildSearchResult(chunk, knowledge, score, matchType, matchedContent))
addedChunkIDs[chunk.ID] = true
} else {
logger.Warnf(ctx, "Knowledge not found for chunk: %s, knowledge_id: %s", chunk.ID, chunk.KnowledgeID)
@@ -1136,7 +1139,8 @@ func (s *knowledgeBaseService) processSearchResults(ctx context.Context,
logger.Warnf(ctx, "Unkonwn match type for chunk: %s", chunkID)
continue
}
searchResults = append(searchResults, s.buildSearchResult(chunk, knowledge, score, matchType))
matchedContent := chunkMatchedContents[chunkID]
searchResults = append(searchResults, s.buildSearchResult(chunk, knowledge, score, matchType, matchedContent))
}
}
logger.Infof(ctx, "Search results processed, total: %d", len(searchResults))
@@ -1166,6 +1170,7 @@ func (s *knowledgeBaseService) buildSearchResult(chunk *types.Chunk,
knowledge *types.Knowledge,
score float64,
matchType types.MatchType,
matchedContent string,
) *types.SearchResult {
return &types.SearchResult{
ID: chunk.ID,
@@ -1185,6 +1190,7 @@ func (s *knowledgeBaseService) buildSearchResult(chunk *types.Chunk,
KnowledgeFilename: knowledge.FileName,
KnowledgeSource: knowledge.Source,
ChunkMetadata: chunk.Metadata,
MatchedContent: matchedContent,
}
}
+3
View File
@@ -194,6 +194,9 @@ type FAQEntry struct {
Score float64 `json:"score,omitempty"`
MatchType MatchType `json:"match_type,omitempty"`
ChunkType ChunkType `json:"chunk_type"`
// MatchedQuestion is the actual question text that was matched in FAQ search
// Could be the standard question or one of the similar questions
MatchedQuestion string `json:"matched_question,omitempty"`
}
// FAQEntryPayload 用于创建/更新 FAQ 条目的 payload
+4
View File
@@ -87,6 +87,10 @@ type SearchResult struct {
// ChunkMetadata stores chunk-level metadata (e.g., generated questions)
ChunkMetadata JSON `json:"chunk_metadata,omitempty"`
// MatchedContent is the actual content that was matched in vector search
// For FAQ: this is the matched question text (standard or similar question)
MatchedContent string `json:"matched_content,omitempty"`
}
// SearchParams represents the search parameters