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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ bot (`sender.login` が `[bot]` で終わる) と trim 後 10 文字未満の bo
| `milestone` | string | milestone title で絞り込み。 |
| `assignee` | string | assignee login で絞り込み。 |
| `type` | 下記参照 | type で絞り込み (既定 `all`)。 |
| `top_k` | number | 最大件数 (既定 10、上限 50)。 |
| `top_k` | number | 最大件数 (既定 10、上限 50)。索引の行数ではなく実体の数で数えます。下記「実体単位の集約」参照。 |
| `fusion` | `"rrf"` / `"dense_only"` / `"sparse_only"` | fusion 戦略 (既定 `rrf`)。scan モードでは無視。 |
| `rerank` | boolean | cross-encoder rerank (既定 `true`)。scan モードでは無視。 |
| `sort` | `"relevance"` / `"updated_desc"` / `"created_desc"` | 並び順。query ありの既定は `relevance`、query なしの既定は `updated_desc`。時系列指定は ranker score を上書きします。 |
Expand All @@ -133,6 +133,12 @@ bot (`sender.login` が `[bot]` で終わる) と trim 後 10 文字未満の bo
| `"pr_review_comment"` | PR の per-line インラインレビューコメント。 |
| `"all"` | 上記すべての union (既定)。 |

#### 実体単位の集約

1 つの実体は複数行として索引されます。ファイルは `doc` 行 + それを触った commit の数だけの `diff` 行、issue / PR は本体 + そのコメントやレビュー、という形です。応答を `top_k` 件に切り詰める前にこれらを 1 件へ畳むので、`top_k` はそのまま独立した実体の数になります。畳む基準は「その行が何を指しているか」であって「どの作業がその行を生んだか」ではありません。同一 commit が触った別々のファイルは別々の結果として残り、issue とそれを閉じる PR も別々に残ります。

代表になるのはその group で最上位に来た行です。したがって「いつ変わったか」を問う検索では、現在の版ではなく該当する古い commit diff が返ります。他の行を吸収した結果には `same_entity` フィールドが付き(`count` は自身を含む件数、`others[]` は畳んだ各行の type / URL / 時刻 / score)、畳んだ分は捨てられません。完全な規則は [docs/0-requirements.ja.md](docs/0-requirements.ja.md) を参照してください。

#### 使用例

特定トピックの意味検索:
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Bot-authored comments (`sender.login` ending in `[bot]`) and comments shorter th
| `milestone` | string | Filter by milestone title. |
| `assignee` | string | Filter by assignee login. |
| `type` | see below | Filter by type (default `all`). |
| `top_k` | number | Max results (default 10, max 50). |
| `top_k` | number | Max results (default 10, max 50). Counts distinct entities, not index rows — see Entity aggregation below. |
| `fusion` | `"rrf"` \| `"dense_only"` \| `"sparse_only"` | Fusion strategy (default `rrf`). Ignored in scan mode. |
| `rerank` | boolean | Cross-encoder rerank (default `true`). Ignored in scan mode. |
| `sort` | `"relevance"` \| `"updated_desc"` \| `"created_desc"` | Result ordering. Default `relevance` with a query, `updated_desc` without. Time sorts override ranker score. |
Expand All @@ -133,6 +133,12 @@ Bot-authored comments (`sender.login` ending in `[bot]`) and comments shorter th
| `"pr_review_comment"` | PR inline review comments (per-line diff comments). |
| `"all"` | Union of every type above (default). |

#### Entity aggregation

One thing is indexed as several rows: a file is a `doc` row plus one `diff` row per commit that touched it, an issue or PR is its own row plus its comments and reviews. Those rows are collapsed into one result before the response is trimmed, so `top_k` returns that many distinct entities. Rows are grouped by what they point at, not by the work that produced them — different files touched by one commit stay separate results, and so do an issue and the PR that closes it.

The representative is the highest-ranked row of the group, so a query about when something changed still returns the relevant old commit diff rather than the current version. A result that absorbed other rows carries a `same_entity` field (`count` including itself, plus `others[]` with the type, URL, timestamp and score of each collapsed row) so nothing is lost. See [docs/0-requirements.md](docs/0-requirements.md) for the full rule.

#### Examples

Semantic search for a specific topic:
Expand Down
47 changes: 45 additions & 2 deletions docs/0-requirements.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,12 +309,13 @@ retrieval layer は hybrid search(dense + sparse)+ cross-encoder rerank + st

