scvcoder commited on
Commit
3665623
·
verified ·
1 Parent(s): 74df841

fix: cross-source reranker → RetrievalResult.excerpts (UI 정렬 일치)

Browse files

- pipeline.build_context: ranker.rank 후 cross-source rerank 적용
- context_builder: rerank 호출 분리, build()는 슬라이스만 (NameError 버그 동시 수정)
- ui/gradio + server: 3-tier 정렬 제거 → reranker 적합도 순으로 카드 표시
- server: constitutional/oldnew/article_history 라벨·배경색 누락 보강

src/kpaa/pipeline.py CHANGED
@@ -78,10 +78,14 @@ async def build_context(
78
  )
79
  raw = await retrieve(plan, client=client, on_progress=on_progress)
80
  ranked = ranker.rank(raw)
81
- block = context_builder.build(ranked, query=plan.query)
 
 
 
 
82
  return RetrievalResult(
83
  plan=plan,
84
- excerpts=ranked,
85
  context_block=block,
86
  elapsed_ms=int((time.monotonic() - t0) * 1000),
87
  )
 
78
  )
79
  raw = await retrieve(plan, client=client, on_progress=on_progress)
80
  ranked = ranker.rank(raw)
81
+ # Cross-source reranker 를 *전체 excerpts* 에 적용해 source 무관 적합도 순으로
82
+ # 재정렬. RetrievalResult.excerpts 가 이 순서를 그대로 가져 LLM 컨텍스트
83
+ # ([근거1]..[근거N]) 와 UI references 패널 모두 reranker 신호로 정렬됨.
84
+ reranked = context_builder.cross_source_rerank(plan.query, ranked, top_k=None)
85
+ block = context_builder.build(reranked, query=plan.query)
86
  return RetrievalResult(
87
  plan=plan,
88
+ excerpts=reranked,
89
  context_block=block,
90
  elapsed_ms=int((time.monotonic() - t0) * 1000),
91
  )
src/kpaa/retrieval/context_builder.py CHANGED
@@ -5,20 +5,15 @@
5
 
6
  토큰 예산은 글자수로 근사 (한국어 1글자 ≈ 1.5~2 토큰).
7
 
8
- KPAA v2.4*답변 LLM 속도* prefill 토큰 수선형 비례 (CPU 50-100 tok/s)
9
- 라서 RAG context 압축이 응답 시간 단축. 정책:
10
-
11
- - max_excerpts=7: ranker 정렬 상위 7건을 LLM 에 전달. LLM 그중 가장
12
- 적합한 3건을 골라 명시 인용 (system prompt 분량 강제).
13
- 7→3 큐레이션 — 5건 cap 에서 빠져나간 좋은 후보까지 LLM
14
- 판단 풀에 들어오면서, 8대비 prefill 토큰은 절감.
15
- 머지 excerpts *retrieval result 자체* 살아있
16
- Gradio references 패널엔 전체 노출, LLM 입력만 cap.
17
- - **다양화 픽 (round-robin by source_type)**: 단순 상위 N 자르기는 ranker
18
- priority 가 같은 type (예: 법조문 3건 연속) 이 7건을 독점
19
- 가능 → 7건이어도 다양성 부족. 그래서 build() 가
20
- source_type 별 큐를 만들어 라운드로빈으로 N개 채움. 같은
21
- 타입 안에선 ranker 순서 유지.
22
  - DEFAULT_MAX_CHARS=8_000: 7건 × 평균 1000자 ≈ 7K자 (≈ 3,500 토큰).
23
  excerpt 가 길면 cap 에 닿아 마지막 항목 절단.
24
  """
@@ -98,23 +93,29 @@ def _rerank_text(e: Excerpt) -> str:
98
  return f"{title}\n{body}" if title else body
99
 
100
 
101
- def _cross_source_rerank(query: str, excerpts: list[Excerpt], *, k: int) -> list[Excerpt]:
102
- """Cross-encoder reranker source 무관 적합도 top-k 선정.
 
 
 
 
 
 
 
 
103
 
104
  이전 버전(2026-05-05까지)에는 `_diversified_pick` 라운드로빈으로 source_type
105
  다양성을 강제했으나, cross-encoder 도입 후엔 source 무관 적합도가 가장 신뢰할
106
  만한 신호 — 정답이 한 source 에 집중돼 있어도 reranker 가 옳게 골라내고,
107
  실제로 다양한 source 가 적합하면 자연스럽게 섞임.
