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
4 changes: 3 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,16 @@ GitHub の issue / pull request / release / documentation / **GitHub Wiki page**

structured filter (`repo` / `state` / `labels` / `milestone` / `assignee` / `type`) はすべてのモードで有効です。

search モードは「1件もマッチしなかったフィルタ」を `filters_unmatched` に載せます (常に存在し、すべて成立していれば `[]`)。`repo` はフルスラッグ `owner/repo` の完全一致なので、短いリポジトリ名を渡すと母集合が空になり、本当にヒットゼロだった場合と同じ形のレスポンスが返ります。このフィールドがその2つを区別します。効くのは多段のエージェンティック検索で、ゼロが正常な中間結果として読まれてしまい、フィルタ不成立が表に出ないまま終わる場面です。

bot (`sender.login` が `[bot]` で終わる) と trim 後 10 文字未満の body は ingest 時点で除外されます。`LGTM` / `+1` / CI ノイズなどは retrieval 面に残りません。

#### パラメータ

| 名前 | 型 | 説明 |
|------|----|------|
| `query` | string (省略可) | 自然言語クエリ。省略または空文字で scan モード。 |
| `repo` | string | repository (`owner/repo`) で絞り込み。 |
| `repo` | string | repository で絞り込み。フルスラッグ (`owner/repo`) の完全一致。短いリポジトリ名は1件もマッチせず、search モードはそれをレスポンスの `filters_unmatched` に `"repo"` として報告します。 |
| `state` | `"open"` / `"closed"` / `"all"` | state で絞り込み (既定 `all`)。 |
| `labels` | string[] | label 名で AND 絞り込み。 |
| `milestone` | string | milestone title で絞り込み。 |
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,16 @@ Three modes are selected by the combination of `query` and `sort`:

Structured filters (`repo`, `state`, `labels`, `milestone`, `assignee`, `type`) apply in every mode.

Search mode reports filters that matched nothing at all in `filters_unmatched` (always present, `[]` when every filter matched something). `repo` is an exact match on the full `owner/repo` slug, so a bare repository name selects an empty population and returns a response shaped exactly like a genuine zero-hit search — this field is what separates the two. It matters most in multi-step agentic search, where a zero reads as a normal intermediate result and the mis-specified filter would otherwise never surface.

Bot-authored comments (`sender.login` ending in `[bot]`) and comments shorter than 10 characters (trimmed) are filtered out at ingest time so noise such as `LGTM`, `+1`, or CI chatter does not dilute the retrieval surface.

#### Parameters

