Skip to main content

sqlite_graphrag/storage/entities/
mod.rs

1//! Persistence layer for entities, relationships and their junction tables.
2//!
3//! The entity graph mirrors the conceptual content of memories: `entities`
4//! holds nodes, `relationships` holds typed edges and `memory_entities` and
5//! `memory_relationships` connect each memory to the graph slice it emitted.
6
7mod merge;
8
9pub use merge::{
10    clear_memory_graph_bindings, count_relationships_by_relation, create_or_fetch_relationship,
11    delete_entities_by_ids, delete_relationship_by_id, delete_relationships_by_relation,
12    find_entity_id, find_orphan_entity_ids, find_relationship, increment_degree,
13    link_memory_entity, link_memory_relationship, list_entity_names_by_relation,
14    recalculate_degree, unlink_memory_entity, RelationshipRow,
15};
16
17use crate::embedder::f32_to_bytes;
18use crate::entity_type::EntityType;
19use crate::errors::AppError;
20use crate::parsers::normalize_entity_name;
21use crate::storage::utils::with_busy_retry;
22use rusqlite::{params, Connection};
23use serde::{Deserialize, Serialize};
24
25/// Input payload used to upsert a single entity.
26///
27/// `name` is normalized to kebab-case by the caller. `description` is
28/// optional and preserved across upserts when the new value is `None`.
29#[derive(Debug, Serialize, Deserialize, Clone)]
30#[serde(deny_unknown_fields)]
31pub struct NewEntity {
32    /// Name of this item.
33    pub name: String,
34    /// Entity type label.
35    #[serde(alias = "type")]
36    pub entity_type: EntityType,
37    /// Human-readable description.
38    pub description: Option<String>,
39}
40
41/// Input payload used to upsert a typed relationship between entities.
42///
43/// `strength` must lie within `[0.0, 1.0]` and is mapped to the `weight`
44/// column of the `relationships` table.
45#[derive(Debug, Serialize, Deserialize, Clone)]
46#[serde(deny_unknown_fields)]
47pub struct NewRelationship {
48    /// Source side of the relationship.
49    #[serde(alias = "from")]
50    pub source: String,
51    /// Target side of the relationship.
52    #[serde(alias = "to")]
53    pub target: String,
54    /// Relationship type.
55    #[serde(alias = "type")]
56    pub relation: String,
57    /// Relationship strength in `[0.0, 1.0]`. Defaults to
58    /// [`crate::constants::DEFAULT_RELATION_WEIGHT`] (0.5) when omitted from
59    /// graph-stdin / graph-file JSON (GAP-CLI-GRAPH-01).
60    #[serde(alias = "weight", default = "default_relationship_strength")]
61    pub strength: f64,
62    /// Human-readable description.
63    pub description: Option<String>,
64}
65
66fn default_relationship_strength() -> f64 {
67    crate::constants::DEFAULT_RELATION_WEIGHT
68}
69
70/// Validates entity name against quality rules.
71///
72/// Rejects names with newlines, names shorter than 2 characters, and
73/// ALL_CAPS abbreviations of 4 characters or fewer (common NER noise).
74///
75/// # Errors
76///
77/// Returns `Err(AppError::Validation)` when the name violates any rule.
78pub fn validate_entity_name(name: &str) -> Result<(), AppError> {
79    if name.len() < 2 {
80        return Err(AppError::Validation(
81            crate::i18n::validation::entity_name_too_short(name),
82        ));
83    }
84    if name.contains('\n') || name.contains('\r') {
85        return Err(AppError::Validation(
86            "entity name must not contain newline characters".to_string(),
87        ));
88    }
89    // v1.1.05 Bug 5: pure digit names are almost always accidental entity IDs
90    // passed as `--from`/`--to` instead of names (or via `--from-id`/`--to-id`).
91    // Reject them so `--create-missing` cannot pollute the graph with ghost nodes.
92    if name.chars().all(|c| c.is_ascii_digit()) {
93        return Err(AppError::Validation(
94            crate::i18n::validation::entity_name_purely_numeric(name),
95        ));
96    }
97    if name.len() <= 4
98        && name
99            .chars()
100            .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
101    {
102        return Err(AppError::Validation(
103            crate::i18n::validation::entity_name_all_caps_noise(name),
104        ));
105    }
106    Ok(())
107}
108
109/// Fuzzy match candidate returned by [`suggest_entity_names`] / [`resolve_entity_fuzzy`].
110#[derive(Debug, Clone)]
111pub struct FuzzyEntityMatch {
112    /// Unique identifier.
113    pub id: i64,
114    /// Name of this item.
115    pub name: String,
116    /// Similarity in `[0.0, 1.0]` (1.0 = exact).
117    pub score: f64,
118}
119
120/// Score how well `query` matches a canonical entity `name`.
121///
122/// Prefers exact, prefix-of-kebab, first-token equality, then Jaro-Winkler
123/// (rapidfuzz) so short nicknames like `danilo` rank `danilo-aguiar-teixeira`
124/// highly.
125pub fn entity_name_similarity(query: &str, name: &str) -> f64 {
126    let q = query.trim().to_ascii_lowercase();
127    let n = name.trim().to_ascii_lowercase();
128    if q.is_empty() || n.is_empty() {
129        return 0.0;
130    }
131    if q == n {
132        return 1.0;
133    }
134    // Prefix of a kebab/snake name: "danilo" ↔ "danilo-aguiar-teixeira"
135    if n.starts_with(&q) {
136        let rest = &n[q.len()..];
137        if rest.is_empty()
138            || rest.starts_with('-')
139            || rest.starts_with('_')
140            || rest.starts_with(' ')
141        {
142            return 0.95;
143        }
144        // Longer shared prefix still strong
145        return 0.88;
146    }
147    if q.starts_with(&n) && n.len() >= 3 {
148        return 0.80;
149    }
150    let first_token = n
151        .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
152        .next()
153        .unwrap_or(n.as_str());
154    if first_token == q {
155        return 0.92;
156    }
157    if n.contains(&q) && q.len() >= 3 {
158        return 0.82;
159    }
160    rapidfuzz::distance::jaro_winkler::normalized_similarity(q.chars(), n.chars())
161}
162
163/// Rank entity names in `namespace` by fuzzy similarity to `query`.
164///
165/// Returns up to `limit` candidates with score ≥ `min_score`, sorted by score
166/// descending (ties break alphabetically).
167pub fn suggest_entity_names(
168    conn: &Connection,
169    namespace: &str,
170    query: &str,
171    limit: usize,
172    min_score: f64,
173) -> Result<Vec<FuzzyEntityMatch>, AppError> {
174    let entities = list_entities(conn, Some(namespace))?;
175    let mut scored: Vec<FuzzyEntityMatch> = entities
176        .into_iter()
177        .filter_map(|e| {
178            let score = entity_name_similarity(query, &e.name);
179            if score >= min_score {
180                Some(FuzzyEntityMatch {
181                    id: e.id,
182                    name: e.name,
183                    score,
184                })
185            } else {
186                None
187            }
188        })
189        .collect();
190    scored.sort_by(|a, b| {
191        b.score
192            .partial_cmp(&a.score)
193            .unwrap_or(std::cmp::Ordering::Equal)
194            .then_with(|| a.name.cmp(&b.name))
195    });
196    scored.truncate(limit.max(1));
197    Ok(scored)
198}
199
200/// Resolve an entity by exact name, then optionally by fuzzy match.
201///
202/// * Exact match always wins.
203/// * When `auto_fuzzy` is true and exactly one candidate scores ≥ `min_score`
204///   (or the top candidate is ≥ 0.90 and beats the runner-up by ≥ 0.05),
205///   that candidate is returned with a stderr warning.
206/// * When no auto-resolution is possible, returns `Ok(None)` after the caller
207///   can surface suggestions via [`suggest_entity_names`].
208pub fn resolve_entity_fuzzy(
209    conn: &Connection,
210    namespace: &str,
211    name: &str,
212    auto_fuzzy: bool,
213) -> Result<Option<(i64, String, bool)>, AppError> {
214    if let Some(id) = find_entity_id(conn, namespace, name)? {
215        return Ok(Some((id, name.to_string(), false)));
216    }
217    // Case-insensitive exact via list (names are normalized kebab, but callers
218    // may pass mixed case).
219    let normalized = crate::parsers::normalize_entity_name(name);
220    if normalized != name {
221        if let Some(id) = find_entity_id(conn, namespace, &normalized)? {
222            return Ok(Some((id, normalized, false)));
223        }
224    }
225    if !auto_fuzzy {
226        return Ok(None);
227    }
228    let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.75)?;
229    if suggestions.is_empty() {
230        return Ok(None);
231    }
232    let top = &suggestions[0];
233    let clear_winner =
234        top.score >= 0.90 && (suggestions.len() == 1 || top.score - suggestions[1].score >= 0.05);
235    let single_strong = suggestions.len() == 1 && top.score >= 0.85;
236    if clear_winner || single_strong {
237        tracing::warn!(
238            target: "entities",
239            query = %name,
240            resolved = %top.name,
241            score = top.score,
242            "fuzzy entity resolution: exact match failed; using best candidate"
243        );
244        return Ok(Some((top.id, top.name.clone(), true)));
245    }
246    Ok(None)
247}
248
249/// Build a NotFound message that includes fuzzy suggestions when available.
250pub fn entity_not_found_with_suggestions(
251    conn: &Connection,
252    namespace: &str,
253    name: &str,
254) -> AppError {
255    let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.70).unwrap_or_default();
256    if suggestions.is_empty() {
257        return AppError::NotFound(format!(
258            "entity '{name}' not found in namespace '{namespace}'"
259        ));
260    }
261    let list: Vec<String> = suggestions
262        .iter()
263        .map(|s| format!("{} (score={:.2})", s.name, s.score))
264        .collect();
265    AppError::NotFound(format!(
266        "entity '{name}' not found in namespace '{namespace}'. Did you mean: {}? \
267         Re-run with --fuzzy to auto-resolve a clear match, or pass the canonical name.",
268        list.join(", ")
269    ))
270}
271
272/// Upserts an entity and returns its primary key.
273///
274/// Uses `ON CONFLICT(namespace, name)` to keep one row per entity within a
275/// namespace, refreshing `type` and `description` opportunistically.
276///
277/// # Errors
278///
279/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
280pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
281    // Step 1: validate the original name — catches ALL_CAPS short noise (NER artefacts),
282    // newlines, and names shorter than 2 characters before any transformation.
283    validate_entity_name(&e.name)?;
284    // Step 2: normalize to kebab-case ASCII (NFKD, lowercase, spaces/underscores → hyphens).
285    let normalized_name = normalize_entity_name(&e.name);
286    // Step 3: guard post-normalization length — a valid original could collapse to < 2 chars
287    // (e.g. a single accented character that strips entirely).
288    if normalized_name.chars().count() < 2 {
289        return Err(AppError::Validation(
290            crate::i18n::validation::entity_name_normalizes_too_short(&e.name, &normalized_name),
291        ));
292    }
293    conn.execute(
294        "INSERT INTO entities (namespace, name, type, description)
295         VALUES (?1, ?2, ?3, ?4)
296         ON CONFLICT(namespace, name) DO UPDATE SET
297           type        = excluded.type,
298           description = COALESCE(excluded.description, entities.description),
299           updated_at  = unixepoch()",
300        params![namespace, normalized_name, e.entity_type, e.description],
301    )?;
302    let id: i64 = conn.query_row(
303        "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
304        params![namespace, normalized_name],
305        |r| r.get(0),
306    )?;
307    Ok(id)
308}
309
310/// Replaces the vector row for an entity in `entity_embeddings`.
311///
312/// v1.0.76: sqlite-vec was removed. Embeddings live in a regular BLOB-backed
313/// table; cosine similarity is computed in pure Rust on demand. The
314/// `entity_type` and `name` arguments are accepted for API compatibility
315/// but are not stored — the entities table is the source of truth.
316///
317/// # Errors
318///
319/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
320pub fn upsert_entity_vec(
321    conn: &Connection,
322    entity_id: i64,
323    namespace: &str,
324    _entity_type: EntityType,
325    embedding: &[f32],
326    _name: &str,
327) -> Result<(), AppError> {
328    // v1.1.1 (P1): an empty vector means the embedding backend was skipped
329    // (`--llm-backend none` without OpenRouter). Writing an empty BLOB would
330    // hide the entity from the re-embed backfill scanner (the row exists but
331    // carries no vector), so skip the write and leave the entity scannable.
332    if embedding.is_empty() {
333        tracing::debug!(
334            entity_id,
335            "empty entity embedding: skipping entity_embeddings row (backfill via enrich re-embed --target entities)"
336        );
337        return Ok(());
338    }
339    let embedding_bytes = f32_to_bytes(embedding);
340    with_busy_retry(|| {
341        conn.execute(
342            "DELETE FROM entity_embeddings WHERE entity_id = ?1",
343            params![entity_id],
344        )?;
345        conn.execute(
346            "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
347             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
348            params![
349                entity_id,
350                namespace,
351                &embedding_bytes,
352                "llm-headless",
353                crate::constants::SQLITE_GRAPHRAG_VERSION,
354                crate::constants::embedding_dim() as i64,
355            ],
356        )?;
357        Ok(())
358    })
359}
360
361/// Upserts a typed relationship between two entity ids.
362///
363/// Conflicts on `(source_id, target_id, relation)` refresh `weight` and
364/// preserve a non-null `description`. Returns the `rowid` of the stored row.
365///
366/// # Errors
367///
368/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
369pub fn upsert_relationship(
370    conn: &Connection,
371    namespace: &str,
372    source_id: i64,
373    target_id: i64,
374    rel: &NewRelationship,
375) -> Result<i64, AppError> {
376    conn.execute(
377        "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
378         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
379         ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
380           weight = excluded.weight,
381           description = COALESCE(excluded.description, relationships.description)",
382        params![
383            namespace,
384            source_id,
385            target_id,
386            rel.relation,
387            rel.strength,
388            rel.description
389        ],
390    )?;
391    let id: i64 = conn.query_row(
392        "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
393        params![source_id, target_id, rel.relation],
394        |r| r.get(0),
395    )?;
396    Ok(id)
397}
398
399/// Entity row with enough data for graph export/query.
400#[derive(Debug, Serialize, Clone)]
401pub struct EntityNode {
402    /// Unique identifier.
403    pub id: i64,
404    /// Name of this item.
405    pub name: String,
406    /// Namespace scope.
407    pub namespace: String,
408    /// Kind discriminator.
409    pub kind: String,
410}
411
412/// Lists entities, filtering by namespace if provided.
413///
414/// # Errors
415///
416/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
417pub fn list_entities(
418    conn: &Connection,
419    namespace: Option<&str>,
420) -> Result<Vec<EntityNode>, AppError> {
421    if let Some(ns) = namespace {
422        let mut stmt = conn.prepare_cached(
423            "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
424        )?;
425        let rows = stmt
426            .query_map(params![ns], |r| {
427                Ok(EntityNode {
428                    id: r.get(0)?,
429                    name: r.get(1)?,
430                    namespace: r.get(2)?,
431                    kind: r.get(3)?,
432                })
433            })?
434            .collect::<Result<Vec<_>, _>>()?;
435        Ok(rows)
436    } else {
437        let mut stmt = conn.prepare_cached(
438            "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
439        )?;
440        let rows = stmt
441            .query_map([], |r| {
442                Ok(EntityNode {
443                    id: r.get(0)?,
444                    name: r.get(1)?,
445                    namespace: r.get(2)?,
446                    kind: r.get(3)?,
447                })
448            })?
449            .collect::<Result<Vec<_>, _>>()?;
450        Ok(rows)
451    }
452}
453
454/// Lists relations filtered by namespace (of source/target entities).
455///
456/// # Errors
457///
458/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
459pub fn list_relationships_by_namespace(
460    conn: &Connection,
461    namespace: Option<&str>,
462) -> Result<Vec<RelationshipRow>, AppError> {
463    if let Some(ns) = namespace {
464        let mut stmt = conn.prepare_cached(
465            "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
466             FROM relationships r
467             JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
468             JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
469             ORDER BY r.id",
470        )?;
471        let rows = stmt
472            .query_map(params![ns], |r| {
473                Ok(RelationshipRow {
474                    id: r.get(0)?,
475                    namespace: r.get(1)?,
476                    source_id: r.get(2)?,
477                    target_id: r.get(3)?,
478                    relation: r.get(4)?,
479                    weight: r.get(5)?,
480                    description: r.get(6)?,
481                })
482            })?
483            .collect::<Result<Vec<_>, _>>()?;
484        Ok(rows)
485    } else {
486        let mut stmt = conn.prepare_cached(
487            "SELECT id, namespace, source_id, target_id, relation, weight, description
488             FROM relationships ORDER BY id",
489        )?;
490        let rows = stmt
491            .query_map([], |r| {
492                Ok(RelationshipRow {
493                    id: r.get(0)?,
494                    namespace: r.get(1)?,
495                    source_id: r.get(2)?,
496                    target_id: r.get(3)?,
497                    relation: r.get(4)?,
498                    weight: r.get(5)?,
499                    description: r.get(6)?,
500                })
501            })?
502            .collect::<Result<Vec<_>, _>>()?;
503        Ok(rows)
504    }
505}
506
507/// Searches the `entity_embeddings` table for the k nearest neighbours
508/// using pure-Rust cosine similarity.
509///
510/// v1.0.76: sqlite-vec was removed. The full table scan + in-process
511/// cosine is O(N × D) per call. For namespaces with more than ~10k
512/// entities, the operator should rely on FTS5 (`hybrid-search`) for
513/// coarse filtering before reaching this function.
514///
515/// # Errors
516///
517/// - [`AppError::Database`] — SQLite query failure.
518/// - [`AppError::Embedding`] — invalid or mismatched embedding dimension.
519pub fn knn_search(
520    conn: &Connection,
521    embedding: &[f32],
522    namespace: &str,
523    k: usize,
524) -> Result<Vec<(i64, f32)>, AppError> {
525    if embedding.len() != crate::constants::embedding_dim() {
526        return Err(AppError::Embedding(
527            crate::i18n::validation::embedding_knn_search_dim_mismatch(
528                embedding.len(),
529                crate::constants::embedding_dim(),
530            ),
531        ));
532    }
533    let mut stmt = conn.prepare_cached(
534        "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
535    )?;
536    let mut scored: Vec<(i64, f32)> = stmt
537        .query_map(params![namespace], |r| {
538            let id: i64 = r.get(0)?;
539            let bytes: Vec<u8> = r.get(1)?;
540            Ok((id, bytes))
541        })?
542        .filter_map(|row| {
543            row.ok().and_then(|(id, bytes)| {
544                let stored = crate::embedder::bytes_to_f32(&bytes);
545                if stored.len() != embedding.len() {
546                    return None;
547                }
548                let score = crate::similarity::cosine_similarity(embedding, &stored);
549                Some((id, score))
550            })
551        })
552        .collect();
553    // `cosine_similarity` returns a value in [-1.0, 1.0]; 1.0 is the
554    // best match. Sort descending and truncate to `k`.
555    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
556    scored.truncate(k);
557    Ok(scored)
558}
559
560#[cfg(test)]
561mod tests_a;
562#[cfg(test)]
563mod tests_b;