108
-
109
- Reranker 미설치/disabled 또는 query 없으면 입력 순서 그대로 슬라이스 (이때는
110
- ranker.rank 의 sort_priority 순).
111
  """
112
- if not excerpts or k <= 0:
113
- return excerpts[:k]
114
- if len(excerpts) <= k:
115
- return list(excerpts)
 
116
  if not query:
117
- return excerpts[:k]
118
  try:
119
  from kpaa.retrieval.reranker import Reranker
120
 
@@ -122,35 +123,40 @@ def _cross_source_rerank(query: str, excerpts: list[Excerpt], *, k: int) -> list
122
  except Exception: # noqa: BLE001
123
  rr = None
124
  if rr is None:
125
- return excerpts[:k]
126
- return rr.rerank(query, excerpts, text_fn=_rerank_text, top_k=k)
 
 
 
 
 
 
 
 
127
 
128
 
129
  def build(
130
  excerpts: list[Excerpt],
131
  *,
132
- query: str | None = None,
133
  max_chars: int = DEFAULT_MAX_CHARS,
134
  max_excerpts: int | None = DEFAULT_MAX_EXCERPTS,
135
  ) -> str:
136
  """`[근거1] ... [근거2] ...` 형태의 단일 문자열 반환.
137
 
138
  Args:
139
- excerpts: ranker 정렬 후의 후보 리스트 (source 간 dedup·우선위 적용됨).
140
- query: 사용자 원본 질문. cross-source reranker 입력. 없으면 입력 순서 슬라이스.
141
  max_chars: 최종 블록 글자 수 cap. 초과 시 마지막 항목 절단.
142
  max_excerpts: LLM 에 전달할 상위 N건 cap. None 이면 무제한 (전체 사용).
143
  기본 7 — 답변 LLM prefill 토큰을 줄여 응답 속도 ↑.
144
- cap 적용 cross-encoder 가 source 무관 적합도 순top-N
145
- 을 골라냄. 라운드로빈 다양화 X (reranker 신호 우선).
146
- retrieval result 의 `excerpts` 자체는 안 건드리므로 UI
147
- references 패널엔 전체가 그대로 노출됨.
148
  """
149
  if not excerpts:
150
  return "(검색된 근거가 없습니다.)"
151
 
152
  if max_excerpts is not None:
153
- excerpts = _cross_source_rerank(query or "", excerpts, k=max_excerpts)
154
 
155
  blocks: list[str] = []
156
  used = 0
@@ -176,4 +182,4 @@ def build(
176
  return "\n\n".join(blocks)
177
 
178
 
179
- __all__ = ["build", "DEFAULT_MAX_CHARS", "DEFAULT_MAX_EXCERPTS"]
 
5
 
6
  토큰 예산은 글자수로 근사 (한국어 1글자 ≈ 1.5~2 토큰).
7
 
8
+ KPAA v2.5cross-source reranker pipeline 단계 *전체 excerpts*
9
+ 적용되어 `RetrievalResult.excerpts` 자체가 reranker 적합도 . build() 는
10
+ 단순히 상위 N건을 슬라이스해 LLM 컨텍스트로 만든다 (재정렬·라운드로빈 ✗).
11
+ UI references 패널도 `RetrievalResult.excerpts` 그대로 표시 답변·UI 양쪽
12
+ 모두 reranker 신호 우선.
13
+
14
+ - max_excerpts=7: 상위 7 LLM 전달. 7→3 큐레이션 — 5건 cap 에서
15
+ 빠져 좋은 후보까지 LLM 판단 오면서, 8건 대비
16
+ prefill 토큰은 절감.
 
 
 
 
 
17
  - DEFAULT_MAX_CHARS=8_000: 7건 × 평균 1000자 ≈ 7K자 (≈ 3,500 토큰).
18
  excerpt 가 길면 cap 에 닿아 마지막 항목 절단.
19
  """
 
93
  return f"{title}\n{body}" if title else body
94
 
95
 
