FAQ 能力
有些问题的答案是固定的——退货政策、报销流程、常见报错处理。这类内容用文档检索绕一圈反而不稳,直接维护成问答对更可靠:建库时把类型选成 FAQ,条目按「标准问 + 相似问 + 反例问 + 答案」录入,检索匹配的是问题而不是文档片段,命中后把准备好的问答交给模型作答。
怎么用
- 建库:新建知识库时在「基本信息」→「知识库类型」里选「问答」,在「FAQ 设置」里选索引方式(界面默认「仅索引问题」+ 每个问题单独索引,见 §1.3)。
- 录入条目:在 FAQ 库里逐条新建,或批量导入 JSON / CSV / Excel(
.json、.csv、.xlsx、.xls,文件在浏览器里解析后提交,界面提供 CSV 与 Excel 示例文件下载)。导入可以选追加或替换,可以先只校验不落库,完成后有导入结果面板。 - 维护:按分类(标签)筛选、搜索,批量启用/停用、设置是否可推荐、改分类、删除;对容易误命中的问题补反例问。也可以导出为 CSV 或 JSON,编辑后再导入。
FAQ 库可以和文档库一起被同一会话检索,命中的 FAQ 分块与其它知识分块一样参与排序、被引用。
下文覆盖 FAQ 条目模型、API、导入导出、去重归一化算法、检索命中策略、与普通知识的区别,以及知识库复制时的状态同步机制。
1. 数据模型
1.1 存储形态:FAQ 条目 = 一个 Chunk
FAQ 条目不是独立表:每个条目是一条 Chunk 记录(chunk_type = "faq"),挂在该 KB 内一条类型为 faq 的 Knowledge 下(首次创建条目时自动创建该 Knowledge)。条目的结构化内容存在 Chunk.Metadata(JSON):
// internal/types/faq.go
type FAQChunkMetadata struct {
StandardQuestion string `json:"standard_question"`
SimilarQuestions []string `json:"similar_questions,omitempty"`
NegativeQuestions []string `json:"negative_questions,omitempty"` // 反例问:命中即过滤
Answers []string `json:"answers,omitempty"`
AnswerStrategy AnswerStrategy `json:"answer_strategy,omitempty"` // all | random
Version int `json:"version,omitempty"` // 每次更新自增
Source string `json:"source,omitempty"`
}
const (
AnswerStrategyAll AnswerStrategy = "all" // 返回全部答案
AnswerStrategyRandom AnswerStrategy = "random" // 随机返回一个
)Chunk 上复用的通用字段:SeqID(自增整数,对外 API 的条目 ID)、TagID(分类标签,默认标签名常量 UntaggedTagName = "未分类")、IsEnabled(停用开关)、Flags(bit0 ChunkFlagRecommended 是否可被推荐)、ContentHash(去重哈希,见 §3)。
1.2 API 投影:FAQEntry
type FAQEntry struct {
ID int64 `json:"id"` // chunk.SeqID
ChunkID string `json:"chunk_id"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
TagID int64 `json:"tag_id"`
TagName string `json:"tag_name"`
IsEnabled bool `json:"is_enabled"`
IsRecommended bool `json:"is_recommended"`
StandardQuestion string `json:"standard_question"`
SimilarQuestions []string `json:"similar_questions"`
NegativeQuestions []string `json:"negative_questions"`
Answers []string `json:"answers"`
AnswerStrategy AnswerStrategy `json:"answer_strategy"`
IndexMode FAQIndexMode `json:"index_mode"`
Score float64 `json:"score,omitempty"` // 检索得分
MatchType MatchType `json:"match_type,omitempty"`
MatchedQuestion string `json:"matched_question,omitempty"` // 实际命中的问题文本
}1.3 KB 级 FAQ 配置(FAQConfig)
| 配置 | 取值 | 默认 | 说明 |
|---|---|---|---|
index_mode | question_only / question_answer | 后端缺省 question_answer;界面新建时默认 question_only | 索引内容是否包含答案(仅索引问题精度更高,索引问答召回更高) |
question_index_mode | combined / separate | 后端缺省 combined;界面新建时默认 separate | 标准问 + 相似问合成一个索引项,或每个问题独立索引项 |
separate 模式下每个相似问单独生成索引项,SourceID = fmt.Sprintf("%s-%s", chunk.ID, hashQuestion(similarQ)),支持相似问级别的精细增删。
2. API 端点
internal/handler/faq.go(路由注册于 internal/router/routes_knowledge.go 的 RegisterFAQRoutes,KB 门禁与知识库一致:读走 Viewer + KBAccess,写走 KB 所有者或 Admin + KBAccess;API Key 读需 retrieve、写需 ingest 能力):
| 方法 | 路径 | 功能 |
|---|---|---|
| GET | /knowledge-bases/:id/faq/entries | 条目列表(分页 / 标签 / 关键词) |
| GET | /knowledge-bases/:id/faq/entries/:entry_id | 单条详情 |
| POST | /knowledge-bases/:id/faq/entry | 同步创建单条 |
| PUT | /knowledge-bases/:id/faq/entries/:entry_id | 更新单条(增量索引) |
| POST | /knowledge-bases/:id/faq/entries | 批量导入 / 更新(异步,append/replace) |
| POST | /knowledge-bases/:id/faq/entries/:entry_id/similar-questions | 追加相似问 |
| PUT | /knowledge-bases/:id/faq/entries/fields | 批量更新字段(启用 / 推荐 / 策略) |
| PUT | /knowledge-bases/:id/faq/entries/tags | 批量更新标签 |
| DELETE | /knowledge-bases/:id/faq/entries | 批量删除 |
| POST | /knowledge-bases/:id/faq/search | FAQ 检索(向量检索) |
| GET | /knowledge-bases/:id/faq/entries/export | 导出(CSV / JSON) |
| GET | /faq/import/progress/:task_id | 导入任务进度 |
| PUT | /knowledge-bases/:id/faq/import/last-result/display | 导入结果面板显示状态(open/close) |
列表查询参数:page / page_size、tag_id(单标签)或 tag_ids(逗号分隔,OR 语义)、keyword + search_field(standard_question / similar_questions / answers,缺省搜全部)、sort_order(asc,默认倒序)。
写入校验(sanitizeFAQEntryPayload + checkFAQQuestionDuplicate):标准问必填;答案至少一个;answer_strategy 只能是 all / random(默认 all。这个字段随条目存储、导出、同步并在 API 中返回,供调用方决定如何展示答案;Yuheng 自己在检索和问答时不据此挑选答案,问答时全部答案都交给模型);相似问 / 反例 / 答案去空白去重;并做四级重复检查——相似问 vs 标准问、相似问互查、反例 vs 标准问及相似问、DB 内跨条目冲突(返回详细冲突信息)。
3. 归一化与内容哈希(去重核心)
FAQ 采用"存储原始文本、按归一化文本判等"的分层设计:
// 写入:DB 保留原始数据,ContentHash 基于归一化副本
func (c *Chunk) SetFAQMetadata(meta *FAQChunkMetadata) error {
meta.Sanitize() // 仅基础清理
c.Metadata, _ = json.Marshal(meta)
normalized := meta.Normalize() // 归一化副本
c.ContentHash = CalculateFAQContentHash(normalized)
return nil
}NormalizeQuestion 的处理链(顺序敏感):去首尾空白 → 移除 URL → 转小写 → 去首尾标点(?。,;、:!?.,;!:'" 等)→ 繁体转简体 → 全角转半角 → 智能空格(中文之间去空格,英文 / 数字间保留)。
CalculateFAQContentHash = SHA256(归一化标准问 + 排序后相似问 + 排序后反例 + 排序后答案)。internal/types/faq_test.go 固化了哈希的关键不变式:大小写 / 标点不敏感、繁简不敏感、全半角不敏感、数组顺序不敏感、写入与读取路径一致。该哈希用于导入去重与克隆同步的条目配对。
4. 批量导入
internal/application/service/knowledge_faq_import.go。入口 POST /knowledge-bases/:id/faq/entries:
type FAQBatchUpsertPayload struct {
Entries []FAQEntryPayload `json:"entries" binding:"required"`
Mode string `json:"mode" binding:"oneof=append replace"`
KnowledgeID string `json:"knowledge_id"`
TaskID string `json:"task_id"` // 可选,不传自动生成 UUID
DryRun bool `json:"dry_run"` // 仅验证不落库
}导入字段(CSV 模板列,与导出格式对称,多值用 ## 分隔):标准问(必填)、相似问题、反例问题、答案(必填)、是否全部回复、是否停用、是否禁止被推荐、分类(默认"未分类")。
进度对象 FAQImportProgress 的统计字段:success_count / failed_count / partial_failed_count(相似问或反例被剔除但条目仍导入)/ skipped_count(重复跳过)/ merged_count / added_count、failed_entries[](含失败原因与原始内容)与 failed_entries_url、import_mode、processing_time;任务状态 pending → processing → completed / failed。
导出支持两种格式:CSV(列:分类、问题、相似问题、反例问题、机器人回答、是否全部回复、是否停用、是否禁止被推荐;含 BOM 保证 Excel UTF-8 兼容)与 JSON(FAQExportEntry,与导入 payload 兼容,支持"导出 → 编辑 → 重新导入"闭环)。
5. 与普通知识(Document)的区别
| 维度 | FAQ | Document |
|---|---|---|
| KB 类型 | faq | document |
| Knowledge.Type | faq(每库通常一条聚合 Knowledge) | 文件 / manual / URL |
| Chunk 来源 | 用户直接录入结构化条目 | 解析器自动分块 |
| Chunk.ChunkType | faq | text / image_ocr / summary 等 |
| Metadata | FAQChunkMetadata(问 / 答 / 反例 / 策略) | 文档元数据(AI 生成问题等) |
| Chunk.Content | 由 buildFAQChunkContent 合成:"Q: 标准问\nSimilar Questions:\n- ...";question_answer 模式追加 Answers;反例问永不写入 Content(不参与索引) | 原文片段 |
| ContentHash | 归一化去重哈希(核心机制) | 一般不使用 |
| 索引粒度 | 按 question_index_mode 一条或多条索引项 | 一 chunk 一索引项(父子分块另计) |
| 处理管线 | 同步创建 / 异步批量导入,即时索引生效 | 异步 DocReader 解析管线 |
| 检索后处理 | 负例过滤 + 迭代召回(见 §6) | 常规融合重排 |
| 状态开关 | is_enabled + is_recommended(Flags)+ answer_strategy | enable_status |
条目更新走增量索引(incrementalIndexFAQEntry):只对变化部分重新 embedding——标准问变化重索引;相似问逐个 diff 增删;答案变化仅在 question_answer 模式触发重索引;借助 SourceID 精确删除失效索引项。
6. 检索命中策略
internal/handler/faq.go 的 SearchFAQ + internal/application/service/knowledgebase_search_faq.go:
type FAQSearchRequest struct {
QueryText string `binding:"required"`
VectorThreshold float64 // 向量相似度阈值(默认 0.7)
MatchCount int // 返回数量(默认 10,上限 50)
FirstPriorityTagIDs []int64 // 一级优先标签(结果排前)
SecondPriorityTagIDs []int64 // 二级优先标签
OnlyRecommended bool // 仅返回可推荐条目
}命中流程:
- 向量召回:
SearchFAQ调用知识库的HybridSearch,但显式关闭关键词匹配(DisableKeywordsMatch: true),因此这个接口实际只做向量检索;缺省阈值 0.7、返回 10 条,上限 50; - 两级标签限定:传了
first_priority_tag_ids/second_priority_tag_ids时,只在这些标签内检索(两级并行查询),一级的结果排在前面,二级去重后追加;only_recommended只在带优先标签的查询里生效。不传优先标签时在全库检索; - 负例过滤(
filterByNegativeQuestions):查询文本与某条目的任一反例问完全匹配(小写比较)→ 该条目从结果中剔除。典型场景:用户问"不支持 X 吗",避免返回"支持 X"的条目; - 迭代召回(
applyFAQPostProcessing):当去重后的唯一条目数不足match_count且向量结果打满时触发iterativeRetrieveWithDeduplication——最多迭代 5 次、每次 TopK 翻倍(种子MatchCount*3与每轮增长均封顶 500,触顶即停),带去重与负例过滤缓存,无新结果提前终止; - 结果附带
score、match_type、matched_question(实际命中的是标准问还是哪个相似问)。
非 FAQ 类型 KB 直接跳过该后处理(if kb.Type != types.KnowledgeBaseTypeFAQ { return chunks, nil }),普通混合检索不受影响;聊天管线的检索在 FAQ 库上同样经过这条后处理路径。
聊天管线侧:FAQ 分块作为普通检索结果进入 RAG 流程——按得分与其它分块统一排序、统一渲染引用,不再有独立的优先级。渲染 Prompt 前,internal/application/service/chat_pipeline/merge_faq.go 的 populateFAQAnswers 会把分块内容替换为标准问 + 答案的完整文本(无条件执行,与命中得分无关),因此模型看到的仍是准备好的问答内容。本项目不包含旧的 FAQ 优先策略(结果前置、得分加权 FAQScoreBoost、高置信直答阈值 FAQDirectAnswerThreshold):这些开关已随重构移除,不再存在,FAQ 命中与其它知识一视同仁。
7. 知识库复制时的状态同步
internal/application/service/faq_clone_sync.go,由 knowledge_clone_move.go 的 cloneFAQKnowledgeBase 调用。触发场景是知识库复制(copy):复制产生的目标库 FAQ chunk 是新记录,运营状态(启停 / 推荐 / 标签 / 答案策略)需要与源库对齐:
- 配对:按
ContentHash匹配源 / 目标条目,得到FAQChunkSyncPair{SrcChunkID, DstChunkID}(归一化哈希保证繁简 / 全半角 / 顺序差异不破坏配对,internal/types/faq_sync_test.go佐证); - 同步内容:
IsEnabled启停状态、Flags的ChunkFlagRecommended推荐位、TagID标签归属、AnswerStrategy答案策略; - 索引侧生效:DB 更新后批量刷新向量存储中对应索引项的
enabled/tag/recommended标志,检索过滤立即生效(差异计算见internal/application/repository/chunk_faq_diff_test.go)。
实现参考
想读源码时按下表定位(路径相对仓库根目录):
| 层 | 文件 |
|---|---|
| FAQ 类型与归一化 / 哈希 | internal/types/faq.go(及 faq_test.go、faq_sync_test.go) |
| FAQ Handler | internal/handler/faq.go |
| 条目 CRUD / 导出服务 | internal/application/service/knowledge_faq.go |
| 异步导入服务 | internal/application/service/knowledge_faq_import.go |
| 复制 / 同步 | internal/application/service/faq_clone_sync.go、knowledge_clone_move.go(cloneFAQKnowledgeBase) |
| 问答时的 FAQ 内容替换 | internal/application/service/chat_pipeline/merge_faq.go |
| 路由 | internal/router/routes_knowledge.go(RegisterFAQRoutes) |
| 前端 | frontend/src/views/knowledge/components/FAQEntryManager.vue |
| FAQ 检索后处理 | internal/application/service/knowledgebase_search_faq.go |
| KB 级 FAQ 配置 | internal/types/knowledgebase.go(FAQConfig) |