在前面的快速入门和架构原理中,我们已经把模型、提示词、链和文档检索放进同一个应用结构。现在继续沿着“报销制度问答”这个场景,深入看一条完整的 RAG 链路:制度文档怎样变成向量,问题怎样找到相关条款,条款又怎样进入模型的回答。
RAG 是 Retrieval-Augmented Generation,即检索增强生成。应用先从自己的数据中检索资料,再把资料和问题一起交给大模型。LangChainGo 提供其中的文档加载、文本切分、向量模型接入、向量存储适配和问答编排组件,应用负责选择并连接它们。
RAG 的两条执行路径
假设公司发布了新的差旅制度,规定“境内住宿费上限为每晚 500 元”。模型未必见过这份制度,但应用可以把它存入知识库。员工提问“出差住宿能报多少”,应用先检索出对应条款,再要求模型依据条款回答。
这分成两个执行阶段:资料变更时建立或更新索引,用户提问时检索并生成回答。
1 | 文档准备:在资料变更时执行 |
在 pgvector 等适配器中,图里的文档向量化由 Store.AddDocuments 内部触发,问题向量化由 Store.SimilaritySearch 内部触发。调用者可以直接传入文档和问题文本,不必先手动计算一次向量。
RAG 主要改变模型本次调用能看到的资料,通常不涉及更新模型权重。知识库更新后,后续问题便可以检索到新内容;能否正确回答,仍取决于资料是否完整、检索是否命中,以及模型是否遵循资料。
串起整条链路的数据与接口
理解实现时,可以先记住六个约定:
| 组件 | 主要约定 | 传递的数据 |
|---|---|---|
| 文档 | schema.Document |
正文、metadata、检索得分 |
| 文档加载器 | documentloaders.Loader |
数据源 → []schema.Document |
| 文本切分器 | textsplitter.TextSplitter |
字符串 → 多个字符串 |
| 向量生成器 | embeddings.Embedder |
文档或问题 → 浮点向量 |
| 向量存储 | vectorstores.VectorStore |
文档写入、相似度查询 |
| 检索器 | schema.Retriever |
问题 → 相关文档 |
Document:保留正文与出处
schema/documents.go 中的文档结构如下:
1 | type Document struct { |
PageContent 是用于切分、向量化和回答的正文。Metadata 可以记录文件名、URL、页码、制度版本、部门、片段编号等。Score 可以由检索实现填入,用于表示该结果与问题的相关程度。
为了让用户能从回答找到原始资料,可以将文件路径、URL、页码等来源信息放入 Metadata,随文档一起存储,并在检索后展示。不同 loader 会提供不同的字段:文本 loader 默认返回空 map,需要应用补充文件来源;PDF loader 会写入页码和总页数,还可以补充文件名,方便定位到具体文件的对应页面。
Embedder:区分文档与问题
embeddings/embedding.go 定义了两个入口:
1 | type Embedder interface { |
这两个方法分别用于准备知识库和处理用户提问。EmbedDocuments 一次接收多个文档片段,为每个片段生成一个向量;EmbedQuery 接收一个问题,生成用于搜索的问题向量。例如,制度入库时,用前者处理各条报销规定;用户问“住宿费能报多少”时,用后者处理这个问题。
有些向量模型还要求调用方说明这段文字的用途:是准备存入知识库的资料,还是用来查找资料的问题。因此,两个方法也给具体实现留出了分别设置请求参数的位置。仓库中的 Bedrock/Cohere 实现就会将文档标记为 search_document,将问题标记为 search_query,再发送给模型。
VectorStore 与 Retriever:保存文档与查找资料
制度文档切分完成后,需要存入知识库;员工提问时,又需要从中查找相关条款。VectorStore 为这两项操作提供统一的方法,具体的数据库读写由 pgvector、Chroma 等适配器完成。接口定义在 vectorstores/vectorstores.go:
1 | type VectorStore interface { |
AddDocuments 接收文档片段,将它们写入存储,并返回记录 ID。以 pgvector 适配器为例,它会先调用配置好的 embedder 生成向量,再把向量、正文和 metadata 一起写入数据库。SimilaritySearch 则接收问题文本和期望返回的数量,查找语义相近的文档。例如,传入“住宿费能报多少”和数量 4,就会尝试找出最多 4 个相关片段。
问答链拿到这些片段后,才能组织资料并调用模型。对它来说,只需把问题交出去,再取回相关文档,文档怎样入库、搜索哪个数据库都可以由其他组件处理。LangChainGo 用 schema.Retriever 表达这一步:
1 | type Retriever interface { |
已有向量存储时,可以用 vectorstores.ToRetriever(store, 4) 创建检索器。这里的 store 指定搜索哪个存储,4 指定最多返回多少个片段。之后,问答链调用 GetRelevantDocuments(ctx, question),检索器便会将请求转交给 store.SimilaritySearch(ctx, question, 4),再把查到的文档返回给问答链。创建时还可以传入过滤条件、分数阈值等查询选项。
这种分工也方便调整检索方式。例如,希望同时搜索关键词和向量,再合并结果,可以编写自己的 Retriever,在 GetRelevantDocuments 中完成这些步骤。只要最后返回 []schema.Document,就能继续交给原来的问答链生成回答。
文档加载:先得到可用的文本
documentloaders.Loader 包含 Load 和 LoadAndSplit。后者通常先调用 Load,再调用 textsplitter.SplitDocuments,并没有在后台建立向量索引。
常见实现的处理方式如下:
| Loader | 处理方式 | 使用时需要补充的信息 |
|---|---|---|
Text |
从 io.Reader 读取全文,返回一个 Document |
文件路径、资料版本等来源字段 |
HTML |
解析 HTML,提取文本并清理,返回一个 Document | URL,以及按站点特点提取正文的逻辑 |
PDF |
按页提取文字,每页生成一个 Document | 文件标识;默认已有 page、total_pages |
CSV |
首行作为表头,每行生成一个文档,正文使用“列名: 值” | 业务主键与字段含义;默认已有 row 行号 |
例如,NewHTML(reader) 接收的是 HTML 内容的 reader。下载网页、处理登录态、限制抓取范围等工作,需要由上游代码安排。通用 HTML 文本提取也可能包含菜单、页脚等内容,知识库通常需要按具体页面结构提取正文。
PDF loader 使用 ledongthuc/pdf 提取已有的文字层。扫描版 PDF 的信息可能只有图片,需要先经过 OCR。对于制度表格,还应检查列名、金额、单位是否在提取后保持了正确对应关系。解析阶段丢失的关系,很难依靠后续向量搜索恢复。
文本切分:决定知识库的检索粒度
将整本制度保存为一个向量,容易让不同条款的语义混在一起;切得过碎,又可能把“适用条件”和“报销上限”分开。因此,chunk 的边界直接影响检索和回答质量。
递归字符切分的执行过程
RecursiveCharacter 默认按以下分隔符依次尝试:
1 | 空行 "\n\n" → 换行 "\n" → 空格 " " → 字符级 "" |
处理一个文本段时,它找到当前可用的分隔符并拆开文本。较短的部分进入待合并列表,过长的部分再用后续分隔符递归拆分。最后,mergeSplits 将小片段合并到接近目标长度,并在进入下一块时尝试保留一部分尾部内容。
这样做是为了尽量先保留段落边界,再处理段落中仍然过长的内容。中文制度中还可以显式加入“。”、“;”等分隔符,或按业务条款预先组织文本。
使用时,可以通过 ChunkSize 设置每块的目标长度,通过 ChunkOverlap 设置相邻块希望保留的重叠长度。片段应尽量包含完整的条款,同时为问题、提示词和其他检索结果留出输入空间。例如,住宿费每晚最多报销 500 元 和 超过标准需提前审批 可能刚好被分到相邻两块。设置适当的 overlap,可以让后一块保留前一块末尾的部分内容,使检索到的片段更容易同时包含报销标准和例外条件。重叠过多也会增加重复内容,因此应结合实际切分结果调整。
按 token 与按 Markdown 结构切分
TokenSplitter 使用 tiktoken-go 编码文本,以 token 窗口切分,再解码为文本。窗口步长为 ChunkSize - ChunkOverlap,因此应用应保证 ChunkSize > 0 且 0 <= ChunkOverlap < ChunkSize。
token 数取决于模型使用的分词规则,同一段文字在不同规则下可能得到不同的 token 数。TokenSplitter 默认采用 cl100k_base 这套规则,因此它切出的“500 个 token”,在另一个模型中可能超过 500 个。选择分词规则时,应与所用模型匹配,才能更准确地控制片段长度。
MarkdownTextSplitter 则通过 CommonMark 解析器识别文档结构,再结合二级 splitter 处理长内容,并提供标题层级、代码块、表格行等相关选项。对于技术文档,保留标题路径往往能帮助片段在脱离原文后仍表达完整含义。
切分以后保留下来的内容
SplitDocuments 取出每篇文档的正文和 metadata,切分正文后,为每个 chunk 新建 metadata map 并复制顶层键值。
它会保留已有的文件来源或页码,但不会自动创建字符偏移、chunk ID 或标题路径;这些字段需要由应用或相应 splitter 的处理结果补充。这里的 map 复制也是浅复制,嵌套 map、切片等值仍可能共享底层对象。
Embedding:把片段与问题转换成可比较的向量
通用的 EmbedderImpl 包装一个 EmbedderClient,后者只需实现 CreateEmbedding(ctx, texts)。例如,openai.LLM 同时提供生成能力和这个 embedding client 方法,可以交给 embeddings.NewEmbedder 使用。
它的文档处理过程可以概括为:
1 | []string |
BatchSize 控制一次请求发送多少个文档片段。例如,设为 32 时,100 个片段会分成四次请求,分别处理 32、32、32 和 4 个片段。每个片段有多长,则由前面的文本切分决定。如果模型提示输入过长,可以缩短片段,或减少每批发送的片段数量。
EmbedQuery 则对单条问题做相同的可选换行处理,再请求一个向量。向量维度、语义能力和语言覆盖来自具体 embedding 模型。通用包装器主要安排输入预处理与批处理。
生成回答的模型和 embedding 模型可以分别选择。例如,文档与问题使用同一个中文 embedding 模型,回答阶段使用另一个聊天模型。二者分别承担“查找资料”和“依据资料表达答案”的任务。
以 pgvector 为例理解向量存储
前面已经把制度切成片段,并介绍了如何为片段生成向量。接下来的问题是:这些内容存在哪里,用户提问时又怎样找到它们?
这里使用 PostgreSQL 和它的 pgvector 扩展。PostgreSQL 保存制度正文、来源和版本等信息,pgvector 为它增加保存向量和比较向量距离的能力。LangChainGo 的 vectorstores/pgvector 适配器把这些数据库操作封装起来,应用通过 AddDocuments 写入资料,通过 SimilaritySearch 查找资料。
一条制度需要保存哪些内容
假设切分后得到这样一个片段:“境内出差住宿费上限为每晚 500 元。”保存它时,需要将三类内容关联起来:
| 内容 | 在这个例子中保存什么 | 用途 |
|---|---|---|
| 正文 | 境内出差住宿费上限为每晚 500 元。 | 检索命中后,交给模型作为回答依据 |
| 向量 | embedding 模型根据这段正文生成的一组数值 | 与问题向量比较,判断内容是否相关 |
| metadata | 来源为“差旅制度.md”,版本为“2026-10” | 筛选有效制度,并展示资料出处 |
向量用于查找,正文用于回答,metadata 则补充来源和筛选条件。三者一起保存,检索命中一个向量后,才能取回它对应的原文和出处。
一份制度通常会产生多个片段。适配器为每条记录生成独立 ID,并通过 collection 将相关记录归在一起。比如,可以把差旅制度的片段放入一个名为 travel_policy 的 collection,查询时就在这个范围内查找。
写入时,文档怎样进入数据库
创建 pgvector store 时,需要提供数据库连接、embedding 组件和 collection 名称。pgvector.New 会准备保存文档所需的表和 collection;数据库环境的准备方式会在后面的完整示例中给出。
准备好 store 后,将文档片段传给 store.AddDocuments 即可。适配器会读取每个片段的正文,调用 EmbedDocuments 生成向量,再将正文、向量和 metadata 一起写入数据库。应用不必先手动生成向量,再调用一次写入方法。
1 | [住宿费条款 + 来源、版本] |
例如,输入十个制度片段,就会为这十个片段分别生成向量,并保存为十条记录。以后检索时,返回的便是其中相关的片段。
提问时,怎样找到住宿费条款
员工问“出差住宿能报多少”,这句话和制度中的“住宿费上限”并不完全相同。向量检索会把问题也转换成向量,再比较它与各个文档向量的距离,因此有机会找到表达不同但含义相关的条款。
调用 store.SimilaritySearch(ctx, "出差住宿能报多少", 4) 后,适配器先通过 EmbedQuery 生成问题向量,再让数据库查找当前 collection 中距离最近的文档向量,最后取回最多四个片段的正文和 metadata。
1 | [问题:出差住宿能报多少] |
当前 pgvector 适配器使用 余弦距离 进行比较,它衡量两个向量的方向是否接近:距离越小,表示它们越接近。查询按距离从小到大排列结果,并将 1 - 距离 放入返回文档的 Score 字段,所以这里的分数越大,表示越相关。
这个分数反映的是检索相关程度,不能直接当作答案正确的概率。如果取回的都是无关资料,后续模型仍然缺少回答依据。实际使用时,可以先查看不同问题命中的片段,再决定是否设置分数阈值,排除相关程度较低的结果。
上面参数中的 4 也只是返回数量的上限。取回更多片段可能补充证据,也可能带来重复和无关内容,需要结合实际问题选择。
怎样避免查到旧版制度
假设旧制度规定住宿费上限为 400 元,新制度调整为 500 元。两段文字都与住宿费问题相关,仅靠向量距离,很难保证每次只返回新版条款。
这时可以利用 metadata 中的版本字段,将查询范围限定为 version=2026-10。在前面介绍的 ToRetriever 中传入版本过滤条件后,检索器每次查找资料时,都会把这个条件一并交给 store。这样,旧版制度即使在语义上很相关,也不会进入本次回答的资料中。
版本管理也需要和写入方式配合。当前适配器的 AddDocuments 会新增记录,重复导入同一份文件不会自动替换原有片段。制度更新时,应用可以保存新版本并切换查询条件,或删除旧片段后重新写入,避免新旧条款混在一起。
资料增多后,怎样加快搜索
知识库较小时,可以先用上述流程验证能否找对条款。片段数量增长后,如果每次都比较大量向量,查询就会变慢。向量索引的作用,是帮助数据库更快地找到可能相关的候选片段。
这个适配器提供 WithHNSWIndex 选项,用于创建 HNSW 向量索引。它通过近似搜索提高检索速度,可能牺牲一部分召回率,因此需要同时观察查询耗时和正确条款的命中情况。实际配置还需与向量维度、距离计算方式匹配,并确认数据库查询使用了该索引。
索引优化的是查找速度。从正文生成向量仍由 embedding 模型负责,检索到条款后的回答生成则由后面的问答链完成。
RetrievalQA:从相关文档走向回答
检索器找到制度条款后,还需要把条款和用户问题一起交给模型,才能得到回答。RetrievalQA 是 LangChainGo 用来串起这两个步骤的问答链:先调用检索器查资料,再将查到的文档和问题交给后续的回答流程。
这段回答流程保存在 RetrievalQA 的 CombineDocumentsChain 字段中,负责组织文档、准备提示词并调用模型。之所以单独设置这个字段,是因为资料的处理方式可以不同:少量条款可以直接拼接,较多资料则可以先提取要点再汇总。
默认调用链
最直接的回答方式,是把检索到的条款拼成一段资料,再连同问题一起发送给模型。LangChainGo 用 StuffDocuments 完成正文拼接,用 LLMChain 填写提示词模板并调用模型。LoadStuffQA(model) 是一个辅助函数,会将这两个组件和默认问答模板组装好,得到可供 RetrievalQA 使用的回答流程。
创建时,可以调用 chains.NewRetrievalQAFromLLM(model, retriever),传入负责生成回答的模型和负责查资料的检索器。这个构造函数会用 LoadStuffQA 准备上述回答流程,再将它与检索器连接起来,返回一个 RetrievalQA 对象。
用户提问后,通过 chains.Call 执行这个对象。问题先用于检索,检索结果随后进入提示词,最后由模型生成回答:
1 | chains.Call(ctx, qa, {"query": 用户问题}) |
普通 RetrievalQA 默认使用不保存内容的 simple memory,因此它本身没有多轮对话历史。业务代码使用包级 chains.Call 可以经过公共的输入输出检查、记忆与链回调流程;直接调用链对象的方法只执行其主体逻辑。
StuffDocuments 怎样构造 context
StuffDocuments 默认使用两个换行连接各文档的 PageContent,然后把结果放入 context。默认 QA 模板要求模型依据这些资料回答,不知道时说明不知道。
LLMChain 随后调用 Prompt.FormatPrompt,使用 promptValue.String() 得到文本,再通过 llms.GenerateFromSinglePrompt 调用模型。当前这条路径将提示词作为单条用户文本发送;即使换成聊天模板,也需要留意这里会先转换为字符串。默认输出解析器只去掉回答首尾的空白。
这里有三个直接影响应用行为的细节:
- 只拼正文。 metadata 和
Score不会自动加入提示词,模型因此未必知道文件名、页码或检索分数。 - 不自动控制总长度。 Stuff 不会根据模型上下文窗口删减、压缩或重新检索资料。
- 不因空结果自动拒答。 空文档列表会形成空 context,随后仍可能进入模型调用。需要硬性拒答时,应在检索后、生成前增加分支。
输入预算需要同时考虑固定提示词、问题、资料以及为输出预留的空间:
1 | T_fixed + T_question + T_context + T_reserved_output <= T_model_window |
chunk 限制只控制单个片段。最终预算还受到 Top-K、来源标识、历史内容和模板长度影响。
返回来源与生成引用
设置 qa.ReturnSourceDocuments = true 后,结果会多出 source_documents。这是返回检索文档的开关,不能据此认定模型已经为每句话标注了依据。
如果希望回答中出现 [1]、[2] 这样的引用,可以在自定义 Retriever 或组合链中将检索结果格式化为带编号的证据块:
1 | [1] 来源:差旅制度.md;版本:2026-10;条款:住宿 |
提示词再要求每个结论引用证据编号,应用同时保存编号与原始文档的映射,并检查返回编号是否存在。需要进一步验证“这句话是否确实由该证据支持”时,还要增加证据一致性校验。
文档组合策略与多轮 RAG
四种文档组合方式
RAG 已检索到的资料有时仍然很多。LangChainGo 在 chains/question_answering.go 中提供了几种组合方式,可以传入 NewRetrievalQA:
| 构造函数 | 执行方式 | 主要代价与适用条件 |
|---|---|---|
LoadStuffQA |
全部正文拼接,一次生成 | 调用少,适合资料能放入一个上下文窗口的情况 |
LoadMapReduceQA |
每块提取相关信息,再汇总 | 对 n 块资料通常约 n+1 次调用;提取阶段可能丢失信息 |
LoadRefineQA |
第一块生成初稿,后续逐块修订 | 对 n 块资料通常约 n 次调用;顺序执行,结果可能受资料顺序影响 |
LoadMapRerankQA |
每块独立回答并评分,返回最高分答案 | 对 n 块资料通常约 n 次调用;依赖模型自评分与输出格式 |
MapReduce 的 map 阶段通过 Apply 执行,可配置并发数,但默认 reduce 阶段仍然使用 Stuff。它没有保证汇总后的输入一定落在模型窗口内,必要时仍需应用分级压缩。
MapRerank 在这里表示对各文档生成的答案按模型评分排序。它与“先用 cross-encoder 重排候选文档,再把前几条交给另一个模型综合回答”有不同的执行过程。需要后者时,可以把重排接在自定义 Retriever 内部。
多轮对话
实际问答中,用户往往会接着上一轮继续问:
1 | 用户:境内出差住宿费上限是多少? |
人读过前面的对话,知道“超过了”指的是住宿费超过报销上限。但如果直接用“那超过了怎么办”去搜索制度,检索器就缺少了“住宿费”和“报销上限”这些关键信息,容易查不到对应条款。
LangChainGo 的 ConversationalRetrievalQA 为此增加了一个步骤:先让模型结合历史对话,把追问补成一个完整问题。在这个例子中,补全后的问题可以是“境内出差住宿费超过报销上限时怎么办?”
接下来就可以沿用前面的检索与回答流程:用完整问题查找制度,取回超标审批条款,再让模型依据条款回答。
1 | [历史对话] + [追问:那超过了怎么办?] |
默认情况下,补全后的问题会同时用于检索和生成回答。历史对话主要用于补全问题,因此模型需要把历史中仍然相关的条件保留下来,例如“境内出差”,避免检索到境外出差的规定。第一轮没有历史时,会直接使用用户原来的问题,省去补全这一步。
接入时,需要为每个会话保存各自的历史,并通过 question 字段传入本轮问题。若追问的检索结果不对,可以开启 ReturnGeneratedQuestion,查看模型补全后的问题,先确认它是否理解了用户在问什么。
第三方库
下面按 RAG 的执行阶段列出直接相关的第三方库。它们分布在不同功能包中,应用根据选择的 loader、模型和存储适配器引入相应依赖。这里区分“库用于什么功能”和“服务端实际执行什么工作”,便于排查问题和替换组件。
文档解析与文本切分
| 第三方库 | 对应位置 | 主要作用 |
|---|---|---|
github.com/ledongthuc/pdf |
documentloaders/pdf.go |
读取 PDF 结构、字体与文字层,按页提取文本 |
github.com/PuerkitoBio/goquery |
documentloaders/html.go |
解析 HTML,选择节点并提取文本 |
github.com/microcosm-cc/bluemonday |
documentloaders/html.go |
对 HTML loader 提取后的内容执行清理 |
gitlab.com/golang-commonmark/markdown |
textsplitter/markdown_splitter.go |
解析 Markdown token,辅助按标题、段落、列表等结构切分 |
github.com/pkoukk/tiktoken-go |
textsplitter/token_splitter.go |
将文本编码为 token,按 token 数切块并解码 |
github.com/gocolly/colly |
tools/scraper/scraper.go |
为可选的网页抓取工具提供采集、链接遍历与并发抓取能力 |
Colly 属于可选抓取工具,基础的 HTML loader 不会自动启动爬虫。递归字符切分算法由 LangChainGo 自己实现,主要使用 strings、unicode/utf8 等 Go 标准库。
HTML 清理用于处理页面内容,不能据此认为已经处理了自然语言中的提示词注入。文档里的“忽略之前要求”等句子即使是普通文本,也需要在应用的证据与指令组织中区别对待。
向量生成与模型接入
| 第三方库 | 使用场景 | 主要作用 |
|---|---|---|
github.com/nlpodyssey/cybertron |
embeddings/cybertron |
在本机 CPU 上运行文本编码模型,生成 embedding;相应实现还会归一化结果 |
github.com/aws/aws-sdk-go-v2/service/bedrockruntime |
embeddings/bedrock |
调用 Bedrock 的 embedding 模型,处理 AWS 服务请求 |
cloud.google.com/go/aiplatform |
llms/googleai/vertex 使用的内部 palmclient |
通过 Vertex AI Prediction API 请求文本向量 |
这些 SDK 或运行库负责模型服务接入、本地推理等具体能力;embeddings.Embedder 则给上层提供统一的文档与问题向量接口。不同实现的批处理、归一化和输入类型需要分别核对。
当前 OpenAI 适配器通过 llms/openai/internal/openaiclient 和 Go 的 net/http、encoding/json 等标准库发起请求,没有依赖一个外部 OpenAI Go SDK 来完成这条调用链。Ollama 适配器也有自己的内部 client。判断实现依赖时,应查看实际 import 和调用位置。
向量存储适配
| 第三方库 | 对应存储 | 主要作用 |
|---|---|---|
github.com/jackc/pgx/v5 |
pgvector、部分 PostgreSQL 类存储 | PostgreSQL 连接、连接池、事务、SQL 与批量写入 |
github.com/pgvector/pgvector-go |
pgvector、部分 PostgreSQL 类存储 | 包装向量值,处理 Go 数据与 PostgreSQL vector 类型之间的表示转换 |
github.com/amikos-tech/chroma-go |
Chroma | 管理 collection,添加文档、查询向量并接入 embedding function |
github.com/milvus-io/milvus-sdk-go/v2 |
vectorstores/milvus |
使用相应版本的 Milvus client 管理 collection、写入与搜索 |
github.com/milvus-io/milvus/client/v2 |
vectorstores/milvus/v2 |
为另一套 Milvus 适配实现提供新版 client、实体及索引配置 |
github.com/pinecone-io/go-pinecone |
Pinecone | 连接向量索引,执行写入与相似度查询 |
github.com/weaviate/weaviate-go-client/v5 |
Weaviate | 访问 schema、写入对象并构造向量查询与过滤条件 |
github.com/redis/rueidis |
Redis Vector | 连接 Redis,执行向量索引和搜索相关命令 |
go.mongodb.org/mongo-driver/v2 |
Mongo Vector | 访问 MongoDB,通过聚合管道等能力执行向量查询 |
github.com/opensearch-project/opensearch-go |
OpenSearch | 管理索引、写入数据并执行搜索请求 |
github.com/google/uuid |
pgvector、Chroma、Qdrant 等 | 为文档或集合相关对象生成标识 |
这些 client 主要负责构造请求、传输数据和解析结果。向量距离计算、持久化与索引搜索通常由对应数据库或服务执行;具体是否使用 HNSW、IVF 等算法,由服务能力和索引配置决定。
Qdrant 在当前仓库中使用 vectorstores/qdrant/rest.go 自行发送 REST 请求,没有在这条适配路径上使用外部 Qdrant Go SDK。
另外,公共接口相同,并不代表过滤、namespace、阈值和分数语义完全相同。例如 pgvector 的当前查询使用余弦距离,Chroma 则使用 collection 配置的距离函数,再由适配器转换返回值。迁移后端时应重新验证检索效果与阈值。
提示词渲染
| 第三方库 | 对应位置 | 主要作用 |
|---|---|---|
github.com/Masterminds/sprig/v3 |
prompts/templates.go 等 |
为 Go text/template 补充字符串、集合等模板函数 |
github.com/nikolalohinski/gonja |
prompts/templates_jinja2.go |
提供可选的 Jinja2 风格模板渲染 |
默认 NewPromptTemplate 使用 Go 模板,核心渲染由标准库 text/template 完成。RetrievalQA、StuffDocuments、MapReduceDocuments 等执行顺序由 LangChainGo 自己编排,没有在运行时启动 Python LangChain。
需要注意,Go 按包编译,某个包的其他源文件仍可能引入额外依赖。例如 documentloaders 将多种 loader 放在同一包,prompts 也包含多种渲染实现;只调用其中一个构造函数,并不保证只下载该构造函数直接使用的库。与此同时,独立存储适配包的存在,也避免了应用必须统一编译所有数据库后端。
完整示例:建立制度知识库,再执行问答
下面使用 OpenAI 兼容的生成与 embedding 接口,以及 PostgreSQL/pgvector。示例分成两个命令模式:-index 加载并写入资料,普通模式复用已经入库的数据回答问题。
示例采用默认 Stuff 路径,返回答案并单独打印检索来源,便于观察各组件的默认行为。它适合验证小规模链路;资料版本切换、幂等更新和严格引用可以在此基础上增加。
准备数据库与资料
在本地测试环境启动已包含 pgvector 的 PostgreSQL。以下凭据仅用于这个本地示例:
1 | docker run --name langchaingo-rag-pg \ |
等待数据库就绪后,配置连接和模型。OPENAI_EMBEDDING_MODEL 是下面示例自行读取的变量;它将被显式传入 WithEmbeddingModel。服务端需要同时提供所选的生成与 embedding 模型。
1 | export POSTGRES_URL='postgres://rag:rag_local@localhost:5432/rag?sslmode=disable' |
程序代码
将下面代码保存为独立示例项目的 main.go。示例使用连接池,并将其交给 pgvector store;程序退出时由连接池释放连接。
1 | package main |
创建独立项目后再安装依赖。下面固定到本文分析的提交,便于保持 API 一致;该提交要求 Go 1.26.0 或以上,本地验证使用 Go 1.26.10。
1 | mkdir langchaingo-rag-demo |
重复执行 -index 会新增记录;这里写入 metadata 的 chunk_id 只是业务标识,pgvector 适配器仍为记录生成独立 UUID,不会因此自动覆盖同名片段。示例使用同一个客户端配置调用两类模型,查询和入库时应保持 embedding 配置一致。
一次普通查询主要包括一次问题向量请求、一次数据库搜索和一次生成请求。示例没有设置分数阈值,以便先观察返回的资料;正式采用阈值时,需要用真实问题评估,避免直接套用任意数值。
排查与优化
假设现行制度规定住宿费每晚最多报销 500 元,系统却回答 400 元。这个错误可能来自旧版资料,也可能是模型没有按资料回答。排查时,可以顺着“查到了什么、发给模型什么、模型回答了什么”逐步检查。
先看查到了什么
开启 ReturnSourceDocuments,查看本次检索返回的片段。如果其中写的是 400 元,就需要检查是否导入了旧版制度,以及查询是否限定了有效版本。
如果返回的都是交通费、餐补等无关条款,可以先确认住宿费规定是否已经入库,再检查切分后的内容。例如,金额所在的片段如果没有保留“住宿费”这个标题,单独看只剩一句“每晚 500 元”,检索时就可能不容易找到它。此时可以让标题和条款一起保留,再重新生成向量并写入知识库。
如果需要综合多条规定,还要看结果是否齐全。回答“超标怎么办”时,只有住宿费上限还不够,还需要取回超标审批条款。可以适当增加返回片段数量,观察是否能找到缺失的依据。
再看模型收到了什么
检索结果正确后,检查最终发给模型的提示词,确认相关条款确实被放了进去。若资料在拼接或裁剪时丢失,模型仍然无法据此回答。
如果提示词已经包含“每晚 500 元”,模型却回答 400 元,就应检查生成阶段:是否混入了旧版条款,提示词是否明确要求依据资料回答,以及无关内容是否过多。可以先只保留相关条款和问题进行验证,再逐步加入其他资料,观察回答从哪里开始出现偏差。
知识库没有相关规定时,也需要明确处理方式。例如,员工问“家属随行的费用能否报销”,但制度没有说明,应用可以在没有检索结果时直接提示资料不足;即使命中了一般差旅条款,也应要求模型说明其中没有家属费用的规定。
用固定问题验证修改
每次调整切分方式、检索数量或提示词后,都用同一组问题重新检查,才能知道改动是否有效。报销场景可以从这些问题开始:
- “住宿费每晚能报多少?”——检查是否找到正确金额。
- “超过标准怎么办?”——结合上一轮对话,检查能否找到审批要求。
- “家属随行能报销吗?”——检查资料不足时是否会编造规定。
验证时,同时保存检索片段和最终回答。这样既能发现“没找到正确条款”,也能发现“找到了条款却答错了”,避免只凭某一次回答判断效果。
回答慢时,分别看各步耗时
一次问答会经历问题向量生成、数据库检索和模型生成。可以分别记录这些步骤的耗时,再决定优化哪里。
数据库检索慢时,检查查询范围和向量索引;模型生成慢时,先看是否发送了太多重复或无关的资料。开启流式输出可以让用户更早看到回答,但检索仍需先完成,因此也要关注用户发问到第一个字出现之间的等待时间。
小结
LangChainGo 的 RAG 流程分为文档准备和在线问答两部分:先加载、切分文档,生成向量并存入知识库;用户提问后,再检索相关片段,将资料和问题一起交给模型生成回答。多轮对话则先结合历史补全追问,再进行检索。
在这条链路中,文档解析、分词和数据库访问使用相应的第三方库,LangChainGo 通过接口和问答链将各步骤连接起来。本文以 pgvector 为例说明了文档如何保存和检索,并用完整 Go 示例演示了入库与问答。