| Name | Type | Description |
|------|------|-------------|
| `query` | string (optional) | Natural-language query. Omit or empty = scan mode. |
| `repo` | string | Filter by repository (`owner/repo`). |
| `repo` | string | Filter by repository — full slug (`owner/repo`), exact match. A bare repository name matches nothing; search mode reports that as `"repo"` in the response's `filters_unmatched`. |
| `state` | `"open"` \| `"closed"` \| `"all"` | Filter by state (default `all`). |
| `labels` | string[] | Filter by label names (AND). |
| `milestone` | string | Filter by milestone title. |
Expand Down
8 changes: 7 additions & 1 deletion docs/0-requirements.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,13 @@ 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`
- top-level metadata: `fusion`、`dense_candidates`、`sparse_candidates`、`rerank_requested`、`rerank_applied`、`filters_unmatched`

**フィルタ不成立(`filters_unmatched`).** `repo` はフルスラッグ(`owner/repo`)の完全一致である——dense 側は Vectorize metadata の `$eq`、sparse 側は `d.repo = ?`。短いリポジトリ名を渡すと1行にもマッチせず、返るレスポンスは「本当にヒットが無かった」場合と同じ形になる。`filters_unmatched` がこの2つを分ける: search mode では常に存在し、`[]` は適用した全フィルタが空でない母集合を選べたこと(つまり `count: 0` は真にヒットゼロ)を意味し、名前が載っていればそのフィルタの母集合が空、すなわち誤っているのはクエリではなくフィルタの値である。

区別にフィールドを割く理由は、エージェンティックな多段検索が silent zero のコストを反転させるからである。単発検索ならゼロは呼び出し側が見に行く行き止まりだが、検索ループの中では「この角度には何も無かった」という正常な中間結果として消費されて次へ回る。フィルタ不成立が表に出ないまま、クエリ予算を1回分、偽陰性に使って終わる。

判定は存在確認クエリ(`SELECT 1 FROM search_docs WHERE repo = ? LIMIT 1`)で、候補集合が空のときだけ走る——候補が1件でもあればフィルタが成立した証拠なので、追加の読みが hot path に乗ることはない。プローブ自体が失敗した場合は「観測していない不成立」を主張せず、何も報告しない。プローブ対象は `repo` のみ: もっともらしく見える誤値(フルスラッグに対する短いリポジトリ名)が存在するのはこのフィルタだからである。短い名前からフルスラッグへの自動解決は意図的に非スコープ——複数リポジトリにマッチする名前の曖昧解決を設計する必要がある。

**scan mode(query 空).** Vectorize / FTS5 / reranker を経由せず、structured store の recency endpoint から集約する。`since` / `until` は store 側へ push down されるので、窓に行があれば、その窓がどれだけ古くても返る。`since` 省略時の既定は `until` の 7 日前(`until` も省略時は現在の 7 日前)。`until` だけ指定した問い合わせが「下限が上限より新しい空窓」に潰れないための既定である。

Expand Down
8 changes: 7 additions & 1 deletion docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,13 @@ 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`
- top-level metadata: `fusion`, `dense_candidates`, `sparse_candidates`, `rerank_requested`, `rerank_applied`, `filters_unmatched`

**Unmatched filters (`filters_unmatched`).** `repo` takes the full slug (`owner/repo`) and matches exactly — on the dense side as a Vectorize metadata `$eq`, on the sparse side as `d.repo = ?`. A bare repository name therefore matches no row, and the response that comes back is shaped exactly like a genuine zero-hit search. `filters_unmatched` separates the two: it is always present in search mode, `[]` means every applied filter selected a non-empty population (so `count: 0` really is "no hits"), and a listed name means that filter's population is empty — the value is wrong, not the query.

The distinction is worth a field because agentic multi-step search inverts the cost of a silent zero. In a single search a zero is a dead end the caller inspects; in a search loop it is a normal intermediate result ("nothing down this angle") that the caller consumes and moves past, so the mis-specified filter never surfaces and one query out of the budget is spent on a false negative.

The check is an existence probe (`SELECT 1 FROM search_docs WHERE repo = ? LIMIT 1`) run only when the candidate set is empty — a non-empty candidate set already proves the filter matched, so the extra read stays off the hot path. A failed probe reports nothing rather than asserting a mismatch it did not observe. Only `repo` is probed: it is the filter with a plausible-looking wrong value. Resolving a short name to a full slug is deliberately out of scope — that needs an ambiguity design for a name matching several repositories.

**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
10 changes: 8 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; repo takes the full slug (owner/repo) and matches " +
"exactly, so a bare repository name selects nothing. In search mode the response carries " +
"filters_unmatched: any filter listed there matched no row in the index at all, which separates a " +
"mis-specified filter from a genuine zero-hit result. " +
"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.",
Expand All @@ -39,7 +42,10 @@ export const TOOLS = [
},
repo: {
type: "string",
description: "Filter by repository (owner/repo)",
description:
"Filter by repository — full slug (owner/repo), exact match. " +
"A bare repository name (\"my-repo\") matches nothing and yields an empty result set; " +
"search mode flags that case as \"repo\" in the response's filters_unmatched.",
},
state: {
type: "string",
Expand Down
12 changes: 12 additions & 0 deletions mcp-server/test/search-tool-schema.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,15 @@ test("tool and type descriptions document the wiki surface", () => {
assert.match(search.description, /wiki/i);
assert.match(typeParam.description, /wiki_doc/);
});

// gh#219: the proxy schema is the description a client actually reads, so the
// exact-match requirement on `repo` has to be stated here — a bare repository
// name silently selects nothing, and the caller has no way to see that from the
// zero-result response alone.
test("repo description states the full-slug exact match and the unmatched-filter signal", () => {
const repoParam = search?.inputSchema?.properties?.repo;
assert.ok(repoParam, "repo param is present in the mirrored schema");
assert.match(repoParam.description, /owner\/repo/);
assert.match(repoParam.description, /exact match/i);
assert.match(repoParam.description, /filters_unmatched/);
});
61 changes: 61 additions & 0 deletions src/fts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
reciprocalRankFusion,
escapeFtsQuery,
tokenizerKindForType,
detectUnmatchedFilters,
} from "./fts.js";