1. query の embedding を Workers AI BGE-M3 で生成
2. structured params から Vectorize filter(dense 側)と D1 SQL WHERE(sparse 側)を同時構築(repo, state, type, milestone は pre-filter)
3. labels / assignee フィルタ指定時、または reranker 有効時は内部 topK をオーバーフェッチ(requestedTopK × 5, max 50)。reranker は最大 50 件まで処理
3. 内部 topK を常にオーバーフェッチ(requestedTopK × 5, max 50)。条件なしなのは、8 の entity 集約がどの経路でも複数行を 1 件に畳むため、rerank 無効時でも候補プールが top_k を上回っていなければ要求件数を満たせないからである。reranker は最大 50 件まで処理
4. dense (Vectorize.query) と sparse (D1 FTS5 MATCH + BM25) を並列実行
5. 両 ranker の結果を Reciprocal Rank Fusion(RRF、k=60)で合成
6. 合成後の rank 順に、labels(AND ロジック、個別フィールド + CSV フォールバック)と assignee を post-filter
7. reranker 有効時(default ON)は post-filter 後の候補を `@cf/baai/bge-reranker-base` で re-score し、reranker score 降順に並び替え
8. requestedTopK にトリムして structured context と共に返す
8. 同一 entity を指す行を畳み、最上位の行を代表にする(Entity Aggregation 参照)
9. requestedTopK にトリムして structured context と共に返す

#### Reciprocal Rank Fusion (RRF)

Expand Down Expand Up @@ -356,6 +357,47 @@ score(d) = sum_over_rankers ( 1 / (k + rank_r(d)) )
- Free tier 10,000 neurons/day で約 1,300 検索/day 上限
- neuron 実測値はレスポンスに `usage` フィールドが含まれる場合に取得し、理論試算と照合する(公式未文書化のため存在しない場合は黙ってスキップ)

### Entity Aggregation

1 つの実体が複数行として索引される。ファイルは `doc` 行 + それを触った commit の数だけの `diff` 行、issue は本体 + コメント数だけの `issue_comment` 行、PR は本体 + `pr_review` / `pr_review_comment` 行。これらが同一の `top_k` プールで枠を奪い合う。本番索引での実測(2026-08-01、`top_k: 10`、rrf + rerank)では独立した情報は 10 枠中 6 前後、`dense_only` では 5 枠中 3 枠が同一ファイルだった。

diff を索引し続けるのは意図的である——diff は判断履歴そのものであり、変更を commit diff として持つ設計が索引の容量を抑えている。したがって畳むのは表示段(fusion / rerank / time sort の後、trim の手前)であって索引側ではない。

**同一実体の定義.** 実体とはその行が指している対象(referent)であって、その行を生んだ作業(event)ではない。

| 行 | key | 畳む |
|---|---|---|
| ファイルの `doc` 行 + 複数 commit の `diff` 行 | `file:{repo}:{doc_path ?? file_path}` | する |
| issue + その `issue_comment` 行 | `thread:{repo}:{number}` | する |
| PR + その `pr_review` / `pr_review_comment` 行 | `thread:{repo}:{number}` | する |
| 同一 commit が触った別々のファイル | — | **しない** |
| issue と、それを閉じる PR | — | **しない** |
| 同一ソースファイルの他 repo への複製 | — | **しない** |

同じ対象の複数の版を畳んでも対象の数は減らないので、集約が独立した情報を隠すことはない。一方「作業」で畳むと、1 つの commit が触った別々のファイルが 1 枠になり、実際に独立した対象が隠れる。だから key はパスを持ち commit SHA を持たない。issue と、それを閉じる PR も 2 実体のまま残す——同一の作業単位ではあるが、両者を結ぶ `Closes #N` は索引に無く、入れるには索引側の変更が要る。

repo 横断の複製(Li+ source が各 user repo の `.claude/` に複製されている件)は対象外。同一と判定するには内容 hash を索引に持たせるか、パスの正規化ヒューリスティクスを置くかが要る。前者は索引側の変更で、後者は本当に別物のファイルを誤って畳む。加えて配布先が古い場合はその差異自体が情報である。

`wiki_doc` / `release` は 1 実体 1 行なので行の identity を key にし、畳まれることはない。

**代表の選び方.** fusion / rerank / time sort 後の順位が最上位の行。最新版は固定**しない**。これが「いつ変わったか」を問うクエリへの答えを残す: そのクエリでは該当する古い `diff` が最上位に来るので、それが代表として残る。最新版固定だと答えそのものが消える。

