From a9672114d04d68baf6cf3a7e85ad9f679e40ad8c Mon Sep 17 00:00:00 2001 From: lkx Date: Thu, 8 Oct 2026 23:16:38 +0800 Subject: [PATCH 1/4] feat(recall): make FTS and vector channel weights configurable per deployment --- docs/en/docs/operate/configuration.md | 12 ++ docs/en/rfcs/0014_memory_layer_design.md | 10 +- docs/en/rfcs/1417_topic_memory.md | 6 +- docs/zh/docs/operate/configuration.md | 8 + docs/zh/rfcs/0014_memory_layer_design.md | 8 +- docs/zh/rfcs/1417_topic_memory.md | 4 +- .../plugin/powercontext_dify/contract.json | 3 +- integrations/dify/plugin/tools/pc_search.yaml | 4 + openapi/powercontext.yaml | 3 + .../builtin/artifacts/memory/fusion.py | 19 ++- .../builtin/artifacts/memory/models.py | 7 +- .../builtin/artifacts/memory/service.py | 12 +- src/powercontext/builtin/artifacts/search.py | 36 +++++ .../builtin/persistence/topic_memory.py | 29 +++- .../builtin/runtime/composition.py | 2 + src/powercontext/builtin/runtime/config.py | 26 ++++ .../builtin/runtime/recall_sufficiency.py | 13 +- .../builtin/runtime/relational.py | 17 ++- src/powercontext/http/_generated/models.py | 13 +- src/powercontext/http/_generated/schema.py | 24 ++- tests/builtin/artifacts/memory/test_fusion.py | 45 +++++- .../builtin/artifacts/memory/test_service.py | 47 +++++- .../artifacts/topic_memory/test_fusion.py | 80 ++++++++++ .../builtin/persistence/test_topic_memory.py | 139 +++++++++++++++++- .../runtime/test_recall_channel_weights.py | 122 +++++++++++++++ .../runtime/test_recall_sufficiency.py | 25 +++- 26 files changed, 678 insertions(+), 36 deletions(-) create mode 100644 tests/builtin/runtime/test_recall_channel_weights.py diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index 70f3b4e145..cce9a78fba 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -95,6 +95,8 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ROUND1_MIN_SEMANTIC_SIMILARITY` | `0.15` | Semantic-similarity admission floor for the first expansion round; must not exceed the round-zero `0.3` | | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ROUND2_MIN_SEMANTIC_SIMILARITY` | `0.10` | Semantic-similarity admission floor for the second expansion round; must not exceed the first round's value | | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ALLOW_WITH_RERANK` | `false` | Whether expansion may still run when `MEMORY_RERANK_ENABLED` is set; by default reranking ends the expansion | +| `POWERCONTEXT_SERVER_RUNTIME_RECALL_FTS_WEIGHT` | `1.0` | Relative FTS contribution in hybrid Memory and Topic Memory RRF; `0` excludes FTS-only matches from hybrid results | +| `POWERCONTEXT_SERVER_RUNTIME_RECALL_VECTOR_WEIGHT` | `1.0` | Relative vector contribution in hybrid Memory and Topic Memory RRF; `0` excludes vector-only matches from hybrid results | | `POWERCONTEXT_SERVER_RUNTIME_MEMORY_SCHEDULE_SECONDS` | unset | Memory automatic admission interval; `SCHEDULE_SECONDS` remains a compatibility alias | | `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_SCHEDULE_SECONDS` | unset | Topic Memory automatic admission interval; unset disables new automatic admission | | `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_SOURCE_WINDOW_LIMIT` | `10` | Maximum Sources per Topic Memory Window, capped at 100; one Scope invocation can finish several Windows | @@ -153,6 +155,16 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | unset | Experience automatic admission interval; unset preserves accepted work and stops new automatic admission | | `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | automatic local project targets | JSON override containing the host identity and explicit Agent Skill targets | +The recall channel weights are a ratio and apply only after hybrid candidates pass their channel admission floors. +Scaling both values equally has no effect. Setting one value to `0` excludes that channel from hybrid fusion, but a +higher weight cannot restore a candidate rejected before fusion. A zero weight does not disable that channel's +retrieval or admission work: vector embeddings may still be generated, and retrieval/admission counts still include +the zero-weight channel. Evaluate weight changes together with recall-gate rounds, semantic admission floors, and +historical Topic candidate selection instead of treating the knobs independently. In particular, weighting FTS more +heavily can move a Topic's normalized score across +`POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_HISTORY_RRF_THRESHOLD` (default `70`) and change which historical Topics +are sent to the planner. + The recall-sufficiency gate is disabled by default. When enabled, the Runtime assesses the first recall's candidate count, family coverage, top-candidate score, and lexical coverage during `prepare_context`; when it judges the result insufficient, it may expand up to two more rounds and relaxes the semantic-similarity admission floor each round. The diff --git a/docs/en/rfcs/0014_memory_layer_design.md b/docs/en/rfcs/0014_memory_layer_design.md index 26a94ca627..cb79b1c892 100644 --- a/docs/en/rfcs/0014_memory_layer_design.md +++ b/docs/en/rfcs/0014_memory_layer_design.md @@ -775,12 +775,14 @@ Candidates below either threshold are not restored merely to fill `limit`. The a backend's internal order within each channel, and then use reciprocal rank fusion: ```text -rrf_score(candidate) = Σ 1 / (60 + rank_in_channel) +hybrid_rrf_score(candidate) = Σ channel_weight / (60 + rank_in_channel) ``` -`rank_in_channel` starts at 1. Single-channel `fts` and `vector` modes use the same one-channel formula to produce their -public score. A candidate rejected by or absent from one channel receives a contribution only from the other, and -`matched_by` contains only admitting channels. The OceanBase adapter +`rank_in_channel` starts at 1. The deployment's non-negative FTS/vector weights are relative: PowerContext normalizes +them to a mean of one, defaults both to `1.0`, and requires at least one to be positive. They apply only to `hybrid`; +single-channel `fts` and `vector` modes keep the unweighted `1 / (60 + rank)` public score. A zero-weight channel does +not contribute candidates or appear in `matched_by`. A candidate rejected by or absent from one channel receives a +contribution only from the other. The OceanBase adapter may use native `HYBRID_SEARCH` in version 4.6+ as an optimization, but it must preserve the same active-head filtering, RRF parameters, and stable tie-breaks externally. The 4.3.5 baseline performs RRF in the application. SQLite performs the same application-level RRF over FTS5 and Vec1 results. Equal RRF scores are broken by ascending diff --git a/docs/en/rfcs/1417_topic_memory.md b/docs/en/rfcs/1417_topic_memory.md index 78485b0b26..1ba52e9fb6 100644 --- a/docs/en/rfcs/1417_topic_memory.md +++ b/docs/en/rfcs/1417_topic_memory.md @@ -432,7 +432,11 @@ windows. Changing the Chunk policy requires rebuilding the corresponding retriev Each retrieval channel first collapses by Topic. When multiple Detail chunks from one Topic match, only the best position is retained for the snippet. The two or four channels enabled for the current deployment are fused by RRF rather than by comparing raw full-text scores with raw vector distances. A Topic occupies at most one final result -position, and `matched_by` records which channels matched it. The per-channel candidate limit applies only after this +position, and `matched_by` records which contributing channels matched it. Hybrid mode applies the deployment's +normalized relative FTS/vector weights to both Topic and Detail channels of the corresponding kind. A zero-weight +channel contributes no candidates. FTS-only and vector-only modes remain unweighted. The weighted theoretical maximum +for the enabled channels is the denominator that keeps the public score normalized to `0..100`. The per-channel +candidate limit applies only after this Topic collapse, so one Topic with many matching chunks cannot hide another Topic. FTS snippets center a bounded window on an Analyzer v1 query match; a vector-only detail hit uses a stable window from its matched chunk. The first-release search limit and historical candidate configuration are both bounded to 20. Search queries are diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index efaabdac03..e486f653ad 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -91,6 +91,8 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ROUND1_MIN_SEMANTIC_SIMILARITY` | `0.15` | 第一轮追加搜索使用的语义相似度准入下限;启用时不得高于首轮的 `0.3` | | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ROUND2_MIN_SEMANTIC_SIMILARITY` | `0.10` | 第二轮追加搜索使用的语义相似度准入下限;启用时不得高于第一轮的值 | | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ALLOW_WITH_RERANK` | `false` | 已启用 `MEMORY_RERANK_ENABLED` 时是否仍允许追加搜索;默认在 rerank 之后不再追加 | +| `POWERCONTEXT_SERVER_RUNTIME_RECALL_FTS_WEIGHT` | `1.0` | hybrid Memory 和 Topic Memory RRF 中 FTS 的相对贡献;`0` 会从 hybrid 结果中排除仅命中 FTS 的候选 | +| `POWERCONTEXT_SERVER_RUNTIME_RECALL_VECTOR_WEIGHT` | `1.0` | hybrid Memory 和 Topic Memory RRF 中 vector 的相对贡献;`0` 会从 hybrid 结果中排除仅命中 vector 的候选 | | `POWERCONTEXT_SERVER_RUNTIME_MEMORY_SCHEDULE_SECONDS` | 未设置 | Memory 自动准入间隔;`SCHEDULE_SECONDS` 保留为兼容别名 | | `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_SCHEDULE_SECONDS` | 未设置 | Topic Memory 自动准入间隔;未设置时不接纳新的自动调用 | | `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_SOURCE_WINDOW_LIMIT` | `10` | 每个 Topic Memory Window 的 Source 数量上限,硬上限为 100;一次 Scope 调用可完成多个 Window | @@ -149,6 +151,12 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | 未设置 | Experience 自动准入间隔;未设置时保留已接受工作,停止新的自动准入 | | `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | 自动生成本机项目 target | 覆盖默认值的 host identity 和显式 Agent Skill targets JSON object | +召回通道权重表示相对比例,只在 hybrid 候选通过各通道准入下限后生效。同比例缩放两个值不会改变结果。将其中一个设为 +`0` 会从 hybrid 融合中排除该通道,但提高权重不能恢复融合前已被拒绝的候选。零权重不会关闭该通道的检索和准入工作: +vector 查询仍可能生成 embedding,检索与准入计数也仍包含零权重通道。调整权重时应同时评估 recall gate 轮次、 +语义准入下限和历史 Topic 候选选择,不要将这些配置孤立调整。特别是,提高 FTS 权重可能使 Topic 的归一化分数跨过 +`POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_HISTORY_RRF_THRESHOLD`(默认为 `70`),从而改变传给规划器的历史 Topic 集合。 + 召回充分性门控默认关闭。启用后,Runtime 在 `prepare_context` 阶段评估首轮候选的数量、来源家族覆盖、最优候选分数和词法 覆盖;判定为不足时最多追加两轮搜索,并在每轮放宽候选准入的语义相似度下限。门控判断本身不调用模型,追加轮次沿用同一请求的 Scope、家族、条数限制和上下文预算,并复用已经生成的查询向量。第一轮和第二轮的语义相似度下限必须保持递减顺序,违反该顺序 diff --git a/docs/zh/rfcs/0014_memory_layer_design.md b/docs/zh/rfcs/0014_memory_layer_design.md index 5019394209..903eb910b2 100644 --- a/docs/zh/rfcs/0014_memory_layer_design.md +++ b/docs/zh/rfcs/0014_memory_layer_design.md @@ -680,11 +680,13 @@ MVP 定义四种 mode: 低于阈值的候选不会为了填满 `limit` 而恢复。准入后的候选分别保持 backend 内部排序,再使用 reciprocal rank fusion: ```text -rrf_score(candidate) = Σ 1 / (60 + rank_in_channel) +hybrid_rrf_score(candidate) = Σ channel_weight / (60 + rank_in_channel) ``` -`rank_in_channel` 从 1 开始;单独的 `fts` 或 `vector` mode 也用单通道公式生成公开 score。被某通道拒绝或未被该通道 -召回的候选只贡献另一通道分数,`matched_by` 也只包含实际准入的通道。OceanBase adapter 可以使用 4.6+ 的原生 +`rank_in_channel` 从 1 开始。部署的 FTS/vector 权重为非负相对比例:PowerContext 会将其归一化到平均值 +为 1,默认均为 `1.0`,并要求至少一个为正数。权重只影响 `hybrid`;单独的 `fts` 或 `vector` mode 仍以 +`1 / (60 + rank)` 生成公开 score。零权重通道不产生候选,也不出现在 `matched_by` 中。被某通道拒绝或未被该通道召回的 +候选只贡献另一通道分数。OceanBase adapter 可以使用 4.6+ 的原生 `HYBRID_SEARCH` 优化,但对外必须保持 同样的 active-head 过滤、RRF 参数和稳定 tie-break;4.3.5 baseline 使用应用层 RRF。SQLite 使用 FTS5 与 Vec1 结果执行 相同的应用层 RRF。相同 RRF score 按 `memory_artifact_id`、UTF-8 `entry_id`、`entry_version_id` 升序打破平局。 diff --git a/docs/zh/rfcs/1417_topic_memory.md b/docs/zh/rfcs/1417_topic_memory.md index 92e0a9ef37..e09016c790 100644 --- a/docs/zh/rfcs/1417_topic_memory.md +++ b/docs/zh/rfcs/1417_topic_memory.md @@ -383,7 +383,9 @@ Chunk 策略变化需要重建对应检索投影。 每个检索通道先按 Topic collapse,同一 Topic 的多个 detail 命中只保留最佳位置用于 snippet。当前部署启用的 两个或四个通道通过 RRF 排名融合,不比较全文分数和向量距离的原始数值。最终一个 Topic 最多占一个结果位置,并保留 -`matched_by` 说明命中通道。每个通道都先按 Topic 折叠,再应用候选上限,因此单个 Topic 的大量命中 chunk 不会 +`matched_by` 说明实际贡献的命中通道。`hybrid` mode 对对应类型的 Topic 和 Detail 通道同时应用部署级归一化 FTS/vector 相对 +权重;零权重通道不产生候选,单独的 FTS 和 vector mode 仍不加权。开启通道的加权理论最大值作为归一化分母,使公开 score +保持在 `0..100`。每个通道都先按 Topic 折叠,再应用候选上限,因此单个 Topic 的大量命中 chunk 不会 遮蔽其他 Topic。全文 snippet 以 Analyzer v1 查询命中为中心截取有界窗口;只有向量命中时,则稳定截取其命中 chunk 内部的窗口。 首版搜索 limit 与历史候选配置统一限制为 20。查询最多 8,192 个字符、64 个不同的 Analyzer term;构造后端 diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 8b732152c4..c2f0fe9af7 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -2544,7 +2544,8 @@ "score": { "type": "number", "minimum": 0, - "maximum": 1 + "maximum": 1, + "description": "Raw RRF score. Hybrid mode applies the deployment's normalized relative FTS/vector weights." }, "matched_by": { "type": "array", diff --git a/integrations/dify/plugin/tools/pc_search.yaml b/integrations/dify/plugin/tools/pc_search.yaml index 9342a55c08..ff12144a81 100644 --- a/integrations/dify/plugin/tools/pc_search.yaml +++ b/integrations/dify/plugin/tools/pc_search.yaml @@ -164,6 +164,8 @@ output_schema: score: $ref: '#/definitions/Result/properties/hits/items/properties/score' type: number + description: Raw RRF score. Hybrid mode applies the deployment's normalized + relative FTS/vector weights. matched_by: $ref: '#/definitions/Result/properties/hits/items/properties/matched_by' type: array @@ -272,6 +274,8 @@ output_schema: type: number minimum: 0 maximum: 1 + description: Raw RRF score. Hybrid mode applies the deployment's normalized + relative FTS/vector weights. matched_by: type: array items: diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 2d28d4fc30..38bfcc5896 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -9509,6 +9509,7 @@ components: type: number minimum: 0 maximum: 1 + description: Raw RRF score. Hybrid mode applies the deployment's normalized relative FTS/vector weights. matched_by: type: array items: @@ -9534,6 +9535,8 @@ components: score: type: number minimum: 0 + maximum: 100 + description: Weighted RRF relevance normalized to the 0..100 range. matched_by: type: array minItems: 1 diff --git a/src/powercontext/builtin/artifacts/memory/fusion.py b/src/powercontext/builtin/artifacts/memory/fusion.py index 3182f6325a..84dd3c6d13 100644 --- a/src/powercontext/builtin/artifacts/memory/fusion.py +++ b/src/powercontext/builtin/artifacts/memory/fusion.py @@ -23,7 +23,7 @@ MemoryHit, MemoryMatchedBy, ) -from powercontext.builtin.artifacts.search import AdmissionFloor, admits_fts_text +from powercontext.builtin.artifacts.search import AdmissionFloor, RecallChannelWeights, admits_fts_text _RRF_CONSTANT = 60 _MIN_SEMANTIC_SIMILARITY = 0.3 @@ -69,8 +69,13 @@ def fuse_rankings( fts: Sequence[MemoryChannelHit], vector: Sequence[MemoryChannelHit], limit: int, + channel_weights: RecallChannelWeights | None = None, ) -> tuple[MemoryHit, ...]: - """Fuse ordered channel candidates with reciprocal rank fusion.""" + """Fuse ordered channel candidates with reciprocal rank fusion. + + ``channel_weights=None`` is the historical unweighted behavior used by the + single-channel modes. A caller supplies weights only for hybrid search. + """ if limit < 1: raise ValueError("memory search limit must be positive") # noqa: TRY003 @@ -79,7 +84,14 @@ def fuse_rankings( scores: dict[_HitIdentity, float] = {} channels: dict[_HitIdentity, set[MemoryMatchedBy]] = {} + weights: dict[MemoryMatchedBy, float] = { + "fts": 1.0 if channel_weights is None else channel_weights.fts, + "vector": 1.0 if channel_weights is None else channel_weights.vector, + } for channel, ranking in (("fts", fts), ("vector", vector)): + weight = weights[channel] + if weight == 0.0: + continue seen: set[_HitIdentity] = set() for rank, candidate in enumerate(ranking, start=1): identity = _identity(candidate) @@ -87,7 +99,7 @@ def fuse_rankings( continue seen.add(identity) candidates.setdefault(identity, candidate) - scores[identity] = scores.get(identity, 0.0) + 1.0 / (_RRF_CONSTANT + rank) + scores[identity] = scores.get(identity, 0.0) + weight / (_RRF_CONSTANT + rank) channels.setdefault(identity, set()).add(channel) ordered = sorted( @@ -107,6 +119,7 @@ def fuse_rankings( text=candidates[identity].text, score=scores[identity], matched_by=tuple(channel for channel in ("fts", "vector") if channel in channels[identity]), + score_upper_bound=sum(weights[channel] for channel in channels[identity]) / (_RRF_CONSTANT + 1), ) for identity in ordered ) diff --git a/src/powercontext/builtin/artifacts/memory/models.py b/src/powercontext/builtin/artifacts/memory/models.py index e7b5c553a6..3ff51034a3 100644 --- a/src/powercontext/builtin/artifacts/memory/models.py +++ b/src/powercontext/builtin/artifacts/memory/models.py @@ -205,7 +205,11 @@ class MemoryChannelHit(BaseModel): class MemoryHit(BaseModel): - """A fused retrieval result anchored to exact Memory content.""" + """A fused retrieval result anchored to exact Memory content. + + ``score_upper_bound`` is in-process fusion metadata used by the recall gate. It is + excluded from serialization and is not part of the public Memory hit contract. + """ memory_ref: ArtifactRef entry_id: str @@ -213,6 +217,7 @@ class MemoryHit(BaseModel): text: str score: float matched_by: tuple[MemoryMatchedBy, ...] + score_upper_bound: float | None = Field(default=None, gt=0.0, allow_inf_nan=False, exclude=True) class MemoryRerankTrace(BaseModel): diff --git a/src/powercontext/builtin/artifacts/memory/service.py b/src/powercontext/builtin/artifacts/memory/service.py index c77af62782..d189194d2c 100644 --- a/src/powercontext/builtin/artifacts/memory/service.py +++ b/src/powercontext/builtin/artifacts/memory/service.py @@ -99,7 +99,14 @@ ) from powercontext.builtin.artifacts.memory.reranking import MemoryReranker from powercontext.builtin.artifacts.prompt.service import ScopedPrompts, current_prompt, prompt_operation -from powercontext.builtin.artifacts.search import AdmissionCounts, AdmissionFloor, analyze_fts_query, analyze_text +from powercontext.builtin.artifacts.search import ( + DEFAULT_RECALL_CHANNEL_WEIGHTS, + AdmissionCounts, + AdmissionFloor, + RecallChannelWeights, + analyze_fts_query, + analyze_text, +) from powercontext.builtin.inference import ( EmbeddingModel, EmbeddingVector, @@ -263,6 +270,7 @@ def __init__( capacity_budget: MemoryCapacityBudget | None = None, compaction: MemoryCompactionPolicy | None = None, max_history_revisions: int = 100, + recall_channel_weights: RecallChannelWeights = DEFAULT_RECALL_CHANNEL_WEIGHTS, ) -> None: self._backend = backend self._prompt_context = prompt_context @@ -281,6 +289,7 @@ def __init__( if max_history_revisions < 1: raise _InvalidMemoryOperationError("history-limit") self._max_history_revisions = max_history_revisions + self._recall_channel_weights = recall_channel_weights # One entry, describing the projections of the most recently written Memory # revision. Revisions are immutable, so a hit is always valid for that exact # reference; a rebuilt or externally advanced Memory simply misses. @@ -763,6 +772,7 @@ async def search( fts=admitted_fts if selected_mode in {"fts", "hybrid"} else (), vector=admitted_vector if selected_mode in {"vector", "hybrid"} else (), limit=coarse_limit, + channel_weights=self._recall_channel_weights if selected_mode == "hybrid" else None, ) return await self._reranked_search_result( mode=selected_mode, diff --git a/src/powercontext/builtin/artifacts/search.py b/src/powercontext/builtin/artifacts/search.py index fb158749c2..2d2a0b5791 100644 --- a/src/powercontext/builtin/artifacts/search.py +++ b/src/powercontext/builtin/artifacts/search.py @@ -182,6 +182,42 @@ def lexical_search_score(raw: object, metric: object, /) -> tuple[float, Channel ) +@dataclass(frozen=True) +class RecallChannelWeights: + """Normalized relative weights for lexical and semantic recall channels. + + Inputs express a ratio. The stored weights have a mean of one, so scaling both + configured values by the same factor cannot change a public RRF score. + """ + + fts: float = 1.0 + vector: float = 1.0 + + def __post_init__(self) -> None: + if isinstance(self.fts, bool) or isinstance(self.vector, bool): + raise TypeError("recall channel weights must be numbers") # noqa: TRY003 + if not math.isfinite(self.fts) or not math.isfinite(self.vector): + raise ValueError("recall channel weights must be finite") # noqa: TRY003 + if self.fts < 0.0 or self.vector < 0.0: + raise ValueError("recall channel weights must be non-negative") # noqa: TRY003 + total = self.fts + self.vector + if not math.isfinite(total): + raise ValueError("recall channel weight total must be finite") # noqa: TRY003 + if total <= 0.0: + raise ValueError("at least one recall channel weight must be positive") # noqa: TRY003 + # Divide each input before scaling so a finite, positive subnormal total + # cannot overflow through an intermediate ``2.0 / total`` value. + normalized_fts = 2.0 * (self.fts / total) + normalized_vector = 2.0 * (self.vector / total) + if not math.isfinite(normalized_fts) or not math.isfinite(normalized_vector): + raise ValueError("normalized recall channel weights must be finite") # noqa: TRY003 + object.__setattr__(self, "fts", normalized_fts) + object.__setattr__(self, "vector", normalized_vector) + + +DEFAULT_RECALL_CHANNEL_WEIGHTS = RecallChannelWeights() + + @dataclass(frozen=True) class AdmissionCounts: """Per-family, per-scope admission accounting for one search. diff --git a/src/powercontext/builtin/persistence/topic_memory.py b/src/powercontext/builtin/persistence/topic_memory.py index 9fb0c2cf3f..e7039acf42 100644 --- a/src/powercontext/builtin/persistence/topic_memory.py +++ b/src/powercontext/builtin/persistence/topic_memory.py @@ -25,10 +25,18 @@ from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import ArtifactAddress, ArtifactRef +from powercontext.artifacts.fusion import RrfParameters from powercontext.artifacts.search import ArtifactSearchContractError, ArtifactSearchMatch from powercontext.builtin.artifacts.memory import EmbeddingProfile from powercontext.builtin.artifacts.memory.canonical import canonical_embedding -from powercontext.builtin.artifacts.search import AdmissionCounts, AdmissionFloor, analyze_fts_query, analyze_text +from powercontext.builtin.artifacts.search import ( + DEFAULT_RECALL_CHANNEL_WEIGHTS, + AdmissionCounts, + AdmissionFloor, + RecallChannelWeights, + analyze_fts_query, + analyze_text, +) from powercontext.builtin.artifacts.topic_memory import ( MAX_TOPIC_MEMORY_CHANNEL_CANDIDATES, MAX_TOPIC_MEMORY_QUERY_LENGTH, @@ -80,9 +88,11 @@ def __init__( *, artifacts: ArtifactRepository | None = None, index: TopicMemoryIndex | None = None, + recall_channel_weights: RecallChannelWeights = DEFAULT_RECALL_CHANNEL_WEIGHTS, ) -> None: self.artifacts = ArtifactRepository((TopicMemory,)) if artifacts is None else artifacts self.index = NoTopicMemoryIndex() if index is None else index + self.recall_channel_weights = recall_channel_weights async def initialize(self, connection: AsyncConnection, /, *, configure_retrieval_shape: bool = True) -> None: """Initialize indexes and reject incomplete historical Topic projections. @@ -470,11 +480,22 @@ async def search( ) analyzed = analyze_text(query) used_mode = self._select_mode(mode, query_vector, embedding_profile) - fusion = None + fusion: RrfParameters | None = None if artifact_request is not None: - fusion = topic_fusion_parameters(artifact_request.fusion) - validate_topic_weights(used_mode, fusion) admission = artifact_request.admission.as_floor() + if artifact_request.fusion is not None: + fusion = topic_fusion_parameters(artifact_request.fusion) + if fusion is None and used_mode == "hybrid": + fusion = RrfParameters( + weights={ + "topic_fts": self.recall_channel_weights.fts, + "detail_fts": self.recall_channel_weights.fts, + "topic_vector": self.recall_channel_weights.vector, + "detail_vector": self.recall_channel_weights.vector, + } + ) + if fusion is not None: + validate_topic_weights(used_mode, fusion) query_terms = tuple(sorted(set(analyzed.split()))) if used_mode in {"fts", "hybrid"} and len(query_terms) > MAX_TOPIC_MEMORY_QUERY_TERMS: raise InvalidRepositoryArgumentError( diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 7b716e7e4f..39bcc9d7e5 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -977,6 +977,7 @@ async def open_builtin_contexts( min_tombstone_revisions=config.runtime.memory_compaction_min_tombstone_revisions, ), memory_max_history_revisions=config.runtime.memory_max_history_revisions, + recall_channel_weights=config.runtime.recall_channel_weights, prompt_registry=prompt_registry, prompt_demonstrators=prompt_demonstrators, handoff_verification_keys=handoff_verification_keys, @@ -1054,6 +1055,7 @@ async def open_builtin_contexts( min_tombstone_revisions=config.runtime.memory_compaction_min_tombstone_revisions, ), memory_max_history_revisions=config.runtime.memory_max_history_revisions, + recall_channel_weights=config.runtime.recall_channel_weights, prompt_registry=prompt_registry, prompt_demonstrators=prompt_demonstrators, handoff_verification_keys=handoff_verification_keys, diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 049057e7c2..34dabf716b 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -16,6 +16,7 @@ from __future__ import annotations +import math import re from collections.abc import Mapping from typing import Any, Literal, Self @@ -33,6 +34,7 @@ ) from powercontext.builtin.artifacts.memory.prompts import MemoryExtractionProfile +from powercontext.builtin.artifacts.search import RecallChannelWeights from powercontext.builtin.artifacts.skill import AgentSkillTarget, CodexSkillRoot from powercontext.builtin.artifacts.topic_memory import MAX_TOPIC_MEMORY_SEARCH_LIMIT from powercontext.builtin.artifacts.topic_memory.generation import ( @@ -170,6 +172,8 @@ def validate_memory_capacity_order(self) -> RuntimeConfig: recall_gate_round1_min_semantic_similarity: float = Field(default=0.15, ge=0.0, le=1.0) recall_gate_round2_min_semantic_similarity: float = Field(default=0.10, ge=0.0, le=1.0) recall_gate_allow_with_rerank: bool = False + recall_fts_weight: float = Field(default=1.0, ge=0.0, allow_inf_nan=False) + recall_vector_weight: float = Field(default=1.0, ge=0.0, allow_inf_nan=False) profile_schedule_enabled: bool = False profile_cron: str = "0 2 * * *" profile_timezone: str = "Asia/Shanghai" @@ -199,6 +203,28 @@ def validate_recall_gate_threshold_order(self): raise ValueError(_RECALL_GATE_ROUND2_ORDER_ERROR) return self + @field_validator("recall_fts_weight", "recall_vector_weight", mode="before") + @classmethod + def reject_boolean_recall_weight(cls, value: Any) -> Any: + if isinstance(value, bool): + raise ValueError("recall channel weights must be numbers") # noqa: TRY003, TRY004 - Pydantic validation + return value + + @model_validator(mode="after") + def validate_recall_channel_weights(self) -> RuntimeConfig: + total = self.recall_fts_weight + self.recall_vector_weight + if not math.isfinite(total): + raise ValueError("recall channel weight total must be finite") # noqa: TRY003 + if total <= 0.0: + raise ValueError("at least one recall channel weight must be positive") # noqa: TRY003 + return self + + @property + def recall_channel_weights(self) -> RecallChannelWeights: + """Return the deployment's normalized FTS/vector recall ratio.""" + + return RecallChannelWeights(fts=self.recall_fts_weight, vector=self.recall_vector_weight) + schedule_seconds: float | None = Field(default=None, gt=0) memory_schedule_seconds: float | None = Field(default=None, gt=0) experience_schedule_seconds: float | None = Field(default=None, gt=0) diff --git a/src/powercontext/builtin/runtime/recall_sufficiency.py b/src/powercontext/builtin/runtime/recall_sufficiency.py index 4fb2c45af0..c78e6f6adb 100644 --- a/src/powercontext/builtin/runtime/recall_sufficiency.py +++ b/src/powercontext/builtin/runtime/recall_sufficiency.py @@ -403,10 +403,15 @@ def build_recall_candidates( def _normalize_memory_score(hit: MemoryHit) -> float: - """Normalize an RRF score against its analytic per-channel-count upper bound.""" - - channels = max(1, len(hit.matched_by)) - upper_bound = channels / (_RRF_CONSTANT + 1) + """Normalize an RRF score against the upper bound used by its fusion.""" + + upper_bound = hit.score_upper_bound + if upper_bound is None: + # Compatibility fallback for unweighted or externally reconstructed hits. + # The internal weighted bound is excluded from serialization, so an in-process + # weighted fusion must supply it; the exact weighted scale cannot be recovered here. + channels = max(1, len(hit.matched_by)) + upper_bound = channels / (_RRF_CONSTANT + 1) if upper_bound <= 0.0: return 0.0 return max(0.0, min(1.0, hit.score / upper_bound)) diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 0ad5fb2fe7..80675aa48e 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -85,7 +85,11 @@ current_prompt, prompt_operation, ) -from powercontext.builtin.artifacts.search import AdmissionFloor +from powercontext.builtin.artifacts.search import ( + DEFAULT_RECALL_CHANNEL_WEIGHTS, + AdmissionFloor, + RecallChannelWeights, +) from powercontext.builtin.artifacts.skill import ( ExternalSkillProvider, ExternalSkillRegistryUnavailableError, @@ -318,6 +322,7 @@ class _ScopedServices: memory_capacity_budget: MemoryCapacityBudget memory_compaction: MemoryCompactionPolicy memory_max_history_revisions: int + recall_channel_weights: RecallChannelWeights id_factory: IdFactory handoff_artifact_id: str memory_artifact_id: str @@ -367,6 +372,7 @@ def memory( capacity_budget=self.memory_capacity_budget, compaction=self.memory_compaction, max_history_revisions=self.memory_max_history_revisions, + recall_channel_weights=self.recall_channel_weights, source_resolver=_RelationalMemorySourceResolver( database=self.database, scope_id=self.scope_id, @@ -552,6 +558,7 @@ def __init__( memory_capacity_budget: MemoryCapacityBudget | None = None, memory_compaction: MemoryCompactionPolicy | None = None, memory_max_history_revisions: int = 100, + recall_channel_weights: RecallChannelWeights = DEFAULT_RECALL_CHANNEL_WEIGHTS, id_factory: IdFactory | None = None, handoff_artifact_id: str = "handoff", memory_artifact_id: str = "memory", @@ -578,7 +585,11 @@ def __init__( (Handoff, Memory, Experience, Skill, Profile, Prompt, TopicMemory), sources=source_repository, ) - topic_memory_repository = TopicMemoryRepository(artifacts=artifact_repository, index=self.topic_memory_index) + topic_memory_repository = TopicMemoryRepository( + artifacts=artifact_repository, + index=self.topic_memory_index, + recall_channel_weights=recall_channel_weights, + ) self.repositories = _Repositories( sources=source_repository, artifacts=artifact_repository, @@ -719,6 +730,7 @@ def __init__( ) self._memory_compaction = MemoryCompactionPolicy() if memory_compaction is None else memory_compaction self._memory_max_history_revisions = memory_max_history_revisions + self._recall_channel_weights = recall_channel_weights self._handoff_artifact_id = handoff_artifact_id self._memory_artifact_id = memory_artifact_id self._tracing = tracing @@ -1495,6 +1507,7 @@ def _services_for(self, scope_id: str) -> _ScopedServices: memory_capacity_budget=self._memory_capacity_budget, memory_compaction=self._memory_compaction, memory_max_history_revisions=self._memory_max_history_revisions, + recall_channel_weights=self._recall_channel_weights, id_factory=self._id_factory, handoff_artifact_id=self._handoff_artifact_id, memory_artifact_id=self._memory_artifact_id, diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 271c5a2c21..46ca569695 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -3807,7 +3807,14 @@ class SearchMemoryHit(BaseModel): ) citation: MemoryCitation text: StrictStr - score: Annotated[StrictFloat, Field(ge=0.0, le=1.0)] + score: Annotated[ + StrictFloat, + Field( + description="Raw RRF score. Hybrid mode applies the deployment's normalized relative FTS/vector weights.", + ge=0.0, + le=1.0, + ), + ] matched_by: list[MemoryMatchedBy] @@ -3819,7 +3826,9 @@ class SearchTopicMemoryHit(BaseModel): title: Annotated[StrictStr, Field(max_length=512, min_length=1)] summary: Annotated[StrictStr, Field(max_length=8000, min_length=1)] snippet: Annotated[StrictStr | None, Field(...)] - score: Annotated[StrictFloat, Field(ge=0.0)] + score: Annotated[ + StrictFloat, Field(description="Weighted RRF relevance normalized to the 0..100 range.", ge=0.0, le=100.0) + ] matched_by: Annotated[list[TopicMemoryMatchedBy], Field(min_length=1)] diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 8ec76d7915..62eab792e8 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -9061,7 +9061,22 @@ "properties": { "citation": {"$ref": "#/components/schemas/MemoryCitation"}, "text": {"type": "string"}, - "score": {"type": "number", "maximum": 1.0, "minimum": 0.0}, + "score": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "description": "Raw RRF " + "score. " + "Hybrid " + "mode " + "applies " + "the " + "deployment's " + "normalized " + "relative " + "FTS/vector " + "weights.", + }, "matched_by": {"items": {"$ref": "#/components/schemas/MemoryMatchedBy"}, "type": "array"}, }, "additionalProperties": False, @@ -9074,7 +9089,12 @@ "title": {"type": "string", "maxLength": 512, "minLength": 1}, "summary": {"type": "string", "maxLength": 8000, "minLength": 1}, "snippet": {"type": "string", "nullable": True}, - "score": {"type": "number", "minimum": 0.0}, + "score": { + "type": "number", + "maximum": 100.0, + "minimum": 0.0, + "description": "Weighted RRF relevance normalized to the 0..100 range.", + }, "matched_by": { "items": {"$ref": "#/components/schemas/TopicMemoryMatchedBy"}, "type": "array", diff --git a/tests/builtin/artifacts/memory/test_fusion.py b/tests/builtin/artifacts/memory/test_fusion.py index 168b5d5c25..b64b923252 100644 --- a/tests/builtin/artifacts/memory/test_fusion.py +++ b/tests/builtin/artifacts/memory/test_fusion.py @@ -23,7 +23,7 @@ admit_vector_candidates, fuse_rankings, ) -from powercontext.builtin.artifacts.search import AdmissionFloor +from powercontext.builtin.artifacts.search import AdmissionFloor, RecallChannelWeights def channel_hit( @@ -73,6 +73,49 @@ def test_duplicate_channel_rows_contribute_only_the_first_rank() -> None: assert hits[0].score == pytest.approx(1 / 61) +@pytest.mark.parametrize( + ("fts_weight", "vector_weight", "expected_order"), + [ + (3.0, 1.0, ("lexical", "semantic")), + (1.0, 3.0, ("semantic", "lexical")), + ], +) +def test_hybrid_weights_prefer_the_higher_weight_channel( + fts_weight: float, + vector_weight: float, + expected_order: tuple[str, str], +) -> None: + lexical = channel_hit("lexical") + semantic = channel_hit("semantic") + + hits = fuse_rankings( + fts=(lexical, semantic), + vector=(semantic, lexical), + limit=2, + channel_weights=RecallChannelWeights(fts=fts_weight, vector=vector_weight), + ) + + assert tuple(hit.entry_id for hit in hits) == expected_order + assert hits[0].score_upper_bound == pytest.approx(2 / 61) + + +def test_zero_weight_excludes_only_that_channel_and_its_matches() -> None: + lexical_only = channel_hit("lexical-only") + semantic_only = channel_hit("semantic-only") + + hits = fuse_rankings( + fts=(lexical_only,), + vector=(semantic_only,), + limit=2, + channel_weights=RecallChannelWeights(fts=0.0, vector=1.0), + ) + + assert tuple(hit.entry_id for hit in hits) == ("semantic-only",) + assert hits[0].matched_by == ("vector",) + assert hits[0].score == pytest.approx(2 / 61) + assert hits[0].score_upper_bound == pytest.approx(2 / 61) + + def test_limit_must_be_positive() -> None: with pytest.raises(ValueError, match="positive"): fuse_rankings(fts=(), vector=(), limit=0) diff --git a/tests/builtin/artifacts/memory/test_service.py b/tests/builtin/artifacts/memory/test_service.py index 7e7a5295fd..99cb4c7925 100644 --- a/tests/builtin/artifacts/memory/test_service.py +++ b/tests/builtin/artifacts/memory/test_service.py @@ -18,6 +18,7 @@ from contextlib import suppress from typing import cast +import pytest from sqlalchemy import event, text from powercontext.builtin.artifacts.memory import ( @@ -25,6 +26,7 @@ Memory, MemoryBackend, MemoryCapabilities, + MemoryChannelHit, MemoryCommit, MemoryContent, MemoryEntryInput, @@ -37,6 +39,7 @@ MemoryService, ) from powercontext.builtin.artifacts.memory.canonical import entry_content_hash, memory_content_hash +from powercontext.builtin.artifacts.search import RecallChannelWeights from powercontext.builtin.inference import EmbeddingResult, InferenceUsage from powercontext.builtin.persistence.memory import RelationalMemoryBackend from powercontext.builtin.persistence.sqlite import SQLiteConfig @@ -79,8 +82,9 @@ async def embed_query(self, texts: tuple[str, ...], /) -> EmbeddingResult: class _QuerySearchBackend: - def __init__(self, memory: Memory) -> None: + def __init__(self, memory: Memory, channels: MemorySearchChannels | None = None) -> None: self._memory = memory + self._channels = MemorySearchChannels() if channels is None else channels self.query_vector: tuple[float, ...] | None = None async def capabilities(self) -> MemoryCapabilities: @@ -97,7 +101,7 @@ async def vector_complete(self, _memories, _profile) -> bool: async def search(self, request, /) -> MemorySearchChannels: self.query_vector = request.query_vector - return MemorySearchChannels() + return self._channels def test_memory_vector_search_uses_query_embedding_path() -> None: @@ -121,6 +125,45 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_memory_search_applies_channel_weights_only_in_hybrid_mode() -> None: + memory = Memory( + artifact_id="memory", + revision=1, + content=MemoryContent(manifest=MemoryManifest(entries=())), + ) + lexical = MemoryChannelHit( + memory_ref=memory.as_ref(), + entry_id="lexical", + entry_version_id="lexical-v1", + text="project lexical evidence", + ) + semantic = MemoryChannelHit( + memory_ref=memory.as_ref(), + entry_id="semantic", + entry_version_id="semantic-v1", + text="project semantic evidence", + distance=0.1, + ) + + async def scenario() -> None: + backend = _QuerySearchBackend(memory, MemorySearchChannels(fts=(lexical,), vector=(semantic,))) + service = MemoryService( + backend=cast(MemoryBackend, backend), + embedding_model=_QueryRecordingEmbedding(), + recall_channel_weights=RecallChannelWeights(fts=0.0, vector=1.0), + ) + + fts = await service.search("project", memories=(memory,), mode="fts") + hybrid = await service.search("project", memories=(memory,), mode="hybrid") + + assert tuple(hit.entry_id for hit in fts.hits) == ("lexical",) + assert fts.hits[0].score == pytest.approx(1 / 61) + assert tuple(hit.entry_id for hit in hybrid.hits) == ("semantic",) + assert hybrid.hits[0].score == pytest.approx(2 / 61) + + asyncio.run(scenario()) + + def test_memory_search_applies_injected_reranker_after_coarse_fusion() -> None: async def scenario() -> None: reranker = _SelectingReranker() diff --git a/tests/builtin/artifacts/topic_memory/test_fusion.py b/tests/builtin/artifacts/topic_memory/test_fusion.py index 98a0775e5f..2221198ea0 100644 --- a/tests/builtin/artifacts/topic_memory/test_fusion.py +++ b/tests/builtin/artifacts/topic_memory/test_fusion.py @@ -17,6 +17,7 @@ import pytest from powercontext.artifacts import ArtifactRef +from powercontext.artifacts.fusion import RrfParameters from powercontext.builtin.artifacts.topic_memory import ( TopicMemoryChannelHit, TopicMemoryMatchedBy, @@ -185,6 +186,85 @@ def test_rrf_scores_are_normalized_against_the_enabled_first_place_channels() -> assert all_channels[0].score == pytest.approx(100.0) +@pytest.mark.parametrize( + ("fts_weight", "vector_weight", "expected_order"), + [ + (3.0, 1.0, ("lexical", "semantic")), + (1.0, 3.0, ("semantic", "lexical")), + ], +) +def test_hybrid_weights_prefer_the_higher_weight_channel_without_saturating_scores( + fts_weight: float, + vector_weight: float, + expected_order: tuple[str, str], +) -> None: + lexical = TopicMemoryChannelHit( + artifact_ref=ArtifactRef(family="topic-memory", artifact_id="lexical", revision=1), + title="Lexical topic", + summary="needle", + channel="topic_fts", + ) + semantic = TopicMemoryChannelHit( + artifact_ref=ArtifactRef(family="topic-memory", artifact_id="semantic", revision=1), + title="Semantic topic", + summary="needle", + channel="topic_vector", + distance=0.1, + ) + + result = _fuse_topic_memory_rankings( + "needle", + TopicMemorySearchChannels(topic_fts=(lexical,), topic_vector=(semantic,)), + 2, + fusion=RrfParameters( + weights={ + "topic_fts": fts_weight, + "detail_fts": fts_weight, + "topic_vector": vector_weight, + "detail_vector": vector_weight, + } + ), + ).hits + + assert tuple(hit.artifact_ref.artifact_id for hit in result) == expected_order + assert result[0].score == pytest.approx(37.5) + assert result[1].score == pytest.approx(12.5) + assert all(0.0 < hit.score < 100.0 for hit in result) + + +def test_zero_weight_drops_topic_matches_from_only_that_channel() -> None: + lexical = TopicMemoryChannelHit( + artifact_ref=ArtifactRef(family="topic-memory", artifact_id="lexical", revision=1), + title="Lexical topic", + summary="needle", + channel="topic_fts", + ) + semantic = TopicMemoryChannelHit( + artifact_ref=ArtifactRef(family="topic-memory", artifact_id="semantic", revision=1), + title="Semantic topic", + summary="needle", + channel="topic_vector", + distance=0.1, + ) + + result = _fuse_topic_memory_rankings( + "needle", + TopicMemorySearchChannels(topic_fts=(lexical,), topic_vector=(semantic,)), + 2, + fusion=RrfParameters( + weights={ + "topic_fts": 0.0, + "detail_fts": 0.0, + "topic_vector": 1.0, + "detail_vector": 1.0, + } + ), + ).hits + + assert tuple(hit.artifact_ref.artifact_id for hit in result) == ("semantic",) + assert result[0].matched_by == ("topic_vector",) + + def test_fusion_outcome_counts_retrieved_and_admitted_over_enabled_channels() -> None: def hit( channel: TopicMemoryMatchedBy, diff --git a/tests/builtin/persistence/test_topic_memory.py b/tests/builtin/persistence/test_topic_memory.py index d7c4c5433a..2588348900 100644 --- a/tests/builtin/persistence/test_topic_memory.py +++ b/tests/builtin/persistence/test_topic_memory.py @@ -25,15 +25,19 @@ from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import ArtifactRef +from powercontext.artifacts.fusion import FusionSelection from powercontext.builtin.artifacts.memory import EmbeddingProfile -from powercontext.builtin.artifacts.search import AdmissionFloor, fts_match_query +from powercontext.builtin.artifacts.search import AdmissionFloor, RecallChannelWeights, fts_match_query from powercontext.builtin.artifacts.topic_memory import ( MAX_TOPIC_MEMORY_QUERY_LENGTH, MAX_TOPIC_MEMORY_SEARCH_LIMIT, TOPIC_MEMORY_CHUNK_MAX_COUNT, + TopicArtifactSearchRequest, TopicMemory, TopicMemoryBrowseCursor, TopicMemoryCapabilityError, + TopicMemoryCapabilities, + TopicMemoryChannelHit, TopicMemoryChunk, TopicMemoryContent, TopicMemoryDraft, @@ -140,8 +144,6 @@ def _fts_index() -> CompositeTopicMemoryIndex: def test_public_topic_search_keeps_signed_fts_metadata_and_exact_artifacts() -> None: - from powercontext.builtin.artifacts.topic_memory import TopicArtifactSearchRequest - async def scenario() -> None: index = _fts_index() repository = TopicMemoryRepository(index=index) @@ -185,6 +187,137 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_topic_search_uses_deployment_weights_unless_the_request_overrides_them( + monkeypatch: pytest.MonkeyPatch, +) -> None: + embedding_profile = EmbeddingProfile(profile_id="topic-test-v1", model="test", dimension=2) + lexical_ref = ArtifactRef(family="topic-memory", artifact_id="lexical", revision=1) + semantic_ref = ArtifactRef(family="topic-memory", artifact_id="semantic", revision=1) + channels = TopicMemorySearchChannels( + topic_fts=( + TopicMemoryChannelHit( + artifact_ref=lexical_ref, + title="Needle lexical topic", + summary="Needle lexical evidence", + channel="topic_fts", + ), + ), + topic_vector=( + TopicMemoryChannelHit( + artifact_ref=semantic_ref, + title="Semantic topic", + summary="Semantic evidence", + channel="topic_vector", + distance=0.1, + ), + ), + ) + + class StaticIndex: + capabilities = TopicMemoryCapabilities( + fts=True, + vector=True, + hybrid=True, + embedding_profile=embedding_profile, + ) + tables = () + + async def initialize(self, _connection: AsyncConnection, /) -> None: + pass + + async def validate_current(self, _connection: AsyncConnection, /) -> None: + pass + + async def replace( + self, + _connection: AsyncConnection, + _scope_id: str, + _topic_ref: ArtifactRef, + _projection: TopicMemoryProjection, + /, + ) -> None: + pass + + async def search( + self, + _connection: AsyncConnection, + _scope_id: str, + _request: TopicMemorySearchRequest, + /, + ) -> TopicMemorySearchChannels: + return channels + + async def vector_complete( + self, + _connection: AsyncConnection, + _scope_id: str, + _topic_ref: ArtifactRef, + /, + ) -> bool: + return True + + topics = { + ref.artifact_id: TopicMemory( + artifact_id=ref.artifact_id, + revision=ref.revision, + content=TopicMemoryContent(title=ref.artifact_id, summary="Evidence", detail="Evidence detail"), + ) + for ref in (lexical_ref, semantic_ref) + } + repository = TopicMemoryRepository( + index=StaticIndex(), + recall_channel_weights=RecallChannelWeights(fts=3.0, vector=1.0), + ) + + async def get_topic(_connection: AsyncConnection, _scope_id: str, ref: ArtifactRef) -> TopicMemory: + return topics[ref.artifact_id] + + monkeypatch.setattr(repository.artifacts, "get", get_topic) + + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + async with profile.database.transaction() as connection: + await repository.initialize(connection) + deployment_weighted = await repository.search( + connection, + "scope-a", + "needle", + limit=2, + mode="hybrid", + query_vector=(1.0, 0.0), + embedding_profile=embedding_profile, + ) + request_weighted = await repository.search( + connection, + "scope-a", + "needle", + limit=2, + mode="hybrid", + query_vector=(1.0, 0.0), + embedding_profile=embedding_profile, + artifact_request=TopicArtifactSearchRequest( + query="needle", + mode="hybrid", + fusion=FusionSelection( + method="rrf", + params={ + "weights": { + "topic_fts": 1.0, + "detail_fts": 1.0, + "topic_vector": 3.0, + "detail_vector": 3.0, + } + }, + ), + ), + ) + + assert tuple(hit.artifact_ref for hit in deployment_weighted.hits) == (lexical_ref, semantic_ref) + assert tuple(hit.artifact_ref for hit in request_weighted.hits) == (semantic_ref, lexical_ref) + + asyncio.run(scenario()) + + def test_public_topic_missing_selected_exact_revision_is_a_contract_failure(monkeypatch) -> None: from powercontext.artifacts.search import ArtifactSearchContractError from powercontext.builtin.artifacts.topic_memory import TopicArtifactSearchRequest diff --git a/tests/builtin/runtime/test_recall_channel_weights.py b/tests/builtin/runtime/test_recall_channel_weights.py new file mode 100644 index 0000000000..f7a3a24ac4 --- /dev/null +++ b/tests/builtin/runtime/test_recall_channel_weights.py @@ -0,0 +1,122 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import math +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from powercontext.builtin.artifacts.search import RecallChannelWeights +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import BuiltinConfig, RuntimeConfig, composition +from powercontext.server.settings import ServerSettings + + +def test_recall_channel_weights_default_to_the_historical_equal_ratio() -> None: + config = RuntimeConfig() + + assert config.recall_fts_weight == 1.0 + assert config.recall_vector_weight == 1.0 + assert config.recall_channel_weights.fts == 1.0 + assert config.recall_channel_weights.vector == 1.0 + + +def test_recall_channel_weights_are_normalized_as_a_relative_ratio() -> None: + weights = RuntimeConfig(recall_fts_weight=3.0, recall_vector_weight=1.0).recall_channel_weights + scaled = RuntimeConfig(recall_fts_weight=300.0, recall_vector_weight=100.0).recall_channel_weights + + assert weights.fts == pytest.approx(1.5) + assert weights.vector == pytest.approx(0.5) + assert scaled == weights + + +def test_recall_channel_weights_support_the_reverse_ratio() -> None: + weights = RuntimeConfig(recall_fts_weight=1.0, recall_vector_weight=3.0).recall_channel_weights + + assert weights.fts == pytest.approx(0.5) + assert weights.vector == pytest.approx(1.5) + + +def test_recall_channel_weights_normalize_finite_subnormal_values() -> None: + weights = RuntimeConfig(recall_fts_weight=1e-310, recall_vector_weight=1e-310).recall_channel_weights + + assert math.isfinite(weights.fts) + assert math.isfinite(weights.vector) + assert weights.fts == pytest.approx(1.0) + assert weights.vector == pytest.approx(1.0) + + +def test_recall_channel_weight_total_overflow_reports_the_actual_cause() -> None: + with pytest.raises(ValueError, match="weight total must be finite"): + RecallChannelWeights(fts=1e308, vector=1e308) + + with pytest.raises(ValidationError, match="weight total must be finite"): + RuntimeConfig(recall_fts_weight=1e308, recall_vector_weight=1e308) + + +def test_composition_passes_recall_weights_to_regular_and_topic_worker_contexts( + tmp_path: Path, +) -> None: + runtime_config = RuntimeConfig(recall_fts_weight=1.0, recall_vector_weight=3.0) + config = BuiltinConfig( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'recall-weights.db'}"), + runtime=runtime_config, + ) + expected = runtime_config.recall_channel_weights + + async def scenario() -> None: + async with composition.open_builtin_contexts(config) as contexts: + assert contexts.repositories.topic_memories.recall_channel_weights == expected + async with composition.open_builtin_contexts(config, _topic_memory_worker=True) as worker_contexts: + assert worker_contexts.repositories.topic_memories.recall_channel_weights == expected + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + ("fts", "vector"), + [ + (-1.0, 1.0), + (1.0, -1.0), + (math.nan, 1.0), + (math.inf, 1.0), + (-math.inf, 1.0), + (1.0, math.nan), + (1.0, math.inf), + (1.0, -math.inf), + (0.0, 0.0), + (1e308, 1e308), + (True, 1.0), + (1.0, False), + ], +) +def test_invalid_recall_channel_weights_are_rejected_at_configuration_time(fts: object, vector: object) -> None: + with pytest.raises(ValidationError): + RuntimeConfig.model_validate({"recall_fts_weight": fts, "recall_vector_weight": vector}) + + +def test_recall_channel_weights_load_from_the_server_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_FTS_WEIGHT", "3") + monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_VECTOR_WEIGHT", "1") + + runtime = ServerSettings().runtime + + assert runtime.recall_fts_weight == 3.0 + assert runtime.recall_vector_weight == 1.0 + assert runtime.recall_channel_weights.fts == pytest.approx(1.5) + assert runtime.recall_channel_weights.vector == pytest.approx(0.5) diff --git a/tests/builtin/runtime/test_recall_sufficiency.py b/tests/builtin/runtime/test_recall_sufficiency.py index f25debf30b..4b90792ee2 100644 --- a/tests/builtin/runtime/test_recall_sufficiency.py +++ b/tests/builtin/runtime/test_recall_sufficiency.py @@ -123,7 +123,13 @@ def _experience_candidate(text: str, *, artifact_id: str = "experience") -> Reca ) -def _memory_hit(*, score: float, matched_by: tuple[MemoryMatchedBy, ...], text: str = "alpha beta") -> MemoryHit: +def _memory_hit( + *, + score: float, + matched_by: tuple[MemoryMatchedBy, ...], + text: str = "alpha beta", + score_upper_bound: float | None = None, +) -> MemoryHit: return MemoryHit( memory_ref=MEMORY_REF, entry_id="entry", @@ -131,6 +137,7 @@ def _memory_hit(*, score: float, matched_by: tuple[MemoryMatchedBy, ...], text: text=text, score=score, matched_by=matched_by, + score_upper_bound=score_upper_bound, ) @@ -478,6 +485,22 @@ def test_memory_two_channel_hit_normalizes_against_its_wider_upper_bound() -> No assert candidates[0].score == pytest.approx(0.5) +def test_memory_weighted_hit_normalizes_against_the_fusion_upper_bound() -> None: + candidates = build_recall_candidates( + memory_hits=( + _memory_hit( + score=1.5 / 61, + matched_by=("fts",), + score_upper_bound=1.5 / 61, + ), + ), + topic_memory_hits=(), + experience_hits=(), + ) + + assert candidates[0].score == pytest.approx(1.0) + + # ── lexical_overlap semantics ─────────────────────────────────────────────────────────────── From 542efaa9b96757e57a4cec4961db3112c744d883 Mon Sep 17 00:00:00 2001 From: lkx Date: Fri, 9 Oct 2026 13:04:13 +0800 Subject: [PATCH 2/4] fix(recall): reject channel weight ratios that cannot survive RRF normalization --- docs/en/docs/operate/configuration.md | 8 +- docs/zh/docs/operate/configuration.md | 6 +- src/powercontext/builtin/artifacts/search.py | 10 ++ src/powercontext/builtin/runtime/config.py | 7 +- .../runtime/test_recall_channel_weights.py | 165 +++++++++++++++++- 5 files changed, 183 insertions(+), 13 deletions(-) diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index cce9a78fba..f2eda08805 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -159,9 +159,11 @@ The recall channel weights are a ratio and apply only after hybrid candidates pa Scaling both values equally has no effect. Setting one value to `0` excludes that channel from hybrid fusion, but a higher weight cannot restore a candidate rejected before fusion. A zero weight does not disable that channel's retrieval or admission work: vector embeddings may still be generated, and retrieval/admission counts still include -the zero-weight channel. Evaluate weight changes together with recall-gate rounds, semantic admission floors, and -historical Topic candidate selection instead of treating the knobs independently. In particular, weighting FTS more -heavily can move a Topic's normalized score across +the zero-weight channel. After normalization, each non-zero channel weight must be at least the smallest normal IEEE +754 binary64 value (`2.2250738585072014e-308`); smaller values are rejected at startup. Only an explicit `0` disables +a channel's fusion contribution. Evaluate weight changes together with recall-gate rounds, semantic admission floors, +and historical Topic candidate selection instead of treating the knobs independently. In particular, weighting FTS +more heavily can move a Topic's normalized score across `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_HISTORY_RRF_THRESHOLD` (default `70`) and change which historical Topics are sent to the planner. diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index e486f653ad..86f4e1e81d 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -153,8 +153,10 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 召回通道权重表示相对比例,只在 hybrid 候选通过各通道准入下限后生效。同比例缩放两个值不会改变结果。将其中一个设为 `0` 会从 hybrid 融合中排除该通道,但提高权重不能恢复融合前已被拒绝的候选。零权重不会关闭该通道的检索和准入工作: -vector 查询仍可能生成 embedding,检索与准入计数也仍包含零权重通道。调整权重时应同时评估 recall gate 轮次、 -语义准入下限和历史 Topic 候选选择,不要将这些配置孤立调整。特别是,提高 FTS 权重可能使 Topic 的归一化分数跨过 +vector 查询仍可能生成 embedding,检索与准入计数也仍包含零权重通道。归一化后,每个非零通道权重必须不低于 IEEE 754 +binary64 最小正规数(`2.2250738585072014e-308`),更小的值会在启动时被拒绝。只有显式配置 `0` 才表示关闭该通道的 +融合贡献。调整权重时应同时评估 recall gate 轮次、语义准入下限和历史 Topic 候选选择,不要将这些配置孤立调整。特别是, +提高 FTS 权重可能使 Topic 的归一化分数跨过 `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_HISTORY_RRF_THRESHOLD`(默认为 `70`),从而改变传给规划器的历史 Topic 集合。 召回充分性门控默认关闭。启用后,Runtime 在 `prepare_context` 阶段评估首轮候选的数量、来源家族覆盖、最优候选分数和词法 diff --git a/src/powercontext/builtin/artifacts/search.py b/src/powercontext/builtin/artifacts/search.py index 2d2a0b5791..a6d87a003e 100644 --- a/src/powercontext/builtin/artifacts/search.py +++ b/src/powercontext/builtin/artifacts/search.py @@ -18,6 +18,7 @@ import math import re +import sys import unicodedata from dataclasses import dataclass from itertools import pairwise @@ -30,6 +31,9 @@ _FTS_MIN_MATCHED_TERMS = 2 _FTS_SHORT_QUERY_MAX_TERMS = 2 _MIN_SEMANTIC_SIMILARITY = 0.3 +# RRF divides normalized weights again, so accepting a subnormal non-zero value +# can turn an enabled channel into zero during fusion. +_MIN_NORMALIZED_RECALL_CHANNEL_WEIGHT = sys.float_info.min # Query-only normalization: persisted Analyzer v1 projections remain unchanged. Negations # are deliberately absent; domain constraints such as "without a backup" remain evidence. @@ -211,6 +215,12 @@ def __post_init__(self) -> None: normalized_vector = 2.0 * (self.vector / total) if not math.isfinite(normalized_fts) or not math.isfinite(normalized_vector): raise ValueError("normalized recall channel weights must be finite") # noqa: TRY003 + if (self.fts > 0.0 and normalized_fts < _MIN_NORMALIZED_RECALL_CHANNEL_WEIGHT) or ( + self.vector > 0.0 and normalized_vector < _MIN_NORMALIZED_RECALL_CHANNEL_WEIGHT + ): + raise ValueError( # noqa: TRY003 + "recall channel weight ratio is too extreme to preserve non-zero RRF contributions" + ) object.__setattr__(self, "fts", normalized_fts) object.__setattr__(self, "vector", normalized_vector) diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 34dabf716b..9ade5a7285 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -16,7 +16,6 @@ from __future__ import annotations -import math import re from collections.abc import Mapping from typing import Any, Literal, Self @@ -212,11 +211,7 @@ def reject_boolean_recall_weight(cls, value: Any) -> Any: @model_validator(mode="after") def validate_recall_channel_weights(self) -> RuntimeConfig: - total = self.recall_fts_weight + self.recall_vector_weight - if not math.isfinite(total): - raise ValueError("recall channel weight total must be finite") # noqa: TRY003 - if total <= 0.0: - raise ValueError("at least one recall channel weight must be positive") # noqa: TRY003 + RecallChannelWeights(fts=self.recall_fts_weight, vector=self.recall_vector_weight) return self @property diff --git a/tests/builtin/runtime/test_recall_channel_weights.py b/tests/builtin/runtime/test_recall_channel_weights.py index f7a3a24ac4..831f5a3e6e 100644 --- a/tests/builtin/runtime/test_recall_channel_weights.py +++ b/tests/builtin/runtime/test_recall_channel_weights.py @@ -16,12 +16,22 @@ import asyncio import math +import sys from pathlib import Path import pytest from pydantic import ValidationError +from powercontext import ArtifactRef +from powercontext.builtin.artifacts.memory import MemoryChannelHit, MemoryMatchedBy +from powercontext.builtin.artifacts.memory.fusion import fuse_rankings from powercontext.builtin.artifacts.search import RecallChannelWeights +from powercontext.builtin.artifacts.topic_memory import ( + TopicMemoryChannelHit, + TopicMemoryMatchedBy, + TopicMemorySearchChannels, + fuse_topic_memory_rankings, +) from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import BuiltinConfig, RuntimeConfig, composition from powercontext.server.settings import ServerSettings @@ -29,11 +39,13 @@ def test_recall_channel_weights_default_to_the_historical_equal_ratio() -> None: config = RuntimeConfig() + empty_config = RuntimeConfig.model_validate({}) assert config.recall_fts_weight == 1.0 assert config.recall_vector_weight == 1.0 assert config.recall_channel_weights.fts == 1.0 assert config.recall_channel_weights.vector == 1.0 + assert empty_config.recall_channel_weights == config.recall_channel_weights def test_recall_channel_weights_are_normalized_as_a_relative_ratio() -> None: @@ -52,8 +64,12 @@ def test_recall_channel_weights_support_the_reverse_ratio() -> None: assert weights.vector == pytest.approx(1.5) -def test_recall_channel_weights_normalize_finite_subnormal_values() -> None: - weights = RuntimeConfig(recall_fts_weight=1e-310, recall_vector_weight=1e-310).recall_channel_weights +@pytest.mark.parametrize("configured_weight", [1e-310, 1e-320]) +def test_recall_channel_weights_normalize_equal_finite_subnormal_values(configured_weight: float) -> None: + weights = RuntimeConfig( + recall_fts_weight=configured_weight, + recall_vector_weight=configured_weight, + ).recall_channel_weights assert math.isfinite(weights.fts) assert math.isfinite(weights.vector) @@ -61,6 +77,127 @@ def test_recall_channel_weights_normalize_finite_subnormal_values() -> None: assert weights.vector == pytest.approx(1.0) +@pytest.mark.parametrize( + ("fts", "vector", "expected_fts", "expected_vector"), + [ + (0.0, 1.0, 0.0, 2.0), + (1.0, 0.0, 2.0, 0.0), + (1.0, 1.0, 1.0, 1.0), + (1e-300, 1.0, 2e-300, 2.0), + ], +) +def test_recall_channel_weights_preserve_valid_ratios( + fts: float, + vector: float, + expected_fts: float, + expected_vector: float, +) -> None: + weights = RuntimeConfig(recall_fts_weight=fts, recall_vector_weight=vector).recall_channel_weights + + assert weights.fts == pytest.approx(expected_fts, abs=0.0) + assert weights.vector == pytest.approx(expected_vector, abs=0.0) + + +def test_recall_channel_weights_accept_the_smallest_normalized_normal_weight() -> None: + weights = RuntimeConfig( + recall_fts_weight=sys.float_info.min, + recall_vector_weight=2.0, + ).recall_channel_weights + + assert weights.fts == sys.float_info.min + assert weights.vector == 2.0 + + +@pytest.mark.parametrize( + ("fts", "vector"), + [ + (1e-20, 1e303), + (1e303, 1e-20), + (1e-320, 1e303), + (1e303, 1e-320), + (sys.float_info.min / 2.0, 2.0), + (2.0, sys.float_info.min / 2.0), + ], +) +def test_extreme_nonzero_recall_weight_ratios_are_rejected_at_configuration_time( + fts: float, + vector: float, +) -> None: + with pytest.raises(ValidationError, match="ratio is too extreme to preserve non-zero RRF contributions"): + RuntimeConfig(recall_fts_weight=fts, recall_vector_weight=vector) + + +@pytest.mark.parametrize( + ("fts", "vector"), + [ + (1e-20, 1e303), + (1e303, 1e-20), + (1e-320, 1e303), + (1e303, 1e-320), + ], +) +def test_extreme_nonzero_recall_weight_ratios_are_rejected_by_the_domain_type( + fts: float, + vector: float, +) -> None: + with pytest.raises(ValueError, match="ratio is too extreme to preserve non-zero RRF contributions"): + RecallChannelWeights(fts=fts, vector=vector) + + +@pytest.mark.parametrize( + ("fts", "vector", "memory_channel", "topic_channel"), + [ + (sys.float_info.min, 2.0, "fts", "topic_fts"), + (2.0, sys.float_info.min, "vector", "topic_vector"), + ], +) +def test_smallest_accepted_channel_weight_remains_usable_in_memory_and_topic_fusion( + fts: float, + vector: float, + memory_channel: MemoryMatchedBy, + topic_channel: TopicMemoryMatchedBy, +) -> None: + weights = RuntimeConfig(recall_fts_weight=fts, recall_vector_weight=vector).recall_channel_weights + memory_candidate = MemoryChannelHit( + memory_ref=ArtifactRef(family="memory", artifact_id="memory", revision=1), + entry_id="entry", + entry_version_id="version", + text="needle", + distance=0.1 if memory_channel == "vector" else None, + ) + memory_hits = fuse_rankings( + fts=(memory_candidate,) if memory_channel == "fts" else (), + vector=(memory_candidate,) if memory_channel == "vector" else (), + limit=1, + channel_weights=weights, + ) + topic_candidate = TopicMemoryChannelHit( + artifact_ref=ArtifactRef(family="topic-memory", artifact_id="topic", revision=1), + title="Needle topic", + summary="needle", + channel=topic_channel, + distance=0.1 if topic_channel == "topic_vector" else None, + ) + topic_hits = fuse_topic_memory_rankings( + "needle", + TopicMemorySearchChannels( + topic_fts=(topic_candidate,) if topic_channel == "topic_fts" else (), + topic_vector=(topic_candidate,) if topic_channel == "topic_vector" else (), + ), + 1, + channel_weights=weights, + ) + + assert len(memory_hits) == 1 + assert memory_hits[0].matched_by == (memory_channel,) + assert memory_hits[0].score > 0.0 + assert memory_hits[0].score_upper_bound is not None + assert memory_hits[0].score_upper_bound > 0.0 + assert len(topic_hits) == 1 + assert topic_hits[0].matched_by == (topic_channel,) + assert topic_hits[0].score > 0.0 + + def test_recall_channel_weight_total_overflow_reports_the_actual_cause() -> None: with pytest.raises(ValueError, match="weight total must be finite"): RecallChannelWeights(fts=1e308, vector=1e308) @@ -110,6 +247,14 @@ def test_invalid_recall_channel_weights_are_rejected_at_configuration_time(fts: RuntimeConfig.model_validate({"recall_fts_weight": fts, "recall_vector_weight": vector}) +@pytest.mark.parametrize("field", ["recall_fts_weight", "recall_vector_weight"]) +def test_none_recall_channel_weight_is_rejected_by_its_field(field: str) -> None: + with pytest.raises(ValidationError) as error: + RuntimeConfig.model_validate({field: None}) + + assert error.value.errors()[0]["loc"] == (field,) + + def test_recall_channel_weights_load_from_the_server_environment(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_FTS_WEIGHT", "3") monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_VECTOR_WEIGHT", "1") @@ -120,3 +265,19 @@ def test_recall_channel_weights_load_from_the_server_environment(monkeypatch: py assert runtime.recall_vector_weight == 1.0 assert runtime.recall_channel_weights.fts == pytest.approx(1.5) assert runtime.recall_channel_weights.vector == pytest.approx(0.5) + + +@pytest.mark.parametrize( + ("fts_weight", "vector_weight"), + [("1e-20", "1e303"), ("1e303", "1e-20")], +) +def test_extreme_recall_channel_weight_ratio_from_server_environment_is_rejected( + monkeypatch: pytest.MonkeyPatch, + fts_weight: str, + vector_weight: str, +) -> None: + monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_FTS_WEIGHT", fts_weight) + monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_VECTOR_WEIGHT", vector_weight) + + with pytest.raises(ValidationError, match="ratio is too extreme to preserve non-zero RRF contributions"): + ServerSettings() From b97530121945c184d5243a9e559e5e5ec227276d Mon Sep 17 00:00:00 2001 From: lkx Date: Fri, 9 Oct 2026 19:12:57 +0800 Subject: [PATCH 3/4] fix(recall): resolve deployment weights against family-scoped fusion --- docs/en/docs/operate/configuration.md | 12 +- docs/en/rfcs/1417_topic_memory.md | 11 +- docs/zh/docs/operate/configuration.md | 8 +- docs/zh/rfcs/1417_topic_memory.md | 7 +- src/powercontext/builtin/artifacts/search.py | 7 +- .../builtin/persistence/topic_memory.py | 38 ++++--- .../builtin/persistence/test_topic_memory.py | 103 ++++++++++++------ .../runtime/test_recall_channel_weights.py | 22 ++-- 8 files changed, 133 insertions(+), 75 deletions(-) diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index f2eda08805..1594d6d0e3 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -159,11 +159,13 @@ The recall channel weights are a ratio and apply only after hybrid candidates pa Scaling both values equally has no effect. Setting one value to `0` excludes that channel from hybrid fusion, but a higher weight cannot restore a candidate rejected before fusion. A zero weight does not disable that channel's retrieval or admission work: vector embeddings may still be generated, and retrieval/admission counts still include -the zero-weight channel. After normalization, each non-zero channel weight must be at least the smallest normal IEEE -754 binary64 value (`2.2250738585072014e-308`); smaller values are rejected at startup. Only an explicit `0` disables -a channel's fusion contribution. Evaluate weight changes together with recall-gate rounds, semantic admission floors, -and historical Topic candidate selection instead of treating the knobs independently. In particular, weighting FTS -more heavily can move a Topic's normalized score across +the zero-weight channel. A Topic Artifact search request that supplies its own `fusion` parameters replaces the +deployment recall weights entirely. The deployment weights apply only when the request does not specify `fusion` +parameters. After normalization, each non-zero channel weight must be at least the smallest normal IEEE 754 binary64 +value (`2.2250738585072014e-308`); smaller values are rejected at startup. Only an explicit `0` disables a channel's +fusion contribution. Evaluate weight changes together with recall-gate rounds, semantic admission floors, and +historical Topic candidate selection instead of treating the knobs independently. In particular, weighting FTS more +heavily can move a Topic's normalized score across `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_HISTORY_RRF_THRESHOLD` (default `70`) and change which historical Topics are sent to the planner. diff --git a/docs/en/rfcs/1417_topic_memory.md b/docs/en/rfcs/1417_topic_memory.md index 1ba52e9fb6..6e454a1de9 100644 --- a/docs/en/rfcs/1417_topic_memory.md +++ b/docs/en/rfcs/1417_topic_memory.md @@ -432,11 +432,12 @@ windows. Changing the Chunk policy requires rebuilding the corresponding retriev Each retrieval channel first collapses by Topic. When multiple Detail chunks from one Topic match, only the best position is retained for the snippet. The two or four channels enabled for the current deployment are fused by RRF rather than by comparing raw full-text scores with raw vector distances. A Topic occupies at most one final result -position, and `matched_by` records which contributing channels matched it. Hybrid mode applies the deployment's -normalized relative FTS/vector weights to both Topic and Detail channels of the corresponding kind. A zero-weight -channel contributes no candidates. FTS-only and vector-only modes remain unweighted. The weighted theoretical maximum -for the enabled channels is the denominator that keeps the public score normalized to `0..100`. The per-channel -candidate limit applies only after this +position, and `matched_by` records every admitted channel that matched the selected Topic. A zero-weight channel makes +no ranking contribution and cannot select a Topic by itself, but its evidence remains in `matched_by` when another +positive-weight channel selects the same Topic. Hybrid mode applies the deployment's normalized relative FTS/vector +weights to both Topic and Detail channels of the corresponding kind. FTS-only and vector-only modes remain unweighted. +The weighted theoretical maximum for the enabled channels is the denominator that keeps the public score normalized +to `0..100`. The per-channel candidate limit applies only after this Topic collapse, so one Topic with many matching chunks cannot hide another Topic. FTS snippets center a bounded window on an Analyzer v1 query match; a vector-only detail hit uses a stable window from its matched chunk. The first-release search limit and historical candidate configuration are both bounded to 20. Search queries are diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index 86f4e1e81d..4099192757 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -153,10 +153,10 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 召回通道权重表示相对比例,只在 hybrid 候选通过各通道准入下限后生效。同比例缩放两个值不会改变结果。将其中一个设为 `0` 会从 hybrid 融合中排除该通道,但提高权重不能恢复融合前已被拒绝的候选。零权重不会关闭该通道的检索和准入工作: -vector 查询仍可能生成 embedding,检索与准入计数也仍包含零权重通道。归一化后,每个非零通道权重必须不低于 IEEE 754 -binary64 最小正规数(`2.2250738585072014e-308`),更小的值会在启动时被拒绝。只有显式配置 `0` 才表示关闭该通道的 -融合贡献。调整权重时应同时评估 recall gate 轮次、语义准入下限和历史 Topic 候选选择,不要将这些配置孤立调整。特别是, -提高 FTS 权重可能使 Topic 的归一化分数跨过 +vector 查询仍可能生成 embedding,检索与准入计数也仍包含零权重通道。如果 Topic Artifact 搜索请求显式提供自己的 `fusion` +参数,它会完整取代部署级召回权重;只有请求未指定 `fusion` 时,部署级权重才生效。归一化后,每个非零通道权重必须不低于 IEEE 754 binary64 +最小正规数(`2.2250738585072014e-308`),更小的值会在启动时被拒绝。只有显式配置 `0` 才表示关闭该通道的融合贡献。调整权重时应同时评估 +recall gate 轮次、语义准入下限和历史 Topic 候选选择,不要将这些配置孤立调整。特别是,提高 FTS 权重可能使 Topic 的归一化分数跨过 `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_HISTORY_RRF_THRESHOLD`(默认为 `70`),从而改变传给规划器的历史 Topic 集合。 召回充分性门控默认关闭。启用后,Runtime 在 `prepare_context` 阶段评估首轮候选的数量、来源家族覆盖、最优候选分数和词法 diff --git a/docs/zh/rfcs/1417_topic_memory.md b/docs/zh/rfcs/1417_topic_memory.md index e09016c790..e6a5df3a4a 100644 --- a/docs/zh/rfcs/1417_topic_memory.md +++ b/docs/zh/rfcs/1417_topic_memory.md @@ -383,9 +383,10 @@ Chunk 策略变化需要重建对应检索投影。 每个检索通道先按 Topic collapse,同一 Topic 的多个 detail 命中只保留最佳位置用于 snippet。当前部署启用的 两个或四个通道通过 RRF 排名融合,不比较全文分数和向量距离的原始数值。最终一个 Topic 最多占一个结果位置,并保留 -`matched_by` 说明实际贡献的命中通道。`hybrid` mode 对对应类型的 Topic 和 Detail 通道同时应用部署级归一化 FTS/vector 相对 -权重;零权重通道不产生候选,单独的 FTS 和 vector mode 仍不加权。开启通道的加权理论最大值作为归一化分母,使公开 score -保持在 `0..100`。每个通道都先按 Topic 折叠,再应用候选上限,因此单个 Topic 的大量命中 chunk 不会 +`matched_by` 说明被选中 Topic 通过准入的所有命中通道。零权重通道不贡献排名分数,也无法单独选中 Topic;但当同一 Topic +被其他正权重通道选中时,该通道的证据仍保留在 `matched_by` 中。`hybrid` mode 对对应类型的 Topic 和 Detail 通道同时应用部署级 +归一化 FTS/vector 相对权重,单独的 FTS 和 vector mode 仍不加权。开启通道的加权理论最大值作为归一化分母,使公开 score 保持在 +`0..100`。每个通道都先按 Topic 折叠,再应用候选上限,因此单个 Topic 的大量命中 chunk 不会 遮蔽其他 Topic。全文 snippet 以 Analyzer v1 查询命中为中心截取有界窗口;只有向量命中时,则稳定截取其命中 chunk 内部的窗口。 首版搜索 limit 与历史候选配置统一限制为 20。查询最多 8,192 个字符、64 个不同的 Analyzer term;构造后端 diff --git a/src/powercontext/builtin/artifacts/search.py b/src/powercontext/builtin/artifacts/search.py index a6d87a003e..ab1ff842ad 100644 --- a/src/powercontext/builtin/artifacts/search.py +++ b/src/powercontext/builtin/artifacts/search.py @@ -31,8 +31,8 @@ _FTS_MIN_MATCHED_TERMS = 2 _FTS_SHORT_QUERY_MAX_TERMS = 2 _MIN_SEMANTIC_SIMILARITY = 0.3 -# RRF divides normalized weights again, so accepting a subnormal non-zero value -# can turn an enabled channel into zero during fusion. +# Reject subnormal normalized weights. This conservative boundary leaves +# headroom for RRF division and prevents more extreme ratios from underflowing. _MIN_NORMALIZED_RECALL_CHANNEL_WEIGHT = sys.float_info.min # Query-only normalization: persisted Analyzer v1 projections remain unchanged. Negations @@ -219,7 +219,8 @@ def __post_init__(self) -> None: self.vector > 0.0 and normalized_vector < _MIN_NORMALIZED_RECALL_CHANNEL_WEIGHT ): raise ValueError( # noqa: TRY003 - "recall channel weight ratio is too extreme to preserve non-zero RRF contributions" + "normalized non-zero recall channel weights must be at least " + "the smallest normal IEEE 754 binary64 value" ) object.__setattr__(self, "fts", normalized_fts) object.__setattr__(self, "vector", normalized_vector) diff --git a/src/powercontext/builtin/persistence/topic_memory.py b/src/powercontext/builtin/persistence/topic_memory.py index e7039acf42..2d57550aa9 100644 --- a/src/powercontext/builtin/persistence/topic_memory.py +++ b/src/powercontext/builtin/persistence/topic_memory.py @@ -480,22 +480,9 @@ async def search( ) analyzed = analyze_text(query) used_mode = self._select_mode(mode, query_vector, embedding_profile) - fusion: RrfParameters | None = None if artifact_request is not None: admission = artifact_request.admission.as_floor() - if artifact_request.fusion is not None: - fusion = topic_fusion_parameters(artifact_request.fusion) - if fusion is None and used_mode == "hybrid": - fusion = RrfParameters( - weights={ - "topic_fts": self.recall_channel_weights.fts, - "detail_fts": self.recall_channel_weights.fts, - "topic_vector": self.recall_channel_weights.vector, - "detail_vector": self.recall_channel_weights.vector, - } - ) - if fusion is not None: - validate_topic_weights(used_mode, fusion) + fusion = self._search_fusion_parameters(used_mode, artifact_request) query_terms = tuple(sorted(set(analyzed.split()))) if used_mode in {"fts", "hybrid"} and len(query_terms) > MAX_TOPIC_MEMORY_QUERY_TERMS: raise InvalidRepositoryArgumentError( @@ -807,6 +794,29 @@ def _select_mode( raise TopicMemoryCapabilityError(selected) return selected + def _search_fusion_parameters( + self, + mode: TopicMemoryUsedSearchMode, + request: TopicArtifactSearchRequest | None, + ) -> RrfParameters | None: + """Use an explicit request policy or the deployment's hybrid default.""" + + if request is not None and request.fusion is not None: + params = topic_fusion_parameters(request.fusion) + elif mode == "hybrid": + params = RrfParameters( + weights={ + "topic_fts": self.recall_channel_weights.fts, + "detail_fts": self.recall_channel_weights.fts, + "topic_vector": self.recall_channel_weights.vector, + "detail_vector": self.recall_channel_weights.vector, + } + ) + else: + return None + validate_topic_weights(mode, params) + return params + def _canonical_query_vector( self, mode: TopicMemoryUsedSearchMode, diff --git a/tests/builtin/persistence/test_topic_memory.py b/tests/builtin/persistence/test_topic_memory.py index 2588348900..bbd4da84fa 100644 --- a/tests/builtin/persistence/test_topic_memory.py +++ b/tests/builtin/persistence/test_topic_memory.py @@ -21,7 +21,7 @@ from pathlib import Path import pytest -from sqlalchemy import delete, event, func, insert, select, text, update +from sqlalchemy import Table, delete, event, func, insert, select, text, update from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import ArtifactRef @@ -35,8 +35,8 @@ TopicArtifactSearchRequest, TopicMemory, TopicMemoryBrowseCursor, - TopicMemoryCapabilityError, TopicMemoryCapabilities, + TopicMemoryCapabilityError, TopicMemoryChannelHit, TopicMemoryChunk, TopicMemoryContent, @@ -45,6 +45,7 @@ TopicMemoryProjectionError, TopicMemorySearchChannels, TopicMemorySearchRequest, + TopicMemorySearchResult, TopicMemoryStorageInvariantError, prepare_topic_memory_projection, ) @@ -187,7 +188,7 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_topic_search_uses_deployment_weights_unless_the_request_overrides_them( +def test_topic_search_deployment_weights_change_results_and_request_fusion_replaces_them( monkeypatch: pytest.MonkeyPatch, ) -> None: embedding_profile = EmbeddingProfile(profile_id="topic-test-v1", model="test", dimension=2) @@ -220,7 +221,7 @@ class StaticIndex: hybrid=True, embedding_profile=embedding_profile, ) - tables = () + tables: tuple[Table, ...] = () async def initialize(self, _connection: AsyncConnection, /) -> None: pass @@ -264,21 +265,44 @@ async def vector_complete( ) for ref in (lexical_ref, semantic_ref) } - repository = TopicMemoryRepository( + default_repository = TopicMemoryRepository(index=StaticIndex()) + equal_repository = TopicMemoryRepository( + index=StaticIndex(), + recall_channel_weights=RecallChannelWeights(fts=1.0, vector=1.0), + ) + fts_weighted_repository = TopicMemoryRepository( index=StaticIndex(), recall_channel_weights=RecallChannelWeights(fts=3.0, vector=1.0), ) + vector_weighted_repository = TopicMemoryRepository( + index=StaticIndex(), + recall_channel_weights=RecallChannelWeights(fts=1.0, vector=3.0), + ) async def get_topic(_connection: AsyncConnection, _scope_id: str, ref: ArtifactRef) -> TopicMemory: return topics[ref.artifact_id] - monkeypatch.setattr(repository.artifacts, "get", get_topic) + for repository in ( + default_repository, + equal_repository, + fts_weighted_repository, + vector_weighted_repository, + ): + monkeypatch.setattr(repository.artifacts, "get", get_topic) async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: - async with profile.database.transaction() as connection: - await repository.initialize(connection) - deployment_weighted = await repository.search( + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile, + profile.database.transaction() as connection, + ): + await default_repository.initialize(connection) + + async def search( + repository: TopicMemoryRepository, + *, + artifact_request: TopicArtifactSearchRequest | None = None, + ) -> TopicMemorySearchResult: + return await repository.search( connection, "scope-a", "needle", @@ -286,34 +310,45 @@ async def scenario() -> None: mode="hybrid", query_vector=(1.0, 0.0), embedding_profile=embedding_profile, + artifact_request=artifact_request, ) - request_weighted = await repository.search( - connection, - "scope-a", - "needle", - limit=2, + + default_result = await search(default_repository) + equal_result = await search(equal_repository) + fts_weighted_result = await search(fts_weighted_repository) + vector_weighted_result = await search(vector_weighted_repository) + request_weighted_result = await search( + fts_weighted_repository, + artifact_request=TopicArtifactSearchRequest( + query="needle", mode="hybrid", - query_vector=(1.0, 0.0), - embedding_profile=embedding_profile, - artifact_request=TopicArtifactSearchRequest( - query="needle", - mode="hybrid", - fusion=FusionSelection( - method="rrf", - params={ - "weights": { - "topic_fts": 1.0, - "detail_fts": 1.0, - "topic_vector": 3.0, - "detail_vector": 3.0, - } - }, - ), + fusion=FusionSelection( + method="rrf", + params={ + "weights": { + "topic_vector": 3.0, + "detail_vector": 3.0, + } + }, ), - ) + ), + ) - assert tuple(hit.artifact_ref for hit in deployment_weighted.hits) == (lexical_ref, semantic_ref) - assert tuple(hit.artifact_ref for hit in request_weighted.hits) == (semantic_ref, lexical_ref) + default_scores = {hit.artifact_ref.artifact_id: hit.score for hit in default_result.hits} + equal_scores = {hit.artifact_ref.artifact_id: hit.score for hit in equal_result.hits} + fts_weighted_scores = {hit.artifact_ref.artifact_id: hit.score for hit in fts_weighted_result.hits} + vector_weighted_scores = {hit.artifact_ref.artifact_id: hit.score for hit in vector_weighted_result.hits} + request_weighted_scores = {hit.artifact_ref.artifact_id: hit.score for hit in request_weighted_result.hits} + + assert default_result.hits == equal_result.hits + assert default_scores == {"lexical": 25.0, "semantic": 25.0} + assert equal_scores == default_scores + assert tuple(hit.artifact_ref for hit in fts_weighted_result.hits) == (lexical_ref, semantic_ref) + assert fts_weighted_scores == {"lexical": 37.5, "semantic": 12.5} + assert tuple(hit.artifact_ref for hit in vector_weighted_result.hits) == (semantic_ref, lexical_ref) + assert vector_weighted_scores == {"lexical": 12.5, "semantic": 37.5} + assert tuple(hit.artifact_ref for hit in request_weighted_result.hits) == (semantic_ref, lexical_ref) + assert request_weighted_scores == vector_weighted_scores asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_recall_channel_weights.py b/tests/builtin/runtime/test_recall_channel_weights.py index 831f5a3e6e..74199ea2c3 100644 --- a/tests/builtin/runtime/test_recall_channel_weights.py +++ b/tests/builtin/runtime/test_recall_channel_weights.py @@ -23,6 +23,7 @@ from pydantic import ValidationError from powercontext import ArtifactRef +from powercontext.artifacts.fusion import RrfParameters from powercontext.builtin.artifacts.memory import MemoryChannelHit, MemoryMatchedBy from powercontext.builtin.artifacts.memory.fusion import fuse_rankings from powercontext.builtin.artifacts.search import RecallChannelWeights @@ -30,8 +31,8 @@ TopicMemoryChannelHit, TopicMemoryMatchedBy, TopicMemorySearchChannels, - fuse_topic_memory_rankings, ) +from powercontext.builtin.artifacts.topic_memory.fusion import _fuse_topic_memory_rankings from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import BuiltinConfig, RuntimeConfig, composition from powercontext.server.settings import ServerSettings @@ -123,7 +124,7 @@ def test_extreme_nonzero_recall_weight_ratios_are_rejected_at_configuration_time fts: float, vector: float, ) -> None: - with pytest.raises(ValidationError, match="ratio is too extreme to preserve non-zero RRF contributions"): + with pytest.raises(ValidationError, match="must be at least the smallest normal IEEE 754 binary64 value"): RuntimeConfig(recall_fts_weight=fts, recall_vector_weight=vector) @@ -140,7 +141,7 @@ def test_extreme_nonzero_recall_weight_ratios_are_rejected_by_the_domain_type( fts: float, vector: float, ) -> None: - with pytest.raises(ValueError, match="ratio is too extreme to preserve non-zero RRF contributions"): + with pytest.raises(ValueError, match="must be at least the smallest normal IEEE 754 binary64 value"): RecallChannelWeights(fts=fts, vector=vector) @@ -178,15 +179,22 @@ def test_smallest_accepted_channel_weight_remains_usable_in_memory_and_topic_fus channel=topic_channel, distance=0.1 if topic_channel == "topic_vector" else None, ) - topic_hits = fuse_topic_memory_rankings( + topic_hits = _fuse_topic_memory_rankings( "needle", TopicMemorySearchChannels( topic_fts=(topic_candidate,) if topic_channel == "topic_fts" else (), topic_vector=(topic_candidate,) if topic_channel == "topic_vector" else (), ), 1, - channel_weights=weights, - ) + fusion=RrfParameters( + weights={ + "topic_fts": weights.fts, + "detail_fts": weights.fts, + "topic_vector": weights.vector, + "detail_vector": weights.vector, + } + ), + ).hits assert len(memory_hits) == 1 assert memory_hits[0].matched_by == (memory_channel,) @@ -279,5 +287,5 @@ def test_extreme_recall_channel_weight_ratio_from_server_environment_is_rejected monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_FTS_WEIGHT", fts_weight) monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_VECTOR_WEIGHT", vector_weight) - with pytest.raises(ValidationError, match="ratio is too extreme to preserve non-zero RRF contributions"): + with pytest.raises(ValidationError, match="must be at least the smallest normal IEEE 754 binary64 value"): ServerSettings() From 34808b1a52e39c258a15b22e5b2418ffb1acd962 Mon Sep 17 00:00:00 2001 From: lkx Date: Sat, 10 Oct 2026 08:15:53 +0800 Subject: [PATCH 4/4] chore(desktop): regenerate API declarations and stop tracking the pnpm store --- .gitignore | 3 +++ desktop/src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 2 ++ desktop/ui/src/generated/operations.ts | 2 +- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 4a425bb4f2..2ee69b9ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -272,3 +272,6 @@ integrations/pi/plugins/powercontext/*.tgz # Local acceptance databases and redacted run artifacts .artifacts/ + +# pnpm package store is a local cache, never committed. +.pnpm-store/ diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 262ef4302a..b4149a2334 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "b5631d32c669bbf4b6d340e063ffbbb93a974160380d634ed893dd24b42bb549", + "contractSha256": "c3febd536fbea1b9d87300827f96ddde4f0045d8871b0905ed6a06381086756c", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 9258fb3015..17e222cb83 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -3820,6 +3820,7 @@ export interface components { SearchMemoryHit: { citation: components["schemas"]["MemoryCitation"]; text: string; + /** @description Raw RRF score. Hybrid mode applies the deployment's normalized relative FTS/vector weights. */ score: number; matched_by: components["schemas"]["MemoryMatchedBy"][]; }; @@ -3828,6 +3829,7 @@ export interface components { title: string; summary: string; snippet: string | null; + /** @description Weighted RRF relevance normalized to the 0..100 range. */ score: number; matched_by: components["schemas"]["TopicMemoryMatchedBy"][]; }; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 7926bed1be..b04364a259 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "b5631d32c669bbf4b6d340e063ffbbb93a974160380d634ed893dd24b42bb549"; +export const contractSha256 = "c3febd536fbea1b9d87300827f96ddde4f0045d8871b0905ed6a06381086756c"; export const operations = { "get_liveness": { "method": "GET",