Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/AgentMemory.Abstractions/Options/MemoryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,34 @@ public sealed record MemoryOptions
/// </remarks>
public bool ResolveTemporalQueries { get; init; }

/// <summary>
/// Stops vector recall shipping the stored embedding back with every hit (rank 13 / payload).
/// </summary>
/// <remarks>
/// <para>
/// Every entity, fact and preference vector search returns the whole node — including its
/// embedding, which at 384 dimensions is roughly <b>3 KB per item and ~130 KB per turn</b> moved
/// across the wire, deserialized, and thrown away. Nothing on the recall path reads it: the
/// similarity was computed inside the index.
/// </para>
/// <para>
/// A measured prototype showed <b>−91% on the entity transaction and −21% on the whole turn</b>
/// with every quality guard flat.
/// </para>
/// <para>
/// <b>Opt-in, and that is a correctness decision rather than caution.</b> Recalled memories come
/// back with <c>Embedding = null</c> when this is on. Anything that re-uses a recalled vector —
/// notably the TCK conformance bridge, which serialises embeddings on three search endpoints —
/// must leave it off. Making it a setting the caller enables means such a consumer is unaffected
/// <i>by construction</i>, rather than by remembering to check.
Comment on lines +192 to +196
/// </para>
/// <para>
/// Honoured by the entity, fact and preference vector searches, which share one query finisher.
/// Message and trace searches keep returning whole nodes.
/// </para>
/// </remarks>
public bool OmitEmbeddingsFromRecall { get; init; }

// NOTE: extraction at the Core layer is explicit (call ExtractAndPersistAsync /
// ExtractFromSessionAsync). Automatic extraction on message persist is an adapter concern, configured
// by AgentFrameworkOptions.AutoExtractOnPersist. The former EnableAutoExtraction flag here was read
Expand Down
5 changes: 3 additions & 2 deletions src/AgentMemory.Neo4j/Queries/EntityQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,15 @@ public static string GetByName(bool includeAliases, bool hasOwnerFilter, bool in
/// <paramref name="recencyRerank"/> is set (D1) the clamped ACT-R retention score is blended into the
/// order key; when unset the query is byte-for-byte today's semantic-only ranking.
/// </summary>
public static string SearchByVector(bool hasOwnerFilter, bool includeShared, int topK, bool recencyRerank = false) =>
public static string SearchByVector(
bool hasOwnerFilter, bool includeShared, int topK, bool recencyRerank = false, bool omitEmbedding = false) =>
VectorRerank.Finish(
new CypherBuilder()
.WithVectorSearch("entity_embedding_idx", "$embedding", "node", topK)
.Where("score >= $minScore")
.And("node.invalidated_at IS NULL")
.And(includeShared ? "(node.owner_id = $ownerId OR node.owner_id IS NULL)" : "node.owner_id = $ownerId", when: hasOwnerFilter),
recencyRerank);
recencyRerank, omitEmbedding);

// ── GetByTypeAsync ─────────────────────────────────────────────────

Expand Down
5 changes: 3 additions & 2 deletions src/AgentMemory.Neo4j/Queries/FactQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ ORDER BY score DESC
/// order key (<c>$tmpWeight</c>); when unset the query is byte-for-byte today's semantic-only ranking.
/// </summary>
public static string SearchByVector(
bool hasOwnerFilter, bool includeShared, int topK, bool recencyRerank = false, bool currentValidTime = false) =>
bool hasOwnerFilter, bool includeShared, int topK, bool recencyRerank = false,
bool currentValidTime = false, bool omitEmbedding = false) =>
VectorRerank.Finish(
new CypherBuilder()
.WithVectorSearch("fact_embedding_idx", "$embedding", "node", topK)
Expand All @@ -317,7 +318,7 @@ public static string SearchByVector(
.And("(node.valid_from IS NULL OR node.valid_from <= datetime($now))", when: currentValidTime)
.And("(node.valid_until IS NULL OR node.valid_until > datetime($now))", when: currentValidTime)
.And(includeShared ? "(node.owner_id = $ownerId OR node.owner_id IS NULL)" : "node.owner_id = $ownerId", when: hasOwnerFilter),
recencyRerank);
recencyRerank, omitEmbedding);

