Repository navigation
feat: persist declared concepts, and stop losing short follow-ups in recall - #123
Open
chengxinshengglj-png wants to merge 3 commits into
Open
chengxinshengglj-png wants to merge 3 commits into
chengxinshengglj-png wants to merge 3 commits into
Conversation
The DSH path stopped at the navigation layer. Extraction emitted only summary/outcome/triples, so gm_nodes and gm_edges were written by one caller only: the manual `gm_record` tool. Concepts that the assistant learned on its own never reached the durable graph, and recall's knowledge-graph section stayed empty no matter how long the session ran. Extend the provider contract with two optional blocks: - `entities`: name (2..24 chars), kind in EVENT/SKILL/TASK, and a one-sentence description; - `relations`: from/to naming declared entities, one of the seven declared edge kinds, and a short instruction. extractOnce now maps them through a new store helper after the navigation triples are written. A concept that already exists is CONFIRMED rather than replaced: content is kept, confidence rises, and provenance is only added to, so a recurring mention cannot silently rewrite an established description or detach the evidence behind it. Relations are keyed by (from, to, kind) and update in place. Both blocks are optional. A host that ignores concepts keeps the exact previous shape, and a turn with no reusable concept legitimately omits them, so this is additive for existing deployments. One related finding: TypeBox's runtime check enforces shape, minLength and additionalProperties but not `enum`. Kind values are therefore re-checked at persistence time instead of being trusted from the contract.
A three-slot pool that is then filtered for "already visible in this
session" can come back empty, and an empty recall block injects nothing.
Measured on a live database: a short follow-up whose top three hits all
belonged to the current session produced zero injected memory, while the
same query with a wider pool had nine cross-session memories available.
Three changes, each independently useful:
- `recall(query, { overFetch })` decouples retrieval width from render
width. The default of 1 preserves the previous behaviour exactly; the
DSH path asks for 5x and slices after filtering.
- The lexical arm now probes individual terms instead of requiring the
whole sentence. Chinese questions are sentences, so
"客必得 账号 关联 任务" never appeared verbatim in any summary and the
arm returned nothing; it now matches on term overlap. A majority-hit
requirement keeps precision: sharing one word of a two-term query is
not evidence, which is what "发布端口,配置文件" needed — it used to
return five summaries that never mentioned the port.
- Recency enters the fusion as a bounded tie-breaker (30-day half life,
0.15 ceiling, below one reciprocal-rank step). It reorders near-ties
toward what the user is doing now without overriding similarity.
Question shells are stripped before probing so "体系7审计好了吗?" reaches
a summary that says "体系7审计已通过".
…imitives Two additions, both opt-in. **Reranking.** A bi-encoder embeds the query and the document separately, so it cannot see term-level interaction; on this corpus a cross-encoder put a true match at 0.9974 while the runner-up sat at 0.3435, an ordering the embedding scores never produced. `Recaller.setRerankFn()` takes an optional `RerankFn`, and the DSH path builds an HTTP client when `rerankEndpoint` is configured. It is off unless an endpoint is set, for a measured reason: the local CPU cross-encoder costs ~140 ms per document, so eight candidates add ~1.2 s to a turn that otherwise recalls in ~150 ms. `rerankTopN` (default 6) bounds the cost, `rerankTimeoutMs` (default 1500) bounds the worst case, and every failure mode — no endpoint, timeout, non-200, malformed payload, mismatched result count, thrown error — returns the embedding order unchanged. Only the head is scored; the tail keeps its order so a disagreeing reranker can never erase a candidate. The integration shape (best-effort, opt-in, a broken reranker may only lose the rerank) follows slow-stack/mneme (MIT). **Consolidation primitives.** `src/consolidate/cluster.ts` adapts the clustering core from slow-stack/mneme's `dream/clustering.js` (MIT, attribution in the file header): cosine similarity, k-means with k-means++ seeding and empty-cluster repair, and same-key conflict candidate detection. It is a standalone module with tests and no database access; nothing calls it yet. **Why not the whole autoDream.** It was measured first. Over 1,172 turn memories across 35 sessions, only 14 same-session pairs exceed 0.90 similarity and no cross-session pair exceeds 0.93, while summaries have a median length of 93 characters with no empty, confirmatory, or filler rows. The corpus this plugin produces is already specific and non-duplicative, so a consolidation pass would find almost nothing to merge while costing an LLM call per batch. The primitives are kept because conflict detection — the part that must not be guessed at — is the piece worth having ready.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
English | 中文
中文
问题
在 DSH 入口下,概念图(
gm_nodes/gm_edges)永远不会被自动抽取写入。dsh.ts是否 importupsertEdgedsh.ts里写节点的调用点gm_recordindex.ts里写边的调用点gm_record,且只在传relatedSkill时建一条SOLVED_BYsrc/extractor/extract.ts的提示词所以问题不是“忘了写”,而是契约里根本没有概念的位置。本机实测(1,159 条轮次记忆、2,983 条导航三元组):
nodesForTurnMemories()能取到至少一个节点的轮次记忆占比 0%;assembleContext()每轮渲染出的知识图谱段都是空的,提示词里只有<memory_capsules>和<navigation_graph>。做法
把「概念」提升为抽取契约的一等字段,并在
extractOnce落库。src/extractor/contract.ts—— 新增两个可选块:from/to必须指回同批entities的name;additionalProperties: false保持不变。src/store/store.ts—— 新增applyConceptExtraction():DELETE掉该节点原有的gm_node_sources,等于摘掉别的轮次的证据;复现一个概念应当是「提高置信度 + 增加来源」;(from, to, type)去重,重复观察只更新instruction;src/extractor/extract.ts—— 三个必需字段不变,提示词补充两个可选块与一个带概念的示例。没有引入任何宿主侧的猜测、改写或语义推断,schema 仍是唯一闸门。dsh.ts—— 在replaceNavigationTriples之后调用。为什么是可选而不是必需
Extractor,字段可选则旧 payload 行为完全不变;一个顺带发现
TypeBox 的
Value.Check不校验enum。minLength、maxLength、additionalProperties都生效,唯独enum放行。这意味着现有契约里的outcome枚举在运行时也没有被拦住。本 PR 在持久化层自行校验类型集合,没有擅自改
outcome的语义 —— 是否要在assertGraphExtractionContract里补一次显式枚举断言,由你判断。第二部分:召回命中率(第二个提交)
补上概念层之后,本机又实测了一周的真实会话,发现召回有一个独立的缺陷:短追问会整块消失。
recall()按recallMaxNodes(本机配 3)取候选,dsh.ts随后用filterDshRecallMemories丢掉「同会话且当前可见」的条目。如果这 3 条恰好全来自当前会话,过滤后为 0,dsh.ts直接return decision—— 这一轮一条记忆都不注入。实测(同一会话、同一查询):
三处改动:
1.
recall(query, { overFetch })—— 把「检索宽度」与「渲染宽度」解耦。默认1完全保持原有行为;DSH 路径传5,过滤后再slice(0, recallMaxNodes)。向量路径本来就是全表扫描、词法是一次 LIKE,扩大候选池没有可测量的成本(实测 98102 ms vs 120123 ms)。2. 词法臂改为按词探测 —— 原来要求整句作为子串出现。中文提问天然是句子,
客必得 账号 关联 任务永远不会原样出现在任何 summary 里,所以这一臂长期是空的。现在按标点/空白切词、剥掉疑问外壳(好了吗/是多少/吗…)后逐个 LIKE,并按命中数排序。精度用多数命中守住:两词查询要求两词都命中。这条规则来自实测——
发布端口,配置文件在宽松门槛下召回了 5 条,没有一条提到端口,只共享了「配置文件」,属于噪声。3. 新近度作为有界平局裁决 —— 30 天半衰期、上限 0.15,低于一个 reciprocal-rank 步长(1/1 − 1/2 = 0.5)。所以它只能重排近似并列的候选,不会盖过 embedding 给出的相似度顺序。
第三部分:精排(可选)与巩固原语(第三个提交)
精排:双塔嵌入把 query 和文档分开编码,看不到词级交互。本机实测,cross-encoder 给一条真匹配打 0.9974,次名只有 0.3435 —— 这个排序 embedding 分数根本没给出。
Recaller.setRerankFn()接受一个可选的RerankFn;DSH 路径在配置了rerankEndpoint时构造 HTTP 客户端。为什么不默认开:本机 CPU cross-encoder 约 140 ms/条,8 条候选会给一轮增加 1.2 s(而召回本身只要 150 ms)。
rerankTopN(默认 6)限制成本,rerankTimeoutMs(默认 1500)限制最坏情况。所有失败路径 —— 没配端点、超时、非 200、坏 payload、条数不符、抛异常 —— 都原样返回 embedding 顺序。只精排头部,尾部保持原序,因此精排"不同意"也删不掉候选。接线方式(best-effort、opt-in、精排坏了只损失精排)参考 slow-stack/mneme(MIT)。
巩固原语:
src/consolidate/cluster.ts改编自 mneme 的dream/clustering.js(MIT,文件头已署名):余弦相似度、k-means(k-means++ 播种 + 空簇修复)、同 key 冲突候选检测。独立模块、有测试、不碰数据库,目前没有任何调用方。为什么没搬整个 autoDream:先量过。1,172 条轮次记忆 / 35 个会话里,同会话相似度 >0.90 的只有 14 对,跨会话 >0.93 的 0 对;summary 中位 93 字,没有空行、确认行或填充行。这个插件产出的语料本身就具体且不重复,巩固几乎找不到可合并项,却要每批付一次 LLM 调用。原语留下是因为冲突检测那部分不能靠猜,值得先备好。
测试
新增
test/concept-extraction.test.ts(6 例)+test/recall-pool.test.ts(6 例)+test/rerank.test.ts(7 例)+test/consolidate-cluster.test.ts(9 例)+test/extraction-contract.test.ts增补 2 例:validatedCount递增、来源从 2 条变 4 条而不是被重置;overFetch严格等于原行为;overFetch: 3返回更宽的池;rerankTopN只影响头部条数;index回填而非依赖返回顺序;payload 条数不符时返回 null;全量:27 个文件 / 181 个测试通过(原 23 / 149)。
影响面
5 行 entity/relation(约 100200 token)。llmMaxTokens默认 4096,实测远未触顶4 个节点、03 条边<knowledge_graph>段开始有内容,受既有recallMaxNodes限制overFetch: 1);仅 DSH 路径请求 5 倍池并在过滤后截断,检索耗时无可测变化rerankEndpoint才启用,每轮 +0.4~1.2 s(取决于rerankTopN),任何失败都回落原序recall()的第二个参数可选,overFetch缺省即原行为不包含
semanticScoreThreshold与recallMaxNodes的默认值保持上游原样,本机的 0.60 属于本地配置,不是本 PR 的一部分。English
Problem
On the DSH entry point the concept graph is never written by extraction.
dsh.tsimportsupsertEdge?dsh.tsgm_recordtoolindex.tsgm_record, and only aSOLVED_BYwhenrelatedSkillis passedsrc/extractor/extract.tspromptsummary,outcome,triplesonlySo it is not that a write was forgotten — there is no shape in which a concept could arrive. Measured on a database with 1,159 turn memories and 2,983 navigation triples:
nodesForTurnMemories()resolves at least one node for 0 % of turn memories;assembleContext()therefore renders an empty knowledge-graph section on every turn — the prompt only ever carries<memory_capsules>and<navigation_graph>.What this does
Promote concepts to first-class fields on the existing contract and persist them in
extractOnce.src/extractor/contract.ts— two optional blocks:from/tomust name a concept declared in the same payload;additionalProperties: falseis kept.src/store/store.ts— newapplyConceptExtraction():gm_node_sourcesrows on replacement, which would detach the evidence of earlier turns; re-observing a concept should raise confidence and add provenance only;(from, to, kind), so re-observing updates the instruction instead of duplicating the row;src/extractor/extract.ts— the three required fields are unchanged; the prompt documents the two optional blocks and carries one worked example. No host-side guessing, rewriting or semantic inference is added: the schema stays the only gate.dsh.ts— calls the helper right afterreplaceNavigationTriples.Why optional rather than required
Extractor;A side finding worth its own fix
TypeBox's
Value.Checkdoes not enforceenum.minLength,maxLengthandadditionalPropertiesare enforced;enumis not. This meansassertGraphExtractionContractdoes not currently reject an out-of-enumoutcomeeither.This PR re-checks the kind sets at persistence time instead of touching
outcomesemantics. Whether to add an explicit enum assertion to the contract is your call — it would change existing behaviour, so I did not.Part two: recall hit rate (second commit)
With the concept layer in place I measured another week of real sessions and found an independent defect: short follow-ups lost the whole block.
recall()returnsrecallMaxNodescandidates (3 here);dsh.tsthen drops anything already visible in the live session. When all three hits come from that session, the filter leaves nothing anddsh.tsreturns without injecting — zero memory for that turn.Measured on the same session and query:
Three changes:
1.
recall(query, { overFetch })decouples retrieval width from render width. Default1reproduces the old behaviour exactly; the DSH path asks for5and slices back after filtering. The vector path is already a full scan and the lexical path is a single LIKE, so a wider pool is not measurable in wall time (98–102 ms vs 120–123 ms here).2. The lexical arm probes terms instead of requiring the sentence. It used to demand the entire query as a substring. Chinese questions are sentences, so
客必得 账号 关联 任务never appeared verbatim in any summary and this arm had been returning nothing. It now splits on punctuation and whitespace, strips question shells (好了吗,是多少,吗, …), and ranks by how many terms hit.Precision is defended by a majority-hit rule: a two-term query must match both. That rule came from measurement — under a looser threshold
发布端口,配置文件returned five summaries that never mentioned the port, matching only on配置文件.3. Recency joins the fusion as a bounded tie-breaker — 30-day half life, 0.15 ceiling, deliberately below one reciprocal-rank step (1/1 − 1/2 = 0.5). It can reorder near-ties toward current work without overriding the similarity order the embedding provider produced.
Part three: optional reranking, plus consolidation primitives (third commit)
Reranking. A bi-encoder encodes query and document separately, so it cannot see term-level interaction. Measured here, a cross-encoder scored a true match at 0.9974 while the runner-up sat at 0.3435 — an ordering the embedding scores never produced.
Recaller.setRerankFn()accepts an optionalRerankFn; the DSH path builds an HTTP client whenrerankEndpointis configured.Why it is off by default: the local CPU cross-encoder costs ~140 ms per document, so eight candidates add 1.2 s to a turn whose recall otherwise takes 150 ms.
rerankTopN(default 6) bounds the cost andrerankTimeoutMs(default 1500) bounds the worst case. Every failure path — no endpoint, timeout, non-200, malformed payload, mismatched result count, thrown error — returns the embedding order unchanged. Only the head is scored and the tail keeps its order, so a disagreeing reranker can never erase a candidate.The integration shape (best-effort, opt-in, a broken reranker may only lose the rerank) follows slow-stack/mneme (MIT).
Consolidation primitives.
src/consolidate/cluster.tsadapts mneme'sdream/clustering.js(MIT, attribution in the file header): cosine similarity, k-means with k-means++ seeding and empty-cluster repair, and same-key conflict candidate detection. Standalone, tested, no database access, and no caller yet.Why not the whole autoDream: it was measured first. Across 1,172 turn memories in 35 sessions, only 14 same-session pairs exceed 0.90 similarity and no cross-session pair exceeds 0.93; summaries have a median length of 93 characters with no empty, confirmatory, or filler rows. This plugin's output is already specific and non-duplicative, so consolidation would find almost nothing to merge while paying an LLM call per batch. The primitives stay because conflict detection is the part that must not be guessed at.
Tests
New
test/concept-extraction.test.ts(6 cases),test/recall-pool.test.ts(6),test/rerank.test.ts(7),test/consolidate-cluster.test.ts(9), plus 2 intest/extraction-contract.test.ts:validatedCount, and grows provenance 2 → 4 instead of resetting it;overFetchmatches the previous behaviour exactly, and3returns a wider pool;rerankTopNbounds the scored head;indexrather than trusting response order, and returns null on a short payload;Full suite: 27 files / 181 tests pass, up from 23 / 149.
Impact
llmMaxTokensdefaults to 4096 and is far from binding in practicerecallMaxNodesoverFetch: 1); only the DSH path asks for 5x and truncates after filtering, with no measurable retrieval costrerankEndpointis set, costing 0.4–1.2 s per turn depending onrerankTopN, with fallback to the embedding order on any failurerecall()argument is optional, andoverFetchdefaults to the previous behaviourOut of scope
semanticScoreThresholdandrecallMaxNodeskeep their upstream defaults; the 0.60 in my local profile is configuration, not part of this PR.Reproduce