96
+ def cross_source_rerank(
97
+ query: str, excerpts: list[Excerpt], *, top_k: int | None = None
98
+ ) -> list[Excerpt]:
99
+ """Cross-encoder reranker 로 source 무관 적합도 순 정렬.
100
+
101
+ `top_k=None` (기본): 전체를 reranker 점수 순으로 *재정렬*만 (cap ✗).
102
+ `top_k=N`: 상위 N건만.
103
+
104
+ Reranker 미설치/disabled 또는 query 없으면 입력 순서 그대로 (이때는
105
+ ranker.rank 의 sort_priority 순).
106
 
107
  이전 버전(2026-05-05까지)에는 `_diversified_pick` 라운드로빈으로 source_type
108
  다양성을 강제했으나, cross-encoder 도입 후엔 source 무관 적합도가 가장 신뢰할
109
  만한 신호 — 정답이 한 source 에 집중돼 있어도 reranker 가 옳게 골라내고,
110
  실제로 다양한 source 가 적합하면 자연스럽게 섞임.
 
 
 
111
  """
112
+ if not excerpts:
113
+ return []
114
+ k = top_k if top_k is not None else len(excerpts)
115
+ if k <= 0:
116
+ return []
117
  if not query:
118
+ return list(excerpts[:k])
119
  try:
120
  from kpaa.retrieval.reranker import Reranker
121
 
 
123
  except Exception: # noqa: BLE001
124
  rr = None
125
  if rr is None:
126
+ return list(excerpts[:k])
127
+ # Reranker.rerank len<=top_k 일 때 short-circuit 으로 입력 순서를 반환하므로,
128
+ # *전체 재정렬* 이 필요한 본 함수에선 직접 score+sort.
129
+ try:
130
+ pairs = [(query, _rerank_text(e)) for e in excerpts]
131
+ scores = rr.model.predict(pairs, show_progress_bar=False)
132
+ ranked = sorted(zip(excerpts, scores), key=lambda x: -float(x[1]))
133
+ return [e for e, _ in ranked[:k]]
134
+ except Exception: # noqa: BLE001
135
+ return list(excerpts[:k])
136
 
137
 
138
  def build(
139
  excerpts: list[Excerpt],
140
  *,
141
+ query: str | None = None, # noqa: ARG001 — 호환용. rerank 는 pipeline 단계로 이동됨.
142
  max_chars: int = DEFAULT_MAX_CHARS,
143
  max_excerpts: int | None = DEFAULT_MAX_EXCERPTS,
144
  ) -> str:
145
  """`[근거1] ... [근거2] ...` 형태의 단일 문자열 반환.
146
 
147
  Args:
148
+ excerpts: pipeline 에서 cross-source reranker 적용 후의 리스트 (적합도 순).
149
+ query: (deprecated) 호환 위해 남김. 실제 rerank 는 pipeline.build_context 수행.
150
  max_chars: 최종 블록 글자 수 cap. 초과 시 마지막 항목 절단.
151
  max_excerpts: LLM 에 전달할 상위 N건 cap. None 이면 무제한 (전체 사용).
152
  기본 7 — 답변 LLM prefill 토큰을 줄여 응답 속도 ↑.
153
+ 입력이 이미 reranker 적합도 순이므단순 슬라이스.
 
 
 
154
  """
155
  if not excerpts:
156
  return "(검색된 근거가 없습니다.)"
157
 
158
  if max_excerpts is not None:
159
+ excerpts = excerpts[:max_excerpts]
160
 
161
  blocks: list[str] = []
162
  used = 0
 
182
  return "\n\n".join(blocks)
183
 
184
 
185
+ __all__ = ["build", "cross_source_rerank", "DEFAULT_MAX_CHARS", "DEFAULT_MAX_EXCERPTS"]
src/kpaa/server.py CHANGED
@@ -556,6 +556,19 @@ def create_app() -> FastAPI:
556
  @app.get("/", response_class=HTMLResponse)
557
  async def index() -> str:
558
  # 루트 = Open WebUI + 참고자료 분할 화면. 백엔드 정보 페이지는 /info.
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  return _SPLIT_HTML
560
 
561
  @app.get("/info", response_class=HTMLResponse)
@@ -819,6 +832,9 @@ _SPLIT_HTML = """<!doctype html>
819
  --interp-bg: #6633bb;
820
  --prec-bg: #b03060;
821
  --admin-bg: #555555;
 
 
 
822
  }
823
  /* ─ 다크 변수 (OS 자동 — 토글 없음). 컴포넌트 룰은 CSS 끝의 별도 @media 참고. */
824
  @media (prefers-color-scheme: dark) {
@@ -842,6 +858,9 @@ _SPLIT_HTML = """<!doctype html>
842
  --interp-bg: #8b5cf6;
843
  --prec-bg: #db2777;
844
  --admin-bg: #6b7280;
 
 
 
845
  }
846
  }
847
 