describe("fts: tokenizerKindForType", () => {
Expand Down Expand Up @@ -114,3 +115,63 @@ describe("fts: reciprocalRankFusion", () => {
expect(reciprocalRankFusion({ rankers: new Map([["dense", new Map()]]) })).toEqual([]);
});
});

// Issue #219. Two searches that both return zero results are indistinguishable
// to the caller: one found nothing, the other filtered on a repo slug that
// selects no rows at all. `detectUnmatchedFilters` is what makes the responses
// differ, so the test pins the decision, not the SQL (the SQL is exercised
// against a real D1 in fts.workers.test.ts).
describe("fts: detectUnmatchedFilters (#219)", () => {
/** Minimal D1 stand-in: records probes, answers from a fixed repo set. */
function fakeDb(indexedRepos: string[], opts: { throws?: boolean } = {}) {
const probes: string[] = [];
const db = {
probes,
prepare() {
return {
bind(repo: string) {
probes.push(repo);
return {
first: async () => {
if (opts.throws) throw new Error("D1_ERROR: unreachable");
return indexedRepos.includes(repo) ? { present: 1 } : null;
},
};
},
};
},
};
return db as typeof db & D1Database;
}

it("flags repo when the filter value selects no indexed row", async () => {
const db = fakeDb(["Liplus-Project/liplus-language"]);
// Bare repository name — the exact-match filter yields an empty population.
expect(await detectUnmatchedFilters(db, { repo: "liplus-language" }, 0)).toEqual(["repo"]);
expect(db.probes).toEqual(["liplus-language"]);
});

it("stays empty for a genuine zero-hit search on a repo that exists", async () => {
const db = fakeDb(["Liplus-Project/liplus-language"]);
expect(
await detectUnmatchedFilters(db, { repo: "Liplus-Project/liplus-language" }, 0),
).toEqual([]);
});

it("does not probe when candidates exist (the filter provably matched)", async () => {
const db = fakeDb([]);
expect(await detectUnmatchedFilters(db, { repo: "anything" }, 7)).toEqual([]);
expect(db.probes).toEqual([]);
});

it("does not probe when no repo filter was applied", async () => {
const db = fakeDb([]);
expect(await detectUnmatchedFilters(db, {}, 0)).toEqual([]);
expect(db.probes).toEqual([]);
});

it("reports nothing when the probe itself fails (never assert an unobserved mismatch)", async () => {
const db = fakeDb([], { throws: true });
expect(await detectUnmatchedFilters(db, { repo: "owner/repo" }, 0)).toEqual([]);
});
});
63 changes: 63 additions & 0 deletions src/fts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,69 @@ export async function deleteFtsRow(
.run();
}

/**
* True when at least one indexed row carries this exact `repo` value.
*
* Existence probe behind the `filters_unmatched` observability field (issue #219).
* The `repo` filter is an exact match on the full slug (`owner/repo`) — a bare
* repository name matches no row, and the resulting empty candidate set is shaped
* exactly like a genuine zero-hit search. The caller runs this probe only on that
* ambiguous shape, so the extra D1 read never sits on the hot path.
*
* `LIMIT 1` on the indexed `repo` column, no FTS5 involvement: the query stops at
* the first matching row instead of counting the population.
*/
export async function repoHasIndexedRows(
db: D1Database,
repo: string,
): Promise<boolean> {
const row = await db
.prepare(`SELECT 1 AS present FROM search_docs WHERE repo = ? LIMIT 1`)
.bind(repo)
.first<{ present: number }>();
return row != null;
}