**返却形式.** `top_k` は実体の数で数えるので、10 を要求した呼び出し側には独立実体 10 件が返る。他の行を吸収した代表 item にはフィールドが 1 つ増える。畳んだ行は参照として付され、捨てられない:

```json
{
"...": "(代表 item の既存フィールド)",
"same_entity": {
"count": 3,
"others": [
{ "type": "diff", "url": "...", "updated_at": "...", "score": 0.0161, "commit_sha": "601aa38" }
]
}
}
```

`count` は代表を含むので必ず 2 以上。1 件以上畳んだ場合のみ付く。フィールド追加であって既存フィールドの変更ではないので、無視する client には集約前と同じ形に見える。

### 切替オプション

`search` の `fusion` パラメータで retrieval mode を切り替え可能:
Expand Down Expand Up @@ -398,6 +440,7 @@ Returns:

- repository、type、state、labels、milestone、assignees、URL、RRF fused score を含む ranked match
- 追加 debug フィールド: `dense_score`、`sparse_score`、`dense_rank`、`sparse_rank`、`rerank_score`(rerank 無効時または fallback 時は null)
- 同一実体の他の行を吸収した結果には `same_entity`(Entity Aggregation 参照)。`top_k` は行数ではなく実体数で数える
- top-level metadata: `fusion`、`dense_candidates`、`sparse_candidates`、`rerank_requested`、`rerank_applied`

**scan mode(query 空).** Vectorize / FTS5 / reranker を経由せず、structured store の recency endpoint から集約する。`since` / `until` は store 側へ push down されるので、窓に行があれば、その窓がどれだけ古くても返る。`since` 省略時の既定は `until` の 7 日前(`until` も省略時は現在の 7 日前)。`until` だけ指定した問い合わせが「下限が上限より新しい空窓」に潰れないための既定である。
Expand Down
47 changes: 45 additions & 2 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,12 +311,13 @@ Expected retrieval behavior:

1. Generate an embedding for the query via Workers AI BGE-M3.
2. Build Vectorize metadata filter (dense side) and D1 SQL WHERE clause (sparse side) from the same structured params (repo, state, type, milestone are pre-filtered on both sides).
3. When labels or assignee filters are present, OR when the reranker is enabled, overfetch internally on both sides (requestedTopK × 5, max 50). The reranker processes at most 50 candidates per call.
3. Overfetch internally on both sides (requestedTopK × 5, max 50). Unconditional: entity aggregation (step 8) collapses several rows into one result on every path, so the candidate pool must exceed top_k even when the reranker is off. The reranker processes at most 50 candidates per call.
4. Query Vectorize (dense) and D1 FTS5 (sparse, BM25) in parallel.
5. Combine the two rankers via Reciprocal Rank Fusion (RRF, k=60).
6. Post-filter labels (AND logic, expanded fields + CSV fallback) and assignee over the fused view.
7. When the reranker is enabled (default ON), re-score the post-filtered candidates with `@cf/baai/bge-reranker-base` and reorder by reranker score, descending.
8. Trim to requestedTopK and return results with structured context.
8. Collapse rows that point at the same entity, keeping the highest-ranked row as the representative (see Entity Aggregation).
9. Trim to requestedTopK and return results with structured context.

#### Reciprocal Rank Fusion (RRF)

Expand Down Expand Up @@ -358,6 +359,47 @@ Cost estimate:
- Free tier (10,000 neurons/day) supports ~1,300 searches/day at this rate.
- Actual neuron usage is read from a `usage` field on the response when present and reconciled against the estimate. The field is not officially documented as of 2026-04, so absence is tolerated silently.

### Entity Aggregation

One underlying thing is indexed as several rows: a file is a `doc` row plus one `diff` row per commit that touched it, an issue is its own row plus one `issue_comment` row per comment, a PR is its own row plus its `pr_review` / `pr_review_comment` rows. They all compete for slots in the same `top_k` pool. Measured on the production index (2026-08-01, `top_k: 10`, rrf + rerank), roughly 6 of 10 slots held independent information; a `dense_only` probe put 3 of 5 slots on one file.

Keeping the diff rows indexed is deliberate — they are the judgment history, and storing changes as commit diffs is what keeps the index small — so the collapse happens at the presentation stage (after fusion, rerank and time sort; before the trim), not in the index.