@@ -879,6 +898,9 @@ _SPLIT_HTML = """<!doctype html>
879
  .badge.interpretation { background: var(--interp-bg); }
880
  .badge.precedent { background: var(--prec-bg); }
881
  .badge.admin_rule { background: var(--admin-bg); }
 
 
 
882
  .ref .citation { font-weight: 600; font-size: 0.9em; color: var(--text); }
883
  .ref .title { color: var(--muted); font-size: 0.86em; margin-bottom: 6px; }
884
  .ref .content { font-size: 0.84em; line-height: 1.55; color: var(--text); background: var(--content-bg); padding: 8px 10px; border-radius: 6px; white-space: pre-wrap; max-height: 240px; overflow-y: auto; border: 1px solid var(--border-soft); }
@@ -924,7 +946,7 @@ _SPLIT_HTML = """<!doctype html>
924
  // 테마는 OS prefers-color-scheme 자동 — 별도 토글 없음.
925
 
926
  // ─ references polling ─
927
- const LABEL = { case: "상담사례", guide: "안내서", law: "법조문", related_law: "관련 법령", pipc: "PIPC 결정", interpretation: "법령해석례", precedent: "판례", admin_rule: "행정규칙" };
928
  const refsEl = document.getElementById("refs");
929
  const refsCountEl = document.getElementById("refs-count");
930
  const metaEl = document.getElementById("meta");
