优化: 知识检索同项目加权与拒绝池
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user