/// <summary>
/// Last-resort owner-scoped similarity search that does NOT use the global vector index.
Expand Down
5 changes: 3 additions & 2 deletions src/AgentMemory.Neo4j/Queries/PreferenceQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,15 @@ public static string GetByCategory(bool hasOwnerFilter, bool includeShared)
/// <paramref name="recencyRerank"/> is set (D1) the clamped ACT-R retention score is blended into the
/// order key; when unset the query is byte-for-byte today's semantic-only ranking.
/// </summary>
public static string SearchByVector(bool hasOwnerFilter, bool includeShared, int topK, bool recencyRerank = false) =>
public static string SearchByVector(
bool hasOwnerFilter, bool includeShared, int topK, bool recencyRerank = false, bool omitEmbedding = false) =>
VectorRerank.Finish(
new CypherBuilder()
.WithVectorSearch("preference_embedding_idx", "$embedding", "node", topK)
.Where("score >= $minScore")
.And("node.invalidated_at IS NULL")
.And(includeShared ? "(node.owner_id = $ownerId OR node.owner_id IS NULL)" : "node.owner_id = $ownerId", when: hasOwnerFilter),
recencyRerank);
recencyRerank, omitEmbedding);

// ── Dedup-on-create ────────────────────────────────────────────────

Expand Down
16 changes: 13 additions & 3 deletions src/AgentMemory.Neo4j/Queries/VectorRerank.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,18 @@ internal static class VectorRerank
/// </summary>
/// <param name="builder">A builder already carrying the CALL+YIELD and any owner WHERE clauses.</param>
/// <param name="recencyRerank">When true, blend the clamped retention score into the order key.</param>
public static string Finish(CypherBuilder builder, bool recencyRerank)
public static string Finish(CypherBuilder builder, bool recencyRerank, bool omitEmbedding = false)
{
// The embedding is projected away at the FINAL return only. The recency branch below still
// reads node.last_accessed_at and node.created_at in its WITH clauses, so stripping earlier
// would silently disable the re-ranker rather than shrink the payload.
//
// `node {.*, embedding: NULL}` keeps every other property and yields a MAP, not a Node --
// which is why the read sites need dictionary mappers. That is the documented trap here, and
// the reason this is projected rather than removed with an all-but-one helper that Cypher
// does not have.
var projection = omitEmbedding ? "node {.*, embedding: NULL} AS node" : "node";

if (recencyRerank)
{
builder = builder
Expand All @@ -46,11 +56,11 @@ public static string Finish(CypherBuilder builder, bool recencyRerank)
// Retention score clamped to [0,1] so it is scale-comparable to the cosine score.
.With($"node, score, CASE WHEN ({RetentionExpr}) > 1.0 THEN 1.0 ELSE ({RetentionExpr}) END AS sTmp")
// Convex blend: (1−w)·semantic + w·recency.
.Return("node, ((1.0 - $tmpWeight) * score + $tmpWeight * sTmp) AS score");
.Return($"{projection}, ((1.0 - $tmpWeight) * score + $tmpWeight * sTmp) AS score");
}
else
{
builder = builder.Return("node, score");
builder = builder.Return($"{projection}, score");
}

return builder
Expand Down
55 changes: 37 additions & 18 deletions src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ internal sealed partial class Neo4jEntityRepository : IEntityRepository, IUpsert

private readonly INeo4jTransactionRunner _tx;
private readonly bool _rescueShortOwnerResults;
/// <summary>Payload projection: drop the ~3 KB vector nothing on the recall path reads.</summary>
private readonly bool _omitEmbeddingsFromRecall;
private readonly ILogger<Neo4jEntityRepository> _logger;
private readonly MemoryRankingOptions _ranking;
private readonly MemoryDecayOptions _decay;
Expand Down Expand Up @@ -66,6 +68,7 @@ public Neo4jEntityRepository(
IOptions<MemoryOptions>? memoryOptions = null)
{
_rescueShortOwnerResults = memoryOptions?.Value.RescueShortOwnerResults ?? false;
_omitEmbeddingsFromRecall = memoryOptions?.Value.OmitEmbeddingsFromRecall ?? false;
_tx = tx;
_logger = logger;
_ranking = ranking?.Value ?? MemoryRankingOptions.Default;
Expand Down Expand Up @@ -213,9 +216,12 @@ public async Task<IReadOnlyList<Entity>> GetByNameAsync(

var ranking = _rankingContext?.Current ?? _ranking; // per-request intent (D3) overrides the configured ranking
bool recencyRerank = ranking.RecencyRerankEnabled;
// Payload projection: nothing on the recall path reads the vector back, and it is ~3 KB an item.
bool omitEmbedding = _omitEmbeddingsFromRecall;
async Task<List<(Entity, double)>> QueryAsync(int width, CancellationToken ct)
{
var cypher = EntityQueries.SearchByVector(hasOwner, includeShared, width, recencyRerank);
var cypher = EntityQueries.SearchByVector(
Comment on lines +219 to +223
hasOwner, includeShared, width, recencyRerank, omitEmbedding);
var parameters = new Dictionary<string, object?>
{
["embedding"] = queryEmbedding.ToList(),
Expand All @@ -231,8 +237,12 @@ public async Task<IReadOnlyList<Entity>> GetByNameAsync(
var records = await cursor.ToListAsync().ConfigureAwait(false);
return records.Select(r =>
{
var node = r["node"].As<INode>();
var score = r["score"].As<double>();
// Projected recall returns a MAP, not a Node, and carries no embedding to read.
if (omitEmbedding)
return (MapToEntity(r["node"].As<IReadOnlyDictionary<string, object>>(), null), score);

var node = r["node"].As<INode>();
return (MapToEntity(node, ReadEmbedding(node)), score);
}).ToList();
}, ct).ConfigureAwait(false) ?? [];
Expand Down Expand Up @@ -592,11 +602,17 @@ await _tx.WriteAsync(async runner =>
}, cancellationToken).ConfigureAwait(false);
}

private static Entity MapToEntity(INode node, float[]? embedding)
/// <summary>Maps from a node's properties, so a projected MAP maps identically to a Node.</summary>
/// <remarks>
/// The recall payload projection returns <c>node {.*, embedding: NULL}</c>, which is a MAP rather
/// than a Node. Both paths share this body so the two cannot drift into mapping the same stored
/// entity differently depending on which query fetched it.
/// </remarks>
private static Entity MapToEntity(IReadOnlyDictionary<string, object> properties, float[]? embedding)
{
double? latitude = null;
double? longitude = null;
if (node.Properties.TryGetValue("location", out var locValue) && locValue is Point pt)
if (properties.TryGetValue("location", out var locValue) && locValue is Point pt)
{
// WGS-84: X = longitude, Y = latitude
latitude = pt.Y;
Expand All @@ -605,32 +621,35 @@ private static Entity MapToEntity(INode node, float[]? embedding)

return new Entity
{
EntityId = node["id"].As<string>(),
OwnerId = node.Properties.TryGetValue("owner_id", out var oid) ? oid.As<string>() : null,
Name = node["name"].As<string>(),
CanonicalName = node.Properties.TryGetValue("canonical_name", out var cn) ? cn.As<string>() : null,
Type = node["type"].As<string>(),
Subtype = node.Properties.TryGetValue("subtype", out var st) ? st.As<string>() : null,
Description = node.Properties.TryGetValue("description", out var desc) ? desc.As<string>() : null,
Confidence = node["confidence"].As<double>(),
EntityId = properties["id"].As<string>(),
OwnerId = properties.TryGetValue("owner_id", out var oid) ? oid.As<string>() : null,
Name = properties["name"].As<string>(),
CanonicalName = properties.TryGetValue("canonical_name", out var cn) ? cn.As<string>() : null,
Type = properties["type"].As<string>(),
Subtype = properties.TryGetValue("subtype", out var st) ? st.As<string>() : null,
Description = properties.TryGetValue("description", out var desc) ? desc.As<string>() : null,
Confidence = properties["confidence"].As<double>(),
Embedding = embedding,
Latitude = latitude,
Longitude = longitude,
Aliases = node.Properties.TryGetValue("aliases", out var al)
Aliases = properties.TryGetValue("aliases", out var al)
? al.As<IList<object>>().Select(a => a.ToString()!).ToList()
: Array.Empty<string>(),
Attributes = DeserializeMetadata(node.Properties.TryGetValue("attributes", out var attr) ? attr.As<string>() : null),
SourceMessageIds = node.Properties.TryGetValue("source_message_ids", out var sm)
Attributes = DeserializeMetadata(properties.TryGetValue("attributes", out var attr) ? attr.As<string>() : null),
SourceMessageIds = properties.TryGetValue("source_message_ids", out var sm)
? sm.As<IList<object>>().Select(v => v.ToString()!).ToList()
: Array.Empty<string>(),
CreatedAtUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(node["created_at"]),
UpdatedAtUtc = node.Properties.TryGetValue("updated_at", out var ua) && ua is not null
CreatedAtUtc = Neo4jDateTimeHelper.ReadDateTimeOffset(properties["created_at"]),
UpdatedAtUtc = properties.TryGetValue("updated_at", out var ua) && ua is not null
? Neo4jDateTimeHelper.ReadNullableDateTimeOffset(ua)
: null,
Metadata = DeserializeMetadata(node.Properties.TryGetValue("metadata", out var md) ? md.As<string>() : null)
Metadata = DeserializeMetadata(properties.TryGetValue("metadata", out var md) ? md.As<string>() : null)
};
}

private static Entity MapToEntity(INode node, float[]? embedding) =>
MapToEntity(node.Properties, embedding);

public async Task<Entity?> ApplyConfidenceDeltaAsync(
string entityId, double delta, MemoryScope? scope = null, CancellationToken cancellationToken = default)
{
Expand Down
Loading