修复: 知识工具检索接分词与晋升补嵌入

knowledge_search 整句 LIKE 改分词检索,自然语言查询可命中
晋升(手动/复用达标)后补 spawn 嵌入,进入向量检索面
本地截断改调 audit truncate_chars
配置加载下沉 load_knowledge_config 供工具路径共用
This commit is contained in:
lxy
2026-10-01 08:27:48 +08:00
parent cfccba863c
commit 7c62d8dbe7
4 changed files with 76 additions and 36 deletions
+32 -12
View File
@@ -13,7 +13,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
use df_ai::router::{
is_likely_image_model, select_model_id, Modality, TaskRequirements,
};
use df_storage::crud::{AiConversationRepo, AiMessageRepo, KnowledgeRepo};
use df_storage::crud::{AiConversationRepo, AiMessageRepo, AiProviderRepo, KnowledgeRepo};
use df_storage::db::Database;
use df_storage::models::{AiProviderRecord, KnowledgeRecord};
@@ -196,12 +196,12 @@ fn flush_cjk_bigrams(buf: &mut Vec<char>, out: &mut Vec<String>) {
/// 嵌入模型由 embedding provider 的池构成(用户在 KnowledgeSettings 配 embedding provider,
/// 该 provider 的 model_configs 通常即嵌入模型),故 needs_tool_use=false 宽松过滤即可命中。
async fn resolve_embed_provider(
state: &AppState,
providers: &AiProviderRepo,
config: &crate::state::KnowledgeConfig,
) -> Option<(Box<dyn LlmProvider>, String)> {
let id = config.embedding_provider_id.as_ref()?;
let fallback_model = config.embedding_model.clone().unwrap_or_else(|| "embedding-3".to_string());
let rec = match state.ai_providers.get_by_id(id).await {
let rec = match providers.get_by_id(id).await {
Ok(Some(r)) => r,
_ => {
tracing::warn!("embedding provider 不存在: {}", id);
@@ -236,7 +236,7 @@ async fn generate_embedding(
text: &str,
config: &crate::state::KnowledgeConfig,
) -> Option<Vec<f32>> {
let (provider, model) = resolve_embed_provider(state, config).await?;
let (provider, model) = resolve_embed_provider(&state.ai_providers, config).await?;
// 截断防超 token 上限(8192 token ≈ 8000 中文字)
let input: String = text.chars().take(8000).collect();
match provider.embed(&model, vec![input]).await {
@@ -312,19 +312,32 @@ pub async fn spawn_embedding_for_knowledge(
record: &df_storage::models::KnowledgeRecord,
) {
let config = state.knowledge_config.lock().await.clone();
spawn_embedding_for_record(&state.db, &config, record).await;
}
/// [`spawn_embedding_for_knowledge`] 的 db+config 版,嵌入语义与发布路径完全一致:
/// vector_enabled 关闭跳过;provider 解析失败标 failed(修好配置后可补偿重试)。
///
/// 供只持 db 无 AppState 上下文的调用方使用(AI 工具 handler 捕获仅 Arc<Database>,
/// 自动晋升在 spawn 的后台任务内同样拿不到 AppState),配置经
/// [`crate::state::load_knowledge_config`] 从 KV 现读,与 IPC 路径同一真相源。
pub(crate) async fn spawn_embedding_for_record(
db: &Arc<Database>,
config: &crate::state::KnowledgeConfig,
record: &KnowledgeRecord,
) {
if !config.vector_enabled {
return;
}
let Some((provider, model)) = resolve_embed_provider(state, &config).await else {
// provider 解析失败(配置缺/密钥不可用)也标记 failed,允许用户修好配置后补偿重试。
// 老行为是静默 return(无人感知),现在落 failed 让状态可见可补。
let repo = KnowledgeRepo::new(&state.db);
let Some((provider, model)) = resolve_embed_provider(&AiProviderRepo::new(db), config).await
else {
let repo = KnowledgeRepo::new(db);
mark_embedding_failed(&repo, &record.id, "provider 解析失败").await;
return;
};
// 发布路径单条:Box → Arc 装箱复用 spawn_embedding_task(统一嵌入逻辑,DRY)。
// 单条:Box → Arc 装箱复用 spawn_embedding_task(统一嵌入逻辑,DRY)。
spawn_embedding_task(
state.db.clone(),
db.clone(),
Arc::from(provider),
model,
record.id.clone(),
@@ -362,7 +375,7 @@ pub async fn retry_failed_embeddings(state: &AppState) -> anyhow::Result<usize>
if !config.vector_enabled {
return Ok(count); // 关闭:不动 failed 状态,仅返回条数(前端可据此提示)
}
let resolved = resolve_embed_provider(state, &config).await;
let resolved = resolve_embed_provider(&state.ai_providers, &config).await;
tauri::async_runtime::spawn(async move {
match resolved {
Some((provider, model)) => {
@@ -507,7 +520,9 @@ pub(crate) async fn build_knowledge_context(
let conv_id = conv_id.to_string();
let query_clone = query.to_string();
let user_message_id = user_message_id.map(|s| s.to_string());
// 晋升开关与阈值取配置快照值拷贝进闭包(决策单未拍板前默认保守,开关默认关)
// 晋升开关与阈值取配置快照值拷贝进闭包(决策单未拍板前默认保守,开关默认关);
// 配置整体快照一并拷入,晋升成功后补嵌入(spawn_embedding_for_record)用
let embed_config = config.clone();
let auto_publish_enabled = config.auto_publish_enabled;
let auto_publish_threshold = config.auto_publish_reuse_threshold;
tauri::async_runtime::spawn(async move {
@@ -526,6 +541,11 @@ pub(crate) async fn build_knowledge_context(
// 晋升留痕:生命线记 status_changed(source_ref=auto_publish),
// fire-and-forget 与上方 record_referenced 同风格,失败仅 warn 非阻断。
timeline.record_auto_publish(id, auto_publish_threshold).await;
// 晋升即进检索面,补嵌入对齐 IPC 发布路径(失败标 failed 可补偿重试,
// 非阻断;vector_enabled 关闭时内部直接跳过)
if let Ok(Some(rec)) = repo.get_by_id(id).await {
spawn_embedding_for_record(&db, &embed_config, &rec).await;
}
}
Ok(false) => {}
Err(e) => tracing::warn!("candidate 自动晋升检查失败(非阻断): {e}"),
+16 -14
View File
@@ -30,8 +30,11 @@ use df_storage::db::Database;
use df_storage::models::KnowledgeRecord;
use df_types::types::new_id;
use crate::commands::ai::audit::truncate_chars;
use crate::commands::ai::knowledge_inject::{extract_query_keywords, spawn_embedding_for_record};
use crate::commands::knowledge_timeline::KnowledgeTimeline;
use crate::commands::now_millis;
use crate::state::load_knowledge_config;
/// search 结果单条 content 的截断长度:知识全文可能很长,原样返回会回灌 prompt
/// 挤占上下文;截 500 字给 LLM 足够判断「这条是否相关」,需全文时走 IPC/前端看详情。
@@ -49,7 +52,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry,
db: Arc<Database>,
"knowledge_search",
"检索本地知识库(仅已发布知识,按关键词 LIKE 匹配,按复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词(必填),limit=返回条数(可选,默认 5,上限 10)",
"检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)",
RiskLevel::Low,
schema: object_schema(vec![("query", "string", true), ("limit", "integer", false)]),
args => {
@@ -59,7 +62,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
}
let limit = args["limit"].as_u64().unwrap_or(5).clamp(1, 10) as usize;
let repo = df_storage::crud::KnowledgeRepo::new(&db);
let hits = repo.search(query, None, limit).await?;
// 整句 LIKE 对中文自然语言命中率≈0(知识库走查 2026-09-29),与注入检索同源
// 分词(ASCII 词 + 中文 2-gram)后逐词匹配,任一关键词命中即入候选。
// 仅已发布(include_candidates=false):LLM 检索面与人工审核面一致。
let keywords = extract_query_keywords(query);
let hits = repo.search_keywords(&keywords, false, limit).await?;
// 空结果返回可执行的下一步指引而非裸空数组:弱模型拿到 [] 常直接回「没找到」
// 结束,带上「让用户口述 + insert 沉淀」能把检索失败转化为知识积累入口。
if hits.is_empty() {
@@ -71,21 +78,10 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let results: Vec<serde_json::Value> = hits
.iter()
.map(|k| {
// 按 chars 截断而非字节切片:中文内容按字节切会落在 UTF-8 边界上 panic
let snippet = if k.content.chars().count() > SEARCH_CONTENT_SNIPPET_LEN {
let truncated: String = k
.content
.chars()
.take(SEARCH_CONTENT_SNIPPET_LEN)
.collect();
format!("{truncated}…")
} else {
k.content.clone()
};
serde_json::json!({
"title": k.title,
"kind": k.kind,
"content": snippet,
"content": truncate_chars(&k.content, SEARCH_CONTENT_SNIPPET_LEN),
"status": k.status,
"reuse_count": k.reuse_count,
})
@@ -194,6 +190,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
KnowledgeTimeline::new(&db)
.record_manual_publish(id, "agent")
.await;
// 转正即进检索面,后台补嵌入对齐 IPC 发布路径(knowledge_update_status);
// 失败标 failed 可补偿重试,非阻断,vector_enabled 关闭时内部直接跳过
if let Some(rec) = repo.get_by_id(id).await? {
let config = load_knowledge_config(&db).await;
spawn_embedding_for_record(&db, &config, &rec).await;
}
Ok(serde_json::json!({
"id": id,
"status": "published",