Skip to content

Commit 3edb10a

Browse files
mogeryclaude
andcommitted
feat(api/search): neighbor + group-aware budgeting for highlights
Replace the service-side raw-line char budget with client-side budgeting over the model's scored spans (highlight-budget.ts): - neighbor policy: each selected line may pull in its ±1 adjacent lines for context, capped at 35% of the budget so context can't crowd out answer lines. - group budget: merge adjacent selected lines into blocks, rank blocks by best span score, emit best blocks in page order until the char budget is reached — so many tiny unrelated lines don't each eat the budget. generateHighlightsBatch now returns scored spans (index+score) and no longer sends max_highlight_chars (budgeting moved client-side). highlights.ts runs selectHighlightIndices before assembleAnswer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bec36dd commit 3edb10a

5 files changed

Lines changed: 251 additions & 35 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { selectHighlightIndices } from "./highlight-budget";
2+
3+
describe("selectHighlightIndices", () => {
4+
it("returns [] when there are no scored spans", () => {
5+
expect(selectHighlightIndices([10, 10], [])).toEqual([]);
6+
});
7+
8+
it("ignores out-of-range / invalid span indices", () => {
9+
const out = selectHighlightIndices(
10+
[10, 10],
11+
[
12+
{ index: 5, score: 0.9 },
13+
{ index: -1, score: 0.8 },
14+
],
15+
);
16+
expect(out).toEqual([]);
17+
});
18+
19+
it("expands a selected line with its ±1 neighbors", () => {
20+
const out = selectHighlightIndices(
21+
[10, 10, 10, 10, 10],
22+
[{ index: 2, score: 0.9 }],
23+
);
24+
expect(out).toEqual([1, 2, 3]);
25+
});
26+
27+
it("does not add neighbors that exceed the neighbor budget", () => {
28+
// Neighbors are 100 chars each; neighbor budget = floor(100 * 0.35) = 35.
29+
const out = selectHighlightIndices(
30+
[100, 100, 10, 100, 100],
31+
[{ index: 2, score: 0.9 }],
32+
{ maxChars: 100, neighborBudgetFraction: 0.35 },
33+
);
34+
expect(out).toEqual([2]);
35+
});
36+
37+
it("gives neighbor budget to the higher-scoring line first", () => {
38+
// Budget allows exactly one 50-char neighbor. index 4 (0.9) should claim it
39+
// (its neighbor 3), leaving index 0 (0.4) without one.
40+
const out = selectHighlightIndices(
41+
[50, 50, 50, 50, 50],
42+
[
43+
{ index: 0, score: 0.4 },
44+
{ index: 4, score: 0.9 },
45+
],
46+
{ maxChars: 1000, neighborBudgetFraction: 0.05 },
47+
);
48+
expect(out).toEqual([0, 3, 4]);
49+
});
50+
51+
it("merges adjacent selections (incl. a bridging neighbor) into one block", () => {
52+
const out = selectHighlightIndices(
53+
[10, 10, 10, 10],
54+
[
55+
{ index: 0, score: 0.3 },
56+
{ index: 2, score: 0.9 },
57+
],
58+
);
59+
expect(out).toEqual([0, 1, 2, 3]);
60+
});
61+
62+
it("emits the highest-scoring block first and drops blocks that exceed budget", () => {
63+
// Two non-adjacent blocks of 100 chars; budget fits only one.
64+
const out = selectHighlightIndices(
65+
[100, 100, 100, 100, 100, 100, 100],
66+
[
67+
{ index: 0, score: 0.5 },
68+
{ index: 6, score: 0.9 },
69+
],
70+
{ maxChars: 150, neighborBudgetFraction: 0 },
71+
);
72+
expect(out).toEqual([6]);
73+
});
74+
75+
it("returns chosen blocks in page order, not score order", () => {
76+
const out = selectHighlightIndices(
77+
[100, 100, 100, 100, 100, 100, 100],
78+
[
79+
{ index: 0, score: 0.5 },
80+
{ index: 6, score: 0.9 },
81+
],
82+
{ maxChars: 500, neighborBudgetFraction: 0 },
83+
);
84+
expect(out).toEqual([0, 6]);
85+
});
86+
87+
it("always keeps the top block even if it alone exceeds the budget", () => {
88+
const out = selectHighlightIndices([1000], [{ index: 0, score: 0.5 }], {
89+
maxChars: 800,
90+
});
91+
expect(out).toEqual([0]);
92+
});
93+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { ScoredSpan } from "./highlight-model";
2+
3+
// Turn the model's scored spans into the final set of line indices to emit,
4+
// within a character budget. Two policies, applied in order:
5+
//
6+
// 1. Neighbor policy: each selected line may pull in its ±1 adjacent lines for
7+
// context, but neighbors collectively get at most NEIGHBOR_BUDGET_FRACTION
8+
// of the budget — so context never crowds out actual answer lines.
9+
// 2. Group budget: merge adjacent selected lines into contiguous blocks, rank
10+
// blocks by their best span score, and emit the best blocks (in page order)
11+
// until the budget is reached. Budgeting by block rather than raw line keeps
12+
// a coherent passage together instead of letting many tiny unrelated lines
13+
// each eat into the budget.
14+
15+
// Total character budget for the assembled snippet (approximate — measured on
16+
// raw line text, not the markdown assembleAnswer adds back).
17+
const MAX_HIGHLIGHT_CHARS = 800;
18+
// Neighbor (context) lines may use at most this fraction of the budget.
19+
const NEIGHBOR_BUDGET_FRACTION = 0.35;
20+
21+
interface Block {
22+
indices: number[];
23+
chars: number;
24+
score: number; // best core-span score in the block
25+
}
26+
27+
/**
28+
* Choose which line indices to emit. `lineLengths[i]` is the character length of
29+
* line i (sentences[i].text.length); `scored` are the model's selected spans.
30+
* Returns indices in ascending (page) order, ready for assembleAnswer.
31+
*/
32+
export function selectHighlightIndices(
33+
lineLengths: number[],
34+
scored: ScoredSpan[],
35+
opts: { maxChars?: number; neighborBudgetFraction?: number } = {},
36+
): number[] {
37+
const maxChars = opts.maxChars ?? MAX_HIGHLIGHT_CHARS;
38+
const neighborFraction =
39+
opts.neighborBudgetFraction ?? NEIGHBOR_BUDGET_FRACTION;
40+
const n = lineLengths.length;
41+
42+
// Core selected lines (valid, in range). Keep the best score per index.
43+
const coreScore = new Map<number, number>();
44+
for (const s of scored) {
45+
if (!Number.isInteger(s.index) || s.index < 0 || s.index >= n) continue;
46+
const prev = coreScore.get(s.index);
47+
if (prev === undefined || s.score > prev) coreScore.set(s.index, s.score);
48+
}
49+
if (coreScore.size === 0) return [];
50+
51+
// Process core lines best-score first so higher-value lines claim neighbor
52+
// budget before lower-value ones.
53+
const coreByScore = [...coreScore.entries()].sort((a, b) => b[1] - a[1]);
54+
55+
// Neighbor expansion (±1 line), capped at a fraction of the total budget.
56+
const neighborBudget = Math.floor(maxChars * neighborFraction);
57+
const selected = new Set<number>(coreScore.keys());
58+
let neighborChars = 0;
59+
for (const [idx] of coreByScore) {
60+
for (const nb of [idx - 1, idx + 1]) {
61+
if (nb < 0 || nb >= n) continue;
62+
if (selected.has(nb)) continue;
63+
if (neighborChars + lineLengths[nb] > neighborBudget) continue;
64+
selected.add(nb);
65+
neighborChars += lineLengths[nb];
66+
}
67+
}
68+
69+
// Merge adjacent selected indices into contiguous blocks.
70+
const sorted = [...selected].sort((a, b) => a - b);
71+
const blocks: Block[] = [];
72+
for (const idx of sorted) {
73+
const last = blocks[blocks.length - 1];
74+
const score = coreScore.get(idx) ?? -Infinity;
75+
if (last && idx === last.indices[last.indices.length - 1] + 1) {
76+
last.indices.push(idx);
77+
last.chars += lineLengths[idx];
78+
last.score = Math.max(last.score, score);
79+
} else {
80+
blocks.push({ indices: [idx], chars: lineLengths[idx], score });
81+
}
82+
}
83+
84+
// Rank blocks by best score and emit until the budget is reached. Always keep
85+
// the top block so we return something even if it alone exceeds the budget.
86+
blocks.sort((a, b) => b.score - a.score);
87+
const chosen: Block[] = [];
88+
let total = 0;
89+
for (const b of blocks) {
90+
if (chosen.length > 0 && total + b.chars > maxChars) continue;
91+
chosen.push(b);
92+
total += b.chars;
93+
}
94+
95+
return chosen.flatMap(b => b.indices).sort((a, b) => a - b);
96+
}

apps/api/src/search/highlight-model.test.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,14 @@ describe("generateHighlightsBatch", () => {
6363
query: "q1",
6464
lines: ["a one", "b one"],
6565
});
66-
// Threshold + top_k + budget are always sent so the cutoff matches the trained format.
66+
// Threshold + top_k are sent; char budgeting is done client-side (group-aware),
67+
// so max_highlight_chars is intentionally NOT sent.
6768
expect(typeof sent.requests[0].threshold).toBe("number");
6869
expect(typeof sent.requests[0].top_k).toBe("number");
69-
expect(typeof sent.requests[0].max_highlight_chars).toBe("number");
70+
expect(sent.requests[0].max_highlight_chars).toBeUndefined();
7071
});
7172

