ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AI Agent知识检索系统设计:从向量数据库接入到检索合同规范

AI Agent知识检索系统设计:从向量数据库接入到检索合同规范 1. 项目缘起为什么“检索合同”比接入向量库更重要最近在折腾一个AI Agent项目核心功能是让Agent能够基于我自己的知识库进行问答。和很多开发者一样我的第一反应是赶紧选个向量数据库把文档灌进去然后让Agent去检索。于是我锁定了Qdrant——一个性能不错、API也相对友好的向量数据库。但在真正动手写代码、调用client.upsert之前我强迫自己停了下来。因为我意识到直接接入Qdrant可能是一个巨大的陷阱一个会让后续开发、调试和维护成本指数级增长的陷阱。问题出在哪里出在“检索”这个动作本身。当我们说“Agent去检索”听起来很简单但背后是一系列复杂且模糊的约定。比如Agent应该检索多少条结果三条还是十条检索到的文本片段chunk是直接拼接起来扔给大模型还是需要先做一次筛选或排序如果检索到的内容相关性都不高是返回空结果还是返回一个“抱歉我没找到”的提示更进一步对于同一个问题用户可能换不同的方式提问例如“上下文理解”和“语境推测”我们的检索系统应该如何处理这种语义相似但表述不同的情况是要求知识库里的表述必须完全统一还是依赖embedding模型的语义理解能力这些问题在项目初期往往被一句“先用起来再说”给掩盖了。但等到Agent开始给出驴唇不对马嘴的回答或者在不同场景下表现极不稳定时我们再回头去排查会发现问题根源盘根错节可能是分块策略不对可能是embedding模型选型不佳也可能是检索后处理逻辑有缺陷。此时代码已经和Qdrant的API紧密耦合牵一发而动全身。所以我提出了“检索合同”这个概念。它不是一个法律文件而是一个在技术实现之前由项目团队哪怕只有你一个人共同定义并达成共识的、关于“检索行为”的明确规范。它规定了从用户问题输入到最终交给大模型的上下文之间每一个环节的输入、输出、边界条件和异常处理。在接入Qdrant之前先写好这份“合同”相当于为整个检索系统绘制了一张精确的蓝图。它能迫使我们在编码前思考清楚核心逻辑极大降低后续的沟通成本和迭代风险。接下来我就结合自己的实践详细拆解这份“检索合同”该如何撰写以及它如何指导我们更稳健地接入Qdrant。2. 检索合同的核心条款定义清晰的输入输出与边界一份好的检索合同应该像API接口文档一样清晰、无歧义。它不需要关心底层用的是Qdrant、Milvus还是Pinecone它只关心行为。我们可以从以下几个核心条款开始构建。2.1 条款一问题预处理规范这一条款定义了用户原始问题Query在进入向量检索之前需要经过哪些加工。输入原始用户问题字符串可能包含错别字、口语化表达、无关信息。处理流程关键信息提取与问题重述对于复杂问题可能需要先提取核心实体或意图。例如用户问“昨天会上老板说的那个关于AI安全项目的下一步计划是什么”系统可能需要将其重述为“AI安全项目的下一步计划”。这一步可以依赖一个轻量级的意图识别模型或规则目的是让问题更贴近知识库中文档的表述方式。查询扩展是否使用同义词或近义词扩展查询例如将“如何部署”扩展为“如何安装、部署、配置”。这对于召回率有帮助但可能引入噪声。合同需要明确是否启用以及扩展词库的来源。长度截断embedding模型通常有最大输入长度限制如512个token。合同需规定问题的最大长度以及超长后的截断策略截断头部、尾部或中间。输出一个或多个经过预处理的查询字符串这些字符串将用于生成查询向量Query Embedding。实操心得不要过度设计预处理。对于很多垂直领域Agent一个简单的关键词提取去除停用词加上长度截断就足够了。复杂的NLU处理不仅增加延迟还可能扭曲用户原意。我们的合同应写明“默认仅进行基础清洗和截断仅在明确检测到特定意图模板如时间、人物时才触发问题重述。”2.2 条款二检索执行规范这是合同的核心直接对应Qdrant的search操作。输入查询向量由预处理后的问题生成。关键参数定义top_k返回最相似的前K个向量片段。这是最重要的参数之一。top_k太小可能导致召回不全漏掉关键信息太大则会给后续的上下文构建带来噪声和成本压力。合同需要根据知识库的颗粒度和平均文档长度确定一个基准值例如top_k5。得分阈值score_threshold设定一个相关性分数的最低门槛。Qdrant返回的分数通常是余弦相似度范围-1到1或0到1。合同需要规定低于多少分的结果将被视为“不相关”而直接过滤掉。例如可以规定“仅保留相似度得分 0.7 的结果”。这能有效防止低质量信息进入下游。过滤条件filter是否使用元数据过滤例如只检索某个特定标签tag、某个日期之后、或某个来源的文档。合同需要定义可用的元数据字段及其过滤逻辑。搜索类型使用精确搜索exact search还是近似最近邻搜索ANN生产环境通常用ANN以平衡精度和速度但合同需明确使用的具体算法如HNSW及其参数如ef和m。输出一个有序的列表列表中的每一项包含原始文本片段chunk text、相关性得分score、以及关联的元数据metadata如来源文件名、页码等。实操心得top_k和score_threshold需要联动调整。一个实用的方法是在知识库中构造一批“标准问题-答案对”进行测试检索。观察不同top_k下正确答案的排名位置同时观察正确答案与错误答案的得分分布从而确定一个合理的top_k和阈值。合同里可以这样写“初步设定top_k8score_threshold0.65。后续需根据验证集上的召回率RecallK和准确率进行校准。”2.3 条款三检索后处理与上下文构建规范检索到的原始结果列表不能直接扔给大模型必须经过后处理组装成高质量的提示词Prompt上下文。输入条款二输出的有序结果列表。处理流程去重不同chunk可能包含高度重复的内容尤其是滑动窗口分块时。合同需规定去重策略例如基于文本指纹如simhash或embedding相似度进行去重。重排序Re-ranking向量检索的排序基于余弦相似度有时不是最优的。可以引入一个更精细但更耗时的重排序模型如Cross-Encoder对top_k的结果进行二次排序提升排名准确性。合同需明确是否启用重排序以及启用条件如当top_k结果得分非常接近时。上下文窗口管理大模型如GPT-4有上下文长度限制。合同需要规定如何从处理后的列表中选取chunk来填充上下文。常见策略有分数优先按得分从高到低选取直到总token数达到上限。多样性优先在保证一定相关性的前提下尽可能选取来自不同文档或不同主题的chunk以提供更全面的信息。上下文格式化如何将选中的chunk文本组织成一段连贯的上下文是简单用“---”分隔还是为每个chunk添加引文出处如[文档1] ...清晰的格式化能帮助大模型更好地理解和引用来源。输出一个结构化的字符串作为最终注入到大模型系统提示词System Prompt或用户消息中的“检索上下文”。实操心得对于大多数应用重排序带来的收益可能抵不上其增加的延迟尤其是在top_k不大的情况下。我的建议是合同里可以先不启用重排序但保留接口。更关键的是上下文格式化。一定要在合同里明确格式模板例如“每个chunk以‘### 来源[文件名]第X页’开头后接空行和chunk正文。chunk之间用两个换行符分隔。”这能保证提示词的一致性。2.4 条款四异常与边界情况处理规范这是检验合同是否健壮的关键部分。输入任何可能出现的非理想情况。处理规则无结果当检索结果列表为空或所有结果得分均低于阈值时合同规定Agent应如何响应是直接回复“未找到相关信息”还是尝试调用其他工具如网络搜索或引导用户换种方式提问低置信度结果当仅有1-2个低分结果如得分在0.5-0.65之间时是否使用合同可以规定在此情况下Agent在回复时应增加不确定性表述例如“根据有限的信息可能是...”。信息冲突当检索到的不同chunk内容相互矛盾时可能源于知识库未及时更新如何处理合同可以规定优先选择分数更高的、或更新时间更近的chunk并在回复中说明存在不同说法。敏感词/安全过滤是否需要在检索前后对查询和结果进行内容安全过滤合同需明确过滤词库和过滤动作是拒绝检索还是替换结果。输出针对每种异常情况的标准化处理流程或回复模板。实操心得这部分最容易被忽略但也最容易导致线上事故。务必在合同里为每种异常情况写好“兜底”方案。例如“当检索结果为空时统一回复‘我在当前知识库中没有找到关于{用户问题关键词}的明确信息。您可以尝试简化问题或确认该信息是否已录入知识库。’” 这能保证Agent行为的可预测性。3. 从合同到代码以Qdrant为例的模块化实现有了白纸黑字的检索合同我们再来对接Qdrant就会变得目标清晰、有条不紊。整个检索系统可以抽象为一个独立的服务或模块其内部实现可以随时替换只要对外遵守合同约定的接口即可。3.1 定义数据模型与接口首先根据合同定义清晰的数据结构。from pydantic import BaseModel from typing import List, Optional from enum import Enum class SearchType(str, Enum): EXACT exact ANN ann # 使用HNSW class RetrievalResult(BaseModel): 单个检索结果项对应合同条款二的输出项 text: str score: float metadata: dict # 包含source, page等 class RetrievedContext(BaseModel): 检索后处理完成的上下文对应合同条款三的输出 context_text: str supporting_documents: List[RetrievalResult] # 实际用到的源结果 class RetrievalContract: 检索合同的具体参数配置 def __init__(self): self.query_preprocess_rules { max_query_length: 512, enable_query_expansion: False, expansion_thesaurus: None, } self.search_params { top_k: 8, score_threshold: 0.65, search_type: SearchType.ANN, ann_ef_construct: 100, # HNSW参数 ann_m: 16, } self.postprocess_rules { enable_reranking: False, reranker_model: None, context_window_token_limit: 4000, context_format_template: ## 来源{source}\n{text}\n\n, deduplication_method: simhash, # or embedding } self.exception_handling { empty_results_response: 未在知识库中找到相关信息。, low_confidence_prefix: 根据有限资料推测可能是, low_confidence_threshold: 0.6, }3.2 实现Qdrant客户端封装接下来实现一个遵守合同的Qdrant客户端。关键是将合同参数映射到Qdrant的API调用。import qdrant_client from qdrant_client.http import models from sentence_transformers import SentenceTransformer class QdrantRetriever: def __init__(self, contract: RetrievalContract, embedding_model: SentenceTransformer): self.contract contract self.embed_model embedding_model self.client qdrant_client.QdrantClient(hostlocalhost, port6333) self.collection_name knowledge_base def _preprocess_query(self, raw_query: str) - str: 执行合同条款一的预处理 # 1. 简单清洗去除多余空格、换行 query .join(raw_query.strip().split()) # 2. 长度截断按字符简单估算生产环境应用tokenizer max_len self.contract.query_preprocess_rules[max_query_length] if len(query) max_len: query query[:max_len] ... # 3. 查询扩展如果启用 if self.contract.query_preprocess_rules[enable_query_expansion]: query self._expand_query(query) return query def retrieve(self, raw_query: str) - RetrievedContext: 核心检索流程串联合同所有条款 # 1. 预处理查询 processed_query self._preprocess_query(raw_query) query_vector self.embed_model.encode(processed_query).tolist() # 2. 执行检索合同条款二 search_params self.contract.search_params search_result self.client.search( collection_nameself.collection_name, query_vectorquery_vector, limitsearch_params[top_k], score_thresholdsearch_params[score_threshold], search_paramsmodels.SearchParams( hnsw_efsearch_params.get(ann_ef, 128), exact (search_params[search_type] SearchType.EXACT) ) ) # 将Qdrant返回结果转换为我们定义的RetrievalResult列表 raw_results [ RetrievalResult( texthit.payload[text], scorehit.score, metadatahit.payload.get(metadata, {}) ) for hit in search_result ] # 3. 后处理与构建上下文合同条款三 processed_results self._postprocess_results(raw_results) context_text self._build_context(processed_results) # 4. 异常处理合同条款四 if not processed_results: # 处理无结果情况 context_text self.contract.exception_handling[empty_results_response] return RetrievedContext(context_textcontext_text, supporting_documents[]) return RetrievedContext(context_textcontext_text, supporting_documentsprocessed_results) def _postprocess_results(self, results: List[RetrievalResult]) - List[RetrievalResult]: 结果后处理去重、重排序、筛选 if not results: return [] # 去重 if self.contract.postprocess_rules[deduplication_method] simhash: results self._deduplicate_by_simhash(results) # 重排序如果启用 if self.contract.postprocess_rules[enable_reranking]: results self._rerank_results(results) # 应用低置信度过滤合同条款四 threshold self.contract.exception_handling[low_confidence_threshold] results [r for r in results if r.score threshold] return results def _build_context(self, results: List[RetrievalResult]) - str: 构建最终上下文字符串 context_parts [] total_tokens_estimate 0 limit self.contract.postprocess_rules[context_window_token_limit] for result in results: # 简单按token数估算生产环境应用准确计数 chunk_token_est len(result.text.split()) * 1.3 if total_tokens_estimate chunk_token_est limit: break formatted_chunk self.contract.postprocess_rules[context_format_template].format( sourceresult.metadata.get(source, 未知), textresult.text ) context_parts.append(formatted_chunk) total_tokens_estimate chunk_token_est return .join(context_parts).strip()3.3 在Agent框架中集成检索模块最后将封装好的检索模块接入你的Agent框架无论是LangChain、LlamaIndex还是自研框架。此时Agent的核心逻辑变得非常清晰。class KnowledgeableAgent: def __init__(self, llm_client, retriever: QdrantRetriever): self.llm llm_client self.retriever retriever self.system_prompt_template 你是一个专业的助手请根据以下提供的参考信息来回答问题。 如果信息不足以回答问题请如实告知。 参考信息 {context} 问题{question} def answer(self, user_question: str) - str: # 1. 根据合同执行检索 retrieval_outcome self.retriever.retrieve(user_question) # 2. 组装提示词 prompt self.system_prompt_template.format( contextretrieval_outcome.context_text, questionuser_question ) # 3. 调用大模型 response self.llm.generate(prompt) # 可选4. 如果需要可以在回复中附上引用来源 if retrieval_outcome.supporting_documents: response \n\n---\n*以上回答基于知识库中的信息。* return response通过这种方式检索逻辑被完整地封装在QdrantRetriever和RetrievalContract中。如果你想更换向量数据库或者调整检索策略比如把top_k从8改成5或者启用重排序你只需要修改合同配置和对应的retriever实现而Agent的核心问答逻辑完全不需要变动。这就是“合同”带来的解耦和灵活性。4. 合同驱动下的调优与迭代以语义统一性问题为例让我们回到一个从热词中看到的、非常具体且常见的问题“我的向量数据库包含试卷的解析内容意思相近的词需要完全统一吗比如 ‘上下文理解’ 和 ‘语境推测’ 这两个词” 这是一个典型的检索合同条款一问题预处理和条款二检索执行需要共同回答的问题。在没有合同的情况下开发者可能会纠结于是否要费力地去清洗知识库把所有近义词都统一成一种表述。但有了合同我们可以系统地分析并做出决策。第一步明确目标。我们的目标是让用户无论用“上下文理解”还是“语境推测”提问都能检索到关于这个核心概念的相关解析内容。第二步评估现有能力。Embedding模型的能力我们使用的embedding模型如BGE、text2vec在训练时已经学习了大量的语言知识对于语义相近的词语它们生成的向量在空间上应该是接近的。这意味着即使用户查询词和知识库中的词不完全一致只要语义相似也有机会被检索到。知识库的现状清洗和统一整个知识库的术语工作量巨大且可能破坏原文的多样性和自然性。第三步在合同中制定策略。基于以上评估我们可以在合同中明确如下条款对于知识库文档处理侧在合同附录中注明“不强制要求知识库文本在术语层面完全统一。鼓励使用自然、多样的表述。依赖embedding模型的语义理解能力作为主要召回手段。”对于查询检索执行侧在query_preprocess_rules中我们可以增加一个可选策略“对于特定领域的核心术语配置同义词词典。在查询预处理阶段将用户查询中的词条自动扩展为其同义词集合再进行向量化。” 例如配置{上下文理解: [语境推测, 情境理解]}。第四步实现与测试。根据合同我们实现两种方案基线方案仅使用原始查询进行检索。查询扩展方案启用同义词扩展。然后我们构造测试集一批用“上下文理解”提问的问题和另一批用“语境推测”提问的相同意图的问题。分别用两种方案进行检索评估召回率是否能找到正确答案和准确率找到的结果是否相关。第五步根据测试结果更新合同。如果测试发现在不扩展的情况下对于“语境推测”的查询召回率显著下降那么合同就应该正式启用查询扩展策略。如果两者表现接近为了简洁和效率合同可以决定不启用扩展。这个过程展示了合同如何驱动我们进行数据驱动的、理性的迭代而不是凭感觉做决定。所有决策的依据、测试的结果都可以作为合同的附录保存下来成为团队共享的知识。5. 避坑指南撰写与执行检索合同时的常见陷阱在实际操作中即使有了“检索合同”的意识也可能会踩一些坑。这里分享几个我总结出来的关键点。陷阱一合同条款过于理想化脱离实际基础设施能力。比如合同规定要对所有查询进行复杂的句法分析以进行重述但实际部署环境延迟要求极高无法承受额外的NLU服务开销。解决方案起草合同时每一项条款都要评估其计算成本、延迟影响和实现复杂度。优先采用简单、可落地的方案复杂策略作为“实验性条款”待后续验证。陷阱二忽略了元数据Metadata的设计。合同里只规定了检索文本和分数但没规定必须存储和返回哪些元数据。等到需要在结果中显示“该信息来源于XX报告第Y页”或者想按时间过滤信息时才发现当初存入Qdrant的向量没有附带必要的元数据。解决方案在合同最开始的“数据规范”部分就明确定义知识库每条记录必须包含的元数据字段如source来源文件、page页码、timestamp更新时间、doc_type文档类型等。并在检索后处理条款中规定这些元数据如何被用于过滤和格式化。陷阱三阈值参数score_threshold设置僵化。合同里写死了score_threshold0.7但这个阈值可能因embedding模型不同、数据分布不同而有巨大差异。用OpenAI的text-embedding-ada-002和用开源的BGE模型得出的相似度分数分布完全不同。解决方案合同中不写死绝对值而是定义阈值校准方法。例如“score_threshold应通过验证集进行校准。在验证集上该阈值应保证在[召回率]不低于X%的情况下[准确率]达到Y%。初始值可设为0.65上线前必须校准。”陷阱四合同没有版本管理。随着业务发展检索策略肯定会调整。如果合同只是口头约定或散落在不同的文档里很快就会失效导致线上线下行为不一致。解决方案将RetrievalContract类本身进行版本化如添加version字段并将其配置如top_k,score_threshold存储在配置文件或配置中心。任何更改都需要通过修改配置版本并经过测试来完成做到有迹可循。陷阱五把合同当成一次性文档写完就扔。检索合同不是项目启动时的“仪式”而应该是贯穿整个Agent生命周期的“活文档”。每一次bad case错误回答的分析都应该回溯到合同的某个条款思考是条款定义不周还是执行有偏差。解决方案建立定期如每两周的检索质量评审会基于真实的用户提问和Agent回答对照合同条款逐一检查并持续更新合同和对应的代码实现。撰写一份清晰的检索合同前期似乎多花了一些时间但它带来的长期收益是巨大的它让团队对“检索”有了共同且精确的理解让系统架构变得模块化和可测试让性能调优和数据迭代有了明确的依据。在狂热地接入Qdrant、Milvus这些强大的向量数据库之前先把这份“合同”写清楚无疑是打造一个健壮、可靠、可维护的AI Agent知识系统的第一步。
返回列表