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