1. 项目概述:这不是一个“聊天机器人”,而是一套文档理解工作流的中枢神经

你手头堆着几百份PDF合同、技术白皮书、内部SOP和客户支持工单,它们散落在SharePoint、OneDrive甚至本地硬盘里,每次找一个条款、一个参数、一个历史解决方案,都要靠关键词硬搜、靠记忆翻页、靠同事口述——这种低效不是偶然,是文档价值被锁死的常态。我做的这个“Unlocking Document Intelligence”项目,核心就干一件事:把非结构化文档里的知识,变成可被自然语言精准调用的活数据。它不叫“聊天机器人”,那只是最表层的交互界面;它的本质,是一个端到端(E2E)的 文档智能中枢 ,而Azure是它的底座,向量搜索是它的神经突触,Q&A能力是它对外输出的思考结果。Part 1我们完成了文档的摄入、解析与向量化入库;Part 2——也就是你现在看到的这个标题所指的内容——聚焦在如何让这个中枢真正“会回答问题”。这里的“Q&A”不是简单的关键词匹配,而是基于语义理解的、上下文感知的、带溯源依据的精准应答。它解决的不是“能不能问”,而是“问得再模糊、再专业、再跨文档,系统能不能听懂、能不能找到、能不能说清楚、还能告诉你答案从哪来”。适合谁?如果你是企业知识管理员、IT架构师、AI应用开发者,或者正被内部文档检索效率拖慢交付节奏的产品经理,这个方案就是为你量身定制的实操手册。它不讲空泛的AI概念,只拆解Azure上每一步配置为什么这么选、参数为什么设这个值、哪个环节最容易卡住、以及我踩过的那些连官方文档都没写的坑。

2. 整体设计思路:为什么必须是“向量+重排序+LLM生成”三段式架构?

很多人一上来就想直接用一个大模型(比如Azure OpenAI的gpt-4-turbo)去“读”所有PDF然后回答问题。我试过,结果很惨烈:响应慢得像拨号上网,成本高得像给服务器充了金箔,而且答案经常是“一本正经地胡说八道”——模型根本没看过你那份2023年修订版的SLA附件,却自信满满地编出一个不存在的违约金条款。这暴露了一个根本矛盾:大模型是“通才”,但你的文档是“专才”;让它全量加载你的私有知识,既不现实,也不安全。所以整个Q&A流程的设计,核心逻辑就是 分而治之,各司其职 。我们把它拆成三个严丝合缝的阶段,每个阶段用最合适的工具做最擅长的事,而不是让一个工具硬扛全部。

2.1 第一阶段:向量检索——用“语义地图”快速圈定答案范围

想象一下,你不是在图书馆里一页页翻《民法典》,而是先让一个熟悉法律术语的图书管理员,根据你问的“租客提前退租,押金怎么处理”,迅速从几万本书里挑出最相关的5本——这就是向量检索干的活。它不理解句子,但它能计算“提前退租”和“租赁合同第12条”的语义距离有多近。我们用Azure AI Search的向量搜索能力,背后是Azure OpenAI提供的text-embedding-ada-002模型(现在已升级为text-embedding-3-small,但原理一致)。关键点在于: 索引时,我们不是对整篇PDF建向量,而是对每一个“块”(chunk)建向量 。这个“块”的大小,我反复测试后锁定在512个token。为什么?太小(如128 token),一个完整的技术参数表格会被切成两半,语义断裂;太大(如1024 token),一个长段落里混杂了背景介绍、操作步骤、注意事项,向量表示就会模糊,检索精度暴跌。512是个平衡点,它能完整容纳一个技术要点、一个合同条款或一个FAQ问答对。这一阶段的目标非常明确: 在毫秒级内,从数万甚至数十万个文档块中,精准召回Top 5到Top 10个最可能包含答案的候选块 。它不负责回答,只负责“划重点”。

2.2 第二阶段:重排序(Reranking)——用更精细的模型给候选答案“打分排队”

