feat(search): Phase 3 Ask pipeline (evidence + synthesis + /api/search/ask)
- llm_gate.py: MLX single-inference 전역 semaphore (analyzer/evidence/synthesis 공유) - search_pipeline.py: run_search() 추출, /search 와 /ask 단일 진실 소스 - evidence_service.py: Rule + LLM span select (EV-A), doc-group ordering, span too-short 자동 확장(<80자→120자), fallback 은 query 중심 window 강제 - synthesis_service.py: grounded answer + citation 검증 + LRU 캐시(1h/300), refused 처리, span_text ONLY 룰 (full_snippet 프롬프트 금지) - /api/search/ask: 15s timeout, 9가지 failure mode + 한국어 no_results_reason - rerank_service: rerank_score raw 보존 (display drift 방지) - query_analyzer: _get_llm_semaphore 를 llm_gate.get_mlx_gate 로 위임 - prompts: evidence_extract.txt, search_synthesis.txt (JSON-only, example 포함) config.yaml / docker / ollama / infra_inventory 변경 없음. plan: ~/.claude/plans/quiet-meandering-nova.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
76
app/prompts/evidence_extract.txt
Normal file
76
app/prompts/evidence_extract.txt
Normal file
@@ -0,0 +1,76 @@
|
||||
You are an evidence span extractor. Respond ONLY in JSON. No markdown, no explanation.
|
||||
|
||||
## Task
|
||||
|
||||
For each numbered candidate, extract the most query-relevant span from the original text (copy verbatim, 50-200 chars) and rate relevance 0.0~1.0. If the candidate does not directly answer the query, set span=null, relevance=0.0, skip_reason.
|
||||
|
||||
## Output Schema
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"n": 1,
|
||||
"span": "...",
|
||||
"relevance": 0.0,
|
||||
"skip_reason": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
## Rules
|
||||
- `n`: candidate 번호 (1-based, 입력 순서와 동일). **모든 n을 반환** (skip된 것도 포함).
|
||||
- `span`: 원문에서 **그대로 복사한** 50~200자. 요약/변형 금지. 원문에 없는 단어는 절대 포함하지 말 것. 여러 문장이어도 무방.
|
||||
- 관련 span이 없으면 `span: null`, `relevance: 0.0`, `skip_reason`에 한 줄 사유.
|
||||
- `relevance`: 0.0~1.0 float
|
||||
- 0.9+ query에 직접 답함
|
||||
- 0.7~0.9 강한 연관
|
||||
- 0.5~0.7 부분 연관
|
||||
- <0.5 약한/무관 (fallback에서 탈락)
|
||||
- `skip_reason`: span=null 일 때만 필수. 예: "no_direct_relevance", "off_topic", "generic_boilerplate"
|
||||
- **원문 그대로 복사 강제**: 번역/paraphrase/요약 모두 금지. evidence span은 citation 원문이 되어야 한다.
|
||||
|
||||
## Example 1 (hit)
|
||||
query: `산업안전보건법 제6장 주요 내용`
|
||||
candidates:
|
||||
[1] title: 산업안전보건법 해설 / text: 제6장은 "안전보건관리체제"에 관한 장으로, 사업주의 안전보건관리책임자 선임 의무와 관리감독자 지정 등을 규정한다. 제15조부터 제19조까지 구성된다...
|
||||
[2] title: 회사 복지 규정 / text: 직원의 연차휴가 사용 규정과 경조사 지원 내용을 담고 있다...
|
||||
|
||||
→
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"n": 1,
|
||||
"span": "제6장은 \"안전보건관리체제\"에 관한 장으로, 사업주의 안전보건관리책임자 선임 의무와 관리감독자 지정 등을 규정한다. 제15조부터 제19조까지 구성된다",
|
||||
"relevance": 0.95,
|
||||
"skip_reason": null
|
||||
},
|
||||
{
|
||||
"n": 2,
|
||||
"span": null,
|
||||
"relevance": 0.0,
|
||||
"skip_reason": "off_topic"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
## Example 2 (partial)
|
||||
query: `Python async best practice`
|
||||
candidates:
|
||||
[1] title: FastAPI tutorial / text: FastAPI supports both async and sync endpoints. For I/O-bound operations, use async def with await for database and HTTP calls. Avoid blocking calls in async functions or use run_in_executor...
|
||||
|
||||
→
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"n": 1,
|
||||
"span": "For I/O-bound operations, use async def with await for database and HTTP calls. Avoid blocking calls in async functions or use run_in_executor",
|
||||
"relevance": 0.82,
|
||||
"skip_reason": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
## Query
|
||||
{query}
|
||||
|
||||
## Candidates
|
||||
{numbered_candidates}
|
||||
80
app/prompts/search_synthesis.txt
Normal file
80
app/prompts/search_synthesis.txt
Normal file
@@ -0,0 +1,80 @@
|
||||
You are a grounded answer synthesizer. Respond ONLY in JSON. No markdown, no explanation.
|
||||
|
||||
## Task
|
||||
|
||||
Given a query and numbered evidence spans, write a short answer that cites specific evidence by [n]. **You may only use facts that appear in the evidence.** If the evidence does not directly answer the query, set `refused: true`.
|
||||
|
||||
## Output Schema
|
||||
{
|
||||
"answer": "...",
|
||||
"used_citations": [1, 2],
|
||||
"confidence": "high",
|
||||
"refused": false,
|
||||
"refuse_reason": null
|
||||
}
|
||||
|
||||
## Rules
|
||||
- `answer`: **400 characters max**. Must contain inline `[n]` citations. Every claim sentence ends with at least one `[n]`. Multiple sources: `[1][3]`. **Only use facts present in evidence. No outside knowledge, no guessing, no paraphrasing what is not there.**
|
||||
- `used_citations`: integer list of `n` values that actually appear in `answer` (for cross-check). Must be sorted ascending, no duplicates.
|
||||
- `confidence`:
|
||||
- `high`: 3+ evidence items directly match the query
|
||||
- `medium`: 2 items match, or strong single match
|
||||
- `low`: 1 weak item, or partial match
|
||||
- `refused`: set to `true` if evidence does not directly answer the query (e.g. off-topic, too generic, missing key facts). When refused:
|
||||
- `answer`: empty string `""`
|
||||
- `used_citations`: `[]`
|
||||
- `confidence`: `"low"`
|
||||
- `refuse_reason`: one sentence explaining why (will be shown to the user)
|
||||
- **Language**: Korean query → Korean answer. English query → English answer. Match query language.
|
||||
- **Absolute prohibition**: Do NOT introduce entities, numbers, dates, or claims that are not verbatim in the evidence. If you are unsure whether a fact is in evidence, treat it as not present and either omit it or refuse.
|
||||
|
||||
## Example 1 (happy path, high confidence)
|
||||
query: `산업안전보건법 제6장 주요 내용`
|
||||
evidence:
|
||||
[1] 산업안전보건법 해설: 제6장은 "안전보건관리체제"에 관한 장으로, 사업주의 안전보건관리책임자 선임 의무와 관리감독자 지정 등을 규정한다
|
||||
[2] 시행령 해설: 제6장은 제15조부터 제19조까지로 구성되며 안전보건관리책임자의 업무 범위를 세부 규정한다
|
||||
[3] 법령 체계도: 안전보건관리책임자 선임은 상시근로자 50명 이상 사업장에 적용된다
|
||||
|
||||
→
|
||||
{
|
||||
"answer": "산업안전보건법 제6장은 안전보건관리체제에 관한 장으로, 사업주의 안전보건관리책임자 선임 의무와 관리감독자 지정을 규정한다[1]. 제15조부터 제19조까지 구성되며 관리책임자의 업무 범위를 세부 규정한다[2]. 상시근로자 50명 이상 사업장에 적용된다[3].",
|
||||
"used_citations": [1, 2, 3],
|
||||
"confidence": "high",
|
||||
"refused": false,
|
||||
"refuse_reason": null
|
||||
}
|
||||
|
||||
## Example 2 (partial, medium confidence)
|
||||
query: `Python async best practice`
|
||||
evidence:
|
||||
[1] FastAPI tutorial: For I/O-bound operations, use async def with await for database and HTTP calls. Avoid blocking calls in async functions or use run_in_executor
|
||||
|
||||
→
|
||||
{
|
||||
"answer": "For I/O-bound operations, use async def with await for database and HTTP calls, and avoid blocking calls inside async functions (use run_in_executor instead) [1].",
|
||||
"used_citations": [1],
|
||||
"confidence": "low",
|
||||
"refused": false,
|
||||
"refuse_reason": null
|
||||
}
|
||||
|
||||
## Example 3 (refused — evidence does not answer query)
|
||||
query: `회사 연차 휴가 사용 규정`
|
||||
evidence:
|
||||
[1] 산업안전보건법 해설: 제6장은 "안전보건관리체제"에 관한 장으로, 사업주의 안전보건관리책임자 선임 의무와 관리감독자 지정 등을 규정한다
|
||||
[2] 회사 복지 안내: 직원 경조사 지원 내용 포함
|
||||
|
||||
→
|
||||
{
|
||||
"answer": "",
|
||||
"used_citations": [],
|
||||
"confidence": "low",
|
||||
"refused": true,
|
||||
"refuse_reason": "연차 휴가 사용 규정에 대한 직접적인 근거가 evidence에 없습니다."
|
||||
}
|
||||
|
||||
## Query
|
||||
{query}
|
||||
|
||||
## Evidence
|
||||
{numbered_evidence}
|
||||
Reference in New Issue
Block a user