"""检索结果 AI 总结 对检索返回的 chunk 命中结果,调用 Ollama 本地模型生成一段针对用户 query 的总结回答。 仅基于检索结果内容,不编造未提及的信息。 """ import structlog from app.config import settings from app.models.search import SearchHit from app.services.ollama import OllamaClient logger = structlog.get_logger() class ResultSummarizer: """检索结果总结器""" def __init__(self, ollama: OllamaClient | None = None) -> None: self.ollama = ollama or OllamaClient() async def summarize(self, query: str, hits: list[SearchHit]) -> str: """对检索结果生成针对 query 的总结 取前 settings.result_summary_max_hits 条命中拼接为上下文, 无命中时返回空字符串(不调 LLM)。 """ if not hits: return "" max_hits = settings.result_summary_max_hits selected = hits[:max_hits] context = self._build_context(selected) prompt = self._build_prompt(query, context) try: summary = await self.ollama.generate(prompt) except Exception: logger.warning("检索结果总结生成失败,返回空字符串", exc_info=True) return "" logger.info("检索结果总结完成", query=query, hits_count=len(selected), summary_len=len(summary)) return summary.strip() @staticmethod def _build_context(hits: list[SearchHit]) -> str: """拼接命中结果为带编号的上下文""" blocks: list[str] = [] for i, hit in enumerate(hits, start=1): header_parts = [f"[{i}]"] if hit.title: header_parts.append(hit.title) if hit.section_path: header_parts.append(hit.section_path) header = " / ".join(header_parts) blocks.append(f"{header}\n{hit.text}") return "\n\n---\n\n".join(blocks) @staticmethod def _build_prompt(query: str, context: str) -> str: return ( "请根据以下检索结果,针对用户问题生成一段简洁的总结回答。\n" "要求:\n" "- 综合多条结果信息,不要简单逐条罗列;\n" "- 只基于检索结果内容,不编造未提及的信息;\n" "- 用中文回答,简洁明了。\n\n" f"用户问题:{query}\n\n" f"检索结果:\n{context}" )