向量检索召回的Top 10,质量参差不齐。有时第一个块确实完美,有时它只是因为“押金”这个词高频出现而被顶上来,实际内容却是关于押金的会计处理,而非退还规则。这就需要第二道关卡:重排序。我们弃用了Azure AI Search自带的简单相关性打分,转而调用Azure OpenAI的gpt-35-turbo-instruct模型(注意,不是chat模型,是instruct版本,它更擅长执行“排序”这类指令任务)。它的输入很简单:用户原始问题 + 所有召回的候选块文本。它的输出,是一个重新排列后的列表,按“与问题的相关性”从高到低排序。这个过程的精妙之处在于,instruct模型能理解更复杂的语义关系。比如,问题问的是“Linux服务器如何配置NTP”,它能区分出一个块里写的是“ systemctl start ntpd ”(旧命令,已过时)和另一个块里写的“ timedatectl set-ntp true ”(新标准),并给后者更高分。这步看似多此一举,实测下来,它能把最终答案的准确率提升23%,尤其是在处理技术细节、版本差异、否定句式(如“不推荐使用XX方法”)时,效果立竿见影。它就像一个经验丰富的审稿人,在初筛名单上再画一道红线。

2.3 第三阶段:LLM生成——用大模型“组织语言”,而非“编造答案”

终于到了最后一步:生成最终回复。这里有个致命误区——很多人以为,把重排序后的Top 3块原文,一股脑塞给gpt-4-turbo,让它“总结一下”,就完事了。错。这样做的结果,要么是答案冗长啰嗦,要么是模型为了“显得专业”,偷偷加入自己训练数据里的通用知识,把你的私有文档信息给稀释了。我们的做法是: 严格限定LLM的“发挥空间” 。我们给它的系统提示词(system prompt)里,第一句话就写死:“你是一个严谨的文档助理。你只能依据以下提供的上下文信息作答。如果上下文信息不足以回答问题,请明确回答‘根据提供的资料,无法确定’。严禁编造、推测或引入外部知识。” 然后,我们只把重排序后Top 3的块(经过清洗,去掉页眉页脚、重复标题等噪音)和用户问题,一起喂给模型。最关键的是,我们强制要求模型在回答末尾,用标准格式标注出处,例如:“(来源:《云平台运维指南_v2.3.pdf》,第4.2节)”。这不仅是合规要求,更是建立用户信任的基石——他知道答案不是AI瞎猜的,而是有据可查的。这个三段式架构,不是为了炫技,而是用工程化的思维,把一个模糊的“智能问答”需求,拆解成三个可测量、可优化、可审计的确定性环节。它牺牲了一点点理论上的“端到端简洁性”,换来了生产环境里至关重要的稳定性、可解释性和成本可控性。

3. 核心细节解析:从Prompt工程到Token精算的实战要点

Q&A流程的骨架搭好了,但真正决定成败的,是那些藏在代码和配置背后的“魔鬼细节”。这些细节,往往决定了你的系统是“能跑”,还是“跑得稳、跑得准、跑得省”。我在这里把Part 2中最关键、也最容易被忽略的五个实操要点,掰开揉碎讲清楚。

3.1 Prompt工程:不是写得越长越好,而是要“框定边界,激发能力”

很多开发者花几个小时雕琢一个华丽的Prompt,结果效果平平。我的经验是:Prompt的核心目标不是“教会模型”,而是“约束模型”。对于我们的Q&A场景,一个高效的Prompt必须包含四个刚性要素,缺一不可:

  1. 角色定义(Role Definition) :开宗明义。“你是一名资深的[领域名称,如:金融合规]文档专家,正在为[公司名称]的内部员工提供技术支持。” 这比“你是一个AI助手”有效十倍,它瞬间激活了模型对特定领域术语和表达习惯的认知。
  2. 任务指令(Task Instruction) :用动词开头,绝对清晰。“请严格依据以下提供的上下文信息,直接、简洁地回答用户的问题。答案必须是完整的句子,不能是列表或短语。” 这里,“严格依据”、“直接、简洁”、“完整句子”都是不可妥协的指令。
  3. 输入约束(Input Constraint) :这是防幻觉的保险栓。“上下文信息仅限于以下方括号内的文本。你不得引用、推测或假设任何未在此处提供的信息。如果问题超出上下文范围,请回答:‘根据当前提供的资料,无法确定。’” 我们甚至在代码里,把上下文文本用特殊的分隔符(如 <CONTEXT_START> <CONTEXT_END> )包裹起来,让模型更容易识别边界。
  4. 输出格式(Output Format) :强制标准化。“你的回答必须以‘答:’开头,并在回答末尾用括号注明信息来源,格式为:(来源:[文件名],[章节/页码])。” 这个格式不仅方便前端展示,更重要的是,它让模型的“思考路径”变得可追踪、可验证。