**What counts as one entity.** The entity is the *referent* a row points at, never the *event* that produced the row.

| rows | key | collapse |
|---|---|---|
| a file's `doc` row + its `diff` rows across commits | `file:{repo}:{doc_path ?? file_path}` | yes |
| an issue + its `issue_comment` rows | `thread:{repo}:{number}` | yes |
| a PR + its `pr_review` / `pr_review_comment` rows | `thread:{repo}:{number}` | yes |
| different files touched by one commit | — | **no** |
| an issue and the PR that closes it | — | **no** |
| the same source file copied into another repo | — | **no** |

Collapsing versions of one referent cannot hide an independent thing, because the number of referents is unchanged. Collapsing by event would: one commit touches several distinct files, and folding them into a single slot hides files that are genuinely independent. So the key carries the path and not the commit SHA, and an issue and the PR that closes it stay two entities — they are one unit of work, but the `Closes #N` link that would join them is not in the index, and putting it there is an index-side change.

Cross-repo duplication (the same Li+ source file copied into every user repo's `.claude/`) is out of scope: deciding those are one entity needs either a content hash in the index or a path-normalizing heuristic — the first is an index change, the second folds genuinely different files, and a stale copy's difference is itself information.

`wiki_doc` and `release` rows have exactly one row per referent, so they key on the row identity and never collapse.

**Representative.** The highest-ranked row of the group after fusion / rerank / time sort — the newest version is *not* pinned. This is what keeps "when did this change" answerable: for such a query the relevant old `diff` ranks top, so it is the row that survives. Pinning the newest version would delete the answer.

**Response shape.** `top_k` counts entities, so a caller asking for 10 gets 10 independent entities. A representative that absorbed other rows carries one additional field; the collapsed rows are referenced, never dropped:

```json
{
"...": "(the representative's existing fields)",
"same_entity": {
"count": 3,
"others": [
{ "type": "diff", "url": "...", "updated_at": "...", "score": 0.0161, "commit_sha": "601aa38" }
]
}
}
```

`count` includes the representative, so it is at least 2. The field is present only when at least one row was collapsed, and it is additive — a client that ignores it sees the pre-aggregation shape.

### Fusion mode toggle

`search` accepts a `fusion` parameter:
Expand Down Expand Up @@ -400,6 +442,7 @@ Returns:

- ranked matches with repository, type, state, labels, milestone, assignees, URL, and RRF fused `score`
- additional debug fields per result: `dense_score`, `sparse_score`, `dense_rank`, `sparse_rank`, `rerank_score` (null when rerank disabled or when graceful fallback engaged)
- `same_entity` on results that absorbed other rows of the same entity (see Entity Aggregation); `top_k` counts entities, not rows
- top-level metadata: `fusion`, `dense_candidates`, `sparse_candidates`, `rerank_requested`, `rerank_applied`

**Scan mode (empty query).** Vectorize / FTS5 / reranker are skipped and the result set is aggregated from the structured store's recency endpoints. `since` / `until` are pushed down to the store, so a window returns rows whenever it holds rows, however far back it sits. `since` defaults to 7 days before `until` (before now when `until` is omitted), so an `until`-only query does not degenerate into an empty window above its own ceiling.
Expand Down
9 changes: 7 additions & 2 deletions mcp-server/server/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export const TOOLS = [
"optionally narrow via since / until; " +
"(3) doc content fetch — include_content: true inlines raw content on top doc and wiki_doc results. " +
"Structured filters (repo, state, labels, milestone, assignee, type) apply across all modes; " +
"type: \"wiki_doc\" narrows to GitHub Wiki pages only.",
"type: \"wiki_doc\" narrows to GitHub Wiki pages only. " +
"Results are aggregated per underlying entity: a file's doc row and its commit diffs are one result, " +
"an issue or PR and its comments / reviews are one result. top_k therefore counts distinct entities, " +
"and a result that absorbed others carries same_entity { count, others[] } with links to them.",
inputSchema: {
type: "object",
properties: {
Expand Down Expand Up @@ -81,7 +84,9 @@ export const TOOLS = [
},
top_k: {
type: "number",
description: "Max results (default: 10, max: 50)",
description:
"Max results (default: 10, max: 50). Counts distinct entities, not index rows " +
"(a file's doc row and its commit diffs collapse into one result).",
},
fusion: {
type: "string",
Expand Down
Loading
Loading