/**
* Names of the applied filters whose selected population is empty (issue #219).
*
* Answers the question the candidate count cannot: "did the search find nothing,
* or did the filter select nothing?" Both produce `count: 0`, and a caller doing
* multi-step agentic search reads a zero as a normal intermediate result
* ("nothing down this angle") and moves on — so a mis-specified filter is
* consumed as a false negative instead of being noticed.
*
* Rules the shape of the check:
* - probe only when `candidateCount === 0`. A non-empty candidate set proves
* every applied filter matched, so the extra D1 read stays off the hot path.
* - a probe failure is not a finding. An unreachable D1 yields an empty list
* (the safer direction: never assert a mismatch that was not observed).
*
* `repo` is the only filter probed. It is the one where a plausible-looking wrong
* value exists — the bare repository name against the required `owner/repo` slug.
* `state` / `type` are enum-constrained, and `milestone` / `assignee` do not have
* a comparable near-miss form.
*/
export async function detectUnmatchedFilters(
db: D1Database,
filters: { repo?: string },
candidateCount: number,
): Promise<string[]> {
const unmatched: string[] = [];
if (candidateCount > 0) return unmatched;
if (filters.repo) {
try {
if (!(await repoHasIndexedRows(db, filters.repo))) unmatched.push("repo");
} catch (err) {
console.error(
"detectUnmatchedFilters: repo probe failed:",
err instanceof Error ? err.message : String(err),
);
}
}
return unmatched;
}

/** Hit returned by FTS5 BM25 query. `score` is the raw bm25() value (lower = better). */
export interface FtsHit {
vectorId: string;
Expand Down
37 changes: 37 additions & 0 deletions src/fts.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { env, applyD1Migrations } from "cloudflare:test";
import {
upsertFtsRow,
queryFts,
repoHasIndexedRows,
deleteFtsRow,
backfillNatSegments,
tokenizerKindForType,
Expand Down Expand Up @@ -589,6 +590,42 @@ describe("fts D1: structured filters", () => {
});
});

// Issue #219: an unmatched `repo` filter and a genuine zero-hit search produce
// the same empty candidate set, so the response cannot be told apart by the
// caller. `repoHasIndexedRows` is the probe that separates them — it is what
// makes `filters_unmatched` in the search response answerable.
describe("fts D1: repoHasIndexedRows (filters_unmatched probe)", () => {
it("separates an unmatched repo filter from a genuine zero-hit search", async () => {
const repo = "t/repo-probe";
await upsertFtsRow(
env.DB_FTS,
mkRow({ vectorId: "i:repo-probe", type: "issue", repo, content: "indexed probe subject" }),
);

// Both queries return zero hits — indistinguishable on the hit set alone.
const wrongSlug = await queryFts(env.DB_FTS, "indexed", 10, { repo: "repo-probe" });
const noSuchTerm = await queryFts(env.DB_FTS, "unrelatedtermnowhere", 10, { repo });
expect(wrongSlug).toEqual([]);
expect(noSuchTerm).toEqual([]);

// The probe is what tells them apart: the bare name selects no population,
// the full slug selects one that simply held no match for the query.
expect(await repoHasIndexedRows(env.DB_FTS, "repo-probe")).toBe(false);
expect(await repoHasIndexedRows(env.DB_FTS, repo)).toBe(true);
});

it("matches the full slug exactly (no prefix or suffix match)", async () => {
const repo = "t/repo-exact";
await upsertFtsRow(
env.DB_FTS,
mkRow({ vectorId: "i:repo-exact", type: "issue", repo, content: "exact match subject" }),
);
expect(await repoHasIndexedRows(env.DB_FTS, "t/repo-exac")).toBe(false);
expect(await repoHasIndexedRows(env.DB_FTS, "t/repo-exact-extra")).toBe(false);
expect(await repoHasIndexedRows(env.DB_FTS, "t/repo-exact")).toBe(true);
});
});

describe("fts D1: queryFts edge cases", () => {
it("returns [] for an empty / whitespace query (no MATCH)", async () => {
const repo = "t/empty";
Expand Down
Loading
Loading