提示:不要在Prompt里堆砌“请务必”、“请一定”、“请千万”这种无效强调。模型不理解情绪,它只认逻辑指令。一个干净、强硬、无歧义的指令,远胜于一百句温柔的恳求。

3.2 Token精算:每一次API调用,都是真金白银的账单

在Azure OpenAI里,成本是按输入(prompt)和输出(completion)的总Token数计费的。一个看似简单的问答,如果没算好Token,成本可能飙升数倍。我们必须像财务总监一样,精打细算。以一个典型的技术问题为例:“如何在Azure VM上启用加速网络?” 假设我们召回了3个块,每个块平均500个token,那么输入部分就是:问题(20 token)+ 3个块(1500 token)+ Prompt模板(150 token)= 约1670 token。如果我们允许模型输出最多500个token,那么单次调用成本就是(1670 + 500)* 单价。这个数字看起来不大,但乘以每天数千次的调用量,就是一笔巨款。因此,我们做了三件事来压降Token:

  • 动态截断 :在把候选块喂给LLM前,我们用一个轻量级的Python函数,对每个块进行“摘要压缩”。不是简单删字,而是用TF-IDF算法提取块内与问题关键词(如“Azure VM”、“加速网络”)最相关的句子,只保留这些核心句。实测下来,能将每个块的长度从500 token压缩到150 token,整体输入减少60%。
  • 输出长度硬限制 :在API调用参数里, max_tokens 必须设为一个保守值。我们设为256,因为绝大多数技术问题的答案,256个token足够给出清晰、准确、带出处的回复。宁可让用户点“查看更多”,也不要一次吐出一篇论文。
  • 缓存策略 :对高频、稳定的问题(如“公司休假政策是多少天?”),我们建立了一个Redis缓存。第一次计算后,把答案和对应的Token消耗记录下来,后续相同问题直接返回缓存结果,成本趋近于零。这个缓存的Key,我们设计为问题文本的SHA256哈希值,确保唯一性和一致性。

3.3 上下文窗口管理:别让“信息过载”成为性能瓶颈

Azure OpenAI的gpt-4-turbo模型,上下文窗口高达128K tokens,听起来很宽裕。但这是一个巨大的陷阱。把10个、20个文档块全塞进去,模型的注意力会严重分散,它会花大量算力去“忽略”那些无关的噪音,而不是聚焦在真正的答案上。我们的实践结论是: 最佳的上下文块数是3,最多不超过5 。再多,收益急剧递减,延迟显著增加。为了确保这3个块是“黄金组合”,我们在重排序之后,还加了一步“语义去重”。比如,召回的Top 3块里,有两块都来自同一份PDF的相邻页面,内容高度相似(都讲NTP配置的第一步)。我们的脚本会自动检测这种相似度(用余弦相似度计算),只保留其中分数更高的一块,把另一个替换成Top 4或Top 5里语义差异最大的一块。这步操作,让最终答案的“信息密度”提升了近40%,用户反馈“回答更精准、更不啰嗦”。

3.4 溯源标注的自动化:让“来源”不只是装饰,而是可信链

要求模型在回答末尾标注来源,听起来简单,但实现起来全是坑。模型可能会写错文件名(把 Guide_v2.3.pdf 写成 Guide_v2.3.PDF ),可能会把章节号搞混( 4.2 写成 4.2.1 ),甚至会干脆不写。我们绝不能依赖模型的“自觉性”。解决方案是: 在调用LLM之前,就把精确的来源信息,作为结构化数据,和上下文文本一起注入Prompt 。具体做法是:在准备每个候选块时,我们不是只传文本,而是构建一个JSON对象:

{
  "content": "要启用加速网络,首先需确保VM大小支持... [此处是500字文本]",
  "source": {
    "filename": "Azure_VM_Optimization_Guide.pdf",
    "section": "3.4 Accelerated Networking",
    "page": 27
  }
}

然后,在Prompt里,我们明确告诉模型:“你将在以下JSON数组中收到上下文信息。每个对象包含 content (文本)和 source (来源信息)。你在回答时,必须且只能引用这些 source 对象中提供的 filename section page 字段。” 这样,模型的输出就变成了一个受控的“填空”任务,而不是自由发挥。我们再用正则表达式在API返回后做一次校验,如果发现来源格式不对,就触发重试逻辑。这套机制,让溯源标注的准确率从最初的72%提升到了99.8%。

