优化: 知识检索同项目加权与拒绝池

This commit is contained in:
lxy
2026-10-05 23:41:27 +08:00
parent 0f3c3ce1aa
commit 9e81182b4f
9 changed files with 387 additions and 22 deletions
+157 -3
View File
@@ -547,16 +547,24 @@ impl IdeaRepo {
.transaction()
.map_err(storage_err)?;
// 1. 读主体当前 related_ids
// 1. 读主体当前 related_ids — 同段校验主体存在且未软删(deleted_at IS NULL)。
// 治走查🟡:原仅读 related_ids,主体不存在/已软删时步骤 5 UPDATE 静默 affected=0
// 仍提交,而步骤 3/4 已改写目标侧 related_ids → 「目标有关联、主体无记录」单向脏关联。
// 查无立即 Err 回滚整个事务,目标侧零改写。
let old_raw: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
"SELECT related_ids FROM ideas WHERE id = ?1 AND deleted_at IS NULL",
params![subject_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
.flatten()
.ok_or_else(|| {
df_types::error::Error::Storage(format!(
"灵感 {subject_id} 不存在或已删除,无法建立关联"
))
})?;
// 2. 解析新旧集合
let old_set: std::collections::HashSet<String> =
@@ -815,6 +823,82 @@ impl KnowledgeRepo {
.map_err(storage_err)?
}
/// 项目维度软排序检索(跨会话召回最小闭环,D2 拍板:软排序非硬过滤)。
///
/// 排序语义:同项目(source_project = ?)第一档 > 全局(IS NULL)第二档 > 异项目第三档;
/// 同档内保持原有 title 命中分 → reuse_count → created_at 顺序。`limit` 不变只改顺序,
/// token 膨胀为零。`project_id = None` 时行为与 [`Self::search_keywords`] 逐字一致(回归门)。
///
/// 与 [`Self::search_keywords`] 的关系:后者是本方法的 project_id=None 特例(旧签名
/// 保留给 MCP 等外部调用方,内部转调本方法,零重复实现)。
pub async fn search_keywords_scoped(
&self,
keywords: &[String],
include_candidates: bool,
limit: usize,
project_id: Option<&str>,
) -> Result<Vec<KnowledgeRecord>> {
if project_id.is_none() {
return self.search_keywords(keywords, include_candidates, limit).await;
}
if keywords.is_empty() {
return Ok(Vec::new());
}
let project_id = project_id.unwrap().to_string();
let conn = self.conn.clone();
// 每个关键词占一个编号占位符(1..n);project_id 占 ?(n+1),WHERE 的 ORDER BY CASE
// 与三档投影各复用同号(SQLite 允许 ?N 复用)。patterns 只装关键词一份。
let mut patterns: Vec<String> = Vec::with_capacity(keywords.len());
let mut where_parts: Vec<String> = Vec::with_capacity(keywords.len());
let mut order_terms: Vec<String> = Vec::with_capacity(keywords.len() + 1);
for (i, kw) in keywords.iter().enumerate() {
let n = i + 1;
where_parts.push(format!(
"(title LIKE ?{n} ESCAPE '|' OR content LIKE ?{n} ESCAPE '|')"
));
order_terms.push(format!("CASE WHEN title LIKE ?{n} ESCAPE '|' THEN 1 ELSE 0 END"));
patterns.push(format!("%{}%", escape_like(kw)));
}
// 项目档位表达式复用 ?(n+1):0=同项目 1=全局(NULL) 2=异项目
// 档位是**第一排序键**(ASC:0 最前),title 命分是同档内第二键(DESC)——
// 若档位与 title 分相加,档位会被 title 分稀释(异项目 title 高分压过同项目)。
let pid_n = keywords.len() + 1;
let project_tier =
format!("CASE WHEN source_project = ?{pid_n} THEN 0 WHEN source_project IS NULL THEN 1 ELSE 2 END");
let title_terms = order_terms.join(" + ");
let status_clause = if include_candidates {
"status IN ('published','candidate')"
} else {
"status = 'published'"
};
let limit_i = limit as i64;
let sql = format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
WHERE {status_clause} AND ({}) \
ORDER BY ({project_tier}) ASC, ({title_terms}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}",
where_parts.join(" OR "),
pid_n + 1,
);
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut param_refs: Vec<&dyn rusqlite::ToSql> =
patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
param_refs.push(&project_id);
param_refs.push(&limit_i);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(param_refs.as_slice(), |row| knowledge_from_row(row))
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at
///
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
@@ -1544,6 +1628,76 @@ mod tests {
assert_eq!(hits[0].id, "pub1");
}
// ---------- search_keywords_scoped(跨会话召回 D2 软排序) ----------
/// 同项目知识排前:同档内 title 命分定序,异档同项目压过异项目高复用
#[tokio::test]
async fn scoped_same_project_ranks_above_foreign_despite_reuse() {
let repo = setup_repo().await;
// 外项目知识:标题命中 + 复用 999(档位应压过复用)
let mut foreign = krec("foreign", "Rust 所有权", "...", "lesson", "published", None, 999);
foreign.source_project = Some("other-proj".to_string());
repo.insert(foreign).await.unwrap();
// 同项目(proj-1,krec 默认):内容命中 + 复用 1
repo.insert(krec("same", "无关标题", "聊聊 Rust 所有权", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["所有权".to_string(), "Rust".to_string()];
let hits = repo
.search_keywords_scoped(&kws, false, 10, Some("proj-1"))
.await
.unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].id, "same", "同项目知识应压过异项目(即便后者 title 命中+高复用)");
assert_eq!(hits[1].id, "foreign");
}
/// 全局知识(NULL)居中档:同项目 > 全局 > 异项目
#[tokio::test]
async fn scoped_global_null_tier_between_same_and_foreign() {
let repo = setup_repo().await;
let mut foreign = krec("foreign", "Rust 异步", "...", "lesson", "published", None, 1);
foreign.source_project = Some("other-proj".to_string());
repo.insert(foreign).await.unwrap();
let mut global = krec("global", "Rust 生命周期", "...", "lesson", "published", None, 1);
global.source_project = None;
repo.insert(global).await.unwrap();
repo.insert(krec("same", "Rust 借用", "...", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["Rust".to_string()];
let hits = repo
.search_keywords_scoped(&kws, false, 10, Some("proj-1"))
.await
.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids, vec!["same", "global", "foreign"], "三档顺序: 同项目>全局>异项目");
}
/// 回归门:project_id=None 与旧 search_keywords 行为逐字一致(同输入同顺序)
#[tokio::test]
async fn scoped_none_project_matches_legacy_search_keywords() {
let repo = setup_repo().await;
let mut foreign = krec("foreign", "Rust 异步", "...", "lesson", "published", None, 50);
foreign.source_project = Some("other-proj".to_string());
repo.insert(foreign).await.unwrap();
repo.insert(krec("same", "Rust 借用", "...", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["Rust".to_string()];
let legacy = repo.search_keywords(&kws, false, 10).await.unwrap();
let scoped = repo
.search_keywords_scoped(&kws, false, 10, None)
.await
.unwrap();
let a: Vec<_> = legacy.iter().map(|h| h.id.as_str()).collect();
let b: Vec<_> = scoped.iter().map(|h| h.id.as_str()).collect();
assert_eq!(a, b, "project_id=None 应与旧路径行为逐字一致(回归门)");
}
#[tokio::test]
async fn search_keywords_like_wildcards_escaped() {
let repo = setup_repo().await;