diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index 920d5877d..0d6923745 100644 --- a/crates/utopia-cli/src/main.rs +++ b/crates/utopia-cli/src/main.rs @@ -82,7 +82,7 @@ struct ManifestDataDir { /// not a side effect of a code change. // 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current` // 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71 -const CURRENT_SCHEMA_VERSION: u32 = 73; +const CURRENT_SCHEMA_VERSION: u32 = 74; fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); diff --git a/crates/utopia-server/src/api/mapping_routes.rs b/crates/utopia-server/src/api/mapping_routes.rs index a1f533819..875e9ca25 100644 --- a/crates/utopia-server/src/api/mapping_routes.rs +++ b/crates/utopia-server/src/api/mapping_routes.rs @@ -302,6 +302,7 @@ pub async fn create( concept, None, None, + None, &[], ) .await?; diff --git a/crates/utopia-server/src/extraction.rs b/crates/utopia-server/src/extraction.rs index 9d5a1a4f6..e111adbda 100644 --- a/crates/utopia-server/src/extraction.rs +++ b/crates/utopia-server/src/extraction.rs @@ -205,13 +205,15 @@ async fn resolve_uncached( type_id: Option, name: &str, ctx: Option<&[f32]>, + name_vec: Option<&[f32]>, text: Option<&str>, exclude: &[Uuid], needs_adjudication: &mut bool, ) -> anyhow::Result { - let r = - utopia_store::resolution::resolve_mention(pool, kb_id, type_id, name, ctx, text, exclude) - .await?; + let r = utopia_store::resolution::resolve_mention( + pool, kb_id, type_id, name, ctx, name_vec, text, exclude, + ) + .await?; // 疑似重复对(画像灰区 / 类型漂移 / 同名并列)入审核队列。多数走批量裁决器, // 同名并列(`ReviewStage::Human`)分不出谁是谁,只能等人裁——它自己带着 stage。 for review in &r.reviews { @@ -263,6 +265,7 @@ pub(crate) async fn resolve_handle( type_id: Option, name: &str, ctx: Option<&[f32]>, + name_vec: Option<&[f32]>, text: Option<&str>, response_claims: &mut HashMap>, handled_by_name: &mut HashMap>, @@ -302,6 +305,7 @@ pub(crate) async fn resolve_handle( type_id, name, ctx, + name_vec, text, &excluded, needs_adjudication, @@ -466,12 +470,12 @@ mod tests { "Zhang Wei", None, None, + None, &mut response_claims, &mut document_claims, &mut bare_cache, &mut needs_adjudication, - &mut human_reviews, - ) + &mut human_reviews) .await?; let b = resolve_handle( &pool, @@ -480,12 +484,12 @@ mod tests { "Zhang Wei", None, None, + None, &mut response_claims, &mut document_claims, &mut bare_cache, &mut needs_adjudication, - &mut human_reviews, - ) + &mut human_reviews) .await?; assert_ne!(a, b); assert!(human_reviews); @@ -558,12 +562,12 @@ mod tests { "Zhang Wei", None, None, + None, &mut later_response_claims, &mut document_claims, &mut bare_cache, &mut needs_adjudication, - &mut human_reviews, - ) + &mut human_reviews) .await?; assert_ne!(c, a); assert_ne!(c, b); @@ -586,12 +590,12 @@ mod tests { "Zhang Wei", None, None, + None, &mut another_response_claims, &mut document_claims, &mut bare_cache, &mut needs_adjudication, - &mut human_reviews, - ) + &mut human_reviews) .await?; assert_eq!( c_again, c, @@ -713,6 +717,7 @@ mod tests { "Zhang Wei", Some(&ctx), None, + None, &mut response_claims, &mut document_claims, &mut bare_cache, diff --git a/crates/utopia-server/src/extraction_open.rs b/crates/utopia-server/src/extraction_open.rs index 6b76cad74..39988677a 100644 --- a/crates/utopia-server/src/extraction_open.rs +++ b/crates/utopia-server/src/extraction_open.rs @@ -103,6 +103,77 @@ fn locate_time(chunk: &str, quote: Option<(&str, Option<(i32, i32)>)>, words: &s /// 名字的查找键:空白折叠、小写。陈述里写的名字和 `e` 里列的名字要一字不差, /// 差的只许是空白和大小写 +/// 一次送去嵌入的名字数。嵌入端点按请求限批,与 `pipeline` 的 chunk 批同一档 +const NAME_EMBED_BATCH: usize = 16; +/// 抽完一篇文档补多少条还没有向量的名字。一次一批,剩下的下一篇再补 +const NAME_VECTOR_PENDING: i64 = 256; + +/// 一批名字各算一条向量,键是 `name_key`。数量对不上整批放弃(配对按位置,错一条全体 +/// 错位,与 `pipeline::embed_pending` 同一条规矩);任何失败只记日志、返回空——名字向量 +/// 是召回的辅助,抽取不因它失败 +async fn embed_names( + state: &AppState, + settings: &LlmSettings, + client: &utopia_llm::LlmClient, + names: &[(String, String)], +) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + for batch in names.chunks(NAME_EMBED_BATCH) { + let texts: Vec = batch.iter().map(|(_, t)| t.clone()).collect(); + let _permit = crate::llm_util::acquire_embed(state, settings).await; + match client.embed(&texts).await { + Ok(vectors) if vectors.len() == batch.len() => { + out.extend(batch.iter().map(|(k, _)| k.clone()).zip(vectors)); + } + Ok(vectors) => { + tracing::warn!( + sent = batch.len(), + got = vectors.len(), + "名字向量数量对不上,这一批放弃" + ); + } + Err(e) => { + tracing::warn!(error = %e, "名字向量没算出来,这一批退回字面召回"); + } + } + } + out +} + +/// 抽完一篇文档,把这个库里还没有向量的名字事实补上一批(这篇新写的名字都在里面)。 +/// 消解时算过的那些这里会再算一次——消解拿不到名字事实的 id(本名在 `create_entity` +/// 的一条语句里落下);省的只是一次嵌入调用,不值得为它改消解的返回值 +async fn embed_pending_names( + state: &AppState, + settings: &LlmSettings, + client: &utopia_llm::LlmClient, + kb_id: Uuid, +) -> anyhow::Result { + let pending = + utopia_store::name_vectors::pending(&state.pool, kb_id, NAME_VECTOR_PENDING).await?; + if pending.is_empty() { + return Ok(0); + } + let mut items: Vec<(Uuid, Uuid, Vec)> = Vec::with_capacity(pending.len()); + for batch in pending.chunks(NAME_EMBED_BATCH) { + let texts: Vec = batch.iter().map(|(_, _, n)| n.clone()).collect(); + let _permit = crate::llm_util::acquire_embed(state, settings).await; + let vectors = client.embed(&texts).await?; + if vectors.len() != batch.len() { + anyhow::bail!("嵌入返回 {} 条,送去的是 {} 条", vectors.len(), batch.len()); + } + items.extend( + batch + .iter() + .map(|(f, e, _)| (*f, *e)) + .zip(vectors) + .map(|((f, e), v)| (f, e, v)), + ); + } + utopia_store::name_vectors::set(&state.pool, kb_id, &items).await?; + Ok(items.len()) +} + fn name_key(name: &str) -> String { name.split_whitespace() .collect::>() @@ -194,6 +265,9 @@ pub(crate) async fn run_open( let mut human_reviews_found = false; let mut statement_count = 0usize; + // 名字向量的嵌入客户端(0041 决定 3 通道 2)。没配嵌入模型就是 None:召回退回 + // 字面相等,抽取照常 + let embed = crate::llm_util::embed_client(settings); for chunk in chunks.iter() { // 被接管则安静退场(重抽自增 epoch):检查放在调用模型之前 if utopia_store::documents::extract_epoch(pool, document_id).await? != my_epoch { @@ -306,6 +380,24 @@ pub(crate) async fn run_open( .await; } + // 名字向量(0041 决定 3 通道 2):这一块里有名字的东西,名字字符串各算一条, + // 消解时拿它在同库的名字向量里找近邻。一块一批;算不出来(端点抖了)不拦抽取, + // 只是这一块少一条召回通道 + let name_vecs: HashMap> = match &embed { + Some(client) => { + let mut wanted: Vec<(String, String)> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for e in &extraction.entities { + let n = e.name.trim(); + if e.named && !n.is_empty() && seen.insert(name_key(n)) { + wanted.push((name_key(n), n.to_string())); + } + } + embed_names(state, settings, client, &wanted).await + } + None => HashMap::new(), + }; + // ---- 东西:有名字的走身份消解,被描述的建成没有名字事实的实体 ---- let mut response_claims: HashMap> = HashMap::new(); let mut local: HashMap = HashMap::new(); @@ -331,6 +423,7 @@ pub(crate) async fn run_open( bound, name, ctx, + name_vecs.get(&key).map(Vec::as_slice), Some(&chunk.text), &mut response_claims, &mut handled_by_name, @@ -793,6 +886,16 @@ pub(crate) async fn run_open( state.emit_review(kb_id); } + // 名字向量:这篇新写的名字事实,向量补上(0041 决定 3 通道 2)。算不出来只记日志—— + // 文档已经抽完了,不能因为召回的辅助数据没算而把它标成 failed + if let Some(client) = &embed { + match embed_pending_names(state, settings, client, kb_id).await { + Ok(n) if n > 0 => tracing::info!(%document_id, names = n, "名字向量已补"), + Ok(_) => {} + Err(e) => tracing::warn!(%document_id, error = %e, "名字向量没补上,下一篇再补"), + } + } + // 灰区对进了审核队列 → 治理 / 裁决任务,同库已排着的不重复。 // 不排类型消解、不排自动扩本体:开放图谱里没有类也没有关系可扩,那是对齐的事 if kb.governance { diff --git a/crates/utopia-server/src/mappings.rs b/crates/utopia-server/src/mappings.rs index da8c91c77..c10c5814e 100644 --- a/crates/utopia-server/src/mappings.rs +++ b/crates/utopia-server/src/mappings.rs @@ -400,6 +400,7 @@ async fn explore(state: &AppState, kb_id: Uuid, run: Uuid) -> anyhow::Result<()> name, None, None, + None, &[], ) .await?; diff --git a/crates/utopia-store/src/lib.rs b/crates/utopia-store/src/lib.rs index 0ba5c895a..947de5e4e 100644 --- a/crates/utopia-store/src/lib.rs +++ b/crates/utopia-store/src/lib.rs @@ -26,6 +26,7 @@ pub mod materialize; pub mod members; pub mod memory; pub mod model_limits; +pub mod name_vectors; pub mod names; pub mod ontology; pub mod palette; diff --git a/crates/utopia-store/src/name_vectors.rs b/crates/utopia-store/src/name_vectors.rs new file mode 100644 index 000000000..ff3e4afeb --- /dev/null +++ b/crates/utopia-store/src/name_vectors.rs @@ -0,0 +1,135 @@ +//! 名字向量:召回的第二条通道(0041 决定 3,第 2 刀)。 +//! +//! 通道 1 是字面相等:mention 的名字与某条名字事实 `normalize_name` 之后一样。它找不到 +//! 简称(海探1 ↔ 海洋探测器1号)、找不到另一种文字写的同一个名字(#709),而这两种 +//! 恰恰是「多名」被错拆成两个实体的主因。这里给每条名字事实存名字字符串本身的向量, +//! 查询时在同一个库里取最近的几条;**只提议,不决定**——最近的名字是不是同一个东西, +//! 由消解那头按既有规矩(画像、裁决器,往后是第 3 刀的证据)去判。 +//! +//! 向量随嵌入模型走,不定维(与 `chunks.embedding` 同)。查询照 0035 的两条规矩写: +//! `<=>` 两侧 cast 到字面维度、谓词里带 `vector_dims(...) = N`,HNSW 建好了就走索引, +//! 没建走精确路径,结果一样。 + +use pgvector::Vector; +use sqlx::PgPool; +use utopia_core::AppResult; +use uuid::Uuid; + +use crate::vector_index::{self, Target}; + +/// 一次最多提议几条。名字向量的近邻里真正相关的很少超过前几个;再多只是给裁决器 +/// 添噪音。数值待 `identity.mjs` 定,先取一个不会刷爆队列的 +pub const TOP_K: usize = 8; + +/// 余弦下限。名字字符串的向量比整段文本的向量「紧」——两个不相干的名字也能有 +/// 0.4 上下的余弦——所以这条线比画像的 `SIM_ATTACH` 高。同样是待测量的临时值 +pub const SIM_FLOOR: f32 = 0.60; + +/// 还没有向量的名字事实:现行的、`known_as` 上的、`name_vectors` 里没有它的。 +/// 按写入先后取(`facts.id` 是 uuid v7,按它排即按写入排;这张表没有 created_at—— +/// 端到端跑出来的:按一个不存在的列排,补向量每篇都静默失败),一次取一批 +pub async fn pending( + pool: &PgPool, + kb_id: Uuid, + limit: i64, +) -> AppResult> { + let rows: Vec<(Uuid, Uuid, String)> = sqlx::query_as( + "SELECT f.id, f.subject_id, f.object_value->>'value' + FROM facts f + JOIN relation_types nr ON nr.id = f.predicate_id + LEFT JOIN name_vectors v ON v.fact_id = f.id + WHERE f.kb_id = $1 AND nr.kb_id = $1 AND nr.builtin AND nr.key = $2 + AND f.invalidated_at IS NULL AND f.object_value IS NOT NULL + AND v.fact_id IS NULL + ORDER BY f.id + LIMIT $3", + ) + .bind(kb_id) + .bind(crate::names::KNOWN_AS) + .bind(limit) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// 写一批名字向量:`(fact_id, entity_id, embedding)`。同一条事实重写就覆盖(换了模型 +/// 重算)。第一次写下这个维度的向量,索引就该排上了(0035) +pub async fn set(pool: &PgPool, kb_id: Uuid, items: &[(Uuid, Uuid, Vec)]) -> AppResult<()> { + if items.is_empty() { + return Ok(()); + } + let mut tx = pool.begin().await?; + for (fact_id, entity_id, emb) in items { + sqlx::query( + "INSERT INTO name_vectors (fact_id, kb_id, entity_id, embedding) + VALUES ($1, $2, $3, $4) + ON CONFLICT (fact_id) DO UPDATE SET embedding = EXCLUDED.embedding", + ) + .bind(fact_id) + .bind(kb_id) + .bind(entity_id) + .bind(Vector::from(emb.clone())) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + if let Some((_, _, first)) = items.first() { + vector_index::request(pool, Target::NameVectors, first.len()).await?; + } + Ok(()) +} + +/// 一条被召回的名字:它挂在哪个实体上、那个实体是什么类、名字本身、与查询的余弦 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Near { + pub entity_id: Uuid, + pub fact_id: Uuid, + pub name: String, + pub type_label: Option, + pub similarity: f32, +} + +/// 同库里与查询向量最近的 `k` 条名字,按相似度降序。只看现行的名字事实、没被合并掉 +/// 的实体。不按 `description` 过滤:被描述的东西没有名字事实(0044),JOIN facts 已经 +/// 把它排除了;万一将来一个有名字的东西也带上描述,它的名字照样该被召回(#877 评审)。 +/// +/// 里层按索引能接住的形状取 `k * 4` 条最近的,外层再用事实与实体的状态过滤——过滤 +/// 放在里层会让索引用不上(0035);取四倍是给过滤留余量,名字事实作废和实体合并 +/// 都不常见,通常一条都不会被滤掉 +pub async fn nearest(pool: &PgPool, kb_id: Uuid, query: &[f32], k: usize) -> AppResult> { + let dims = query.len(); + if dims == 0 || k == 0 { + return Ok(Vec::new()); + } + let distance = vector_index::distance("v.embedding", 2, dims); + let same_dims = vector_index::same_dims("v.embedding", dims); + let sql = format!( + // 外层次序照 `vector_index::RESORT` 的规矩(`distance + 0, id`),列名限定到 CTE: + // 外层还联着 facts / entities / entity_types,裸写 `id` 会二义 + "WITH near AS MATERIALIZED ( + SELECT v.fact_id AS id, v.entity_id, ({distance}) AS distance + FROM name_vectors v + WHERE v.kb_id = $1 AND {same_dims} + ORDER BY {distance} + LIMIT $3 + ) + SELECT n.entity_id, n.id AS fact_id, + f.object_value->>'value' AS name, + et.label AS type_label, + (1 - n.distance)::real AS similarity + FROM near n + JOIN facts f ON f.id = n.id AND f.invalidated_at IS NULL + JOIN entities e ON e.id = n.entity_id AND e.merged_into IS NULL + LEFT JOIN entity_types et ON et.id = e.type_id + ORDER BY n.distance + 0, n.id + LIMIT $4", + ); + let rows: Vec = sqlx::query_as(&sql) + .bind(kb_id) + .bind(Vector::from(query.to_vec())) + .bind((k * 4) as i64) + .bind(k as i64) + .fetch_all(pool) + .await?; + Ok(rows) +} diff --git a/crates/utopia-store/src/resolution.rs b/crates/utopia-store/src/resolution.rs index 10f7dd29b..14549d698 100644 --- a/crates/utopia-store/src/resolution.rs +++ b/crates/utopia-store/src/resolution.rs @@ -233,9 +233,84 @@ impl ReviewStage { } } -/// 单条 mention 消解。`context` 为 mention 所在分块的向量(无 embedding 模型时为 None, -/// 退化为 v1 行为:同名归并到事实最多的候选)。 +/// 消解一个 mention。召回走两条通道(0041 决定 3):名字字面相等(通道 1, +/// `resolve_by_name` 里的那条 SQL)和名字向量最近邻(通道 2,给了 `name_vector` 才走)。 +/// +/// **通道 2 只提议,不决定。** 它召回到的实体这一刀不参与归并——决定该由证据来做, +/// 那是 0041 的第 3 刀,还没建——只给裁决器排一对(`name_vector|<余弦>`),让它拿两份 +/// 画像和先例去判是不是一个。于是这一刀加的是「多问一句」,不是「多合一次」:错合 +/// 是静默的、要人回头拆,多问只是贵一点。 +/// +/// 同一个字面名字不走通道 2:那是通道 1 的地盘,它已经按画像判过了,再排一对等于 +/// 让裁决器复议一个刚做过的决定。大类对不上的也不提议(海探1 是设备,不会是一个人)。 +#[allow(clippy::too_many_arguments)] pub async fn resolve_mention( + pool: &PgPool, + kb_id: Uuid, + type_id: Option, + raw_name: &str, + context: Option<&[f32]>, + // mention 名字本身的向量(不是块的)。None = 没配嵌入模型,或这次没算 + name_vector: Option<&[f32]>, + text: Option<&str>, + exclude: &[Uuid], +) -> AppResult { + let mut r = resolve_by_name(pool, kb_id, type_id, raw_name, context, text, exclude).await?; + let Some(query) = name_vector else { + return Ok(r); + }; + let mention_name = normalize_name(raw_name).to_lowercase(); + let mention_family = match type_id { + Some(t) => type_label(pool, t) + .await? + .as_deref() + .and_then(crate::governance::type_family), + None => None, + }; + let mut seen: HashSet = r.reviews.iter().map(|v| v.other_id).collect(); + seen.insert(r.entity_id); + seen.extend(exclude.iter().copied()); + for near in crate::name_vectors::nearest(pool, kb_id, query, crate::name_vectors::TOP_K).await? + { + if near.similarity < crate::name_vectors::SIM_FLOOR { + break; // 降序:后面的更远 + } + if near.name.to_lowercase() == mention_name || !seen.insert(near.entity_id) { + continue; + } + let near_family = near + .type_label + .as_deref() + .and_then(crate::governance::type_family); + if let (Some(a), Some(b)) = (mention_family, near_family) { + if a != b { + continue; + } + } + r.reviews.push(ReviewRequest { + other_id: near.entity_id, + score: near.similarity, + reason: format!("name_vector|{:.2}", near.similarity), + stage: ReviewStage::Adjudicating, + }); + } + Ok(r) +} + +async fn type_label(pool: &PgPool, type_id: Uuid) -> AppResult> { + Ok( + sqlx::query_scalar("SELECT label FROM entity_types WHERE id = $1") + .bind(type_id) + .fetch_optional(pool) + .await?, + ) +} + +/// 通道 1:名字字面相等的候选,按画像分层归并或新建(原 `resolve_mention` 的全部)。 +/// `context` 为 mention 所在分块的向量(无 embedding 模型时为 None,退化为 v1 行为: +/// 同名归并到事实最多的候选)。 +#[allow(clippy::too_many_arguments)] +async fn resolve_by_name( pool: &PgPool, kb_id: Uuid, // None = 抽取器给的类型不在本体里,或库里根本没有类(0009) diff --git a/crates/utopia-store/src/vector_index.rs b/crates/utopia-store/src/vector_index.rs index 17f9d5fcb..23f86e1f0 100644 --- a/crates/utopia-store/src/vector_index.rs +++ b/crates/utopia-store/src/vector_index.rs @@ -57,6 +57,8 @@ pub enum Target { Chunks, /// `entities.profile_embedding`:实体画像,类型消解按主语逐个扫它(#514) EntityProfiles, + /// `name_vectors.embedding`:名字字符串的向量,召回的第二条通道(0041 第 2 刀) + NameVectors, } impl Target { @@ -64,6 +66,7 @@ impl Target { match self { Target::Chunks => "chunks", Target::EntityProfiles => "entities", + Target::NameVectors => "name_vectors", } } @@ -71,6 +74,7 @@ impl Target { match self { Target::Chunks => "embedding", Target::EntityProfiles => "profile_embedding", + Target::NameVectors => "embedding", } } @@ -83,6 +87,7 @@ impl Target { match key { "chunks" => Some(Target::Chunks), "entities" => Some(Target::EntityProfiles), + "name_vectors" => Some(Target::NameVectors), _ => None, } } diff --git a/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs b/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs index 788af8ba4..4974f8e97 100644 --- a/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs +++ b/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs @@ -113,7 +113,8 @@ async fn drift_reviews( name: &str, type_id: Uuid, ) -> anyhow::Result> { - let r = resolution::resolve_mention(pool, f.kb, Some(type_id), name, None, None, &[]).await?; + let r = + resolution::resolve_mention(pool, f.kb, Some(type_id), name, None, None, None, &[]).await?; assert!( r.created, "a cross-type same name is a new entity: keep apart, never merge" diff --git a/crates/utopia-store/tests/store/a_described_thing_is_an_entity_without_a_name.rs b/crates/utopia-store/tests/store/a_described_thing_is_an_entity_without_a_name.rs index b7719202b..4dbac1384 100644 --- a/crates/utopia-store/tests/store/a_described_thing_is_an_entity_without_a_name.rs +++ b/crates/utopia-store/tests/store/a_described_thing_is_an_entity_without_a_name.rs @@ -100,7 +100,7 @@ async fn a_described_thing_has_a_description_and_no_name() -> anyhow::Result<()> assert_eq!(recalled, None); // 后来一条同样措辞的提及走消解:另建一个,不归到被描述的那个身上 let later = - resolution::resolve_mention(&pool, f.kb, None, description, None, None, &[]).await?; + resolution::resolve_mention(&pool, f.kb, None, description, None, None, None, &[]).await?; assert!(later.created, "描述不是桥,提及不归到它身上"); assert_ne!(later.entity_id, id); diff --git a/crates/utopia-store/tests/store/a_name_created_twice_at_once_is_one_entity.rs b/crates/utopia-store/tests/store/a_name_created_twice_at_once_is_one_entity.rs index fa408d006..69ddb2c05 100644 --- a/crates/utopia-store/tests/store/a_name_created_twice_at_once_is_one_entity.rs +++ b/crates/utopia-store/tests/store/a_name_created_twice_at_once_is_one_entity.rs @@ -42,7 +42,16 @@ async fn a_name_created_twice_at_once_is_one_entity() -> anyhow::Result<()> { let name = format!("澜图数据-{tag}"); let go = || { - utopia_store::resolution::resolve_mention(&pool, kb, Some(class), &name, None, None, &[]) + utopia_store::resolution::resolve_mention( + &pool, + kb, + Some(class), + &name, + None, + None, + None, + &[], + ) }; let (a, b, c, d) = tokio::join!(go(), go(), go(), go()); let ids = [a?.entity_id, b?.entity_id, c?.entity_id, d?.entity_id]; diff --git a/crates/utopia-store/tests/store/a_name_is_a_fact.rs b/crates/utopia-store/tests/store/a_name_is_a_fact.rs index bd6d86d0d..ce5373f7b 100644 --- a/crates/utopia-store/tests/store/a_name_is_a_fact.rs +++ b/crates/utopia-store/tests/store/a_name_is_a_fact.rs @@ -58,7 +58,10 @@ async fn teardown(pool: &PgPool, f: &Fixture) -> anyhow::Result<()> { async fn mention(pool: &PgPool, f: &Fixture, name: &str) -> anyhow::Result { // 不给向量:召回到候选就走「并到事实最多的那个」,量的正是召回找不找得到 - Ok(resolution::resolve_mention(pool, f.kb, Some(f.equipment), name, None, None, &[]).await?) + Ok( + resolution::resolve_mention(pool, f.kb, Some(f.equipment), name, None, None, None, &[]) + .await?, + ) } fn values(v: &[utopia_core::models::NameView]) -> Vec { diff --git a/crates/utopia-store/tests/store/a_namesake_tie_goes_to_review_not_a_coin_flip.rs b/crates/utopia-store/tests/store/a_namesake_tie_goes_to_review_not_a_coin_flip.rs index 0d956a0b5..60b6f33f9 100644 --- a/crates/utopia-store/tests/store/a_namesake_tie_goes_to_review_not_a_coin_flip.rs +++ b/crates/utopia-store/tests/store/a_namesake_tie_goes_to_review_not_a_coin_flip.rs @@ -133,6 +133,7 @@ async fn a_namesake_tie_creates_an_entity_and_two_reviews() -> anyhow::Result<() "Zhang Wei", Some(&ctx), None, + None, &[], ) .await?; diff --git a/crates/utopia-store/tests/store/a_similar_name_is_proposed_not_merged.rs b/crates/utopia-store/tests/store/a_similar_name_is_proposed_not_merged.rs new file mode 100644 index 000000000..2af38290b --- /dev/null +++ b/crates/utopia-store/tests/store/a_similar_name_is_proposed_not_merged.rs @@ -0,0 +1,334 @@ +//! 名字向量召回(0041 决定 3 通道 2,第 2 刀):**相近的名字来报到,但只提议,不归并。** +//! +//! 库里有 海洋探测器1号,一篇新文档只写 海探1。字面召回(通道 1)碰不上它——不相等、 +//! 又短于 containment 的四字门槛——从前这里静默长出第二个实体(#709)。现在 mention 的 +//! 名字向量在同库的名字向量里取最近邻,够近就给裁决器排一对 `name_vector|<余弦>`; +//! mention 自己照常按字面路径走(这里是新建)。是不是一个,裁决器拿两份画像判。 +//! +//! 连库才测得到:近邻是 SQL 里的 `<=>`。没有 `UTOPIA_DATABASE_URL` 时跳过,自建自拆。 +//! 向量都是手摆的三维——测的是召回和提议的规矩,不是某个嵌入模型的远近观。 + +use sqlx::PgPool; +use utopia_store::resolution::ReviewStage; +use utopia_store::{name_vectors, names}; +use uuid::Uuid; + +struct Fx { + org: Uuid, + kb: Uuid, + device: Uuid, + person: Uuid, + probe: Uuid, + captain: Uuid, +} + +async fn seed(pool: &PgPool, tag: &str) -> anyhow::Result { + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (device, person) = (Uuid::now_v7(), Uuid::now_v7()); + let (probe, captain) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, $2)") + .bind(org) + .bind(tag) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, $3)") + .bind(ws) + .bind(org) + .bind(tag) + .execute(pool) + .await?; + sqlx::query("INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, $3)") + .bind(kb) + .bind(ws) + .bind(tag) + .execute(pool) + .await?; + // 两个大类:设备(type_family 认不出,None)与人(Person) + for (id, key, label) in [(device, "device", "Device"), (person, "person", "Person")] { + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, $3, $4)") + .bind(id) + .bind(kb) + .bind(key) + .bind(label) + .execute(pool) + .await?; + } + for (id, type_id, name) in [ + (probe, device, "海洋探测器1号"), + (captain, person, "海洋探测队长"), + ] { + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, $4)", + ) + .bind(id) + .bind(kb) + .bind(type_id) + .bind(name) + .execute(pool) + .await?; + let fact = names::record(pool, kb, id, name, None, None) + .await? + .expect("a named entity gets a name fact"); + // 两个名字的向量故意一样:只有大类能把它们分开 + name_vectors::set(pool, kb, &[(fact, id, vec![1.0, 0.0, 0.0])]).await?; + } + Ok(Fx { + org, + kb, + device, + person, + probe, + captain, + }) +} + +async fn teardown(pool: &PgPool, f: &Fx) -> anyhow::Result<()> { + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") + .bind(f.kb) + .execute(pool) + .await?; + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(pool) + .await?; + Ok(()) +} + +fn vector_reviews(r: &utopia_store::resolution::Resolution) -> Vec<(Uuid, f32)> { + r.reviews + .iter() + .filter(|v| v.reason.starts_with("name_vector|")) + .map(|v| (v.other_id, v.score)) + .collect() +} + +/// 相近的名字:新建实体,给裁决器排一对,绝不静默归并 +#[tokio::test] +async fn a_near_name_creates_an_entity_and_proposes_a_pair() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool, "name-vector-near").await?; + let run = async { + let near: Vec = vec![0.95, 0.31, 0.0]; + let r = utopia_store::resolution::resolve_mention( + &pool, + f.kb, + Some(f.device), + "海探1", + None, + Some(&near), + None, + &[], + ) + .await?; + assert!(r.created, "字面碰不上,mention 该新建实体"); + assert_ne!( + r.entity_id, f.probe, + "向量召回只提议,不能直接归并到 海洋探测器1号" + ); + let proposed = vector_reviews(&r); + assert!( + proposed.iter().any(|(id, _)| *id == f.probe), + "海洋探测器1号 该被提议:{:?}", + r.reviews + ); + // 夹具里两个名字的向量一样,而 Device 认不出大类、不拦 Person:海洋探测队长 也进来。 + // 这不是漏,是规矩——分不出大类时宁可多问;大类分得出时的拦截见下一条测试 + assert_eq!( + proposed.len(), + 2, + "Device 无大类,不拦同向量的 Person:{:?}", + r.reviews + ); + assert!( + proposed.iter().all(|(_, s)| *s >= name_vectors::SIM_FLOOR), + "分数是余弦本身" + ); + assert!( + r.reviews + .iter() + .all(|v| v.stage == ReviewStage::Adjudicating), + "名字相近的对交给批量裁决器,不是人" + ); + Ok::<_, anyhow::Error>(()) + } + .await; + teardown(&pool, &f).await?; + run +} + +/// 大类对不上的不提议:设备的名字再像,也不是那个人 +#[tokio::test] +async fn a_near_name_of_another_family_is_not_proposed() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool, "name-vector-family").await?; + let run = async { + // mention 是个人;海洋探测器1号 的类 Device 认不出大类(None),照规矩不拦; + // 海洋探测队长 是 Person,同类,拦不住——所以两条都会进来。换成 Organization + // 的 mention 才能看到 Person 被拦: + let organization = Uuid::now_v7(); + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, 'organization', 'Organization')") + .bind(organization) + .bind(f.kb) + .execute(&pool) + .await?; + let near: Vec = vec![1.0, 0.0, 0.0]; + let r = utopia_store::resolution::resolve_mention( + &pool, + f.kb, + Some(organization), + "海洋探测公司", + None, + Some(&near), + None, + &[], + ) + .await?; + let proposed: Vec = vector_reviews(&r).into_iter().map(|(id, _)| id).collect(); + assert!( + !proposed.contains(&f.captain), + "Person 与 Organization 大类不同,海洋探测队长 不该被提议:{:?}", + r.reviews + ); + assert!( + proposed.contains(&f.probe), + "Device 认不出大类,不拦;海洋探测器1号 照常提议:{:?}", + r.reviews + ); + let _ = f.person; + Ok::<_, anyhow::Error>(()) + } + .await; + teardown(&pool, &f).await?; + run +} + +/// 不够近的不提议;没给向量的照旧 +#[tokio::test] +async fn a_far_name_or_no_vector_proposes_nothing() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool, "name-vector-far").await?; + let run = async { + let far: Vec = vec![0.0, 1.0, 0.0]; + let r = utopia_store::resolution::resolve_mention( + &pool, + f.kb, + Some(f.device), + "深海机器人", + None, + Some(&far), + None, + &[], + ) + .await?; + assert!( + vector_reviews(&r).is_empty(), + "余弦 0 低于下限,不提议:{:?}", + r.reviews + ); + let r = utopia_store::resolution::resolve_mention( + &pool, + f.kb, + Some(f.device), + "海探1", + None, + None, + None, + &[], + ) + .await?; + assert!( + vector_reviews(&r).is_empty(), + "没给名字向量就不走通道 2:{:?}", + r.reviews + ); + Ok::<_, anyhow::Error>(()) + } + .await; + teardown(&pool, &f).await?; + run +} + +/// 字面命中的候选是通道 1 的事:同一个名字不再经通道 2 复议 +#[tokio::test] +async fn the_same_literal_name_is_not_proposed_twice() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool, "name-vector-literal").await?; + let run = async { + let same: Vec = vec![1.0, 0.0, 0.0]; + // 没有上下文向量:v1 路径归并到唯一的字面候选 + let r = utopia_store::resolution::resolve_mention( + &pool, + f.kb, + Some(f.device), + "海洋探测器1号", + None, + Some(&same), + None, + &[], + ) + .await?; + assert_eq!(r.entity_id, f.probe, "字面相等走通道 1"); + // 夹具里 海洋探测队长 的向量与之相同、大类又拦不住,它照常被提议;要看的只是 + // 已归并到的 海洋探测器1号 自己不会再被排一对 + assert!( + !vector_reviews(&r).iter().any(|(id, _)| *id == f.probe), + "已经归并到它,不再对它排 name_vector:{:?}", + r.reviews + ); + Ok::<_, anyhow::Error>(()) + } + .await; + teardown(&pool, &f).await?; + run +} + +/// 补向量的往返:新记的名字事实在 `pending` 里,写了向量就不在了。端到端跑出来的窟窿—— +/// 之前 `pending` 按一个不存在的列排序,每篇文档的补向量都静默失败,通道 2 从未触发 +#[tokio::test] +async fn a_new_name_is_pending_until_its_vector_is_set() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool, "name-vector-pending").await?; + let run = async { + let fresh = Uuid::now_v7(); + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, '远洋重工')", + ) + .bind(fresh) + .bind(f.kb) + .bind(f.device) + .execute(&pool) + .await?; + let fact = names::record(&pool, f.kb, fresh, "远洋重工", None, None) + .await? + .expect("a name fact"); + let pending = name_vectors::pending(&pool, f.kb, 100).await?; + assert!( + pending.iter().any(|(fid, eid, name)| *fid == fact && *eid == fresh && name == "远洋重工"), + "新记的名字该在待补清单里:{pending:?}" + ); + // 夹具里两个已有向量的名字不在清单里 + assert!(pending.iter().all(|(_, eid, _)| *eid != f.probe && *eid != f.captain)); + name_vectors::set(&pool, f.kb, &[(fact, fresh, vec![0.0, 0.0, 1.0])]).await?; + let after = name_vectors::pending(&pool, f.kb, 100).await?; + assert!(after.iter().all(|(fid, _, _)| *fid != fact), "写了向量就不该再待补"); + Ok::<_, anyhow::Error>(()) + } + .await; + teardown(&pool, &f).await?; + run +} diff --git a/crates/utopia-store/tests/store/an_untyped_name_meets_its_namesake.rs b/crates/utopia-store/tests/store/an_untyped_name_meets_its_namesake.rs index afce5bed7..dae8cb4ac 100644 --- a/crates/utopia-store/tests/store/an_untyped_name_meets_its_namesake.rs +++ b/crates/utopia-store/tests/store/an_untyped_name_meets_its_namesake.rs @@ -43,6 +43,7 @@ async fn an_untyped_mention_attaches_to_its_untyped_namesake() -> anyhow::Result "Securities and Exchange Commission", None, None, + None, &[], ) .await?; @@ -54,6 +55,7 @@ async fn an_untyped_mention_attaches_to_its_untyped_namesake() -> anyhow::Result "SECURITIES AND EXCHANGE COMMISSION", None, None, + None, &[], ) .await?; diff --git a/crates/utopia-store/tests/store/facts_corroborate_an_identity.rs b/crates/utopia-store/tests/store/facts_corroborate_an_identity.rs index 7a63afce2..1b8d44eab 100644 --- a/crates/utopia-store/tests/store/facts_corroborate_an_identity.rs +++ b/crates/utopia-store/tests/store/facts_corroborate_an_identity.rs @@ -147,6 +147,7 @@ async fn a_fact_in_the_text_settles_a_namesake_tie() -> anyhow::Result<()> { Some(f.person), "Zhang Wei", Some(&ctx), + None, Some("Zhang Wei of Finance signed off on the quarterly report."), &[], ) @@ -182,6 +183,7 @@ async fn a_clue_that_points_at_both_settles_nothing() -> anyhow::Result<()> { Some(f.person), "Zhang Wei", Some(&ctx), + None, Some("Platform Engineering and Finance both sent a Zhang Wei to the review."), &[], ) @@ -214,6 +216,7 @@ async fn an_employer_they_share_is_not_a_clue() -> anyhow::Result<()> { Some(f.person), "Zhang Wei", Some(&ctx), + None, Some("Zhang Wei has worked at Nebula Holdings for six years."), &[], ) @@ -249,6 +252,7 @@ async fn text_that_names_neither_changes_nothing() -> anyhow::Result<()> { Some(f.person), "Zhang Wei", Some(&ctx), + None, // 提到的是第三家公司:一致的证据没有,"不一致"也不算证据 Some("Zhang Wei moonlights at Zenith Robotics on weekends."), &[], @@ -290,6 +294,7 @@ async fn the_grey_zone_listens_to_the_facts_too() -> anyhow::Result<()> { Some(f.person), "Zhang Wei", Some(&ctx), + None, Some("The Finance lead, Zhang Wei, approved it."), &[], ) diff --git a/crates/utopia-store/tests/store/human_type_decisions.rs b/crates/utopia-store/tests/store/human_type_decisions.rs index 4b3401f92..4a3c99d0c 100644 --- a/crates/utopia-store/tests/store/human_type_decisions.rs +++ b/crates/utopia-store/tests/store/human_type_decisions.rs @@ -228,6 +228,7 @@ async fn extraction_does_not_fill_in_a_type_a_human_left_empty() -> anyhow::Resu &name, Some(&ctx), None, + None, &[], ) .await?; diff --git a/crates/utopia-store/tests/store/phrase_signature_evidence.rs b/crates/utopia-store/tests/store/phrase_signature_evidence.rs index e56ad1cd4..5fc106ced 100644 --- a/crates/utopia-store/tests/store/phrase_signature_evidence.rs +++ b/crates/utopia-store/tests/store/phrase_signature_evidence.rs @@ -30,14 +30,30 @@ async fn multiple_evidence_does_not_multiply_statements_or_examples() -> anyhow: .await?; let mut statements = Vec::new(); for name in ["甲", "乙", "丙", "丁"] { - let subject = - utopia_store::resolution::resolve_mention(&pool, kb, None, name, None, None, &[]) - .await? - .entity_id; - let object = - utopia_store::resolution::resolve_mention(&pool, kb, None, "买方", None, None, &[]) - .await? - .entity_id; + let subject = utopia_store::resolution::resolve_mention( + &pool, + kb, + None, + name, + None, + None, + None, + &[], + ) + .await? + .entity_id; + let object = utopia_store::resolution::resolve_mention( + &pool, + kb, + None, + "买方", + None, + None, + None, + &[], + ) + .await? + .entity_id; statements.push( graph::insert_open_statement( &pool, diff --git a/crates/utopia-store/tests/store/review_stages.rs b/crates/utopia-store/tests/store/review_stages.rs index db6369cd8..cbb281095 100644 --- a/crates/utopia-store/tests/store/review_stages.rs +++ b/crates/utopia-store/tests/store/review_stages.rs @@ -60,17 +60,17 @@ async fn human_review_is_visible_but_never_pending_adjudication() -> anyhow::Res let run = async { let ordinary = - resolution::resolve_mention(&pool, kb, Some(ty), "Acme", None, None, &[]).await?; + resolution::resolve_mention(&pool, kb, Some(ty), "Acme", None, None, None, &[]).await?; assert_eq!(ordinary.entity_id, existing); assert!(!ordinary.created, "exclude=[] must retain ordinary recall"); let split = - resolution::resolve_mention(&pool, kb, Some(ty), "Acme", None, None, &[existing]) + resolution::resolve_mention(&pool, kb, Some(ty), "Acme", None, None, None, &[existing]) .await?; assert_ne!(split.entity_id, existing); assert!(split.created, "the excluded candidate cannot be attached"); let recalled_split = - resolution::resolve_mention(&pool, kb, Some(ty), "Acme", None, None, &[existing]) + resolution::resolve_mention(&pool, kb, Some(ty), "Acme", None, None, None, &[existing]) .await?; assert_eq!(recalled_split.entity_id, split.entity_id); assert!( diff --git a/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md b/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md index b685c9ce0..14dff1554 100644 --- a/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md +++ b/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md @@ -1,6 +1,6 @@ # 0041 · A name is a claim about an entity -- **Status**: decision 1 settled 2026-09-13 (names are facts) · cut 0 built: `scripts/bench/identity.mjs`, baseline on `dev` forward F1 0.43, reverse 0.54 · cut 1 implemented (#670): migration 0055 and `names` in the store, the extractor's `names`, shared-name pairs to the adjudicator; forward 0.68, reverse 0.68, the two orders agree on all 210 pairs (one run each; two cut-1 runs differed by 0.07 forward) · re-measured on DeepSeek-V3 after the review fixes: dev 0.46 / 0.40 (2 of 21 anchors unresolved), cut 1 0.61 / 0.44–0.53 over three runs; on V3 the adjudicator keeps 海洋探测器1号 and 海探1 apart in reverse order even with the shared name listed, which is cut 3's question · decisions 2 and 5 revised by cut 1 · cuts 2–4 not started +- **Status**: decision 1 settled 2026-09-13 (names are facts) · cut 0 built: `scripts/bench/identity.mjs`, baseline on `dev` forward F1 0.43, reverse 0.54 · cut 1 implemented (#670): migration 0055 and `names` in the store, the extractor's `names`, shared-name pairs to the adjudicator; forward 0.68, reverse 0.68, the two orders agree on all 210 pairs (one run each; two cut-1 runs differed by 0.07 forward) · re-measured on DeepSeek-V3 after the review fixes: dev 0.46 / 0.40 (2 of 21 anchors unresolved), cut 1 0.61 / 0.44–0.53 over three runs; on V3 the adjudicator keeps 海洋探测器1号 and 海探1 apart in reverse order even with the shared name listed, which is cut 3's question · decisions 2 and 5 revised by cut 1 · cut 2 channel 2 (name vectors) built 2026-09-23: migration 0080 `name_vectors`, recall proposes a `name_vector|` pair for the adjudicator and never merges; channel 3 (neighbours) and the retirement of `recall_keys` wait for the bench · cuts 3–4 not started - **Written**: 2026-09-13 (conventions in the [README](README.md)) - **Related**: [0009](0009-no-type-is-a-type.md) made an undecided type an honest state, and [0016](0016-close-the-open-seams-before-cutting-new-ones.md) B3 let a declared `disjointWith` keep names apart; #270 stopped a namesake tie from being settled by candidate order and #331 let facts break it; #428 and [0025](0025-governance-reads-the-ledger-before-it-decides.md) moved duplicates through a queue an agent works; #582 and #583 made the extractor copy the words that name each side of a fact; [0037](0037-a-relation-carries-its-own-attributes.md) put attributes on edges. diff --git a/docs/design/identity.md b/docs/design/identity.md index c5eb6b8db..d12bafb89 100644 --- a/docs/design/identity.md +++ b/docs/design/identity.md @@ -16,9 +16,14 @@ the server keeps one only when it occurs verbatim in its quote and the quote in another entity of the document already claims is dropped (`name_claimed_by_another`) [0041 d2]. A described thing gets no name fact and is never recalled by name [#731]. -**Resolution, one mention at a time, while extraction writes.** Recall is an exact match of the -mention's name (and its generic-suffix-stripped keys) against the name facts of entities of the same -type; a context vector (`profile_embedding`, the running mean of chunk vectors) attaches at cosine +**Resolution, one mention at a time, while extraction writes.** Recall has two channels. The first +is an exact match of the mention's name (and its generic-suffix-stripped keys) against the name +facts of entities of the same type. The second is the mention's name vector against the name +vectors of the base (`name_vectors`, one row per name fact, embedded after each document; migration +0080): the nearest few within the same type family at cosine 0.60 or above are *proposed* as a +`name_vector` pair for the adjudicator and never attached, so a short form or a name in another +script meets its entity through a question rather than a silent second entity [0041 d3 channel 2, +#709]. Only the first channel decides anything: a context vector (`profile_embedding`, the running mean of chunk vectors) attaches at cosine 0.55 or above, makes a new entity below 0.35, and in between makes a new entity and a pair for the adjudicator; two same-name candidates within a tie margin go to a person unless a candidate's object name appears in the chunk [0041, #270, #331]. A name another entity of a compatible type already diff --git a/migrations/0080_a_name_has_a_vector.sql b/migrations/0080_a_name_has_a_vector.sql new file mode 100644 index 000000000..061382ffc --- /dev/null +++ b/migrations/0080_a_name_has_a_vector.sql @@ -0,0 +1,36 @@ +-- 一个名字有一条向量(0041 决定 3 的第二条召回通道,第 2 刀)。 +-- +-- 召回今天只认字面:mention 的名字(及其泛用后缀变体)与名字事实精确相等才成候选。 +-- 于是 海探1 在一篇从没写过全名的文档里谁也碰不上,一个名字写成两种文字也永远 +-- 是两个实体(#709)。名字事实本身是对的(0041 决定 1),缺的是「相近的名字也来 +-- 报个到」这一条路——名字字符串的向量,同类里取最近的几条。 +-- +-- 存法:一张从表,一条名字事实一行。不放进 `facts` 加列——那张表 `SELECT *` 进 +-- `Fact` 的地方太多,为万分之一的行加一列全表都要跟着动;也不放进 `entities`—— +-- 一个实体有几个名字就该有几条向量,简称和全名各算各的。`embedding` 不定维,随 +-- 所选嵌入模型(与 `chunks.embedding` 同一条规矩);HNSW 由 `vector_index` 按第一次 +-- 写下的维度排任务去建,查询照 0035 的两条规矩写。 +-- +-- 同库不变量(0070 §1b):行自己带 kb_id,复合外键把「事实存在」和「事实同库」 +-- 合成一条约束;kb_id 落定后不改(0070 的通用触发器)。名字事实作废(invalidated_at) +-- 时向量留着,查询那头按事实是否现行过滤——作废是可撤的,向量不必重算。 +CREATE TABLE name_vectors ( + fact_id UUID PRIMARY KEY, + kb_id UUID NOT NULL, + entity_id UUID NOT NULL, + embedding vector NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT name_vectors_fact_same_kb + FOREIGN KEY (kb_id, fact_id) REFERENCES facts (kb_id, id) ON DELETE CASCADE, + CONSTRAINT name_vectors_entity_same_kb + FOREIGN KEY (kb_id, entity_id) REFERENCES entities (kb_id, id) ON DELETE CASCADE +); + +-- 合并搬名字事实时(0041:名字随合并走、撤回搬回),向量跟着事实走,entity_id 得 +-- 能改;只有库不能改 +CREATE TRIGGER name_vectors_keep_their_kb + BEFORE UPDATE OF kb_id ON name_vectors + FOR EACH ROW EXECUTE FUNCTION kb_ownership_is_not_reassigned(); + +-- 按实体找它的名字向量(合并搬动、面板展示) +CREATE INDEX name_vectors_entity_idx ON name_vectors (kb_id, entity_id); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e81d9e072..1bfe312bd 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1896,6 +1896,8 @@ export const en = { /* 画像分不开时的并列:分数是真的,所以百分比照常显示(与 namesake 的哨兵值不同) */ namesake_tie: "Same name, and the profiles cannot tell them apart", shared_name: "Another entity already has this name", + /* 名字向量召回(0041 第 2 刀):简称、另一种文字的同一个名字;只提议,裁决器判 */ + name_vector: "A similar name, found by vector recall", /* 名字互相包含:等值召回看不见,简称会静默变成第二个实体 */ contains: "One name contains the other", ambiguous_name: "Same name, context did not settle it", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 85599626d..2add82f51 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1664,6 +1664,7 @@ export const zh: Strings = { namesake: "同一篇文档里有两个同名实体", namesake_tie: "同名,画像分不出谁是谁", shared_name: "另一个实体已经叫这个名字", + name_vector: "名字相近(向量召回),等裁决", contains: "一个名字包含另一个", ambiguous_name: "同名,但上下文没能定夺", type_drift: "同名,但类型不同",