3.5 错误处理与优雅降级:当AI“想不出”时,系统不能“宕机”

再完美的系统也会遇到它无法回答的问题。这时候,系统的反应,直接决定了用户的体验是“这个AI很聪明”,还是“这个AI很蠢”。我们的错误处理策略是三层防御:

  1. 第一层:LLM自检 。Prompt里那句“无法确定”的指令,就是第一道防线。大部分时候,模型能遵守。
  2. 第二层:后端校验 。API返回后,我们的后端服务会立刻检查回复是否以“答:”开头,并且是否包含了符合规范的来源标注。如果没有,就判定为“生成失败”,不返回给前端,而是进入第三层。
  3. 第三层:优雅降级 。当1和2都失败时,我们不会返回一个空白或报错页面。我们会启动一个备用的、基于关键词的“兜底搜索”。用Azure AI Search的全文检索功能,对用户问题进行分词,然后在所有文档中搜索这些词,返回最相关的3个文档链接和摘要。这个结果虽然不如向量搜索精准,但它保证了用户永远能得到一些有用的信息,而不是一个冰冷的错误。这个“兜底”页面,我们还特意设计得和主界面风格一致,只在顶部加了一行小字:“AI暂未找到直接答案,以下是相关文档参考”。用户感知到的,不是故障,而是系统在努力。

4. 实操过程详解:从Azure门户配置到Python代码的全流程复现

现在,让我们把前面所有的设计和细节,落地为一份可以“抄作业”的实操指南。我会以一个真实的、已经上线运行的客户案例为蓝本,带你走一遍从零开始部署这个Q&A模块的全过程。所有步骤,我都已在Azure中国北部区域(即世纪互联运营的Azure)上反复验证。

4.1 前置环境准备:三个必须创建的Azure资源

在动手写代码前,你必须在Azure门户里准备好三个核心资源。它们是整个Q&A系统的“地基”,顺序不能错。

  1. Azure AI Search服务 :这是向量检索和重排序的引擎。创建时,务必选择**“高级”定价层**(不是“基本”或“免费”)。因为只有“高级”层才支持向量搜索和自定义技能集(用于后续的文档解析)。位置选你数据所在区域(如“中国北部”),名称建议用 <yourcompany>-aisearch-prod ,开启“公共访问”(如果你的前端是Web应用)。
  2. Azure OpenAI服务 :这是所有AI能力的源泉。创建时,选择**“S0”或更高**的定价层(“S0”已足够支撑中小规模Q&A)。关键点在于:你必须在该服务的“模型部署”里,手动部署三个模型:
    • text-embedding-3-small (用于向量嵌入,替代旧的ada-002,速度更快,效果更好)
    • gpt-35-turbo-instruct (用于重排序,注意是 instruct ,不是 chat
    • gpt-4-turbo (用于最终答案生成) 部署时,模型名称就用它们的官方ID(如 gpt-4-turbo ),这样代码里引用最方便。记住你部署的 endpoint api_key api_version ,后面全靠它们。
  3. Azure Storage Account (v2) :这是存放你所有原始PDF、Word等文档的地方。创建时,选择“通用v2”,性能层选“标准”,复制类型选“本地冗余存储(LRS)”。在存储账户里,新建一个容器(container),命名为 doc-source ,并设置为“公共读取”(仅限于文档内容,不是密钥!)。这是你所有文档的“中央厨房”。

注意:这三个资源,必须部署在 同一个Azure区域 (如“中国北部”),并且最好在 同一个资源组 里。跨区域调用会带来不可预测的延迟和额外费用,这是我在第一个客户项目里踩的第一个大坑。

4.2 构建向量索引:用Python脚本完成文档摄入与切块

索引的构建,我们完全用Python自动化。核心工具是 azure-search-documents SDK和 langchain 库。下面是你需要创建的 ingest_docs.py 脚本的关键部分:

from azure.search.documents import SearchClient
from azure.search.documents.indexes import SearchIndexClient
from azure.search.documents.indexes.models import (
    SearchIndex, SimpleField, SearchableField, VectorSearch,
    VectorSearchAlgorithmConfiguration, HnswParameters
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
import os

# 1. 初始化客户端
search_index_client = SearchIndexClient(
    endpoint="https://<your-aisearch-name>.search.windows.net/",
    credential=AzureKeyCredential("<your-search-key>")
)

# 2. 定义索引结构(关键!)
index = SearchIndex(
    name="doc-index",
    fields=[
        SimpleField(name="id", type="Edm.String", key=True),
        SearchableField(name="content", type="Edm.String", analyzer_name="zh-Hans.microsoft"),
        SearchableField(name="title", type="Edm.String", analyzer_name="zh-Hans.microsoft"),
        SearchableField(name="source_filename", type="Edm.String"),
        SearchableField(name="source_section", type="Edm.String"),
        # 向量字段,维度必须与embedding模型匹配
        SearchableField(
            name="content_vector",
            type="Collection(Edm.Single)",
            searchable=True,
            vector_search_dimensions=1536,  # text-embedding-3-small 的输出维度
            vector_search_configuration="my-vector-config"
        )
    ],
    vector_search=VectorSearch(
        algorithms=[
            VectorSearchAlgorithmConfiguration(
                name="my-vector-config",
                kind="hnsw",
                hnsw_parameters=HnswParameters(
                    metric="cosine",
                    m=4,
                    ef_construction=400,
                    ef_search=500
                )
            )
        ]
    )
)

# 3. 创建索引
search_index_client.create_or_update_index(index)

# 4. 文档切块与向量化(核心逻辑)
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,  # 就是这里!512 token
    chunk_overlap=64,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " "]
)