72-
it("returns the selected span indices per page, aligned by request order", async () => {
73+
it("returns the scored spans per page, aligned by request order", async () => {
7374
mockFetchOnce({
7475
results: [
7576
{
@@ -90,7 +91,13 @@ describe("generateHighlightsBatch", () => {
9091
{ logger },
9192
);
9293

93-
expect(out).toEqual([[0, 2], [1]]);
94+
expect(out).toEqual([
95+
[
96+
{ index: 0, score: 0.9 },
97+
{ index: 2, score: 0.5 },
98+
],
99+
[{ index: 1, score: 0.7 }],
100+
]);
94101
});
95102

96103
it("returns an empty array for a page with no selected spans", async () => {
@@ -107,26 +114,31 @@ describe("generateHighlightsBatch", () => {
107114
expect(out).toEqual([[], []]);
108115
});
109116

110-
it("ignores malformed (non-integer / missing) indices", async () => {
117+
it("ignores malformed (non-integer index / missing score) spans", async () => {
111118
mockFetchOnce({
112119
results: [
113120
{
114121
highlights: [
115122
{ index: 1, score: 0.9 },
116-
{ score: 0.8 }, // missing index
117-
{ index: 2.5, score: 0.7 }, // non-integer
123+
{ index: 4 }, // missing score
124+
{ index: 2.5, score: 0.7 }, // non-integer index
118125
{ index: 3, score: 0.6 },
119126
],
120127
},
121128
],
122129
});
123130

124131
const out = await generateHighlightsBatch(
125-
[{ query: "q", lines: ["a", "b", "c", "d"] }],
132+
[{ query: "q", lines: ["a", "b", "c", "d", "e"] }],
126133
{ logger },
127134
);
128135

129-
expect(out).toEqual([[1, 3]]);
136+
expect(out).toEqual([
137+
[
138+
{ index: 1, score: 0.9 },
139+
{ index: 3, score: 0.6 },
140+
],
141+
]);
130142
});
131143

132144
it("falls back to nulls (one per item) when the service errors", async () => {

apps/api/src/search/highlight-model.ts

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,29 @@ import { config } from "../config";
55
// trained on the line-like spans `parseMarkdownToSentences` produces, so callers
66
// pass those spans as explicit `lines`, the service scores each against the
77
// query, and returns the selected spans as `highlights[]` with their `index`
8-
// back into the input `lines` array. Callers map those indices through
9-
// `assembleAnswer` to rebuild structure (tables/code). We push every page
10-
// through one `/batch_highlight` call (resident GPU model). Endpoint + token are
11-
// config-gated (HIGHLIGHT_MODEL_URL / HIGHLIGHT_MODEL_TOKEN); callers must
12-
// confirm they're set via highlightsEnvReady() before invoking.
8+
// back into the input `lines` array (and a `score`). Callers run those scored
9+
// spans through the neighbor/group budgeter, then `assembleAnswer` to rebuild
10+
// structure (tables/code). We push every page through one `/batch_highlight`
11+
// call (resident GPU model). Endpoint + token are config-gated
12+
// (HIGHLIGHT_MODEL_URL / HIGHLIGHT_MODEL_TOKEN); callers must confirm they're
13+
// set via highlightsEnvReady() before invoking.
1314

1415
// Experimental score cutoff for keeping a span, tuned against the line-span
1516
// format the model was trained on.
1617
const HIGHLIGHT_THRESHOLD = 0.08;
1718
// Cap on how many spans to keep per page after thresholding (highest-scoring).
19+
// We do char budgeting ourselves (group-aware, in selectHighlightIndices), so we
20+
// don't send max_highlight_chars — the service would budget by raw lines.
1821
const HIGHLIGHT_TOP_K = 12;
19-
// Character budget per page; the service keeps the highest-scoring spans until
20-
// the budget is reached (measured on raw span text).
21-
const MAX_HIGHLIGHT_CHARS = 800;
2222
const REQUEST_TIMEOUT_MS = 30000;
2323

24+
// One scored candidate span: `index` into the page's `lines`, `score` from the
25+
// model. Consumed by the neighbor/group budgeter (selectHighlightIndices).
26+
export interface ScoredSpan {
27+
index: number;
28+
score: number;
29+
}
30+
2431
interface HighlightItem {
2532
query: string;
2633
// Candidate spans for the page, in document order — from
@@ -41,14 +48,18 @@ interface BatchHighlightResponse {
4148
results?: HighlightResult[];
4249
}
4350

44-
// Pull the selected span indices out of one page's result. The service already
45-
// applied the threshold / char budget, so we just collect the kept indices.
46-
function selectedIndices(result: HighlightResult | undefined): number[] {
51+
// Pull the scored spans out of one page's result, keeping only well-formed ones
52+
// (integer index, numeric score). The service already applied threshold + top_k.
53+
function scoredSpans(result: HighlightResult | undefined): ScoredSpan[] {
4754
const highlights = result?.highlights ?? [];
48-
const out: number[] = [];
55+
const out: ScoredSpan[] = [];
4956
for (const h of highlights) {
50-
if (typeof h.index === "number" && Number.isInteger(h.index)) {
51-
out.push(h.index);
57+
if (
58+
typeof h.index === "number" &&
59+
Number.isInteger(h.index) &&
60+
typeof h.score === "number"
61+
) {
62+
out.push({ index: h.index, score: h.score });
5263
}
5364
}
5465
return out;
@@ -57,14 +68,14 @@ function selectedIndices(result: HighlightResult | undefined): number[] {
5768
/**
5869
* Score many pages' candidate spans in a single batch call to the
5970
* query-highlights model. Returns an array aligned to `items`: for each page,
60-
* the indices (into that page's `lines`) the model selected, or null when the
61-
* whole batch fails (so callers can keep the provider snippet). A page with no
62-
* spans clearing the threshold yields an empty array, not null.
71+
* the scored spans (index into that page's `lines`, plus score) the model
72+
* selected, or null when the whole batch fails (so callers can keep the provider
73+
* snippet). A page with no spans clearing the threshold yields an empty array.
6374
*/
6475
export async function generateHighlightsBatch(
6576
items: HighlightItem[],
6677
opts: { logger: Logger },
67-
): Promise<(number[] | null)[]> {
78+
): Promise<(ScoredSpan[] | null)[]> {
6879
if (items.length === 0) return [];
6980

7081
const controller = new AbortController();
@@ -83,7 +94,6 @@ export async function generateHighlightsBatch(
8394
lines: item.lines,
8495
threshold: HIGHLIGHT_THRESHOLD,
8596
top_k: HIGHLIGHT_TOP_K,
86-
max_highlight_chars: MAX_HIGHLIGHT_CHARS,
8797
})),
8898
}),
8999
signal: controller.signal,
@@ -98,15 +108,15 @@ export async function generateHighlightsBatch(
98108

99109
const data = (await res.json()) as BatchHighlightResponse;
100110
const results = data.results ?? [];
101-
const indices = items.map((_, i) => selectedIndices(results[i]));
111+
const spans = items.map((_, i) => scoredSpans(results[i]));
102112

103113
opts.logger.info("query highlights batch generated", {
104114
pages: items.length,
105-
withHighlights: indices.filter(x => x.length > 0).length,
115+
withHighlights: spans.filter(x => x.length > 0).length,
106116
elapsedMs: Date.now() - start,
107117
});
108118

109-
return indices;
119+
return spans;
110120
} catch (error) {
111121
opts.logger.warn("query highlights batch failed", {
112122
error: error instanceof Error ? error.message : String(error),

0 commit comments

Comments
 (0)