当前位置:Java -> 如何利用RAG技术让Spring AI和OpenAI GPT更好地应用于自己的文件
项目 AIDocumentLibraryChat 使用 Spring AI 项目与 OpenAI 结合进行文档库搜索问题的解答。为此,使用了“检索增强生成”技术对文档进行处理。
该流程如下:
该流程如下:
搜索文档:
上传的文档存储在数据库中,以便获取答案的源文档。文档文本必须拆分成块,以创建每个块的嵌入。嵌入由 OpenAI 的嵌入模型创建,并且是一个表示文本块的超过 1500 个维度的向量。嵌入存储在 AI 文档中,包括块文本和向量数据库中源文件的 ID。
文档搜索获取搜索提示并使用 Open AI 嵌入模型将其转化为嵌入。该嵌入用于在向量数据库中搜索最近邻向量。这意味着搜索提示的嵌入和具有最大相似性的文本块。AIDocument 中的 ID 用于读取关系数据库中文档。使用搜索提示和 AIDocument 的文本块创建文档提示。然后,调用 OpenAI GPT 模型以基于搜索提示和文档上下文创建答案。这导致模型创建更接近提供的文档并提高准确性的答案。GPT 模型的答案返回并显示文档链接,以提供答案的来源。
该项目的架构基于 Spring Boot 与 Spring AI。Angular UI 提供用户界面,显示文档列表,上传文档,并提供带有答案和源文档的搜索提示。它通过 REST 接口与 Spring Boot 后端通信。Spring Boot 后端为前端提供 REST 控制器,并使用 Spring AI 与 OpenAI 模型和 PostgreSQL 向量数据库通信。文档使用 Jpa 存储在 PostgreSQL 关系数据库中。选择使用 PostgreSQL 数据库,因为它在 Docker 镜像中将关系数据库和向量数据库结合在一起。
前端基于运用 Angular 构建的懒加载独立组件。这些懒加载独立组件在app.config.ts中进行了配置:
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideAnimations(), provideHttpClient()]
};
配置设置路由并启用 HTTP 客户端和动画。
延迟加载的路由在 app.routes.ts中定义:
export const routes: Routes = [
{
path: "doclist",
loadChildren: () => import("./doc-list").then((mod) => mod.DOCLIST),
},
{
path: "docsearch",
loadChildren: () => import("./doc-search").then((mod) => mod.DOCSEARCH),
},
{ path: "**", redirectTo: "doclist" },
];
在 'loadChildren' 中,'import("...").then((mod) => mod.XXX)' 懒加载提供的路径,并设置在 'mod.XXX' 常量中定义的导出路由。
延迟加载的路由 "docsearch" 在 index.ts 中导出常量:
export * from "./doc-search.routes";
这导出了 doc-search.routes.ts:
export const DOCSEARCH: Routes = [
{
path: "",
component: DocSearchComponent,
},
{ path: "**", redirectTo: "" },
];
它定义了路由到 'DocSearchComponent'。
文件上传可以在DocImportComponent中找到,使用的模板是doc-import.component.html:
<h1 mat-dialog-title i18n="@@docimportImportFile">Import file</h1>
<div mat-dialog-content>
<p i18n="@@docimportFileToImport">File to import</p>
@if(uploading) {
<div class="upload-spinner"><mat-spinner></mat-spinner></div>
} @else {
<input type="file" (change)="onFileInputChange($event)">
}
@if(!!file) {
<div>
<ul>
<li>Name: {{file.name}}</li>
<li>Type: {{file.type}}</li>
<li>Size: {{file.size}} bytes</li>
</ul>
</div>
}
</div>
<div mat-dialog-actions>
<button mat-button (click)="cancel()" i18n="@@cancel">Cancel</button>
<button mat-flat-button color="primary" [disabled]="!file || uploading"
(click)="upload()" i18n="@@docimportUpload">Upload</button>
</div>
文件上传使用了''标签。它提供了文件上传功能,并在每次上传后调用'onFileInputChange(...)'方法。
'上传'按钮在单击时调用'upload()'方法将文件发送到服务器。
doc-import.component.ts中包含了与模板相关的方法:
@Component({
selector: 'app-docimport',
standalone: true,
imports: [CommonModule,MatFormFieldModule, MatDialogModule,MatButtonModule, MatInputModule, FormsModule, MatProgressSpinnerModule],
templateUrl: './doc-import.component.html',
styleUrls: ['./doc-import.component.scss']
})
export class DocImportComponent {
protected file: File | null = null;
protected uploading = false;
private destroyRef = inject(DestroyRef);
constructor(private dialogRef: MatDialogRef<DocImportComponent>,
@Inject(MAT_DIALOG_DATA) public data: DocImportComponent,
private documentService: DocumentService) { }
protected onFileInputChange($event: Event): void {
const files = !$event.target ? null :
($event.target as HTMLInputElement).files;
this.file = !!files && files.length > 0 ?
files[0] : null;
}
protected upload(): void {
if(!!this.file) {
const formData = new FormData();
formData.append('file', this.file as Blob, this.file.name as string);
this.documentService.postDocumentForm(formData)
.pipe(tap(() => {this.uploading = true;}),
takeUntilDestroyed(this.destroyRef))
.subscribe(result => {this.uploading = false;
this.dialogRef.close();});
}
}
protected cancel(): void {
this.dialogRef.close();
}
}
这是一个独立的组件,带有其模块导入和注入的'DestroyRef'。
'onFileInputChange(...)'方法接受事件参数,并将其'files'属性存储在'files'常量中。然后检查第一个文件,并将其存储在'file'组件属性中。
'upload()'方法检查'file'属性并创建用于文件上传的'FormData()'。'formData'常量包含数据类型('file')、内容('this.file')和附加名称('this.file.name')。然后使用'documentService'将'FormData()'对象发布到服务器。'takeUntilDestroyed(this.destroyRef)'函数在组件销毁后取消订阅Rxjs管道。这使得在Angular中取消订阅管道非常方便。
后端是一个带有Spring AI框架的Spring Boot应用程序。Spring AI管理对OpenAI模型和矢量数据库请求的请求。
数据库设置使用Liquibase,脚本可以在db.changelog-1.xml中找到:
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
<changeSet id="1" author="angular2guy">
<sql>CREATE EXTENSION if not exists hstore;</sql>
</changeSet>
<changeSet id="2" author="angular2guy">
<sql>CREATE EXTENSION if not exists vector;</sql>
</changeSet>
<changeSet id="3" author="angular2guy">
<sql>CREATE EXTENSION if not exists "uuid-ossp";</sql>
</changeSet>
<changeSet author="angular2guy" id="4">
<createTable tableName="document">
<column name="id" type="bigint">
<constraints primaryKey="true"/>
</column>
<column name="document_name" type="varchar(255)">
<constraints notNullConstraintName="document_document_name_notnull"
nullable="false"/>
</column>
<column name="document_type" type="varchar(25)">
<constraints notNullConstraintName="document_document_type_notnull"
nullable="false"/>
</column>
<column name="document_content" type="blob"/>
</createTable>
</changeSet>
<changeSet author="angular2guy" id="5">
<createSequence sequenceName="document_seq" incrementBy="50"
startValue="1000" />
</changeSet>
<changeSet id="6" author="angular2guy">
<createTable tableName="vector_store">
<column name="id" type="uuid"
defaultValueComputed="uuid_generate_v4 ()">
<constraints primaryKey="true"/>
</column>
<column name="content" type="text"/>
<column name="metadata" type="json"/>
<column name="embedding" type="vector(1536)">
<constraints notNullConstraintName=
"vectorstore_embedding_type_notnull" nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet id="7" author="angular2guy">
<sql>CREATE INDEX vectorstore_embedding_index ON vector_store
USING HNSW (embedding vector_cosine_ops);</sql>
</changeSet>
</databaseChangeLog>
在changeset 4中,创建了Jpa文档实体的表,其中包含主键'id'。内容类型/大小未知,因此设置为'blob'。在changeset 5中,创建了Jpa实体的序列,使用了Spring Boot 3.x默认属性的Hibernate 6序列。在changeset 6中,创建了'vector_store'表,其中包含由'uuid-ossp'扩展创建的'uuid'类型的主键'id'。'content'列是'text'类型(在其他数据库中为'clob'),大小灵活。'metadata'列存储'AIDocuments'的'metadata',为'json'类型。'embedding'列存储具有OpenAI维度的嵌入向量。在changeset 7中,设置了用于快速搜索'embeddings'列的索引。由于Liquibase的'<createIndex ...>'的参数有限,因此直接使用'<sql>'来创建。
前端的DocumentController如下所示:
@RestController
@RequestMapping("rest/document")
public class DocumentController {
private final DocumentMapper documentMapper;
private final DocumentService documentService;
public DocumentController(DocumentMapper documentMapper,
DocumentService documentService) {
this.documentMapper = documentMapper;
this.documentService = documentService;
}
@PostMapping("/upload")
public long handleDocumentUpload(
@RequestParam("file") MultipartFile document) {
var docSize = this.documentService
.storeDocument(this.documentMapper.toEntity(document));
return docSize;
}
@GetMapping("/list")
public List<DocumentDto> getDocumentList() {
return this.documentService.getDocumentList().stream()
.flatMap(myDocument ->Stream.of(this.documentMapper.toDto(myDocument)))
.flatMap(myDocument -> {
myDocument.setDocumentContent(null);
return Stream.of(myDocument);
}).toList();
}
@GetMapping("/doc/{id}")
public ResponseEntity<DocumentDto> getDocument(
@PathVariable("id") Long id) {
return ResponseEntity.ofNullable(this.documentService
.getDocumentById(id).stream().map(this.documentMapper::toDto)
.findFirst().orElse(null));
}
@GetMapping("/content/{id}")
public ResponseEntity<byte[]> getDocumentContent(
@PathVariable("id") Long id) {
var resultOpt = this.documentService.getDocumentById(id).stream()
.map(this.documentMapper::toDto).findFirst();
var result = resultOpt.stream().map(this::toResultEntity)
.findFirst().orElse(ResponseEntity.notFound().build());
return result;
}
private ResponseEntity<byte[]> toResultEntity(DocumentDto documentDto) {
var contentType = switch (documentDto.getDocumentType()) {
case DocumentType.PDF -> MediaType.APPLICATION_PDF;
case DocumentType.HTML -> MediaType.TEXT_HTML;
case DocumentType.TEXT -> MediaType.TEXT_PLAIN;
case DocumentType.XML -> MediaType.APPLICATION_XML;
default -> MediaType.ALL;
};
return ResponseEntity.ok().contentType(contentType)
.body(documentDto.getDocumentContent());
}
@PostMapping("/search")
public DocumentSearchDto postDocumentSearch(@RequestBody
SearchDto searchDto) {
var result = this.documentMapper
.toDto(this.documentService.queryDocuments(searchDto));
return result;
}
}
'handleDocumentUpload(...)'处理了位于'/rest/document/upload'路径下的'文档上传'。
'getDocumentList()'处理文档列表的GET请求,并删除文档内容以减少响应大小。
'getDocumentContent(...)'处理文档内容的GET请求。它使用'documentService'加载文档,并将'DocumentType'映射到'MediaType'。然后返回内容和内容类型,浏览器根据内容类型打开内容。
'postDocumentSearch(...)'方法将请求内容放入'SearchDto'对象,并返回'documentService.queryDocuments(...)'调用的AI生成结果。
DocumentService的'storeDocument(...)'方法如下所示:
public Long storeDocument(Document document) {
var myDocument = this.documentRepository.save(document);
Resource resource = new ByteArrayResource(document.getDocumentContent());
var tikaDocuments = new TikaDocumentReader(resource).get();
record TikaDocumentAndContent(org.springframework.ai.document.Document
document, String content) { }
var aiDocuments = tikaDocuments.stream()
.flatMap(myDocument1 -> this.splitStringToTokenLimit(
myDocument1.getContent(), CHUNK_TOKEN_LIMIT)
.stream().map(myStr -> new TikaDocumentAndContent(myDocument1, myStr)))
.map(myTikaRecord -> new org.springframework.ai.document.Document(
myTikaRecord.content(), myTikaRecord.document().getMetadata()))
.peek(myDocument1 -> myDocument1.getMetadata()
.put(ID, myDocument.getId().toString())).toList();
LOGGER.info("Name: {}, size: {}, chunks: {}", document.getDocumentName(),
document.getDocumentContent().length, aiDocuments.size());
this.documentVsRepository.add(aiDocuments);
return Optional.ofNullable(myDocument.getDocumentContent()).stream()
.map(myContent -> Integer.valueOf(myContent.length).longValue())
.findFirst().orElse(0L);
}
private List<String> splitStringToTokenLimit(String documentStr,
int tokenLimit) {
List<String> splitStrings = new ArrayList<>();
var tokens = new StringTokenizer(documentStr).countTokens();
var chunks = Math.ceilDiv(tokens, tokenLimit);
if (chunks == 0) {
return splitStrings;
}
var chunkSize = Math.ceilDiv(documentStr.length(), chunks);
var myDocumentStr = new String(documentStr);
while (!myDocumentStr.isBlank()) {
splitStrings.add(myDocumentStr.length() > chunkSize ?
myDocumentStr.substring(0, chunkSize) : myDocumentStr);
myDocumentStr = myDocumentStr.length() > chunkSize ?
myDocumentStr.substring(chunkSize) : "";
}
return splitStrings;
}
'storeDocument(...)'方法将文档保存到关系数据库中。然后,将文档转换为'Spring AI'的'TikaDocumentReader'读取'ByteArrayResource',并将其转换为'AIDocument'列表。然后将AIDocument列表压扁以使用'splitToTokenLimit(...)'方法将文档拆分为片段,这些片段以存储文档的'metadata'映射中的'id'转换为新的AIDocument。'metadata'中的'id'使得加载匹配的文档实体变得可能。然后将AIDocuments的嵌入隐式地创建,并使用'documentVsRepository.add(...)'方法调用OpenAI Embedding模型,并将带有嵌入的AIDocuments存储在矢量数据库中。然后返回结果。
'queryDocument(...)'方法如下所示:
public AiResult queryDocuments(SearchDto searchDto) {
var similarDocuments = this.documentVsRepository
.retrieve(searchDto.getSearchString());
var mostSimilar = similarDocuments.stream()
.sorted((myDocA, myDocB) -> ((Float) myDocA.getMetadata().get(DISTANCE))
.compareTo(((Float) myDocB.getMetadata().get(DISTANCE)))).findFirst();
var documentChunks = mostSimilar.stream().flatMap(mySimilar ->
similarDocuments.stream().filter(mySimilar1 ->
mySimilar1.getMetadata().get(ID).equals(
mySimilar.getMetadata().get(ID)))).toList();
Message systemMessage = switch (searchDto.getSearchType()) {
case SearchDto.SearchType.DOCUMENT -> this.getSystemMessage(
documentChunks, (documentChunks.size() <= 0 ? 2000
: Math.floorDiv(2000, documentChunks.size())));
case SearchDto.SearchType.PARAGRAPH ->
this.getSystemMessage(mostSimilar.stream().toList(), 2000);
};
UserMessage userMessage = new UserMessage(searchDto.getSearchString());
Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
LocalDateTime start = LocalDateTime.now();
AiResponse response = aiClient.generate(prompt);
LOGGER.info("AI response time: {}ms",
ZonedDateTime.of(LocalDateTime.now(),
ZoneId.systemDefault()).toInstant().toEpochMilli()
- ZonedDateTime.of(start, ZoneId.systemDefault()).toInstant()
.toEpochMilli());
var documents = mostSimilar.stream().map(myGen ->
myGen.getMetadata().get(ID)).filter(myId ->
Optional.ofNullable(myId).stream().allMatch(myId1 ->
(myId1 instanceof String))).map(myId ->
Long.parseLong(((String) myId)))
.map(this.documentRepository::findById)
.filter(Optional::isPresent)
.map(Optional::get).toList();
return new AiResult(searchDto.getSearchString(),
response.getGenerations(), documents);
}
private Message getSystemMessage(
List<org.springframework.ai.document.Document> similarDocuments,
int tokenLimit) {
String documents = similarDocuments.stream()
.map(entry -> entry.getContent())
.filter(myStr -> myStr != null && !myStr.isBlank())
.map(myStr -> this.cutStringToTokenLimit(myStr, tokenLimit))
.collect(Collectors.joining("\n"));
SystemPromptTemplate systemPromptTemplate =
new SystemPromptTemplate(this.systemPrompt);
Message systemMessage = systemPromptTemplate
.createMessage(Map.of("documents", documents));
return systemMessage;
}
private String cutStringToTokenLimit(String documentStr, int tokenLimit) {
String cutString = new String(documentStr);
while (tokenLimit < new StringTokenizer(cutString, " -.;,").countTokens()){
cutString = cutString.length() > 1000 ?
cutString.substring(0, cutString.length() - 1000) : "";
}
return cutString;
}
该方法首先从向量数据库中加载与'searchDto.getSearchString()'最匹配的文档。为此,调用OpenAI嵌入模型将搜索字符串转换为嵌入,并使用该嵌入查询与搜索嵌入和数据库嵌入之间最小距离的AIDocument的向量数据库。然后将最小距离的AIDocument存储在'mostSimilar'变量中。接下来,通过匹配它们的元数据'id'的文档实体id,收集文档块的所有AIDocuments。使用'documentChunks'或'mostSimilar'的内容创建'systemMessage'。'getSystemMessage(...)'方法获取它们并将内容块切割成OpenAI GPT模型可以处理的大小,并返回'Message'。然后将'systemMessage'和'userMessage'转换为使用'aiClient.generate(prompt)'发送到OpenAi GPT模型的'prompt'。之后,AI的答案就可用了,并且文档实体被加载到'mostSimilar' AIDocument的元数据id。使用搜索字符串、GPT答案、文档实体创建'AiResult',并返回。
Spring AI的向量数据库存储库DocumentVsRepositoryBean与Spring AI的'VectorStore'如下:
@Repository
public class DocumentVSRepositoryBean implements DocumentVsRepository {
private final VectorStore vectorStore;
public DocumentVSRepositoryBean(JdbcTemplate jdbcTemplate,
EmbeddingClient embeddingClient) {
this.vectorStore = new PgVectorStore(jdbcTemplate, embeddingClient);
}
public void add(List<Document> documents) {
this.vectorStore.add(documents);
}
public List<Document> retrieve(String query, int k, double threshold) {
return new VectorStoreRetriever(vectorStore, k,
threshold).retrieve(query);
}
public List<Document> retrieve(String query) {
return new VectorStoreRetriever(vectorStore).retrieve(query);
}
}
该存储库具有'vectorStore'属性,用于访问向量数据库。它在构造函数中通过注入参数使用'new PgVectorStore(...)'调用进行创建。PgVectorStore类作为Postgresql向量数据库扩展提供。它具有'embeddingClient'用于使用OpenAI嵌入模型以及'jdbcTemplate'用于访问数据库。
'add(...)'方法调用OpenAI嵌入模型并将AIDocuments添加到向量数据库。
'retrieve(...)'方法查询向量数据库以获取最小距离的嵌入。
Angular使得前端的创建变得简单。独立组件的延迟加载使得初始加载变得很小。Angular Material组件对实现有很大帮助,且易于使用。
Spring Boot与Spring AI使得使用大型语言模型变得容易。Spring AI提供了隐藏嵌入创建的框架,并提供了一个易于使用的接口,用于在向量数据库中存储AIDocuments(支持多个)。还为搜索提示的嵌入创建提供了便利,向量数据库的接口也非常简单。Spring AI提示类使得为OpenAI GPT模型创建提示也变得容易。使用注入的'aiClient'调用模型,并返回结果。
Spring AI是Spring团队推出的一个非常好的框架。实验版本中没有出现任何问题。
使用Spring AI,现在可以轻松地在我们自己的文档上使用大型语言模型。
推荐阅读: 13.为什么TCP连接的时候是3次,关闭的时候却是4次?
本文链接: 如何利用RAG技术让Spring AI和OpenAI GPT更好地应用于自己的文件