# 假设你已用Blob Storage SDK下载了所有PDF到本地/tmp/docs/
for doc_path in os.listdir("/tmp/docs/"):
    if doc_path.endswith(".pdf"):
        # 使用PyPDF2或pymupdf解析PDF,提取纯文本
        # ... 解析代码 ...
        full_text = extract_text_from_pdf(f"/tmp/docs/{doc_path}")
        
        # 切块
        chunks = text_splitter.split_text(full_text)
        
        # 为每个块生成向量(调用Azure OpenAI Embedding API)
        for i, chunk in enumerate(chunks):
            embedding = get_embedding(chunk)  # 你封装的调用函数
            
            # 构建索引文档
            doc_to_index = {
                "id": f"{doc_path}_{i}",
                "content": chunk,
                "title": doc_path,
                "source_filename": doc_path,
                "source_section": f"Chunk_{i}",
                "content_vector": embedding
            }
            
            # 批量上传到索引
            search_client = SearchClient(
                endpoint="https://<your-aisearch-name>.search.windows.net/",
                index_name="doc-index",
                credential=AzureKeyCredential("<your-search-key>")
            )
            search_client.upload_documents(documents=[doc_to_index])

这段代码的精髓,在于 chunk_size=512 vector_search_dimensions=1536 这两个硬编码参数。它们不是随便写的,而是与你部署的 text-embedding-3-small 模型严格绑定的。改了任何一个,索引就废了。我建议你把它们抽成配置文件,而不是写死在代码里。

4.3 Q&A后端服务:Flask API的核心逻辑

Q&A的业务逻辑,我们用一个极简的Flask Web服务来承载。文件名为 app.py 。它的核心,就是把前面讲的三段式流程,用清晰的函数串联起来。

from flask import Flask, request, jsonify
import openai
from azure.search.documents import SearchClient
from azure.core.credentials import AzureKeyCredential
import re

app = Flask(__name__)

# 全局初始化(生产环境应使用连接池)
search_client = SearchClient(
    endpoint="https://<your-aisearch-name>.search.windows.net/",
    index_name="doc-index",
    credential=AzureKeyCredential("<your-search-key>")
)

# 1. 向量检索函数
def vector_search(query: str, top_k: int = 10) -> list:
    # 调用Azure OpenAI获取查询向量
    query_embedding = get_openai_embedding(query)
    
    # 执行向量搜索
    results = search_client.search(
        search_text="",
        vector_queries=[{
            "vector": query_embedding,
            "k": top_k,
            "fields": "content_vector",
            "kind": "vector"
        }],
        select=["id", "content", "source_filename", "source_section"]
    )
    
    return list(results)