@@ -939,19 +961,9 @@ function render(payload) {
939
  const geungeoSet = new Set(payload.geungeo_indices_in_answer || []); // 답변 본문 (근거N) 의 N
940
  const llmCount = rawExcerpts.filter(e => llmSet.has((e.citation || "").trim())).length;
941
 
942
- // 카드별 *원본 LLM 입력 순서* (1-based) 보존 정렬 후에유지되도록 미리 부여.
943
- // 순서가 답변 본문 (근거N) 의 N 과 일치 (context_builder 가 같은 순서로 [근거N] 박음).
944
- const indexed = rawExcerpts.map((e, i) => ({ ...e, _idx: i + 1 }));
945
-
946
- // 3-tier 정렬: LLM 명시 인용(근거N)(0) → LLM 전달(1) → 검색만(2). stable.
947
- // (근거N) chip 카드를 위로 올려 사용자가 명시 인용된 카드 빨리 찾을 수 있게.
948
- const tier = (e) => {
949
- const passed = llmSet.has((e.citation || "").trim());
950
- if (passed && geungeoSet.has(e._idx)) return 0;
951
- if (passed) return 1;
952
- return 2;
953
- };
954
- const excerpts = [...indexed].sort((a, b) => tier(a) - tier(b));
955
 
956
  // 헤더 — "8건 · LLM 전달 7건"
957
  if (rawExcerpts.length) {
@@ -1133,7 +1145,7 @@ _CHAT_HTML = """<!doctype html>
1133
  </section>
1134
  </div>
1135
  <script>
1136
- const LABEL = { case: "상담사례", guide: "안내서", law: "법조문", related_law: "관련 법령", pipc: "PIPC 결정", interpretation: "법령해석례", precedent: "판례", admin_rule: "행정규칙" };
1137
  const messagesEl = document.getElementById("messages");
1138
  const refsEl = document.getElementById("refs");
1139
  const refsCountEl = document.getElementById("refs-count");
 
556
  @app.get("/", response_class=HTMLResponse)
557
  async def index() -> str:
558
  # 루트 = Open WebUI + 참고자료 분할 화면. 백엔드 정보 페이지는 /info.
559
+ # 페이지 진입(리로드 포함) 시 우측 참고자료 서버 상태를 비움 — 이전 세션
560
+ # 잔여 _last_refs 가 폴링에 의해 즉시 렌더되는 것을 방지. HF 백엔드의
561
+ # _split_handler 와 동일 정책.
562
+ _last_refs.update({
563
+ "ts": time.time(),
564
+ "query": "",
565
+ "intents": [],
566
+ "jo_targets": [],
567
+ "elapsed_ms": 0,
568
+ "excerpts": [],
569
+ "llm_excerpt_citations": [],
570
+ "geungeo_indices_in_answer": [],
571
+ })
572
  return _SPLIT_HTML
573
 
574
  @app.get("/info", response_class=HTMLResponse)
 
832
  --interp-bg: #6633bb;
833
  --prec-bg: #b03060;
834
  --admin-bg: #555555;
835
+ --const-bg: #8b1e3f; /* 헌재 — 짙은 와인색 */
836
+ --oldnew-bg: #4a5568; /* 구·신 비교 — 슬레이트 그레이 */
837
+ --history-bg: #6b4c00; /* 조문 변천 — 다크 골드 */
838
  }
839
  /* ─ 다크 변수 (OS 자동 — 토글 없음). 컴포넌트 룰은 CSS 끝의 별도 @media 참고. */
840
  @media (prefers-color-scheme: dark) {
 
858
  --interp-bg: #8b5cf6;
859
  --prec-bg: #db2777;
860
  --admin-bg: #6b7280;
861
+ --const-bg: #c2456a;
862
+ --oldnew-bg: #94a3b8;
863
+ --history-bg: #d4a017;
864
  }
865
  }
866
 
 
898
  .badge.interpretation { background: var(--interp-bg); }
899
  .badge.precedent { background: var(--prec-bg); }
900
  .badge.admin_rule { background: var(--admin-bg); }
901
+ .badge.constitutional { background: var(--const-bg); }
902
+ .badge.oldnew { background: var(--oldnew-bg); }
903
+ .badge.article_history { background: var(--history-bg); }
904
  .ref .citation { font-weight: 600; font-size: 0.9em; color: var(--text); }
905
  .ref .title { color: var(--muted); font-size: 0.86em; margin-bottom: 6px; }
906
  .ref .content { font-size: 0.84em; line-height: 1.55; color: var(--text); background: var(--content-bg); padding: 8px 10px; border-radius: 6px; white-space: pre-wrap; max-height: 240px; overflow-y: auto; border: 1px solid var(--border-soft); }
 
946
  // 테마는 OS prefers-color-scheme 자동 — 별도 토글 없음.
947
 
948
  // ─ references polling ─
949
+ const LABEL = { case: "상담사례", guide: "안내서", law: "법조문", related_law: "관련 법령", pipc: "PIPC 결정", interpretation: "법령해석례", precedent: "판례", admin_rule: "행정규칙", constitutional: "헌법재판소", oldnew: "구·신 비교", article_history: "조문 변천" };
950
  const refsEl = document.getElementById("refs");
951
  const refsCountEl = document.getElementById("refs-count");
952
  const metaEl = document.getElementById("meta");
 
961
  const geungeoSet = new Set(payload.geungeo_indices_in_answer || []); // 답변 본문 (근거N) 의 N
962
  const llmCount = rawExcerpts.filter(e => llmSet.has((e.citation || "").trim())).length;
963
 
964
+ // pipeline cross-source reranker 매긴 적합 그대로 표시.
965
+ // 1-based 인덱스가 답변 본문 (근거N) 의 N 과 일치 (context_builder 가 같은 순서로 [근거N] 박음).
966
+ const excerpts = rawExcerpts.map((e, i) => ({ ...e, _idx: i + 1 }));
 
 
 
 
 
 
 
 
 
 
967
 
968
  // 헤더 — "8건 · LLM 전달 7건"
969
  if (rawExcerpts.length) {
 
1145
  </section>
1146
  </div>
1147
  <script>
1148
+ const LABEL = { case: "상담사례", guide: "안내서", law: "법조문", related_law: "관련 법령", pipc: "PIPC 결정", interpretation: "법령해석례", precedent: "판례", admin_rule: "행정규칙", constitutional: "헌법재판소", oldnew: "구·신 비교", article_history: "조문 변천" };
1149
  const messagesEl = document.getElementById("messages");
1150
  const refsEl = document.getElementById("refs");
1151
  const refsCountEl = document.getElementById("refs-count");
src/kpaa/ui/gradio.py CHANGED
@@ -60,6 +60,9 @@ _BADGE_COLOR = {
60
  "interpretation": "#6633bb",
61
  "precedent": "#b03060",
62
  "admin_rule": "#555",
 
 
 
63
  }
64
 
65
  _EXAMPLE_QUESTIONS = [
@@ -79,16 +82,15 @@ def _render_references_html(
79
  ) -> str:
80
  """우측 패널 HTML 카드 묶음 — server.py 분할 화면과 동일 정책.
81
 
82
- 표시 단계 (2-tier):
83
- - LLM 전달 (llm_passed_citations): 좌측 회색 표지 + 회색 "LLM 전달" 배지.
84
- 그 N건 *전부* 가 LLM 이 본 후보 (의미 매칭 추적은 안 함).
85
- - 검색만: 표시 없음.
86
- + 답변 본문에 (근거N) 으로 *명시* 등장한 카드는 추가로 "근거N" outline chip.
87
- (LLM 명시적으로 라벨링한 신호이고, 명시 안 된 청크도 답변에 영향 줬을
88
- 있으므로 별도 "채택" 강조 색상은 두지 않음.)
89
 
90
  Args:
91
- excerpts: 검색된 전체 (ranker 정렬 서). 1-based 위치 = (근거N) N.
92
  elapsed_ms: 검색 시간.
93
  llm_passed_citations: LLM 입력으로 전달된 상위 N건 citation set.
94
  geungeo_indices: 답변에 (근거N) 으로 등장한 N 들 (1-based).
@@ -104,20 +106,8 @@ def _render_references_html(
104
 
105
  llm_count = sum(1 for e in excerpts if (e.citation or "").strip() in llm_set)
106
 
107
- # 카드별 원본 LLM 입력 순서(1-based) 보존 — 정렬 후에도 (근거N) 매핑 유지.
108
- indexed: list[tuple[int, Excerpt]] = list(enumerate(excerpts, 1))
109
-
110
- # 3-tier 정렬 (stable): LLM 명시 인용(근거N) → LLM 전달 → 나머지.
111
- def _tier(item: tuple[int, Excerpt]) -> int:
112
- idx, e = item
113
- passed = (e.citation or "").strip() in llm_set
114
- if passed and idx in geungeo_set:
115
- return 0
116
- if passed:
117
- return 1
118
- return 2
119
-
120
- sorted_items = sorted(indexed, key=_tier)
121
 
122
  # 헤더 요약
123
  summary_parts = [f"총 {len(excerpts)}건 · 검색 {elapsed_ms}ms"]
@@ -130,7 +120,7 @@ def _render_references_html(
130
  f'<div style="padding:8px 12px;color:#888;font-size:0.82em;">{summary}</div>'
131
  ]
132
 
133
- for idx, e in sorted_items:
134
  label = _SOURCE_LABEL.get(e.source_type, e.source_type)
135
  color = _BADGE_COLOR.get(e.source_type, "#666")
136
  url = (e.metadata or {}).get("url", "").strip()
 
60
  "interpretation": "#6633bb",
61
  "precedent": "#b03060",
62
  "admin_rule": "#555",
63
+ "constitutional": "#8b1e3f",
64
+ "oldnew": "#4a5568",
65
+ "article_history": "#6b4c00",
66
  }
67
 
68
  _EXAMPLE_QUESTIONS = [
 
82
  ) -> str:
83
  """우측 패널 HTML 카드 묶음 — server.py 분할 화면과 동일 정책.
84
 
85
+ 카드 순서: pipeline 의 cross-source reranker 가 매긴 적합도 순 (=
86
+ `excerpts` 입력 순서) 그대로. UI 단계에서 별도 정렬 .
87
+
88
+ 배지:
89
+ - "LLM 전달" (llm_passed_citations 매칭): 회색 좌측 표지 + 회색 배지.
90
+ - "근거N" (geungeo_indices 매칭): 답변 본문(근거N) 으로 명시 등장한 카드.
 
91
 
92
  Args:
93
+ excerpts: pipeline reranker 적합도 순. 1-based 위치 = (근거N) N.
94
  elapsed_ms: 검색 시간.
95
  llm_passed_citations: LLM 입력으로 전달된 상위 N건 citation set.
96
  geungeo_indices: 답변에 (근거N) 으로 등장한 N 들 (1-based).
 
106
 
107
  llm_count = sum(1 for e in excerpts if (e.citation or "").strip() in llm_set)
108
 
109
+ # reranker 매긴 순서를 그대로 표시. 1-based 인덱스가 (근거N) N 과 일치.
110
+ indexed_items: list[tuple[int, Excerpt]] = list(enumerate(excerpts, 1))
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
  # 헤더 요약
113
  summary_parts = [f"총 {len(excerpts)}건 · 검색 {elapsed_ms}ms"]
 
120
  f'<div style="padding:8px 12px;color:#888;font-size:0.82em;">{summary}</div>'
121
  ]
122
 
123
+ for idx, e in indexed_items:
124
  label = _SOURCE_LABEL.get(e.source_type, e.source_type)
125
  color = _BADGE_COLOR.get(e.source_type, "#666")
126
  url = (e.metadata or {}).get("url", "").strip()