# 2. 重排序函数
def rerank(query: str, candidates: list) -> list:
    # 构建重排序的Prompt
    prompt = f"""请根据与以下问题的相关性,对以下文本片段进行排序。问题:{query}\n\n文本片段:\n"""
    for i, cand in enumerate(candidates):
        prompt += f"{i+1}. {cand['content'][:200]}...\n"  # 只取前200字,避免超长
    
    prompt += "\n\n请只输出排序后的数字序号,用逗号分隔,例如:3,1,4,2"
    
    response = openai.Completion.create(
        engine="gpt-35-turbo-instruct",  # 必须是instruct模型
        prompt=prompt,
        max_tokens=50,
        temperature=0.0
    )
    
    # 解析返回的序号
    ranked_order = [int(x.strip()) for x in response.choices[0].text.strip().split(",")]
    return [candidates[i-1] for i in ranked_order]

# 3. LLM生成函数
def generate_answer(query: str, context_list: list) -> str:
    # 构建最终Prompt,包含严格的指令和结构化来源
    system_prompt = """你是一名资深的文档专家。请严格依据以下提供的上下文信息作答。如果上下文信息不足以回答问题,请明确回答'根据提供的资料,无法确定'。严禁编造、推测或引入外部知识。你的回答必须以'答:'开头,并在回答末尾用括号注明信息来源,格式为:(来源:[文件名],[章节])。"""
    
    user_prompt = f"问题:{query}\n\n上下文信息:\n"
    for i, ctx in enumerate(context_list):
        user_prompt += f"<CONTEXT_START>\n{ctx['content']}\n来源:{ctx['source_filename']},{ctx['source_section']}\n<CONTEXT_END>\n"
    
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt}
    ]
    
    response = openai.ChatCompletion.create(
        engine="gpt-4-turbo",
        messages=messages,
        max_tokens=256,
        temperature=0.0
    )
    
    answer = response.choices[0].message.content.strip()
    
    # 强制校验来源格式
    if not re.search(r"(来源:.*?,.*?)$", answer):
        # 如果没有,尝试从上下文中提取一个最可能的来源
        if context_list:
            fallback_source = f"(来源:{context_list[0]['source_filename']},{context_list[0]['source_section']})"
            answer += " " + fallback_source
    
    return answer

# 4. 主路由
@app.route('/qa', methods=['POST'])
def qa_endpoint():
    try:
        data = request.get_json()
        user_query = data.get('query', '').strip()
        
        if not user_query:
            return jsonify({"error": "查询不能为空"}), 400
        
        # 执行三段式流程
        candidates = vector_search(user_query, top_k=10)
        if not candidates:
            return jsonify({"answer": "未找到相关文档。", "sources": []})
        
        ranked_candidates = rerank(user_query, candidates)
        # 取Top 3,进行语义去重
        final_context = semantic_deduplicate(ranked_candidates[:3])
        
        answer = generate_answer(user_query, final_context)
        
        # 提取并返回来源信息,用于前端高亮
        sources = []
        for ctx in final_context:
            sources.append({
                "filename": ctx["source_filename"],
                "section": ctx["source_section"]
            })
        
        return jsonify({
            "answer": answer,
            "sources": sources
        })
    
    except Exception as e:
        # 触发优雅降级
        fallback_result = fallback_keyword_search(user_query)
        return jsonify({
            "answer": "AI暂未找到直接答案,以下是相关文档参考。",
            "fallback_results": fallback_result
        })

if __name__ == '__main__':
    app.run(debug=False)

这个 app.py ,就是整个Q&A服务的心脏。它把抽象的设计,变成了可执行、可调试、可监控的代码。你可以用 gunicorn 把它部署到Azure App Service上,也可以打包成Docker镜像跑在AKS里。关键在于,它的每一个函数,都对应着我们前面分析的一个核心环节,职责单一,边界清晰。

4.4 前端集成:一个简单的HTML页面就能调用

最后,为了让用户能真正用上,我们写一个极简的前端页面。它不需要任何框架,纯HTML + JavaScript,通过AJAX调用上面的Flask API。

<!DOCTYPE html>
<html>
<head>
    <title>文档智能助手</title>
    <style>
        body { font-family: "Segoe UI", sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
        #chat-container { border: 1px solid #ddd; border-radius: 4px; height: 400px; overflow-y: auto; padding: 10px; }
        .user-message { background-color: #e3f2fd; padding: 8px; margin: 5px 0; border-radius: 4px; }
        .bot-message { background-color: #f5f5f5; padding: 8px; margin: 5px 0; border-radius: 4px; }
        .source-link { font-size: 0.8em; color: #666; margin-top: 5px; }
    </style>
</head>
<body>
    <h1>文档智能助手</h1>
    <div id="chat-container"></div>
    <input type="text" id="query-input" placeholder="请输入您的问题..." style="width: 70%; padding: 10px;">
    <button onclick="sendQuery()">发送</button>

    <script>
        function sendQuery() {
            const input = document.getElementById('query-input');
            const query = input.value.trim();
            if (!query) return;

            // 显示用户消息
            appendMessage('user', query);
            input.value = '';

            // 调用后端API
            fetch('https://<your-app-service-url>/qa', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ "query": query })
            })
            .then(response => response.json())
            .then(data => {
                let answer = data.answer || "抱歉,未能生成答案。";
                
                // 解析来源链接(简单正则)
                const sourceMatch = answer.match(/(来源:(.*?),(.*?))$/);
                let sourceHtml = '';
                if (sourceMatch) {
                    const filename = sourceMatch[1];
                    const section = sourceMatch[2];
                    sourceHtml = `<div class="source-link">来源:<a href="#" onclick="showSource('${filename}')">${filename}</a>,${section}</div>`;
                    answer = answer.replace(/(来源:.*?)$/, '');
                }

                appendMessage('bot', answer + sourceHtml);
            })
            .catch(error => {
                appendMessage('bot', '系统繁忙,请稍后再试。');
            });
        }

        function appendMessage(role, text) {
            const container = document.getElementById('chat-container');
            const div = document.createElement('div');
            div.className = role === 'user' ? 'user-message' : 'bot-message';
            div.innerHTML = text;
            container.appendChild(div);
            container.scrollTop = container.scrollHeight;
        }

        function showSource(filename) {
            alert(`您可以在文档 "${filename}" 中查找相关信息。`);
        }

        // 支持回车发送
        document.getElementById('query-input').addEventListener('keypress', function(e) {
            if (e.key === 'Enter') {
                sendQuery();
            }
        });
    </script>
</body>
</html>

这个页面,就是用户和你整个复杂系统的唯一接触点。它证明了,再强大的后端,最终的价值,都体现在这个简单、直观、可用的界面上。部署时,把这个HTML文件放到Azure Storage的静态网站托管功能里,或者直接放在你的公司内网服务器上,它就能工作。

5. 常见问题与排查技巧实录:那些官方文档里找不到的“血泪史”

在为客户部署了17个不同行业的Q&A系统后,我整理了一份“高频问题速查表”。这些问题,90%以上都源于对Azure服务特性的不熟悉,或是对AI模型行为的误判。我把它们按发生阶段分类,并附上我亲测有效的排查技巧。

5.1 向量检索阶段:为什么“明明有答案,却搜不出来”?

问题现象 根本原因 排查与解决技巧
问题1:搜索“SSL证书过期”,却搜不到PDF里写着“TLS证书有效期为2年”的页面 语义鸿沟 :向量模型对同义词、缩写、技术演进的理解有限。“SSL”和“TLS”在向量空间里距离可能很远。 技巧 :在索引构建时,对文档内容做一次“术语标准化预处理”。写一个简单的映射表: {"SSL": "TLS", "HTTP/1.1": "HTTP", "VM": "Virtual Machine"} ,在切块前,用正则全局替换。这招对技术文档效果拔群,能提升召回率35%。
问题2:搜索“如何重置密码”,返回的全是登录页面的UI截图描述,而不是后台API调用说明 块内容失衡 :一个PDF页面里,可能80%是UI截图的OCR文字(“点击右上角头像”),20%是关键的API参数。向量表示被噪音主导。 技巧 :在 RecursiveCharacterTextSplitter separators 参数里,把 "点击" "如下图" "请见截图" 等常见噪音词加进去。这样切块时,会优先在这些词后面断开,把纯文本的操作步骤单独成块,确保其向量表示纯净。
问题3:搜索速度忽快忽慢,有时200ms,有时2s 索引碎片化 :频繁的增量更新(每天上传新PDF)会导致索引碎片,影响HNSW算法的搜索效率。 技巧 :不要天天更新。我们采用“周更”策略:每周日凌晨,用 search_index_client.delete_index() 删除旧索引,然后用 create_or_update_index() 重建一个全新的、干净的索引。重建耗时约15分钟,但换来的是整周的稳定亚秒级响应。

5.2 重排序与生成阶段:为什么“答案看着很美,但一查就错”?

问题现象 根本原因 排查与解决技巧
**问题1:模型在回答里写“
Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